mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 15:24:52 +09:00
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:
25 files changed
+948
-1131
No files matched your search
@@ -154,7 +154,7 @@ describe("controller/src/WsServer", function() {
|
||||
it("should reject a validly signed token for a host that is not registered", async function() {
|
||||
await register(controller.generateHostToken(5));
|
||||
assertRejected(
|
||||
"Authentication failed: host is not registered, run the installer again in the host directory"
|
||||
"Authentication failed: host is not registered, start the host again to request to join the cluster"
|
||||
);
|
||||
assert.equal(controller.hosts.has(5), false, "host record was created");
|
||||
});
|
||||
|
||||
+10
-201
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import HostRecord from "@clusterio/controller/dist/node/src/HostRecord.js";
|
||||
import { createLocalHost } from "../../packages/create/local_host.js";
|
||||
|
||||
describe("create/local_host createLocalHost()", function() {
|
||||
let directory;
|
||||
let secret;
|
||||
beforeEach(async function() {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-local-host-"));
|
||||
secret = crypto.randomBytes(256).toString("base64");
|
||||
});
|
||||
afterEach(async function() {
|
||||
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10 });
|
||||
});
|
||||
|
||||
// Written with the controller's own config class to match its file format.
|
||||
async function writeControllerConfig(fields = {}) {
|
||||
const configPath = path.join(directory, "config-controller.json");
|
||||
const config = new lib.ControllerConfig("controller", undefined, configPath);
|
||||
config.set("controller.auth_secret", secret);
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
config.set(name, value);
|
||||
}
|
||||
await config.save(true);
|
||||
}
|
||||
|
||||
async function loadHosts(databaseDirectory = "database") {
|
||||
const provider = new lib.JsonIdDatastoreProvider(
|
||||
path.join(directory, databaseDirectory, "hosts.json"), HostRecord.fromJSON.bind(HostRecord),
|
||||
);
|
||||
return await provider.load();
|
||||
}
|
||||
|
||||
it("adds a host the controller loads with a token it accepts", async function() {
|
||||
await writeControllerConfig();
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const result = await createLocalHost({ directory, name: "local" });
|
||||
assert.equal(result.name, "local");
|
||||
assert(Number.isInteger(result.hostId) && result.hostId >= 1 && result.hostId < 2 ** 31);
|
||||
|
||||
const hosts = await loadHosts();
|
||||
assert.deepEqual([...hosts.keys()], [result.hostId]);
|
||||
const record = hosts.get(result.hostId);
|
||||
assert.equal(record.name, "local");
|
||||
assert.equal(record.version, "");
|
||||
assert.deepEqual(record.plugins, new Map());
|
||||
assert(record.tokenValidAfter >= before);
|
||||
|
||||
const payload = jwt.verify(result.token, Buffer.from(secret, "base64"), { audience: "host" });
|
||||
assert.equal(payload.host, result.hostId);
|
||||
assert(payload.iat >= record.tokenValidAfter, "token must not be issued before token_valid_after");
|
||||
});
|
||||
|
||||
it("appends to an existing database in the configured directory", async function() {
|
||||
await writeControllerConfig({ "controller.database_directory": "db" });
|
||||
await fs.mkdir(path.join(directory, "db"));
|
||||
await fs.writeFile(path.join(directory, "db", "hosts.json"), JSON.stringify([
|
||||
{ id: 1, name: "existing", version: "2.0.0", plugins: {} },
|
||||
]));
|
||||
const result = await createLocalHost({ directory, name: "local", id: 7, version: "2.0.3" });
|
||||
assert.equal(result.hostId, 7);
|
||||
|
||||
const hosts = await loadHosts("db");
|
||||
assert.deepEqual([...hosts.keys()], [1, 7]);
|
||||
assert.equal(hosts.get(1).name, "existing");
|
||||
assert.equal(hosts.get(7).version, "2.0.3");
|
||||
await assert.rejects(fs.access(path.join(directory, "database")), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("signs a new token for an existing host with the same id", async function() {
|
||||
await writeControllerConfig();
|
||||
const first = await createLocalHost({ directory, name: "first", id: 7 });
|
||||
const before = await fs.readFile(path.join(directory, "database", "hosts.json"), "utf8");
|
||||
const second = await createLocalHost({ directory, name: "second", id: 7 });
|
||||
assert.equal(second.hostId, 7);
|
||||
assert.equal(second.name, "first", "the name of the existing host should be kept");
|
||||
assert.equal(await fs.readFile(path.join(directory, "database", "hosts.json"), "utf8"), before);
|
||||
|
||||
const record = (await loadHosts()).get(7);
|
||||
for (const token of [first.token, second.token]) {
|
||||
const payload = jwt.verify(token, Buffer.from(secret, "base64"), { audience: "host" });
|
||||
assert.equal(payload.host, 7);
|
||||
assert(payload.iat >= record.tokenValidAfter);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws if the controller config has no auth secret", async function() {
|
||||
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
|
||||
await assert.rejects(createLocalHost({ directory, name: "local" }), /controller\.auth_secret/);
|
||||
});
|
||||
});
|
||||
@@ -1,343 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import events from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import tls from "node:tls";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import HostRecord from "@clusterio/controller/dist/node/src/HostRecord.js";
|
||||
import {
|
||||
PairingError, checkHostToken, createLocalHost, pairHost,
|
||||
} from "../../packages/create/pair.js";
|
||||
|
||||
import "../setup_logging.js";
|
||||
|
||||
const tlsDir = path.join(import.meta.dirname, "..", "file", "tls");
|
||||
|
||||
// Stub controller recording requests, responding with what the test's handler returns.
|
||||
async function startStub(createServer = http.createServer) {
|
||||
const stub = {
|
||||
requests: [],
|
||||
handler: () => [500, { error: "No handler" }],
|
||||
};
|
||||
stub.server = createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const entry = {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body: chunks.length ? JSON.parse(Buffer.concat(chunks)) : undefined,
|
||||
};
|
||||
stub.requests.push(entry);
|
||||
const [status, json, headers = {}] = await stub.handler(entry);
|
||||
res.writeHead(status, { "Content-Type": "application/json", ...headers });
|
||||
res.end(json === undefined ? undefined : JSON.stringify(json));
|
||||
});
|
||||
stub.server.listen(0, "localhost");
|
||||
await events.once(stub.server, "listening");
|
||||
stub.url = `${stub.server instanceof https.Server ? "https" : "http"}://localhost:${stub.server.address().port}/`;
|
||||
stub.close = async () => {
|
||||
stub.server.closeAllConnections();
|
||||
stub.server.close();
|
||||
await events.once(stub.server, "close");
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
// Handler for a request that is approved after the given number of pending polls.
|
||||
function approveAfter(polls, result = { hostId: 1234, token: "host-token", name: "Approved" }) {
|
||||
let pending = 0;
|
||||
return ({ url }) => {
|
||||
if (url === "/api/host-pairing") {
|
||||
return [202, { requestId: "secret", code: "ABC-DEF" }];
|
||||
}
|
||||
if (url === "/api/host-pairing/poll") {
|
||||
pending += 1;
|
||||
return pending > polls ? [200, result] : [202, { status: "pending" }];
|
||||
}
|
||||
return [404, { error: "Not found" }];
|
||||
};
|
||||
}
|
||||
|
||||
describe("create/pair", function() {
|
||||
let stub;
|
||||
beforeEach(async function() {
|
||||
stub = await startStub();
|
||||
});
|
||||
afterEach(async function() {
|
||||
await stub.close();
|
||||
});
|
||||
|
||||
describe("pairHost()", function() {
|
||||
it("returns the host when approved with a user token", async function() {
|
||||
stub.handler = () => [200, { hostId: 5, token: "host-token", name: "Host" }];
|
||||
let pendingCalls = 0;
|
||||
const result = await pairHost({
|
||||
controllerUrl: stub.url, name: "Host", version: "2.0.3", token: "user-token",
|
||||
onPending: () => { pendingCalls += 1; },
|
||||
});
|
||||
assert.deepEqual(result, { hostId: 5, token: "host-token", name: "Host" });
|
||||
assert.equal(pendingCalls, 0);
|
||||
assert.equal(stub.requests.length, 1);
|
||||
const [created] = stub.requests;
|
||||
assert.equal(created.method, "POST");
|
||||
assert.equal(created.url, "/api/host-pairing");
|
||||
assert.equal(created.headers.authorization, "Bearer user-token");
|
||||
assert.deepEqual(created.body, { name: "Host", version: "2.0.3" });
|
||||
});
|
||||
|
||||
it("polls until the request is approved", async function() {
|
||||
stub.handler = approveAfter(2);
|
||||
const codes = [];
|
||||
const result = await pairHost({
|
||||
controllerUrl: stub.url, name: "Host", pollIntervalMs: 10,
|
||||
onPending: ({ code }) => { codes.push(code); },
|
||||
});
|
||||
assert.deepEqual(result, { hostId: 1234, token: "host-token", name: "Approved" });
|
||||
assert.deepEqual(codes, ["ABC-DEF"]);
|
||||
assert.deepEqual(stub.requests.map(r => r.url), [
|
||||
"/api/host-pairing", "/api/host-pairing/poll", "/api/host-pairing/poll", "/api/host-pairing/poll",
|
||||
]);
|
||||
assert.equal(stub.requests[0].headers.authorization, undefined);
|
||||
assert.deepEqual(stub.requests[0].body, { name: "Host", version: "" });
|
||||
for (const poll of stub.requests.slice(1)) {
|
||||
assert.deepEqual(poll.body, { requestId: "secret" });
|
||||
}
|
||||
});
|
||||
|
||||
["sub", "sub/"].forEach(controllerPath => {
|
||||
it(`resolves routes under a controller URL ending in ${controllerPath}`, async function() {
|
||||
stub.handler = ({ url }) => (url === "/sub/api/host-pairing"
|
||||
? [200, { hostId: 5, token: "host-token", name: "Host" }]
|
||||
: [404, {}]
|
||||
);
|
||||
const result = await pairHost({ controllerUrl: `${stub.url}${controllerPath}`, name: "Host" });
|
||||
assert.equal(result.hostId, 5);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws the error from the controller when the request is rejected", async function() {
|
||||
const handler = approveAfter(1);
|
||||
stub.handler = entry => {
|
||||
if (entry.url === "/api/host-pairing/poll" && stub.requests.length > 2) {
|
||||
return [403, { error: "Request was rejected" }];
|
||||
}
|
||||
return handler(entry);
|
||||
};
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: stub.url, name: "Host", pollIntervalMs: 10 }),
|
||||
err => err instanceof PairingError && err.status === 403 && err.message === "Request was rejected",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if the controller does not support pairing", async function() {
|
||||
stub.handler = () => [404, undefined];
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: stub.url, name: "Host" }),
|
||||
err => err instanceof PairingError && err.status === 404 && /must be updated/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on an invalid user token", async function() {
|
||||
stub.handler = () => [401, { error: "Invalid user token" }];
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: stub.url, name: "Host", token: "bad", onPending: () => assert.fail() }),
|
||||
err => err instanceof PairingError && err.status === 401 && err.message === "Invalid user token",
|
||||
);
|
||||
assert.equal(stub.requests.length, 1);
|
||||
});
|
||||
|
||||
it("includes the retry time when rate limited", async function() {
|
||||
stub.handler = () => [429, { error: "Too many requests from this address" }, { "Retry-After": "42" }];
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: stub.url, name: "Host" }),
|
||||
err => err instanceof PairingError && err.status === 429 && /try again in 42 seconds/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if the request expired", async function() {
|
||||
const handler = approveAfter(0);
|
||||
stub.handler = entry => (entry.url === "/api/host-pairing/poll"
|
||||
? [404, { error: "Request not found" }]
|
||||
: handler(entry)
|
||||
);
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: stub.url, name: "Host", pollIntervalMs: 10 }),
|
||||
err => err instanceof PairingError && err.status === 404 && err.message === "Request not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels the request when aborted", async function() {
|
||||
const controller = new AbortController();
|
||||
stub.handler = entry => {
|
||||
if (entry.url === "/api/host-pairing/poll") {
|
||||
controller.abort();
|
||||
}
|
||||
if (entry.url === "/api/host-pairing/cancel") {
|
||||
return [204, undefined];
|
||||
}
|
||||
return approveAfter(Infinity)(entry);
|
||||
};
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: stub.url, name: "Host", pollIntervalMs: 10, signal: controller.signal }),
|
||||
{ name: "AbortError" },
|
||||
);
|
||||
const cancel = stub.requests.find(r => r.url === "/api/host-pairing/cancel");
|
||||
assert.deepEqual(cancel?.body, { requestId: "secret" });
|
||||
});
|
||||
|
||||
it("does not send anything if already aborted", async function() {
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: stub.url, name: "Host", signal: AbortSignal.abort() }),
|
||||
{ name: "AbortError" },
|
||||
);
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
it("throws on invalid controller URLs", async function() {
|
||||
for (const controllerUrl of ["localhost:8080", "ftp://localhost/", "not a url"]) {
|
||||
await assert.rejects(pairHost({ controllerUrl, name: "Host" }), PairingError);
|
||||
}
|
||||
});
|
||||
|
||||
it("trusts the certificate passed as ca", async function() {
|
||||
const [key, cert] = await Promise.all([
|
||||
fs.readFile(path.join(tlsDir, "key.pem")),
|
||||
fs.readFile(path.join(tlsDir, "cert.pem")),
|
||||
]);
|
||||
const httpsStub = await startStub(handler => https.createServer({ key, cert }, handler));
|
||||
try {
|
||||
httpsStub.handler = () => [200, { hostId: 5, token: "host-token", name: "Host" }];
|
||||
const result = await pairHost({ controllerUrl: httpsStub.url, name: "Host", ca: cert });
|
||||
assert.equal(result.hostId, 5);
|
||||
|
||||
// Passing a different certificate replaces the trusted ones, proving ca is used.
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: httpsStub.url, name: "Host", ca: tls.rootCertificates[0] }),
|
||||
err => typeof err.code === "string" && !(err instanceof PairingError),
|
||||
);
|
||||
} finally {
|
||||
await httpsStub.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkHostToken()", function() {
|
||||
it("returns true if the controller accepts the token", async function() {
|
||||
stub.handler = () => [200, { valid: true }];
|
||||
assert.equal(await checkHostToken({ controllerUrl: stub.url, token: "host-token" }), true);
|
||||
assert.equal(stub.requests[0].method, "GET");
|
||||
assert.equal(stub.requests[0].url, "/api/host-pairing/check");
|
||||
assert.equal(stub.requests[0].headers.authorization, "Bearer host-token");
|
||||
});
|
||||
|
||||
it("returns false if the controller does not accept the token", async function() {
|
||||
stub.handler = () => [401, undefined];
|
||||
assert.equal(await checkHostToken({ controllerUrl: stub.url, token: "host-token" }), false);
|
||||
});
|
||||
|
||||
it("throws on other responses", async function() {
|
||||
stub.handler = () => [503, { error: "Controller is in recovery mode" }];
|
||||
await assert.rejects(
|
||||
checkHostToken({ controllerUrl: stub.url, token: "host-token" }),
|
||||
err => err instanceof PairingError && err.status === 503,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("create/pair createLocalHost()", function() {
|
||||
let directory;
|
||||
let secret;
|
||||
beforeEach(async function() {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-pair-"));
|
||||
secret = crypto.randomBytes(256).toString("base64");
|
||||
});
|
||||
afterEach(async function() {
|
||||
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10 });
|
||||
});
|
||||
|
||||
// Written with the controller's own config class to match its file format.
|
||||
async function writeControllerConfig(fields = {}) {
|
||||
const configPath = path.join(directory, "config-controller.json");
|
||||
const config = new lib.ControllerConfig("controller", undefined, configPath);
|
||||
config.set("controller.auth_secret", secret);
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
config.set(name, value);
|
||||
}
|
||||
await config.save(true);
|
||||
}
|
||||
|
||||
async function loadHosts(databaseDirectory = "database") {
|
||||
const provider = new lib.JsonIdDatastoreProvider(
|
||||
path.join(directory, databaseDirectory, "hosts.json"), HostRecord.fromJSON.bind(HostRecord),
|
||||
);
|
||||
return await provider.load();
|
||||
}
|
||||
|
||||
it("adds a host the controller loads with a token it accepts", async function() {
|
||||
await writeControllerConfig();
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const result = await createLocalHost({ directory, name: "local" });
|
||||
assert.equal(result.name, "local");
|
||||
assert(Number.isInteger(result.hostId) && result.hostId >= 1 && result.hostId < 2 ** 31);
|
||||
|
||||
const hosts = await loadHosts();
|
||||
assert.deepEqual([...hosts.keys()], [result.hostId]);
|
||||
const record = hosts.get(result.hostId);
|
||||
assert.equal(record.name, "local");
|
||||
assert.equal(record.version, "");
|
||||
assert.deepEqual(record.plugins, new Map());
|
||||
assert(record.tokenValidAfter >= before);
|
||||
|
||||
const payload = jwt.verify(result.token, Buffer.from(secret, "base64"), { audience: "host" });
|
||||
assert.equal(payload.host, result.hostId);
|
||||
assert(payload.iat >= record.tokenValidAfter, "token must not be issued before token_valid_after");
|
||||
});
|
||||
|
||||
it("appends to an existing database in the configured directory", async function() {
|
||||
await writeControllerConfig({ "controller.database_directory": "db" });
|
||||
await fs.mkdir(path.join(directory, "db"));
|
||||
await fs.writeFile(path.join(directory, "db", "hosts.json"), JSON.stringify([
|
||||
{ id: 1, name: "existing", version: "2.0.0", plugins: {} },
|
||||
]));
|
||||
const result = await createLocalHost({ directory, name: "local", id: 7, version: "2.0.3" });
|
||||
assert.equal(result.hostId, 7);
|
||||
|
||||
const hosts = await loadHosts("db");
|
||||
assert.deepEqual([...hosts.keys()], [1, 7]);
|
||||
assert.equal(hosts.get(1).name, "existing");
|
||||
assert.equal(hosts.get(7).version, "2.0.3");
|
||||
await assert.rejects(fs.access(path.join(directory, "database")), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("signs a new token for an existing host with the same id", async function() {
|
||||
await writeControllerConfig();
|
||||
const first = await createLocalHost({ directory, name: "first", id: 7 });
|
||||
const before = await fs.readFile(path.join(directory, "database", "hosts.json"), "utf8");
|
||||
const second = await createLocalHost({ directory, name: "second", id: 7 });
|
||||
assert.equal(second.hostId, 7);
|
||||
assert.equal(second.name, "first", "the name of the existing host should be kept");
|
||||
assert.equal(await fs.readFile(path.join(directory, "database", "hosts.json"), "utf8"), before);
|
||||
|
||||
const record = (await loadHosts()).get(7);
|
||||
for (const token of [first.token, second.token]) {
|
||||
const payload = jwt.verify(token, Buffer.from(secret, "base64"), { audience: "host" });
|
||||
assert.equal(payload.host, 7);
|
||||
assert(payload.iat >= record.tokenValidAfter);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws if the controller config has no auth secret", async function() {
|
||||
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
|
||||
await assert.rejects(createLocalHost({ directory, name: "local" }), /controller\.auth_secret/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 * as lib from "@clusterio/lib";
|
||||
import { PairingError, checkHostToken, pairHost } from "@clusterio/host";
|
||||
import { ensurePaired } from "@clusterio/host/dist/node/src/pairing.js";
|
||||
|
||||
import "../setup_logging.js";
|
||||
|
||||
// Stub controller recording requests, responding with what the test's handler returns.
|
||||
async function startStub() {
|
||||
const stub = {
|
||||
requests: [],
|
||||
handler: () => [500, { error: "No handler" }],
|
||||
};
|
||||
stub.server = http.createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const entry = {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body: chunks.length ? JSON.parse(Buffer.concat(chunks)) : undefined,
|
||||
};
|
||||
stub.requests.push(entry);
|
||||
const [status, json, headers = {}] = await stub.handler(entry);
|
||||
res.writeHead(status, { "Content-Type": "application/json", ...headers });
|
||||
res.end(json === undefined ? undefined : JSON.stringify(json));
|
||||
});
|
||||
stub.server.listen(0, "127.0.0.1");
|
||||
await events.once(stub.server, "listening");
|
||||
stub.url = `http://127.0.0.1:${stub.server.address().port}/`;
|
||||
stub.close = async () => {
|
||||
stub.server.closeAllConnections();
|
||||
stub.server.close();
|
||||
await events.once(stub.server, "close");
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
const approved = { hostId: 1234, token: "host-token", name: "Approved" };
|
||||
|
||||
// Handler for requests that are approved after the given number of pending polls.
|
||||
function approveAfter(polls, result = approved) {
|
||||
let pending = 0;
|
||||
let requests = 0;
|
||||
return ({ url }) => {
|
||||
if (url === "/api/host-pairing") {
|
||||
requests += 1;
|
||||
return [202, { requestId: `secret-${requests}`, code: `ABC-DE${requests}` }];
|
||||
}
|
||||
if (url === "/api/host-pairing/poll") {
|
||||
pending += 1;
|
||||
return pending > polls ? [200, result] : [202, { status: "pending" }];
|
||||
}
|
||||
if (url === "/api/host-pairing/cancel") {
|
||||
return [204, undefined];
|
||||
}
|
||||
return [404, { error: "Not found" }];
|
||||
};
|
||||
}
|
||||
|
||||
describe("host/src/pairing", function() {
|
||||
let stub;
|
||||
beforeEach(async function() {
|
||||
stub = await startStub();
|
||||
});
|
||||
afterEach(async function() {
|
||||
await stub.close();
|
||||
});
|
||||
|
||||
function pair(options = {}) {
|
||||
return pairHost({ controllerUrl: stub.url, name: "Host", pollIntervalMs: 10, retryDelayMs: 10, ...options });
|
||||
}
|
||||
|
||||
describe("pairHost()", function() {
|
||||
it("returns the host when approved with a user token", async function() {
|
||||
stub.handler = ({ headers }) => (headers.authorization === "Bearer user-token"
|
||||
? [200, approved]
|
||||
: [401, { error: "Invalid user token" }]
|
||||
);
|
||||
const result = await pair({ token: "user-token", onPending: () => assert.fail("should not wait") });
|
||||
assert.deepEqual(result, approved);
|
||||
assert.equal(stub.requests.length, 1);
|
||||
});
|
||||
|
||||
it("polls until the request is approved", async function() {
|
||||
stub.handler = approveAfter(2);
|
||||
const codes = [];
|
||||
const result = await pair({ version: "2.1.0", onPending: code => { codes.push(code); } });
|
||||
assert.deepEqual(result, approved);
|
||||
assert.deepEqual(codes, ["ABC-DE1"]);
|
||||
assert.deepEqual(stub.requests[0].body, { name: "Host", version: "2.1.0" });
|
||||
assert.equal(stub.requests[0].headers.authorization, undefined);
|
||||
for (const poll of stub.requests.slice(1)) {
|
||||
assert.deepEqual(poll.body, { requestId: "secret-1" });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves routes under the path of the controller URL", async function() {
|
||||
stub.handler = ({ url }) => (url === "/sub/api/host-pairing" ? [200, approved] : [404, {}]);
|
||||
const result = await pair({ controllerUrl: `${stub.url}sub/` });
|
||||
assert.equal(result.hostId, 1234);
|
||||
});
|
||||
|
||||
it("makes a new request when the previous one expired", async function() {
|
||||
const handler = approveAfter(0);
|
||||
let expired = false;
|
||||
stub.handler = entry => {
|
||||
if (entry.url === "/api/host-pairing/poll" && !expired) {
|
||||
expired = true;
|
||||
return [404, { error: "Request not found" }];
|
||||
}
|
||||
return handler(entry);
|
||||
};
|
||||
const codes = [];
|
||||
const result = await pair({ onPending: code => { codes.push(code); } });
|
||||
assert.deepEqual(result, approved);
|
||||
assert.deepEqual(codes, ["ABC-DE1", "ABC-DE2"]);
|
||||
});
|
||||
|
||||
it("waits and tries again when the controller is busy", async function() {
|
||||
const handler = approveAfter(0);
|
||||
let busy = 2;
|
||||
stub.handler = entry => {
|
||||
if (entry.url === "/api/host-pairing" && busy > 0) {
|
||||
busy -= 1;
|
||||
return busy ? [429, { error: "Too many requests" }] : [503, { error: "Recovery mode" }];
|
||||
}
|
||||
return handler(entry);
|
||||
};
|
||||
const retries = [];
|
||||
const result = await pair({ onRetry: err => { retries.push(err.message); } });
|
||||
assert.deepEqual(result, approved);
|
||||
assert.deepEqual(retries, ["Too many requests", "Recovery mode"]);
|
||||
});
|
||||
|
||||
it("keeps trying while the controller can't be reached", async function() {
|
||||
const { url } = stub;
|
||||
await stub.close();
|
||||
const abort = new AbortController();
|
||||
const errors = [];
|
||||
await assert.rejects(
|
||||
pairHost({
|
||||
controllerUrl: url, name: "Host", retryDelayMs: 10, signal: abort.signal,
|
||||
onRetry: err => {
|
||||
errors.push(err);
|
||||
if (errors.length === 3) {
|
||||
abort.abort();
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: "AbortError" },
|
||||
);
|
||||
assert.equal(errors.length, 3);
|
||||
stub = await startStub();
|
||||
});
|
||||
|
||||
it("throws when the request is rejected", async function() {
|
||||
const handler = approveAfter(Infinity);
|
||||
stub.handler = entry => (entry.url === "/api/host-pairing/poll" && stub.requests.length > 2
|
||||
? [403, { error: "Request was rejected" }]
|
||||
: handler(entry)
|
||||
);
|
||||
await assert.rejects(
|
||||
pair(),
|
||||
err => err instanceof PairingError && err.status === 403 && err.message === "Request was rejected",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if the controller does not support pairing", async function() {
|
||||
stub.handler = () => [404, undefined];
|
||||
await assert.rejects(
|
||||
pair(),
|
||||
err => err instanceof PairingError && err.status === 404 && /must be updated/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on an invalid user token without waiting", async function() {
|
||||
stub.handler = () => [401, { error: "Invalid user token" }];
|
||||
await assert.rejects(
|
||||
pair({ token: "bad" }),
|
||||
err => err instanceof PairingError && err.status === 401 && err.message === "Invalid user token",
|
||||
);
|
||||
assert.equal(stub.requests.length, 1);
|
||||
});
|
||||
|
||||
it("cancels the request when aborted", async function() {
|
||||
const abort = new AbortController();
|
||||
const handler = approveAfter(Infinity);
|
||||
stub.handler = entry => {
|
||||
if (entry.url === "/api/host-pairing/poll") {
|
||||
abort.abort();
|
||||
}
|
||||
return handler(entry);
|
||||
};
|
||||
await assert.rejects(pair({ signal: abort.signal }), { name: "AbortError" });
|
||||
const cancel = stub.requests.find(r => r.url === "/api/host-pairing/cancel");
|
||||
assert.deepEqual(cancel?.body, { requestId: "secret-1" });
|
||||
});
|
||||
|
||||
it("does not send anything if already aborted", async function() {
|
||||
await assert.rejects(pair({ signal: AbortSignal.abort() }), { name: "AbortError" });
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkHostToken()", function() {
|
||||
it("returns true if the controller accepts the token", async function() {
|
||||
stub.handler = ({ url, headers }) => (
|
||||
url === "/api/host-pairing/check" && headers.authorization === "Bearer host-token"
|
||||
? [200, { valid: true }]
|
||||
: [401, undefined]
|
||||
);
|
||||
assert.equal(await checkHostToken(stub.url, "host-token"), true);
|
||||
assert.equal(await checkHostToken(stub.url, "other-token"), false);
|
||||
});
|
||||
|
||||
it("throws on other responses", async function() {
|
||||
stub.handler = () => [404, undefined];
|
||||
await assert.rejects(checkHostToken(stub.url, "host-token"), PairingError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensurePaired()", function() {
|
||||
let directory;
|
||||
let config;
|
||||
beforeEach(async function() {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-pairing-"));
|
||||
config = new lib.HostConfig("host", undefined, path.join(directory, "config-host.json"));
|
||||
config.set("host.name", "My Host");
|
||||
config.set("host.controller_url", stub.url);
|
||||
});
|
||||
afterEach(async function() {
|
||||
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10 });
|
||||
});
|
||||
|
||||
async function savedConfig() {
|
||||
return JSON.parse(await fs.readFile(path.join(directory, "config-host.json"), "utf8"));
|
||||
}
|
||||
|
||||
it("waits for approval and saves the host when not paired", async function() {
|
||||
this.timeout(5000); // Polls at the default interval of 2 seconds
|
||||
stub.handler = approveAfter(0);
|
||||
await ensurePaired(config, { version: "2.1.0" });
|
||||
assert.deepEqual(stub.requests[0].body, { name: "My Host", version: "2.1.0" });
|
||||
const saved = await savedConfig();
|
||||
assert.equal(saved["host.id"], 1234);
|
||||
assert.equal(saved["host.name"], "Approved");
|
||||
assert.equal(saved["host.controller_token"], "host-token");
|
||||
});
|
||||
|
||||
it("does nothing when the controller accepts the saved token", async function() {
|
||||
config.set("host.controller_token", "valid-token");
|
||||
stub.handler = ({ headers }) => (headers.authorization === "Bearer valid-token"
|
||||
? [200, { valid: true }]
|
||||
: [401, undefined]
|
||||
);
|
||||
await ensurePaired(config, { version: "2.1.0" });
|
||||
assert.deepEqual(stub.requests.map(r => r.url), ["/api/host-pairing/check"]);
|
||||
assert.equal(config.get("host.controller_token"), "valid-token");
|
||||
});
|
||||
|
||||
it("asks to join again with the user token when the saved token is not accepted", async function() {
|
||||
config.set("host.controller_token", "revoked-token");
|
||||
stub.handler = ({ url, headers }) => {
|
||||
if (url === "/api/host-pairing/check") {
|
||||
return [401, undefined];
|
||||
}
|
||||
return headers.authorization === "Bearer user-token"
|
||||
? [200, { hostId: 5, token: "new-token", name: "My Host" }]
|
||||
: [401, { error: "Invalid user token" }];
|
||||
};
|
||||
await ensurePaired(config, { version: "2.1.0", userToken: "user-token" });
|
||||
const saved = await savedConfig();
|
||||
assert.equal(saved["host.id"], 5);
|
||||
assert.equal(saved["host.controller_token"], "new-token");
|
||||
assert(!JSON.stringify(saved).includes("user-token"), "user token must not be saved");
|
||||
});
|
||||
|
||||
it("keeps the saved token when it can't be checked", async function() {
|
||||
config.set("host.controller_token", "some-token");
|
||||
stub.handler = () => [404, undefined];
|
||||
await ensurePaired(config, { version: "2.1.0" });
|
||||
assert.equal(config.get("host.controller_token"), "some-token");
|
||||
assert.equal(stub.requests.length, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
|
||||
requiresFactorio, hasFactorio, pairTestHost, writeHostConfig,
|
||||
} from "./index.js";
|
||||
import { pairHost, checkHostToken, PairingError } from "../../packages/create/pair.js";
|
||||
import { pairHost, checkHostToken, PairingError } from "@clusterio/host";
|
||||
import { externalTest, slowTest } from "../common.js";
|
||||
import { getTestRconPort } from "./rcon_port.js";
|
||||
import { ControllerEcho, HostEchoReceived, InstanceEcho } from "../file/test_plugin/messages.js";
|
||||
@@ -718,7 +718,7 @@ describe("Integration of Clusterio", function() {
|
||||
describe("host pending, approve and reject", function() {
|
||||
let pairings = [];
|
||||
afterEach(async function() {
|
||||
// Stop installers left waiting by a failed test
|
||||
// Stop hosts left waiting by a failed test
|
||||
for (const pairing of pairings) {
|
||||
pairing.abort.abort();
|
||||
}
|
||||
@@ -726,7 +726,7 @@ describe("Integration of Clusterio", function() {
|
||||
pairings = [];
|
||||
});
|
||||
|
||||
// Start pairing like an installer without a token, returns once the request is pending.
|
||||
// Start pairing like a host without a user token, returns once the request is pending.
|
||||
async function requestPairing(name) {
|
||||
const abort = new AbortController();
|
||||
let onPending;
|
||||
@@ -740,7 +740,7 @@ describe("Integration of Clusterio", function() {
|
||||
throw new Error("Pairing finished before the request was pending");
|
||||
});
|
||||
ended.catch(() => {}); // Only of interest while racing the pending request
|
||||
({ code: pairing.code } = await Promise.race([pending, ended]));
|
||||
pairing.code = await Promise.race([pending, ended]);
|
||||
return pairing;
|
||||
}
|
||||
|
||||
@@ -776,12 +776,12 @@ describe("Integration of Clusterio", function() {
|
||||
assert(!requests.some(request => request.code === pairing.code), "Approved request is still pending");
|
||||
const hosts = await control.send(new lib.HostListRequest());
|
||||
assert.equal(hosts.find(host => host.id === approved.hostId)?.name, "approved-host");
|
||||
assert.equal(await checkHostToken({ controllerUrl: url, token: approved.token }), true);
|
||||
assert.equal(await checkHostToken(url, approved.token), true);
|
||||
|
||||
await execCtl(`host revoke ${approved.hostId}`);
|
||||
});
|
||||
|
||||
it("should make the installer fail when the request is rejected", async function() {
|
||||
it("should make the host fail when the request is rejected", async function() {
|
||||
const pairing = await requestPairing("rejected-host");
|
||||
await execCtl(`host reject ${pairing.code}`);
|
||||
await assert.rejects(pairing.result, pairingError(403));
|
||||
@@ -792,7 +792,7 @@ describe("Integration of Clusterio", function() {
|
||||
assert(!hosts.some(host => host.name === "rejected-host"), "Rejected host was added");
|
||||
});
|
||||
|
||||
it("should remove the request when the installer stops waiting", async function() {
|
||||
it("should remove the request when the host stops waiting", async function() {
|
||||
const pairing = await requestPairing("cancelled-host");
|
||||
pairing.abort.abort();
|
||||
await assert.rejects(pairing.result, err => err.name === "AbortError");
|
||||
@@ -836,7 +836,7 @@ describe("Integration of Clusterio", function() {
|
||||
describe("host revoke", function() {
|
||||
it("should remove the host and invalidate its token", async function() {
|
||||
const pairing = await pairTestHost("revokee-host");
|
||||
assert.equal(await checkHostToken({ controllerUrl: url, token: pairing.token }), true);
|
||||
assert.equal(await checkHostToken(url, pairing.token), true);
|
||||
await execCtl(`note set host ${pairing.hostId} short-lived`);
|
||||
|
||||
const control = getControl();
|
||||
@@ -848,7 +848,7 @@ describe("Integration of Clusterio", function() {
|
||||
);
|
||||
const hosts = await control.send(new lib.HostListRequest());
|
||||
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
|
||||
assert.equal(await checkHostToken({ controllerUrl: url, token: pairing.token }), false);
|
||||
assert.equal(await checkHostToken(url, pairing.token), false);
|
||||
const notes = await control.send(new lib.NoteListRequest());
|
||||
assert(!notes.some(note => note.id === `host/${pairing.hostId}`), "Note of the host was not removed");
|
||||
});
|
||||
@@ -880,7 +880,7 @@ describe("Integration of Clusterio", function() {
|
||||
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
|
||||
});
|
||||
|
||||
it("should stop a running host and prevent it from starting again", async function() {
|
||||
it("should stop a running host and have it ask to join again when started", async function() {
|
||||
slowTest(this);
|
||||
this.timeout(30000);
|
||||
const config = "revoke-host-config.json";
|
||||
@@ -905,13 +905,34 @@ describe("Integration of Clusterio", function() {
|
||||
}
|
||||
}
|
||||
|
||||
// Exit code 8 tells the run scripts not to restart the host
|
||||
await assert.rejects(execHost(`--config ${config} run`), err => {
|
||||
const output = `${err.stdout}${err.stderr}`;
|
||||
assert.equal(err.code, 8, `Unexpected exit code ${err.code}:\n${output}`);
|
||||
assert.match(output, /host is not registered/);
|
||||
return true;
|
||||
});
|
||||
// Starting it again asks to join the cluster again, and it starts up once approved
|
||||
const rejoining = await spawnNode(
|
||||
"revoke-host:", `../../packages/host run --config ${config}`, /Waiting for approval/
|
||||
);
|
||||
const exited = events.once(rejoining, "exit");
|
||||
try {
|
||||
const started = new Promise(resolve => {
|
||||
rejoining.stdout.on("data", chunk => {
|
||||
if (String(chunk).includes("Started host")) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
const requests = await getControl().send(new lib.HostPairingListRequest());
|
||||
const request = requests.find(entry => entry.name === "running-revokee");
|
||||
assert(request, "Revoked host did not ask to join again");
|
||||
const newHostId = await getControl().send(new lib.HostPairingApproveRequest(request.code));
|
||||
assert.notEqual(newHostId, pairing.hostId);
|
||||
assert.notEqual(
|
||||
await lib.timeout(started, 10e3, "timeout"), "timeout", "Host did not start once approved"
|
||||
);
|
||||
await execCtl(`host revoke ${newHostId}`);
|
||||
await lib.timeout(exited, 10e3, "timeout");
|
||||
} finally {
|
||||
if (rejoining.exitCode === null && rejoining.signalCode === null) {
|
||||
rejoining.kill("SIGINT");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { LineSplitter, logger } from "@clusterio/lib";
|
||||
|
||||
import { selectTargetCommand, initialize as initializeCtl } from "@clusterio/ctl";
|
||||
import { _listFactorioVersions } from "@clusterio/host/dist/node/src/server.js";
|
||||
import { pairHost } from "../../packages/create/pair.js";
|
||||
import { pairHost } from "@clusterio/host";
|
||||
|
||||
import "../setup_logging.js";
|
||||
|
||||
@@ -276,7 +276,7 @@ export async function pairTestHost(name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a new host config for a paired host, with the fields the installer sets
|
||||
* Write a new host config for a paired host, with the fields the installer and pairing set
|
||||
*
|
||||
* @param {string} configPath - Path to write the config to.
|
||||
* @param {{ hostId: number, token: string, name: string }} pairing - Host returned by pairing.
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("Integration of lib/link", function() {
|
||||
await assert.rejects(
|
||||
hostConnector.connect(),
|
||||
new Error(
|
||||
"Authentication failed: host is not registered, run the installer again in the host directory"
|
||||
"Authentication failed: host is not registered, start the host again to request to join the cluster"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user