Files
factorio-clusterio/test/create.js
T
Danielv123 87d4f5c64e Replace host tokens with host pairing
Hosts join the cluster by running the installer, which shows a code and
waits until the request is approved under Hosts > Add host in the web
interface or with clusterioctl host approve. An admin can pass their
user token to the installer to approve the host right away. The token
is only used for that and is not saved.

The controller only accepts hosts that have a record, and only
approving a host creates one. Revoking a host deletes its record, so
its token can no longer be used to reconnect. Hosts that have connected
before keep working.

Removes host generate-token, revoke-token and create-config from
clusterioctl, bootstrap generate-host-token, the host token messages and
the core.host.generate_token, revoke_token and create_config
permissions, which are also stripped from stored roles. Adds
clusterioctl host pending, approve, reject and revoke, and the
core.host.approve and core.host.revoke permissions.

Standalone installs add the host to the controller database directly,
since the controller is not running during the install.
2026-10-30 06:11:32 +01:00

458 lines
19 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import crypto from "node:crypto";
import events from "node:events";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import jwt from "jsonwebtoken";
import { escapeArg } from "../packages/create/escape_arg.js";
const execFileAsync = promisify(execFile);
const installer = path.resolve(import.meta.dirname, "../packages/create/create.js");
const discoveryUrl = new URL("../packages/lib/dist/node/src/load_plugin_list.js", import.meta.url).href;
describe("create installer CLI", function() {
this.timeout(10000);
let directory;
beforeEach(async function() {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio install #"));
await fs.writeFile(path.join(directory, "package.json"), JSON.stringify({
name: "clusterio-install", private: true,
}));
});
afterEach(async function() {
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10 });
});
async function addPlugin(name, type, source) {
const pluginDir = path.join(directory, "node_modules", name);
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(path.join(pluginDir, "package.json"), JSON.stringify({
name, version: "1.0.0", type, main: "index.js", keywords: ["clusterio-plugin"],
}));
await fs.writeFile(path.join(pluginDir, "index.js"), source);
const packagePath = path.join(directory, "package.json");
const pkg = JSON.parse(await fs.readFile(packagePath));
pkg.dependencies = { ...pkg.dependencies, [name]: "1.0.0" };
await fs.writeFile(packagePath, JSON.stringify(pkg));
}
function runInstaller(plugins, installFails = false) {
// Only replace npm's subprocess: fixtures stand in for downloaded packages.
// Run the actual CLI from outside the installation, like npm init does.
const mockNpm = `
import assert from "node:assert/strict";
import childProcess from "node:child_process";
import { PassThrough } from "node:stream";
childProcess.execFile = (command, options, callback) => {
assert.match(command, /^npm(?:\\.cmd)? install /);
const stdout = new PassThrough();
const stderr = new PassThrough();
queueMicrotask(() => {
stdout.end();
stderr.end();
callback(${installFails} ? new Error("npm install failed") : null, "", "");
});
return { stdout, stderr };
};
`;
return execFileAsync(process.execPath, [
"--import", `data:text/javascript,${encodeURIComponent(mockNpm)}`,
installer, "--mode", "plugins",
...(process.platform === "linux" ? ["--allow-install-as-root"] : []),
"--plugins", ...plugins,
], { cwd: directory });
}
it("installs plugins without importing them or writing a plugin list", async function() {
await addPlugin("@installer-test/deferred", "module", 'throw new Error("installer must not import plugins");');
const { stdout } = await runInstaller(["@installer-test/deferred"]);
assert.match(stdout, /Successfully installed plugins/);
await assert.rejects(fs.access(path.join(directory, "plugin-list.json")), { code: "ENOENT" });
});
[false, true].forEach(existingList => {
it(`defers registration to discovery ${existingList ? "preserving existing entries" : "with no list"}`,
async function() {
await addPlugin("@installer-test/esm", "module", 'export const plugin = { name: "esm_plugin" };');
await addPlugin("@installer-test/commonjs", "commonjs", 'exports.plugin = { name: "cjs_plugin" };');
const listPath = path.join(directory, "plugin-list.json");
const previousEntries = existingList ? [["manual", "./plugins/manual"]] : [];
const previousContents = JSON.stringify(previousEntries);
if (existingList) {
await fs.writeFile(listPath, previousContents);
}
const { stdout } = await runInstaller(["@installer-test/esm", "@installer-test/commonjs"]);
assert.match(stdout, /Successfully installed plugins/);
if (existingList) {
assert.equal(await fs.readFile(listPath, "utf8"), previousContents);
} else {
await assert.rejects(fs.access(listPath), { code: "ENOENT" });
}
// Use the real discovery function invoked by controller/host/ctl commands.
await execFileAsync(process.execPath, [
"--input-type=module", "--eval",
`import { loadPluginList } from ${JSON.stringify(discoveryUrl)};
await loadPluginList("plugin-list.json");`,
], { cwd: directory });
assert.deepEqual(JSON.parse(await fs.readFile(listPath)), [
...previousEntries,
["esm_plugin", "@installer-test/esm"],
["cjs_plugin", "@installer-test/commonjs"],
]);
}
);
});
it("exits with failure when npm installation fails", async function() {
await assert.rejects(runInstaller(["@installer-test/missing"], true), err => {
assert.equal(err.code, 1);
assert.match(err.stdout + err.stderr, /Failed to install: npm install failed/);
assert.doesNotMatch(err.stdout, /Successfully installed/);
return true;
});
});
it("exits with failure on an unexpected installer error", async function() {
await fs.writeFile(path.join(directory, "package.json"), "null");
await assert.rejects(runInstaller([]), err => {
assert.equal(err.code, 1);
assert.match(err.stdout + err.stderr, /TypeError/);
assert.doesNotMatch(err.stdout, /Successfully installed/);
return true;
});
});
describe("host setup", function() {
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const hostBin = path.join("node_modules", ".bin", `clusteriohost${scriptExt}`);
const controllerBin = path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`);
const hostSet = (field, value) => `${hostBin} config set ${field} ${escapeArg(String(value))}`;
const approved = { hostId: 1234, token: "host-token", name: "Approved" };
let server;
let controllerUrl;
let requests;
let handler;
beforeEach(async function() {
requests = [];
handler = () => [500, { error: "No handler" }];
server = http.createServer(async (req, res) => {
let body = "";
for await (const chunk of req) {
body += chunk;
}
const entry = { url: req.url, headers: req.headers, body: body ? JSON.parse(body) : undefined };
requests.push(entry);
const [status, json] = handler(entry);
res.writeHead(status, { "Content-Type": "application/json" });
res.end(json === undefined ? undefined : JSON.stringify(json));
});
server.listen(0, "localhost");
await events.once(server, "listening");
controllerUrl = `http://localhost:${server.address().port}/`;
});
afterEach(function() {
server.closeAllConnections();
server.close();
});
// Stub of the controller pairing endpoints, approving after the given number of pending polls.
function pairingHandler(pendingPolls = 0, result = approved) {
let polls = 0;
return ({ url }) => {
if (url === "/api/host-pairing") {
return [202, { requestId: "secret", code: "ABC-DEF" }];
}
if (url === "/api/host-pairing/poll") {
polls += 1;
return polls > pendingPolls ? [200, result] : [202, { status: "pending" }];
}
if (url === "/api/host-pairing/cancel") {
return [204, undefined];
}
return [404, { error: "Not found" }];
};
}
function run(args, env = {}) {
// Record the commands for npm and the clusterio CLIs instead of running them.
const mockExec = `
import childProcess from "node:child_process";
import fs from "node:fs";
import { PassThrough } from "node:stream";
childProcess.execFile = (command, options, callback) => {
fs.appendFileSync(process.env.CREATE_TEST_COMMANDS, JSON.stringify(command) + "\\n");
const stdout = new PassThrough();
const stderr = new PassThrough();
// Lets tests give the host id a config show prints.
const output = process.env.CREATE_TEST_HOST_ID && command.includes("config show host.id")
? process.env.CREATE_TEST_HOST_ID + "\\n" : "";
queueMicrotask(() => {
stdout.end();
stderr.end();
callback(null, output, "");
});
return { stdout, stderr };
};
if (process.env.CREATE_TEST_SIGINT) {
// Stands in for Ctrl-C, which cannot be sent to a child process on Windows.
// Sent twice like when npm init forwards the one it gets to the installer.
const write = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk, ...rest) => {
if (String(chunk).includes("Waiting for approval")) {
setTimeout(() => {
process.emit("SIGINT", "SIGINT");
process.emit("SIGINT", "SIGINT");
}, 50);
}
return write(chunk, ...rest);
};
}
`;
return execFileAsync(process.execPath, [
"--import", `data:text/javascript,${encodeURIComponent(mockExec)}`,
installer,
...(process.platform === "linux" ? ["--allow-install-as-root"] : []),
...args,
], {
cwd: directory,
env: { ...process.env, CREATE_TEST_COMMANDS: path.join(directory, "commands.jsonl"), ...env },
});
}
// Commands run by the installer, without the getent lookups made when writing systemd units.
async function commands() {
let content;
try {
content = await fs.readFile(path.join(directory, "commands.jsonl"), "utf8");
} catch (err) {
if (err.code === "ENOENT") {
return [];
}
throw err;
}
return content.split("\n").filter(Boolean).map(line => JSON.parse(line))
.filter(command => !command.startsWith("getent "));
}
function hostArgs(...extra) {
return [
"--mode", "host", "--host-name", "Test Host", "--controller-url", controllerUrl,
"--public-address", "host.example.com", "--factorio-dir", "factorio", "--no-remote-npm",
...extra, "--plugins",
];
}
async function writeHostConfig(token) {
await fs.writeFile(path.join(directory, "config-host.json"), JSON.stringify({
"host.name": "Test Host",
"host.controller_url": controllerUrl,
"host.controller_token": token,
}));
}
it("pairs a new host once approved and saves its id and token", async function() {
handler = pairingHandler(0);
const { stdout } = await run(hostArgs());
assert(stdout.includes(
`Waiting for approval. Approve "Test Host" with code ABC-DEF on the Hosts page of ${controllerUrl}, ` +
"or run: npx clusterioctl host approve ABC-DEF"
), stdout);
assert.match(stdout, /Successfully installed host/);
const { version } = JSON.parse(await fs.readFile(path.join(path.dirname(installer), "package.json")));
assert.deepEqual(requests[0].body, { name: "Test Host", version });
assert.equal(requests[0].headers.authorization, undefined);
const recorded = await commands();
assert.match(recorded[0], /^npm(?:\.cmd)? install @clusterio\/host$/);
assert.deepEqual(recorded.slice(1), [
`${hostBin} config create`,
hostSet("host.public_address", "host.example.com"),
hostSet("host.factorio_directory", "factorio"),
hostSet("host.allow_remote_updates", false),
hostSet("host.allow_plugin_updates", false),
hostSet("host.allow_plugin_install", false),
hostSet("host.name", "Test Host"),
hostSet("host.controller_url", controllerUrl),
hostSet("host.id", 1234),
hostSet("host.name", "Approved"),
hostSet("host.controller_token", "host-token"),
]);
await fs.access(path.join(directory, `run-host${process.platform === "win32" ? ".cmd" : ".sh"}`));
});
it("uses --controller-token to approve the host without saving it", async function() {
handler = ({ headers }) => (headers.authorization === "Bearer user-token"
? [200, { hostId: 99, token: "host-token", name: "Test Host" }]
: [401, { error: "Invalid user token" }]
);
const { stdout } = await run(hostArgs("--controller-token", "user-token"));
assert.doesNotMatch(stdout, /Waiting for approval/);
const recorded = await commands();
assert(!recorded.some(command => command.includes("user-token")), "user token must not be saved");
assert.deepEqual(recorded.slice(-2), [
hostSet("host.id", 99),
hostSet("host.controller_token", "host-token"),
]);
});
it("cancels the request on Ctrl-C", async function() {
handler = pairingHandler(Infinity);
await assert.rejects(run(hostArgs(), { CREATE_TEST_SIGINT: "1" }), err => {
assert.equal(err.code, 1);
assert.match(err.stdout + err.stderr, /Pairing cancelled/);
return true;
});
assert.deepEqual(requests.find(r => r.url === "/api/host-pairing/cancel")?.body, { requestId: "secret" });
assert(!(await commands()).some(command => command.includes("host.controller_token")));
});
it("installs without contacting the controller with --no-pair", async function() {
const { stdout } = await run([
"--mode", "host", "--no-pair", "--public-address", "localhost", "--factorio-dir", "factorio",
"--remote-npm", "--plugins",
]);
assert.match(stdout, /Successfully installed host/);
assert.equal(requests.length, 0);
const recorded = await commands();
assert(recorded.includes(hostSet("host.allow_plugin_install", true)));
assert(!recorded.some(command => command.includes("host.controller_token")));
});
it("exits when run again on a host the controller accepts", async function() {
await writeHostConfig("valid-token");
handler = ({ url, headers }) => {
const valid = url === "/api/host-pairing/check" && headers.authorization === "Bearer valid-token";
return valid ? [200, { valid: true }] : [401, undefined];
};
const { stdout } = await run(["--mode", "host"]);
assert.match(stdout, /Host is already set up/);
assert.deepEqual(requests.map(r => r.url), ["/api/host-pairing/check"]);
assert.deepEqual(await commands(), []);
});
it("pairs again when run again on a revoked host", async function() {
await writeHostConfig("revoked-token");
const pairing = pairingHandler(0, { hostId: 5, token: "new-token", name: "Renamed" });
handler = entry => (entry.url === "/api/host-pairing/check" ? [401, undefined] : pairing(entry));
const { stdout } = await run([
"--mode", "host", "--host-name", "Renamed", "--controller-url", controllerUrl,
]);
assert.match(stdout, /Successfully set up host/);
assert.equal(requests[0].headers.authorization, "Bearer revoked-token");
assert.equal(requests[1].body.name, "Renamed");
assert.deepEqual(await commands(), [
hostSet("host.name", "Renamed"),
hostSet("host.controller_url", controllerUrl),
hostSet("host.id", 5),
hostSet("host.controller_token", "new-token"),
]);
});
it("does not send the saved token to another controller", async function() {
await writeHostConfig("old-token");
// Same stub server under its IP address, which the installer sees as another controller.
const { address, port } = server.address();
const otherUrl = `http://${address.includes(":") ? `[${address}]` : address}:${port}/`;
handler = pairingHandler(0, { hostId: 6, token: "other-token", name: "Moved" });
const { stdout } = await run(["--mode", "host", "--host-name", "Moved", "--controller-url", otherUrl]);
assert.match(stdout, /Successfully set up host/);
assert(!requests.some(r => r.url === "/api/host-pairing/check"), "token checked with other controller");
assert(!requests.some(r => String(r.headers.authorization).includes("old-token")));
assert.deepEqual(await commands(), [
hostSet("host.name", "Moved"),
hostSet("host.controller_url", otherUrl),
hostSet("host.id", 6),
hostSet("host.controller_token", "other-token"),
]);
});
it("adds the trailing slash the host requires to the controller URL", async function() {
handler = pairingHandler(0);
const args = hostArgs();
args[args.indexOf(controllerUrl)] = controllerUrl.slice(0, -1);
await run(args);
assert((await commands()).includes(hostSet("host.controller_url", controllerUrl)));
});
it("writes the certificate into the run scripts when run again with one", async function() {
await writeHostConfig("valid-token");
handler = ({ url }) => (url === "/api/host-pairing/check" ? [200, { valid: true }] : [404, undefined]);
const cert = path.resolve(import.meta.dirname, "file", "tls", "cert.pem");
const { stdout } = await run(["--mode", "host", "--self-signed-cert", cert]);
assert.match(stdout, /Host is already set up/);
const script = process.platform === "win32" ? "run-host.cmd" : "run-host.sh";
const content = await fs.readFile(path.join(directory, script), "utf8");
assert(content.includes(`NODE_EXTRA_CA_CERTS=${cert}`), content);
});
it("adds the host of a standalone install to the controller database", async function() {
const secret = crypto.randomBytes(256).toString("base64");
await fs.writeFile(path.join(directory, "config-controller.json"), JSON.stringify({
"controller.auth_secret": secret,
}));
const { stdout } = await run([
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
"--factorio-dir", "factorio", "--remote-npm", "--plugins",
]);
assert.match(stdout, /Successfully installed standalone/);
const hosts = JSON.parse(await fs.readFile(path.join(directory, "database", "hosts.json")));
assert.equal(hosts.length, 1);
assert.equal(hosts[0].name, "local");
const recorded = await commands();
const tokenSet = `${hostBin} config set host.controller_token `;
const token = recorded.find(command => command.startsWith(tokenSet)).slice(tokenSet.length);
const payload = jwt.verify(token, Buffer.from(secret, "base64"), { audience: "host" });
assert.equal(payload.host, hosts[0].id);
for (const expected of [
hostSet("host.id", hosts[0].id),
hostSet("host.name", "local"),
hostSet("host.controller_url", "http://localhost:8123/"),
`${controllerBin} config set controller.allow_remote_updates true`,
`${controllerBin} config set controller.allow_plugin_updates true`,
`${controllerBin} config set controller.allow_plugin_install true`,
hostSet("host.allow_remote_updates", true),
hostSet("host.allow_plugin_updates", true),
hostSet("host.allow_plugin_install", true),
]) {
assert(recorded.includes(expected), `missing ${expected}`);
}
assert(!recorded.some(command => command.includes("generate-host-token")));
});
it("keeps the host of a standalone install when run again", async function() {
const secret = crypto.randomBytes(256).toString("base64");
await fs.writeFile(path.join(directory, "config-controller.json"), JSON.stringify({
"controller.auth_secret": secret,
}));
await fs.mkdir(path.join(directory, "database"));
const existing = [{ id: 1234, name: "local", version: "2.0.3", plugins: {}, token_valid_after: 1 }];
await fs.writeFile(path.join(directory, "database", "hosts.json"), JSON.stringify(existing));
const { stdout } = await run([
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
"--factorio-dir", "factorio", "--remote-npm", "--plugins",
], { CREATE_TEST_HOST_ID: "1234" });
assert.match(stdout, /Successfully installed standalone/);
assert.deepEqual(JSON.parse(await fs.readFile(path.join(directory, "database", "hosts.json"))), existing);
const recorded = await commands();
assert(recorded.includes(hostSet("host.id", 1234)), "host id was changed");
const tokenSet = `${hostBin} config set host.controller_token `;
const token = recorded.find(command => command.startsWith(tokenSet)).slice(tokenSet.length);
assert.equal(jwt.verify(token, Buffer.from(secret, "base64"), { audience: "host" }).host, 1234);
});
});
});