mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
controller.hosts is now a HostManager wrapping the hosts datastore, the same way controller.users wraps the users datastore. It owns creating hosts (moved from HostPairingManager), revoking them (moved from ControlConnection) and signing and verifying host tokens (moved from Controller, generateHostToken is renamed signHostToken to match signUserToken). UserManager gains verifyUserToken, replacing the copies of the user token check in WsServer and routes. The WsServer copy accepted a token without iat when the user had no tokenValidAfter; the shared check always requires iat, which jsonwebtoken adds by default. Follows Cooldude2606's comment on #1161. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
462 lines
19 KiB
JavaScript
462 lines
19 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import jwt from "jsonwebtoken";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
import { Controller, HostPairingManager, HostPairingError } from "@clusterio/controller";
|
|
import {
|
|
pollTimeoutMs, maxPendingPerAddress, maxNameLength,
|
|
} from "@clusterio/controller/dist/node/src/HostPairingManager.js";
|
|
|
|
import "../setup_logging.js";
|
|
|
|
const codePattern = /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/;
|
|
|
|
function makeController() {
|
|
const config = new lib.ControllerConfig("controller");
|
|
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
|
|
return new Controller(lib.logger, [], config);
|
|
}
|
|
|
|
/**
|
|
* Make crypto.randomInt return the given values before falling back to random ones
|
|
*
|
|
* @param {number[]} values - Values to return, in order.
|
|
* @returns {() => void} function restoring crypto.randomInt.
|
|
*/
|
|
function stubRandomInt(values) {
|
|
const original = crypto.randomInt;
|
|
crypto.randomInt = (...args) => (values.length ? values.shift() : original(...args));
|
|
return () => { crypto.randomInt = original; };
|
|
}
|
|
|
|
function assertPairingError(fn, status, message) {
|
|
assert.throws(fn, err => {
|
|
assert(err instanceof HostPairingError, `expected HostPairingError, got ${err}`);
|
|
assert.equal(err.status, status);
|
|
if (message) {
|
|
assert.equal(err.message, message);
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
describe("controller/src/HostPairingManager", function() {
|
|
|
|
describe("HostPairingManager.validateName()", function() {
|
|
it("should require a non-empty string", function() {
|
|
for (const name of [undefined, null, 5, {}, "", " "]) {
|
|
assertPairingError(() => HostPairingManager.validateName(name), 400, "Host name is required");
|
|
}
|
|
});
|
|
it("should trim the name", function() {
|
|
assert.equal(HostPairingManager.validateName(" Host A \t"), "Host A");
|
|
});
|
|
it("should limit the length of the name", function() {
|
|
const longest = "x".repeat(maxNameLength);
|
|
assert.equal(maxNameLength, 100);
|
|
assert.equal(HostPairingManager.validateName(longest), longest);
|
|
assert.equal(HostPairingManager.validateName(` ${longest} `), longest);
|
|
assertPairingError(
|
|
() => HostPairingManager.validateName(`${longest}x`),
|
|
400, "Host name must be at most 100 characters",
|
|
);
|
|
});
|
|
it("should refuse control and bidirectional override characters", function() {
|
|
for (const name of ["x\u001b]0;title\u0007", "line\nfaked", "a\rb", "del\u007f", "c1\u009b", "x\u202ey"]) {
|
|
assertPairingError(
|
|
() => HostPairingManager.validateName(name), 400, "Host name may not contain control characters",
|
|
);
|
|
}
|
|
});
|
|
it("should allow other unicode characters", function() {
|
|
for (const name of ["Fabrikk p\u00e5 \u00d8stlandet", "\u5de5\u5382 1", "box \u{1F44D}\u200d"]) {
|
|
assert.equal(HostPairingManager.validateName(name), name);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("class HostPairingManager", function() {
|
|
/** @type {Controller} */
|
|
let controller;
|
|
/** @type {HostPairingManager} */
|
|
let manager;
|
|
let updates;
|
|
let restoreRandomInt;
|
|
|
|
beforeEach(function() {
|
|
controller = makeController();
|
|
manager = controller.hostPairing;
|
|
updates = [];
|
|
manager.records.on("update", changed => {
|
|
updates.push(...changed.map(details => ({ code: details.code, isDeleted: details.isDeleted })));
|
|
});
|
|
restoreRandomInt = undefined;
|
|
});
|
|
afterEach(function() {
|
|
manager.stop();
|
|
if (restoreRandomInt) {
|
|
restoreRandomInt();
|
|
}
|
|
});
|
|
|
|
function internal(requestId) {
|
|
return manager._bySecret.get(requestId);
|
|
}
|
|
|
|
// Pretend the host last polled the request ms milliseconds earlier.
|
|
function idle(requestId, ms) {
|
|
internal(requestId).lastPollMs -= ms;
|
|
}
|
|
|
|
describe(".create()", function() {
|
|
it("should create a pending request", function() {
|
|
const before = Date.now();
|
|
const { requestId, code } = manager.create("Host A", "2.0.0", "10.0.0.1");
|
|
|
|
assert.equal(typeof requestId, "string");
|
|
assert(requestId.length >= 32, "requestId is too short to be a secret");
|
|
assert.match(code, codePattern);
|
|
assert.notEqual(requestId, code);
|
|
|
|
const details = manager.records.get(code);
|
|
assert(details instanceof lib.HostPairingDetails);
|
|
assert.equal(details.code, code);
|
|
assert.equal(details.name, "Host A");
|
|
assert.equal(details.version, "2.0.0");
|
|
assert.equal(details.remoteAddress, "10.0.0.1");
|
|
assert(details.createdAtMs >= before && details.createdAtMs <= Date.now());
|
|
assert(details.updatedAtMs > 0);
|
|
assert.equal(details.isDeleted, false);
|
|
assert(!JSON.stringify(details).includes(requestId), "requestId is exposed to users");
|
|
assert.deepEqual(updates, [{ code, isDeleted: false }]);
|
|
});
|
|
it("should create unique codes", function() {
|
|
const codes = new Set();
|
|
for (let i = 0; i < 50; i++) {
|
|
const { code } = manager.create(`Host ${i}`, "", `10.1.${i}.1`);
|
|
assert.match(code, codePattern);
|
|
codes.add(code);
|
|
}
|
|
assert.equal(codes.size, 50);
|
|
});
|
|
it("should not reuse the code of a pending request", function() {
|
|
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
|
|
assert.equal(manager.create("Host A", "", "10.0.0.1").code, "222-222");
|
|
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
|
|
});
|
|
it("should not reuse the code of an approved request not yet picked up", function() {
|
|
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 1234, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
|
|
const { code } = manager.create("Host A", "", "10.0.0.1");
|
|
assert.equal(code, "222-222");
|
|
assert.equal(manager.approve(code, undefined, "admin"), 1234);
|
|
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
|
|
});
|
|
});
|
|
|
|
describe("limits", function() {
|
|
it("should limit pending requests per address", function() {
|
|
assert.equal(maxPendingPerAddress, 3);
|
|
for (let i = 0; i < maxPendingPerAddress; i++) {
|
|
manager.create("Host", "", "10.0.0.1");
|
|
}
|
|
assert.throws(() => manager.create("Host", "", "10.0.0.1"), err => {
|
|
assert(err instanceof HostPairingError);
|
|
assert.equal(err.status, 429);
|
|
assert.equal(err.retryAfter, 30);
|
|
assert.equal(err.message, "Too many pending requests from this address");
|
|
return true;
|
|
});
|
|
// Other addresses are not affected
|
|
manager.create("Host", "", "10.0.0.2");
|
|
assert.equal(manager.records.size, maxPendingPerAddress + 1);
|
|
});
|
|
it("should count IPv4 mapped addresses as the IPv4 address", function() {
|
|
manager.create("Host", "", "10.0.0.1");
|
|
manager.create("Host", "", "::ffff:10.0.0.1");
|
|
manager.create("Host", "", "10.0.0.1");
|
|
assertPairingError(() => manager.create("Host", "", "::ffff:10.0.0.1"), 429);
|
|
});
|
|
it("should group IPv6 addresses by /64", function() {
|
|
manager.create("Host", "", "2001:db8:0:1::1");
|
|
manager.create("Host", "", "2001:db8:0:1::2");
|
|
manager.create("Host", "", "2001:db8::1:ffff:0:0:3");
|
|
assertPairingError(
|
|
() => manager.create("Host", "", "2001:0db8:0000:0001:0000:0000:0000:0004"),
|
|
429, "Too many pending requests from this address",
|
|
);
|
|
manager.create("Host", "", "2001:db8:0:2::1");
|
|
});
|
|
it("should free the slot when a request is no longer pending", function() {
|
|
const first = manager.create("Host", "", "10.0.0.1");
|
|
const second = manager.create("Host", "", "10.0.0.1");
|
|
const third = manager.create("Host", "", "10.0.0.1");
|
|
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
|
|
|
|
manager.cancel(first.requestId);
|
|
const fourth = manager.create("Host", "", "10.0.0.1");
|
|
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
|
|
|
|
// Approved and rejected results waiting to be picked up do not count
|
|
manager.approve(second.code, undefined, "admin");
|
|
manager.reject(third.code, "admin");
|
|
manager.create("Host", "", "10.0.0.1");
|
|
manager.create("Host", "", "10.0.0.1");
|
|
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
|
|
assert(manager.records.has(fourth.code));
|
|
});
|
|
it("should limit the total number of pending requests", function() {
|
|
const maxPending = controller.config.get("controller.host_pairing_max_pending");
|
|
assert.equal(maxPending, 50);
|
|
for (let i = 0; i < maxPending; i++) {
|
|
manager.create(`Host ${i}`, "", `10.1.${i}.1`);
|
|
}
|
|
assert.throws(() => manager.create("Host", "", "10.2.0.1"), err => {
|
|
assert(err instanceof HostPairingError);
|
|
assert.equal(err.status, 429);
|
|
assert.equal(err.retryAfter, 30);
|
|
assert.equal(err.message, "Too many pending requests");
|
|
return true;
|
|
});
|
|
assert.equal(manager.records.size, maxPending);
|
|
});
|
|
it("should not limit the total number of pending requests when set to 0", function() {
|
|
controller.config.set("controller.host_pairing_max_pending", 0);
|
|
for (let i = 0; i < 60; i++) {
|
|
manager.create(`Host ${i}`, "", `10.1.${i}.1`);
|
|
}
|
|
assert.equal(manager.records.size, 60);
|
|
});
|
|
});
|
|
|
|
describe(".poll()", function() {
|
|
it("should return null while pending", function() {
|
|
const { requestId } = manager.create("Host A", "", "10.0.0.1");
|
|
assert.equal(manager.poll(requestId), null);
|
|
assert.equal(manager.poll(requestId), null);
|
|
});
|
|
it("should throw 404 for an unknown request", function() {
|
|
assertPairingError(() => manager.poll("invalid"), 404, "Request not found");
|
|
});
|
|
it("should not accept the code in place of the request id", function() {
|
|
const { code } = manager.create("Host A", "", "10.0.0.1");
|
|
assertPairingError(() => manager.poll(code), 404);
|
|
});
|
|
it("should hand over an approved result once", function() {
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
const hostId = manager.approve(code, undefined, "admin");
|
|
const result = manager.poll(requestId);
|
|
assert.deepEqual(Object.keys(result).sort(), ["hostId", "name", "token"]);
|
|
assert.equal(result.hostId, hostId);
|
|
assert.equal(result.name, "Host A");
|
|
const payload = jwt.verify(result.token, controller.authSecret, { audience: "host" });
|
|
assert.equal(payload.host, hostId);
|
|
|
|
assertPairingError(() => manager.poll(requestId), 404, "Request not found");
|
|
assert.equal(manager._byCode.has(code), false);
|
|
});
|
|
it("should report a rejection once", function() {
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
manager.reject(code, "admin");
|
|
assertPairingError(() => manager.poll(requestId), 403, "Request was rejected");
|
|
assertPairingError(() => manager.poll(requestId), 404, "Request not found");
|
|
});
|
|
});
|
|
|
|
describe(".cancel()", function() {
|
|
it("should remove a pending request", function() {
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
manager.cancel(requestId);
|
|
assert.equal(manager.records.has(code), false);
|
|
assertPairingError(() => manager.poll(requestId), 404);
|
|
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
|
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
|
|
});
|
|
it("should ignore unknown requests", function() {
|
|
manager.cancel("invalid");
|
|
});
|
|
it("should not throw away an approved result", function() {
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
const hostId = manager.approve(code, undefined, "admin");
|
|
manager.cancel(requestId);
|
|
assert.equal(manager.poll(requestId).hostId, hostId);
|
|
});
|
|
it("should not throw away a rejection", function() {
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
manager.reject(code, "admin");
|
|
manager.cancel(requestId);
|
|
assertPairingError(() => manager.poll(requestId), 403);
|
|
});
|
|
});
|
|
|
|
describe(".approve()", function() {
|
|
it("should create the host with the requested name", function() {
|
|
const { code } = manager.create("Host A", "", "10.0.0.1");
|
|
const hostId = manager.approve(code, undefined, "admin");
|
|
assert.equal(controller.hosts.get(hostId).name, "Host A");
|
|
assert.equal(manager.records.has(code), false);
|
|
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
|
});
|
|
it("should accept codes in any case and without the dash", function() {
|
|
const { code } = manager.create("Host A", "", "10.0.0.1");
|
|
const hostId = manager.approve(code.toLowerCase().replace("-", ""), undefined, "admin");
|
|
assert(controller.hosts.has(hostId));
|
|
});
|
|
it("should use and trim the name given", function() {
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
const hostId = manager.approve(code, " Renamed ", "admin");
|
|
assert.equal(controller.hosts.get(hostId).name, "Renamed");
|
|
assert.equal(manager.poll(requestId).name, "Renamed");
|
|
});
|
|
it("should reject an invalid name and keep the request pending", function() {
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
assert.throws(
|
|
() => manager.approve(code, "", "admin"),
|
|
new lib.ValidationError("Host name is required"),
|
|
);
|
|
assert.throws(
|
|
() => manager.approve(code, "x".repeat(maxNameLength + 1), "admin"),
|
|
new lib.ValidationError("Host name must be at most 100 characters"),
|
|
);
|
|
assert.equal(controller.hosts.records.size, 0);
|
|
assert.equal(manager.poll(requestId), null);
|
|
manager.approve(code, undefined, "admin");
|
|
assert.equal(controller.hosts.records.size, 1);
|
|
});
|
|
it("should throw NotFoundError for an unknown code", function() {
|
|
assert.throws(
|
|
() => manager.approve("ZZZ-ZZZ", undefined, "admin"),
|
|
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
|
|
);
|
|
});
|
|
it("should only approve a request once", function() {
|
|
const { code } = manager.create("Host A", "", "10.0.0.1");
|
|
manager.approve(code, undefined, "admin");
|
|
assert.throws(() => manager.approve(code, undefined, "admin"), lib.NotFoundError);
|
|
assert.equal(controller.hosts.records.size, 1);
|
|
});
|
|
});
|
|
|
|
describe(".reject()", function() {
|
|
it("should remove the request from the pending list", function() {
|
|
const { code } = manager.create("Host A", "", "10.0.0.1");
|
|
manager.reject(code.toLowerCase(), "admin");
|
|
assert.equal(manager.records.has(code), false);
|
|
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
|
assert.equal(controller.hosts.records.size, 0);
|
|
});
|
|
it("should throw NotFoundError for an unknown code", function() {
|
|
assert.throws(
|
|
() => manager.reject("ZZZ-ZZZ", "admin"),
|
|
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
|
|
);
|
|
});
|
|
it("should not reject an approved request", function() {
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
const hostId = manager.approve(code, undefined, "admin");
|
|
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
|
|
assert.equal(manager.poll(requestId).hostId, hostId);
|
|
});
|
|
});
|
|
|
|
describe(".prune()", function() {
|
|
let warnings;
|
|
let originalWarn;
|
|
beforeEach(function() {
|
|
warnings = [];
|
|
originalWarn = lib.logger.warn;
|
|
lib.logger.warn = msg => { warnings.push(msg); };
|
|
});
|
|
afterEach(function() {
|
|
lib.logger.warn = originalWarn;
|
|
});
|
|
|
|
it("should expire a request the host stopped polling", function() {
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
idle(requestId, pollTimeoutMs + 1);
|
|
manager.prune();
|
|
assert.equal(manager.records.has(code), false);
|
|
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
|
assertPairingError(() => manager.poll(requestId), 404);
|
|
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
|
|
});
|
|
it("should keep a request that is still polled", function() {
|
|
assert.equal(pollTimeoutMs, 30e3);
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
idle(requestId, pollTimeoutMs - 1000);
|
|
manager.prune();
|
|
assert.equal(manager.poll(requestId), null);
|
|
idle(requestId, pollTimeoutMs - 1000);
|
|
manager.prune();
|
|
assert(manager.records.has(code));
|
|
});
|
|
it("should expire a request that has been pending for too long", function() {
|
|
const timeoutMs = controller.config.get("controller.host_pairing_timeout") * 1000;
|
|
assert.equal(timeoutMs, 60 * 60e3);
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
internal(requestId).details.createdAtMs -= timeoutMs - 1000;
|
|
manager.prune();
|
|
assert.equal(manager.poll(requestId), null);
|
|
internal(requestId).details.createdAtMs -= 1001;
|
|
manager.prune();
|
|
assert.equal(manager.records.has(code), false);
|
|
assertPairingError(() => manager.poll(requestId), 404);
|
|
});
|
|
it("should not limit how long a request is pending when the timeout is 0", function() {
|
|
controller.config.set("controller.host_pairing_timeout", 0);
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
internal(requestId).details.createdAtMs -= 365 * 24 * 60 * 60e3;
|
|
manager.prune();
|
|
assert(manager.records.has(code));
|
|
assert.equal(manager.poll(requestId), null);
|
|
});
|
|
it("should drop and warn about an approved host never picked up", function() {
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
const hostId = manager.approve(code, undefined, "admin");
|
|
idle(requestId, pollTimeoutMs + 1);
|
|
manager.prune();
|
|
assertPairingError(() => manager.poll(requestId), 404);
|
|
assert.equal(warnings.length, 1);
|
|
assert.match(warnings[0], new RegExp(`Approved host Host A \\(${hostId}\\) was never picked up`));
|
|
// The host stays until revoked
|
|
assert(controller.hosts.has(hostId));
|
|
});
|
|
it("should drop a rejection never picked up", function() {
|
|
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
|
manager.reject(code, "admin");
|
|
idle(requestId, pollTimeoutMs + 1);
|
|
manager.prune();
|
|
assertPairingError(() => manager.poll(requestId), 404);
|
|
assert.equal(warnings.length, 0);
|
|
});
|
|
});
|
|
|
|
describe("Controller integration", function() {
|
|
it("should broadcast changes to pending requests", function() {
|
|
const broadcasts = [];
|
|
controller.subscriptions.broadcast = event => {
|
|
if (event instanceof lib.HostPairingUpdatesEvent) {
|
|
broadcasts.push(event.updates.map(details => [details.code, details.isDeleted]));
|
|
}
|
|
};
|
|
const { code } = manager.create("Host A", "", "10.0.0.1");
|
|
manager.reject(code, "admin");
|
|
assert.deepEqual(broadcasts, [[[code, false]], [[code, true]]]);
|
|
});
|
|
it("should send pending requests to new subscribers", async function() {
|
|
const { code } = manager.create("Host A", "", "10.0.0.1");
|
|
const event = await controller.handleHostPairingSubscription(
|
|
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, 0)
|
|
);
|
|
assert(event instanceof lib.HostPairingUpdatesEvent);
|
|
assert.deepEqual(event.updates.map(details => details.code), [code]);
|
|
|
|
const lastUpdate = manager.records.get(code).updatedAtMs;
|
|
assert.equal(await controller.handleHostPairingSubscription(
|
|
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, lastUpdate)
|
|
), null);
|
|
});
|
|
});
|
|
});
|
|
});
|