Pair hosts when they start instead of in the installer

The installer only writes the controller URL and host name. Running the
host requests to join the cluster if it has not been approved yet, or if
the controller no longer accepts its token, and waits until an admin
approves it. Once approved it saves its id and token and starts as
normal. A rejected host exits with code 8.

clusteriohost run --user-token approves the host right away with the
token of a user allowed to approve hosts. The token is not saved.

This moves the pairing client into the host package, where it uses the
same certificate setup as the host's connection to the controller, and
removes --no-pair and the rerun handling from the installer.
This commit is contained in:
Danielv123 committed 2026-10-30 21:13:14 +01:00
1 parent 37e79d9c41
commit 4a49a198f0
25 files changed
+948 -1131

No files matched your search

+10 -201
View File
@@ -1,9 +1,7 @@
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";
@@ -135,53 +133,7 @@ describe("create installer CLI", function() {
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" }];
};
}
const controllerUrl = "http://controller.example.com:8080/";
function run(args, env = {}) {
// Record the commands for npm and the clusterio CLIs instead of running them.
@@ -203,20 +155,6 @@ describe("create installer CLI", function() {
});
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)}`,
@@ -231,171 +169,43 @@ describe("create installer CLI", function() {
// 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;
}
const content = await fs.readFile(path.join(directory, "commands.jsonl"), "utf8");
return content.split("\n").filter(Boolean).map(line => JSON.parse(line))
.filter(command => !command.startsWith("getent "));
}
function hostArgs(...extra) {
function hostArgs(url = controllerUrl) {
return [
"--mode", "host", "--host-name", "Test Host", "--controller-url", controllerUrl,
"--public-address", "host.example.com", "--factorio-dir", "factorio", "--no-remote-npm",
...extra, "--plugins",
"--mode", "host", "--host-name", "Test Host", "--controller-url", url,
"--public-address", "host.example.com", "--factorio-dir", "factorio", "--no-remote-npm", "--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);
it("writes the host config and leaves joining the cluster to the host", async function() {
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);
assert.match(stdout, /Start the host with run-host/);
const recorded = await commands();
assert.match(recorded[0], /^npm(?:\.cmd)? install @clusterio\/host$/);
assert.deepEqual(recorded.slice(1), [
`${hostBin} config create`,
hostSet("host.name", "Test Host"),
hostSet("host.controller_url", controllerUrl),
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);
await run(hostArgs(controllerUrl.slice(0, -1)));
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({
@@ -429,7 +239,6 @@ describe("create installer CLI", function() {
]) {
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() {