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.
This commit is contained in:
Danielv123 committed 2026-10-30 06:11:32 +01:00
1 parent fa2883d74e
commit 87d4f5c64e
49 files changed
+4855 -473

No files matched your search

+213 -1
View File
@@ -1,8 +1,11 @@
import assert from "node:assert/strict";
import events from "node:events";
import * as lib from "@clusterio/lib";
import { Controller, ControlConnection, ControllerHooks, InstanceManager } from "@clusterio/controller";
import {
Controller, ControlConnection, ControllerHooks, HostConnection, HostRecord, InstanceManager, InstanceRecord,
} from "@clusterio/controller";
import { MockConnector, MockController, MockLogger } from "../mock.js";
import "../setup_logging.js";
const addr = lib.Address.fromShorthand;
@@ -272,4 +275,213 @@ describe("controller/src/ControlConnection", function() {
assert.equal(player.connector.terminated, true);
});
});
describe("host management", function() {
/** @type {Controller} */
let controller;
/** @type {ControlConnection} */
let connection;
let audits;
let originalAudit;
beforeEach(function() {
const config = new lib.ControllerConfig("controller");
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
controller = new Controller(lib.logger, [], config);
const connector = new lib.VirtualConnector(addr("controller"), addr({ controlId: 1 }));
const user = controller.users.getOrCreateUser("admin");
connection = new ControlConnection({ version: "2.0.0" }, connector, controller, user, 1);
audits = [];
originalAudit = lib.logger.audit;
lib.logger.audit = msg => { audits.push(msg); };
});
afterEach(function() {
lib.logger.audit = originalAudit;
});
it("registers the host pairing and revoke handlers", function() {
for (const Request of [
lib.HostRevokeRequest,
lib.HostPairingListRequest,
lib.HostPairingApproveRequest,
lib.HostPairingRejectRequest,
]) {
assert(connection._requestHandlers.has(Request), `${Request.name} is not handled`);
}
});
describe(".handleHostRevokeRequest()", function() {
let record;
let broadcasts;
beforeEach(function() {
record = new HostRecord(10, "host-10", "", new Map());
controller.hosts.set(record);
broadcasts = [];
controller.subscriptions.broadcast = event => {
if (event instanceof lib.HostUpdatesEvent) {
broadcasts.push(...event.updates.map(host => ({ id: host.id, isDeleted: host.isDeleted })));
}
};
});
function assignInstance(id, name, hostId) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.set("instance.id", id);
instanceConfig.set("instance.name", name);
instanceConfig.set("instance.assigned_host", hostId);
controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped"));
}
async function revoke(hostId = 10) {
await connection.handleHostRevokeRequest(new lib.HostRevokeRequest(hostId));
}
it("deletes and saves the host record", async function() {
let savedWithoutHost;
const originalSave = controller.hosts.save;
controller.hosts.save = async function() {
savedWithoutHost = !this.has(10);
return await originalSave.call(this);
};
await revoke();
assert.equal(controller.hosts.has(10), false);
assert.equal(savedWithoutHost, true, "hosts were not saved after deleting the record");
assert.deepEqual(broadcasts, [{ id: 10, isDeleted: true }]);
assert.equal(audits.length, 1);
assert.match(audits[0], /User admin revoked host host-10 \(10\)/);
});
it("deletes the note of the host", async function() {
controller.setNote("host", "10", "rack 3", "someone");
controller.setNote("host", "11", "rack 4", "someone");
await revoke();
const note = controller.notes.get("host/10");
assert.equal(note.isDeleted, true);
assert.equal(note.content, "");
assert.equal(note.updatedBy, "admin");
assert.equal(controller.notes.get("host/11").isDeleted, false);
});
it("terminates the connection of the host", async function() {
let terminated = false;
controller.wsServer.hostConnections.set(10, { connector: { terminate() { terminated = true; } } });
await revoke();
assert.equal(terminated, true);
});
it("terminates the connection of the host when saving fails", async function() {
let terminated = false;
controller.wsServer.hostConnections.set(10, { connector: { terminate() { terminated = true; } } });
controller.hosts.save = async () => { throw new Error("EPERM: operation not permitted"); };
await assert.rejects(revoke(), /EPERM/);
assert.equal(terminated, true, "revoked host kept its connection");
assert.equal(controller.hosts.has(10), false);
assert.equal(controller.notes.get("host/10"), undefined);
});
it("does not leave a host record behind when the connection closes", async function() {
const connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
connector.terminate = () => { connector.emit("close"); };
const hostConnection = new HostConnection(
new lib.RegisterHostData("", "2.0.0", 10, {}), connector, controller, "10.0.0.1"
);
controller.wsServer.hostConnections.set(10, hostConnection);
assert.equal(controller.hosts.get(10).connected, true);
await revoke();
assert.equal(controller.hosts.has(10), false, "host record was recreated");
assert.deepEqual(broadcasts.at(-1), { id: 10, isDeleted: true });
});
it("refuses to revoke a host with assigned instances", async function() {
controller.setNote("host", "10", "rack 3", "someone");
let terminated = false;
controller.wsServer.hostConnections.set(10, { connector: { terminate() { terminated = true; } } });
assignInstance(101, "alpha", 10);
assignInstance(102, "beta", 10);
assignInstance(201, "gamma", 20);
await assert.rejects(
revoke(),
new lib.ConflictError("Cannot revoke host host-10 while instances are assigned to it: alpha, beta"),
);
assert.equal(controller.hosts.get(10), record);
assert.equal(controller.notes.get("host/10").isDeleted, false);
assert.equal(terminated, false);
assert.deepEqual(broadcasts, []);
});
it("ignores instances assigned to other hosts", async function() {
assignInstance(201, "gamma", 20);
await revoke();
assert.equal(controller.hosts.has(10), false);
});
it("throws NotFoundError for an unknown host", async function() {
await assert.rejects(revoke(99), new lib.NotFoundError("Unknown host id (99)"));
assert.equal(controller.hosts.has(10), true);
});
});
describe("host pairing requests", function() {
it("lists pending requests", async function() {
const first = controller.hostPairing.create("Host A", "2.0.0", "10.0.0.1");
const second = controller.hostPairing.create("Host B", "2.0.0", "10.0.0.2");
const list = await connection.handleHostPairingListRequest(new lib.HostPairingListRequest());
assert(list.every(details => details instanceof lib.HostPairingDetails));
assert.deepEqual(list.map(details => details.code).sort(), [first.code, second.code].sort());
assert.equal(list.find(details => details.code === first.code).name, "Host A");
});
it("leaves out expired, approved and rejected requests when listing", async function() {
const expired = controller.hostPairing.create("Host A", "", "10.0.0.1");
const approved = controller.hostPairing.create("Host B", "", "10.0.0.2");
const rejected = controller.hostPairing.create("Host C", "", "10.0.0.3");
const pending = controller.hostPairing.create("Host D", "", "10.0.0.4");
controller.hostPairing._bySecret.get(expired.requestId).lastPollMs -= 60e3;
await controller.hostPairing.approve(approved.code, undefined, "admin");
controller.hostPairing.reject(rejected.code, "admin");
const list = await connection.handleHostPairingListRequest(new lib.HostPairingListRequest());
assert.deepEqual(list.map(details => details.code), [pending.code]);
});
it("approves a request and returns the new host id", async function() {
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
const hostId = await connection.handleHostPairingApproveRequest(
new lib.HostPairingApproveRequest(code.toLowerCase().replace("-", ""))
);
assert.equal(typeof hostId, "number");
assert.equal(controller.hosts.get(hostId).name, "Host A");
assert.equal(controller.hostPairing.poll(requestId).hostId, hostId);
assert(audits.some(msg => msg.startsWith(`User admin approved host Host A (${hostId})`)));
});
it("approves a request with a different name", async function() {
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
const hostId = await connection.handleHostPairingApproveRequest(
new lib.HostPairingApproveRequest(code, "Renamed")
);
assert.equal(controller.hosts.get(hostId).name, "Renamed");
assert.equal(controller.hostPairing.poll(requestId).name, "Renamed");
});
it("rejects approving an unknown code or with an invalid name", async function() {
await assert.rejects(
connection.handleHostPairingApproveRequest(new lib.HostPairingApproveRequest("ZZZ-ZZZ")),
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
);
const { code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
await assert.rejects(
connection.handleHostPairingApproveRequest(new lib.HostPairingApproveRequest(code, " ")),
new lib.ValidationError("Host name is required"),
);
assert.equal(controller.hosts.size, 0);
});
it("rejects a request", async function() {
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
await connection.handleHostPairingRejectRequest(new lib.HostPairingRejectRequest(code.toLowerCase()));
assert.equal(controller.hostPairing.requests.has(code), false);
assert.throws(() => controller.hostPairing.poll(requestId), /Request was rejected/);
assert.equal(controller.hosts.size, 0);
assert(audits.some(msg => msg.startsWith(`User admin rejected host Host A with code ${code}`)));
});
it("throws NotFoundError when rejecting an unknown code", async function() {
await assert.rejects(
connection.handleHostPairingRejectRequest(new lib.HostPairingRejectRequest("ZZZ-ZZZ")),
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
);
});
});
});
});
+97 -1
View File
@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import { HostConnection } from "@clusterio/controller";
import { Controller, HostConnection, HostRecord } from "@clusterio/controller";
import { MockConnector } from "../mock.js";
const addr = lib.Address.fromShorthand;
function makeSave(instanceId, name) {
return new lib.SaveDetails(instanceId, "file", name, 0, 0, false, false, 0, false);
@@ -40,6 +43,99 @@ function makeInstances(assignments, names = new Map()) {
}
describe("controller/src/HostConnection", function() {
describe("host record", function() {
/** @type {Controller} */
let controller;
let record;
let connector;
let updates;
beforeEach(function() {
controller = new Controller(lib.logger, [], new lib.ControllerConfig("controller"));
record = new HostRecord(10, "host-10", "1.0.0", new Map(), false, "", "203.0.113.1", 123);
controller.hosts.set(record);
connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
updates = [];
controller.hosts.on("update", changed => {
updates.push(...changed.map(host => ({ id: host.id, name: host.name, isDeleted: host.isDeleted })));
});
});
function connect(id = 10) {
return new HostConnection(
new lib.RegisterHostData("", "2.0.0", id, {}), connector, controller, "10.0.0.1"
);
}
function revoke() {
controller.hosts.delete(record);
updates.length = 0;
}
it("should refuse a host without a record", function() {
assert.throws(() => connect(11), /Host 11 is not registered/);
assert.equal(controller.hosts.has(11), false);
assert.deepEqual(updates, []);
});
it("should update the existing record on connect", function() {
const connection = connect();
assert.equal(controller.hosts.get(10), record, "host record was replaced");
assert.equal(connection.id, 10);
assert.equal(record.connected, true);
assert.equal(record.version, "2.0.0");
assert.equal(record.remoteAddress, "10.0.0.1");
assert.equal(record.name, "host-10");
assert.equal(record.publicAddress, "203.0.113.1");
assert.equal(record.tokenValidAfter, 123);
assert.deepEqual(updates, [{ id: 10, name: "host-10", isDeleted: false }]);
});
it("should mark the host disconnected when the connection closes", function() {
connect();
updates.length = 0;
connector.emit("close");
assert.equal(controller.hosts.get(10), record);
assert.equal(record.connected, false);
assert.deepEqual(updates, [{ id: 10, name: "host-10", isDeleted: false }]);
});
it("should save info updates from the host", async function() {
const connection = connect();
updates.length = 0;
await connection.handleHostInfoUpdateEvent(
new lib.HostInfoUpdateEvent(new lib.HostInfoUpdate("renamed", "198.51.100.1"))
);
assert.equal(controller.hosts.get(10).name, "renamed");
assert.equal(controller.hosts.get(10).publicAddress, "198.51.100.1");
assert.deepEqual(updates, [{ id: 10, name: "renamed", isDeleted: false }]);
});
it("should not recreate the record of a revoked host when the connection closes", function() {
connect();
revoke();
connector.emit("close");
assert.equal(controller.hosts.has(10), false, "host record was recreated");
assert.deepEqual(updates, []);
});
it("should not recreate the record of a revoked host on info updates", async function() {
const connection = connect();
revoke();
await connection.handleHostInfoUpdateEvent(
new lib.HostInfoUpdateEvent(new lib.HostInfoUpdate("renamed", "198.51.100.1"))
);
assert.equal(controller.hosts.has(10), false, "host record was recreated");
assert.deepEqual(updates, []);
});
it("should not overwrite a newer record with the same id", function() {
connect();
revoke();
const newer = new HostRecord(10, "newer", "", new Map());
controller.hosts.set(newer);
updates.length = 0;
connector.emit("close");
assert.equal(controller.hosts.get(10), newer);
assert.equal(newer.name, "newer");
assert.deepEqual(updates, []);
});
});
describe(".handleInstanceSaveDetailsUpdatesEvent()", function() {
// Instance 1 is on host 10, instance 2 is on host 20.
const assignments = new Map([[1, 10], [2, 20]]);
+648
View File
@@ -0,0 +1,648 @@
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, HostRecord } from "@clusterio/controller";
import {
addressKey, normaliseCode, pollTimeoutMs, maxPendingMs, maxPending, maxPendingPerAddress,
maxCreatesPerMinute, 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 deferred() {
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
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("addressKey()", function() {
it("should return IPv4 addresses unchanged", function() {
assert.equal(addressKey("10.0.0.1"), "10.0.0.1");
});
it("should strip the IPv4 mapped prefix", function() {
assert.equal(addressKey("::ffff:10.0.0.1"), "10.0.0.1");
});
it("should group IPv6 addresses by their /64", function() {
assert.equal(addressKey("2001:db8:0:1::5"), "2001:db8:0:1::/64");
assert.equal(addressKey("2001:db8::1:ffff:0:0:3"), "2001:db8:0:1::/64");
assert.equal(addressKey("2001:0DB8:0000:0001:aaaa:bbbb:cccc:dddd"), "2001:db8:0:1::/64");
assert.equal(addressKey("2001:db8:0:2::5"), "2001:db8:0:2::/64");
});
it("should expand compressed groups at the start and end", function() {
assert.equal(addressKey("::1"), "0:0:0:0::/64");
assert.equal(addressKey("::"), "0:0:0:0::/64");
assert.equal(addressKey("fe80::1"), "fe80:0:0:0::/64");
assert.equal(addressKey("2001:db8::"), "2001:db8:0:0::/64");
});
it("should return other values unchanged", function() {
assert.equal(addressKey(""), "");
assert.equal(addressKey("not an address"), "not an address");
assert.equal(addressKey("::ffff:not-ipv4"), "::ffff:not-ipv4");
});
});
describe("normaliseCode()", function() {
it("should format codes as shown by the installer", function() {
assert.equal(normaliseCode("ABC-DEF"), "ABC-DEF");
assert.equal(normaliseCode("abcdef"), "ABC-DEF");
assert.equal(normaliseCode("abc-def"), "ABC-DEF");
assert.equal(normaliseCode(" abc def "), "ABC-DEF");
assert.equal(normaliseCode("a.b_c-d e/f"), "ABC-DEF");
});
});
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.requests.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 installer last polled the request ms milliseconds earlier.
function idle(requestId, ms) {
internal(requestId).lastPollMs -= ms;
}
describe(".createHost()", function() {
it("should create a host record with a valid token", async function() {
const before = Math.floor(Date.now() / 1000);
const result = await manager.createHost("Host A");
assert.equal(result.name, "Host A");
assert(Number.isInteger(result.hostId), "hostId is not an integer");
assert(result.hostId >= 1 && result.hostId < 2 ** 31, `hostId ${result.hostId} out of range`);
const record = controller.hosts.get(result.hostId);
assert(record instanceof HostRecord, "host record was not created");
assert.equal(record.name, "Host A");
assert.equal(record.connected, false);
assert(record.tokenValidAfter >= before, "tokenValidAfter predates the call");
assert(record.tokenValidAfter <= Math.floor(Date.now() / 1000), "tokenValidAfter is in the future");
const payload = jwt.verify(result.token, controller.authSecret, { audience: "host" });
assert.equal(payload.host, result.hostId);
assert(payload.iat >= record.tokenValidAfter, "token is not valid for the new record");
});
it("should save the hosts before returning", async function() {
const save = deferred();
let saveCalls = 0;
controller.hosts.save = () => { saveCalls += 1; return save.promise; };
let done = false;
const promise = manager.createHost("Host A").then(result => { done = true; return result; });
await new Promise(resolve => setImmediate(resolve));
assert.equal(saveCalls, 1);
assert.equal(done, false, "createHost returned before the hosts were saved");
save.resolve();
const result = await promise;
assert(controller.hosts.has(result.hostId));
});
it("should remove the record if saving fails", async function() {
restoreRandomInt = stubRandomInt([1234]);
controller.hosts.save = async () => { throw new Error("disk full"); };
await assert.rejects(manager.createHost("Host A"), /disk full/);
assert.equal(controller.hosts.has(1234), false);
assert.equal(controller.hosts.size, 0);
});
it("should not reuse the id of an existing host", async function() {
controller.hosts.set(new HostRecord(5, "existing", "", new Map()));
restoreRandomInt = stubRandomInt([5, 5, 6]);
const result = await manager.createHost("Host A");
assert.equal(result.hostId, 6);
assert.equal(controller.hosts.get(5).name, "existing");
assert.equal(controller.hosts.get(6).name, "Host A");
});
});
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.requests.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 < maxPending; i++) {
const { code } = manager.create(`Host ${i}`, "", `10.1.${i}.1`);
assert.match(code, codePattern);
codes.add(code);
}
assert.equal(codes.size, maxPending);
});
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", async 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(await manager.approve(code, undefined, "admin"), 1234);
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
});
it("should use distinct request ids", function() {
const first = manager.create("Host A", "", "10.0.0.1");
const second = manager.create("Host A", "", "10.0.0.1");
assert.notEqual(first.requestId, second.requestId);
assert.notEqual(first.code, second.code);
});
});
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.requests.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", async 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
await 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.requests.has(fourth.code));
});
it("should limit the total number of pending requests", function() {
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.requests.size, maxPending);
});
it("should limit the number of requests created per minute from an address", function() {
assert.equal(maxCreatesPerMinute, 10);
for (let i = 0; i < maxCreatesPerMinute; i++) {
manager.cancel(manager.create("Host", "", "10.0.0.1").requestId);
}
assert.throws(() => manager.create("Host", "", "10.0.0.1"), err => {
assert(err instanceof HostPairingError);
assert.equal(err.status, 429);
assert.equal(err.message, "Too many requests from this address");
assert(err.retryAfter >= 1 && err.retryAfter <= 60, `bad retryAfter ${err.retryAfter}`);
return true;
});
// IPv6 addresses in the same /64 share the limit
for (let i = 0; i < maxCreatesPerMinute; i++) {
manager.cancel(manager.create("Host", "", `2001:db8:0:1::${i + 1}`).requestId);
}
assertPairingError(
() => manager.create("Host", "", "2001:db8:0:1:ffff::1"), 429, "Too many requests from this address"
);
manager.create("Host", "", "10.0.0.2");
});
it("should allow creating requests again after a minute", function() {
for (let i = 0; i < maxCreatesPerMinute; i++) {
manager.cancel(manager.create("Host", "", "10.0.0.1").requestId);
}
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
for (const times of manager._recentCreates.values()) {
times.forEach((ms, index) => { times[index] = ms - 60e3 - 1; });
}
manager.create("Host", "", "10.0.0.1");
});
it("should not count refused requests towards the rate limit", function() {
const first = manager.create("Host", "", "10.0.0.1");
manager.create("Host", "", "10.0.0.1");
manager.create("Host", "", "10.0.0.1");
const createRefused = () => manager.create("Host", "", "10.0.0.1");
for (let i = 0; i < maxCreatesPerMinute; i++) {
assertPairingError(createRefused, 429);
}
manager.cancel(first.requestId);
manager.create("Host", "", "10.0.0.1");
});
});
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", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await 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.requests.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", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await 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", async function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, undefined, "admin");
assert.equal(controller.hosts.get(hostId).name, "Host A");
assert.equal(manager.requests.has(code), false);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
});
it("should accept codes in any case and without the dash", async function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code.toLowerCase().replace("-", ""), undefined, "admin");
assert(controller.hosts.has(hostId));
});
it("should use and trim the name given", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await 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", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
await assert.rejects(
manager.approve(code, "", "admin"),
new lib.ValidationError("Host name is required"),
);
await assert.rejects(
manager.approve(code, "x".repeat(maxNameLength + 1), "admin"),
new lib.ValidationError("Host name must be at most 100 characters"),
);
assert.equal(controller.hosts.size, 0);
assert.equal(manager.poll(requestId), null);
await manager.approve(code, undefined, "admin");
assert.equal(controller.hosts.size, 1);
});
it("should throw NotFoundError for an unknown code", async function() {
await assert.rejects(
manager.approve("ZZZ-ZZZ", undefined, "admin"),
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
);
});
it("should only approve a request once", async function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
await manager.approve(code, undefined, "admin");
await assert.rejects(manager.approve(code, undefined, "admin"), lib.NotFoundError);
assert.equal(controller.hosts.size, 1);
});
it("should not create two hosts when approved twice at the same time", async function() {
const save = deferred();
controller.hosts.save = () => save.promise;
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const first = manager.approve(code, undefined, "admin");
await assert.rejects(manager.approve(code, undefined, "admin"), lib.NotFoundError);
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
// The request stays while approving, the installer sees it as pending
manager.cancel(requestId);
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assert.equal(manager.poll(requestId), null);
assert(manager.requests.has(code), "request removed while approving");
save.resolve();
const hostId = await first;
assert.equal(controller.hosts.size, 1);
assert.equal(manager.poll(requestId).hostId, hostId);
});
it("should keep the request pending if creating the host fails", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const originalSave = controller.hosts.save;
controller.hosts.save = async () => { throw new Error("disk full"); };
await assert.rejects(manager.approve(code, undefined, "admin"), /disk full/);
assert.equal(controller.hosts.size, 0);
assert(manager.requests.has(code));
assert.equal(manager.poll(requestId), null);
controller.hosts.save = originalSave;
const hostId = await manager.approve(code, undefined, "admin");
assert.equal(manager.poll(requestId).hostId, hostId);
});
});
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.requests.has(code), false);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
assert.equal(controller.hosts.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", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await 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 installer stopped polling", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assert.equal(manager.requests.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.requests.has(code));
});
it("should expire a request that has been pending for too long", function() {
assert.equal(maxPendingMs, 60 * 60e3);
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
internal(requestId).details.createdAtMs -= maxPendingMs - 1000;
manager.prune();
assert.equal(manager.poll(requestId), null);
internal(requestId).details.createdAtMs -= 1001;
manager.prune();
assert.equal(manager.requests.has(code), false);
assertPairingError(() => manager.poll(requestId), 404);
});
it("should expire requests when creating and polling", function() {
const first = manager.create("Host A", "", "10.0.0.1");
idle(first.requestId, pollTimeoutMs + 1);
const second = manager.create("Host B", "", "10.0.0.1");
assert.equal(manager.requests.has(first.code), false);
idle(second.requestId, pollTimeoutMs + 1);
assertPairingError(() => manager.poll(second.requestId), 404);
});
it("should drop and warn about an approved host never picked up", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await 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);
});
it("should forget creation times older than a minute", function() {
manager.create("Host A", "", "10.0.0.1");
assert.equal(manager._recentCreates.size, 1);
manager.prune();
assert.equal(manager._recentCreates.size, 1);
for (const times of manager._recentCreates.values()) {
times.forEach((ms, index) => { times[index] = ms - 60e3 - 1; });
}
manager.prune();
assert.equal(manager._recentCreates.size, 0);
});
});
describe(".start()/.stop()", function() {
it("should run an unreferenced prune interval while started", function() {
manager.start();
assert(manager._pruneInterval, "prune interval not started");
assert.equal(manager._pruneInterval.hasRef(), false);
manager.stop();
assert.equal(manager._pruneInterval, undefined);
});
});
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.requests.get(code).updatedAtMs;
assert.equal(await controller.handleHostPairingSubscription(
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, lastUpdate)
), null);
});
});
});
});
+142 -1
View File
@@ -1,7 +1,32 @@
import assert from "node:assert/strict";
import events from "node:events";
import { Controller, WsServer } from "@clusterio/controller";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import { Controller, HostRecord, WsServer } from "@clusterio/controller";
import { ControllerConfig } from "@clusterio/lib";
import packageJson from "@clusterio/controller/package.json" with { type: "json" };
import "../setup_logging.js";
class MockWebSocket extends events.EventEmitter {
constructor() {
super();
this.sent = [];
this.closed = null;
}
send(message) {
this.sent.push(JSON.parse(message));
}
close(code, reason) {
this.closed = { code, reason };
}
terminate() {
this.emit("close", 1006, "");
}
}
describe("controller/src/WsServer", function() {
describe("class WsServer", function() {
@@ -88,4 +113,120 @@ describe("controller/src/WsServer", function() {
});
});
});
describe(".registerHost()", function() {
const secret = Buffer.from("TestSecretDoNotUse");
const req = { socket: { remoteAddress: "10.0.0.1", remoteFamily: "IPv4" }, headers: {} };
/** @type {Controller} */
let controller;
/** @type {WsServer} */
let wsServer;
let socket;
beforeEach(function() {
const config = new ControllerConfig("controller");
config.set("controller.auth_secret", secret.toString("base64"));
controller = new Controller(lib.logger, [], config);
wsServer = controller.wsServer;
socket = new MockWebSocket();
});
afterEach(function() {
for (const connection of wsServer.hostConnections.values()) {
connection.connector.terminate();
}
});
function addHost(id, tokenValidAfter = 0) {
const record = new HostRecord(id, `host-${id}`, "", new Map(), false, "", "203.0.113.1", tokenValidAfter);
controller.hosts.set(record);
return record;
}
async function register(token, id = 5) {
await wsServer.registerHost(new lib.RegisterHostData(token, packageJson.version, id, {}), socket, req);
}
function assertRejected(reason) {
assert.deepEqual(socket.closed, { code: lib.ConnectionClosed.Unauthorized, reason });
assert.equal(socket.closed.code, 3000);
assert.equal(wsServer.hostConnections.size, 0);
assert(!socket.sent.some(message => message.type === "ready"), "ready was sent");
}
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"
);
assert.equal(controller.hosts.has(5), false, "host record was created");
});
it("should accept a registered host", async function() {
const record = addHost(5);
await register(controller.generateHostToken(5));
assert.equal(socket.closed, null);
assert(socket.sent.some(message => message.type === "ready"), "ready was not sent");
assert(wsServer.hostConnections.has(5));
assert.equal(controller.hosts.size, 1);
assert.equal(controller.hosts.get(5), record, "host record was replaced");
assert.equal(record.connected, true);
assert.equal(record.version, packageJson.version);
assert.equal(record.remoteAddress, "10.0.0.1");
assert.equal(record.name, "host-5");
assert.equal(record.publicAddress, "203.0.113.1");
wsServer.hostConnections.get(5).connector.terminate();
assert.equal(wsServer.hostConnections.has(5), false);
assert.equal(controller.hosts.get(5).connected, false);
});
it("should reject a token issued before tokenValidAfter", async function() {
const token = controller.generateHostToken(5);
addHost(5, Math.floor(Date.now() / 1000) + 60);
await register(token);
assertRejected("Authentication failed: invalid token");
});
it("should accept a token issued at tokenValidAfter", async function() {
const now = Math.floor(Date.now() / 1000);
addHost(5, now);
await register(jwt.sign({ aud: "host", host: 5, iat: now }, secret));
assert.equal(socket.closed, null);
assert(wsServer.hostConnections.has(5));
});
it("should reject a token without an issue time", async function() {
addHost(5);
await register(jwt.sign({ aud: "host", host: 5 }, secret, { noTimestamp: true }));
assertRejected("Authentication failed: invalid token");
});
it("should reject a token for another host", async function() {
addHost(5);
addHost(6);
await register(controller.generateHostToken(6), 5);
assertRejected("Authentication failed: missmatched host id");
});
it("should reject a token signed with another secret", async function() {
addHost(5);
await register(jwt.sign({ aud: "host", host: 5 }, Buffer.from("OtherSecret")));
assertRejected("Authentication failed: invalid signature");
});
it("should reject a host revoked while its previous connection closes", async function() {
const record = addHost(5);
wsServer.hostConnections.set(5, {
remoteAddress: "10.0.0.1",
async disconnect() {
controller.hosts.delete(record);
wsServer.hostConnections.delete(5);
},
});
await register(controller.generateHostToken(5));
assertRejected("Authentication failed: host was revoked");
assert.equal(controller.hosts.has(5), false, "host record was recreated");
});
it("should reject hosts in recovery mode", async function() {
addHost(5);
controller.recoveryMode = true;
await register(controller.generateHostToken(5));
assert.deepEqual(socket.closed, {
code: lib.ConnectionClosed.RecoveryMode,
reason: "Connection Refused: Controller in recovery mode",
});
});
});
});
+324 -1
View File
@@ -3,7 +3,10 @@ import events from "node:events";
import FormData from "form-data";
import http from "node:http";
import { wait, expositionContentType } from "@clusterio/lib";
import jwt from "jsonwebtoken";
import { SubscribableDatastore, wait, expositionContentType } from "@clusterio/lib";
import { Controller, HostPairingManager, HostRecord } from "@clusterio/controller";
import * as routes from "@clusterio/controller/dist/node/src/routes.js";
import * as mock from "../mock.js";
@@ -211,6 +214,326 @@ describe("controller/src/routes", function() {
assert.deepEqual(data, { name: "Test Cluster" });
});
});
describe("/api/host-pairing", function() {
let endpoint, adminToken, playerToken;
beforeEach(function() {
endpoint = `http://localhost:${port}/api/host-pairing`;
// The mock controller lacks what pairing needs, use the real implementations.
controller.hosts = new SubscribableDatastore();
controller.hostPairing = new HostPairingManager(controller);
controller.generateHostToken = Controller.prototype.generateHostToken;
controller.wsServer.remoteAddr = req => req.socket.remoteAddress;
adminToken = controller.users.signUserToken(controller.users.getByName("test"));
playerToken = controller.users.signUserToken(controller.users.getByName("player"));
});
function post(path, body, headers = {}) {
return fetch(`${endpoint}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: typeof body === "string" || body === undefined ? body : JSON.stringify(body),
});
}
async function assertResponse(response, status, body) {
assert.equal(response.status, status);
assert.equal(response.headers.get("cache-control"), "no-store");
if (body !== undefined) {
assert.deepEqual(await response.json(), body);
}
}
async function createRequest(name = "Host A") {
const response = await post("", { name });
assert.equal(response.status, 202);
return await response.json();
}
function check(headers) {
return fetch(`${endpoint}/check`, { headers });
}
function assertNothingCreated() {
assert.equal(controller.hostPairing.requests.size, 0, "a pending request was created");
assert.equal(controller.hosts.size, 0, "a host was created");
}
describe("POST /api/host-pairing", function() {
it("should create a pending request", async function() {
const response = await post("", { name: "Host A", version: "2.0.0" });
assert.equal(response.status, 202);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.deepEqual(Object.keys(body).sort(), ["code", "requestId"]);
assert.match(body.code, /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/);
const details = controller.hostPairing.requests.get(body.code);
assert.equal(details.name, "Host A");
assert.equal(details.version, "2.0.0");
assert.match(details.remoteAddress, /127\.0\.0\.1|::1/);
assert.equal(controller.hosts.size, 0);
});
it("should trim the name and limit the version", async function() {
let { code } = await (await post("", { name: " Host A ", version: "x".repeat(100) })).json();
let details = controller.hostPairing.requests.get(code);
assert.equal(details.name, "Host A");
assert.equal(details.version, "x".repeat(64));
({ code } = await (await post("", { name: "Host B", version: 5 })).json());
details = controller.hostPairing.requests.get(code);
assert.equal(details.version, "");
});
it("should respond with 400 for a missing or invalid name", async function() {
const required = { error: "Host name is required" };
await assertResponse(await post("", {}), 400, required);
await assertResponse(await post("", { name: "" }), 400, required);
await assertResponse(await post("", { name: " " }), 400, required);
await assertResponse(await post("", { name: 5 }), 400, required);
await assertResponse(await post("", undefined), 400, required);
await assertResponse(await post("", "name=Host", { "Content-Type": "text/plain" }), 400, required);
await assertResponse(
await post("", { name: "x".repeat(101) }),
400, { error: "Host name must be at most 100 characters" },
);
assertNothingCreated();
});
it("should respond with 400 for malformed JSON", async function() {
controller.app.set("env", "test"); // Silence the default error handler
const response = await post("", "{\"name\":");
assert.equal(response.status, 400);
assertNothingCreated();
});
it("should respond with 413 for a body over 4kb", async function() {
controller.app.set("env", "test"); // Silence the default error handler
const response = await post("", { name: "x".repeat(5000) });
assert.equal(response.status, 413);
assertNothingCreated();
});
it("should respond with 429 and Retry-After when limited", async function() {
for (let i = 0; i < 3; i++) {
await createRequest();
}
const response = await post("", { name: "Host A" });
assert.equal(response.headers.get("retry-after"), "30");
await assertResponse(response, 429, { error: "Too many pending requests from this address" });
assert.equal(controller.hostPairing.requests.size, 3);
});
it("should respond with 503 in recovery mode", async function() {
controller.recoveryMode = true;
const recovery = { error: "Controller is in recovery mode" };
await assertResponse(await post("", { name: "Host A" }), 503, recovery);
await assertResponse(
await post("", { name: "Host A" }, { "Authorization": `Bearer ${adminToken}` }), 503, recovery
);
assertNothingCreated();
});
it("should approve directly with the token of a user allowed to approve hosts", async function() {
for (const headers of [
{ "Authorization": `Bearer ${adminToken}` },
{ "Authorization": `bearer ${adminToken}` },
{ "X-Access-Token": adminToken },
]) {
const response = await post("", { name: "Host A" }, headers);
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.deepEqual(Object.keys(body).sort(), ["hostId", "name", "token"]);
assert.equal(body.name, "Host A");
assert.equal(controller.hosts.get(body.hostId).name, "Host A");
const payload = jwt.verify(body.token, controller.authSecret, { audience: "host" });
assert.equal(payload.host, body.hostId);
}
assert.equal(controller.hosts.size, 3);
assert.equal(controller.hostPairing.requests.size, 0, "a pending request was created");
});
it("should validate the name before approving directly", async function() {
await assertResponse(
await post("", {}, { "Authorization": `Bearer ${adminToken}` }),
400, { error: "Host name is required" },
);
assertNothingCreated();
});
it("should respond with 403 for a user not allowed to approve hosts", async function() {
await assertResponse(
await post("", { name: "Host A" }, { "Authorization": `Bearer ${playerToken}` }),
403, { error: "User player is not allowed to approve hosts" },
);
assertNothingCreated();
});
it("should respond with 401 for an invalid token without creating a request", async function() {
// The admin user's tokens are revoked after signing this one
const revokedToken = adminToken;
const user = controller.users.getOrCreateUser("test");
user.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
user.saveRecord();
controller.hosts.set(new HostRecord(5, "five", "", new Map()));
const hostToken = controller.generateHostToken(5);
controller.hosts.delete(controller.hosts.get(5));
for (const headers of [
{ "Authorization": "Bearer invalid" },
{ "X-Access-Token": "invalid" },
{ "Authorization": `Bearer ${controller.users.signUserToken({ id: "nobody" })}` },
{ "Authorization": `Bearer ${revokedToken}` },
{ "Authorization": `Bearer ${hostToken}` },
]) {
await assertResponse(
await post("", { name: "Host A" }, headers), 401, { error: "Invalid user token" }
);
}
assertNothingCreated();
});
});
describe("POST /api/host-pairing/poll", function() {
it("should respond with 202 while pending", async function() {
const { requestId } = await createRequest();
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
});
it("should hand over the result of an approved request once", async function() {
const { requestId, code } = await createRequest();
const hostId = await controller.hostPairing.approve(code, "Renamed", "test");
const response = await post("/poll", { requestId });
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.deepEqual(Object.keys(body).sort(), ["hostId", "name", "token"]);
assert.equal(body.hostId, hostId);
assert.equal(body.name, "Renamed");
assert.equal(jwt.verify(body.token, controller.authSecret, { audience: "host" }).host, hostId);
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
});
it("should respond with 403 once for a rejected request", async function() {
const { requestId, code } = await createRequest();
controller.hostPairing.reject(code, "test");
await assertResponse(await post("/poll", { requestId }), 403, { error: "Request was rejected" });
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
});
it("should respond with 404 for an unknown request", async function() {
const { code } = await createRequest();
const notFound = { error: "Request not found" };
await assertResponse(await post("/poll", { requestId: "invalid" }), 404, notFound);
await assertResponse(await post("/poll", { requestId: code }), 404, notFound);
});
it("should respond with 400 without a requestId in the body", async function() {
const { requestId } = await createRequest();
const required = { error: "requestId is required" };
await assertResponse(await post("/poll", {}), 400, required);
await assertResponse(await post("/poll", { requestId: 5 }), 400, required);
await assertResponse(await post(`/poll?requestId=${encodeURIComponent(requestId)}`, {}), 400, required);
});
it("should respond with 503 in recovery mode", async function() {
const { requestId } = await createRequest();
controller.recoveryMode = true;
await assertResponse(
await post("/poll", { requestId }), 503, { error: "Controller is in recovery mode" }
);
});
});
describe("POST /api/host-pairing/cancel", function() {
it("should cancel a pending request", async function() {
const { requestId, code } = await createRequest();
await assertResponse(await post("/cancel", { requestId }), 204);
assert.equal(controller.hostPairing.requests.has(code), false);
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
});
it("should respond with 204 for an unknown request", async function() {
const { code } = await createRequest();
await assertResponse(await post("/cancel", { requestId: "invalid" }), 204);
await assertResponse(await post("/cancel", { requestId: code }), 204);
assert(controller.hostPairing.requests.has(code), "cancelled by the code");
});
it("should respond with 400 without a requestId in the body", async function() {
const { requestId, code } = await createRequest();
await assertResponse(await post("/cancel", {}), 400, { error: "requestId is required" });
await assertResponse(
await post(`/cancel?requestId=${encodeURIComponent(requestId)}`, {}),
400, { error: "requestId is required" },
);
assert(controller.hostPairing.requests.has(code));
});
it("should respond with 503 in recovery mode", async function() {
const { requestId, code } = await createRequest();
controller.recoveryMode = true;
await assertResponse(
await post("/cancel", { requestId }), 503, { error: "Controller is in recovery mode" }
);
assert(controller.hostPairing.requests.has(code));
});
});
describe("request ids", function() {
it("should not be accepted in the URL", async function() {
const { requestId } = await createRequest();
const id = encodeURIComponent(requestId);
for (const [method, path] of [
["GET", `/poll?requestId=${id}`],
["GET", `/poll/${id}`],
["POST", `/poll/${id}`],
["POST", `/${id}`],
["GET", `/${id}`],
["POST", `/cancel/${id}`],
]) {
const response = await fetch(`${endpoint}${path}`, { method });
assert.equal(response.status, 404, `${method} ${path}`);
}
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
});
});
describe("GET /api/host-pairing/check", function() {
it("should respond with 200 for a valid host token", async function() {
const { hostId, token } = await controller.hostPairing.createHost("Host A");
for (const headers of [
{ "Authorization": `Bearer ${token}` },
{ "X-Access-Token": token },
]) {
await assertResponse(await check(headers), 200, { valid: true });
}
assert(controller.hosts.has(hostId));
});
it("should respond with 401 without a valid host token", async function() {
await controller.hostPairing.createHost("Host A");
assert.equal((await check({})).status, 401);
assert.equal((await check({ "Authorization": "Bearer invalid" })).status, 401);
assert.equal((await check({ "Authorization": `Bearer ${adminToken}` })).status, 401);
});
it("should respond with 401 for a host that is not registered", async function() {
const token = controller.generateHostToken(99);
assert.equal((await check({ "Authorization": `Bearer ${token}` })).status, 401);
});
it("should respond with 401 once the host is revoked", async function() {
const { hostId, token } = await controller.hostPairing.createHost("Host A");
await assertResponse(await check({ "Authorization": `Bearer ${token}` }), 200, { valid: true });
controller.hosts.delete(controller.hosts.get(hostId));
assert.equal((await check({ "Authorization": `Bearer ${token}` })).status, 401);
});
it("should respond with 401 for a token issued before tokenValidAfter", async function() {
const { hostId, token } = await controller.hostPairing.createHost("Host A");
const record = controller.hosts.getMutable(hostId);
record.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
controller.hosts.set(record);
assert.equal((await check({ "Authorization": `Bearer ${token}` })).status, 401);
});
});
describe("pairing flow", function() {
it("should pair a host approved while the installer polls", async function() {
const { requestId, code } = await createRequest("Host A");
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
const hostId = await controller.hostPairing.approve(code.toLowerCase(), undefined, "test");
const result = await (await post("/poll", { requestId })).json();
assert.deepEqual(result, { hostId, token: result.token, name: "Host A" });
await assertResponse(
await check({ "Authorization": `Bearer ${result.token}` }), 200, { valid: true }
);
});
});
});
describe("/api/upload-save", function() {
let endpoint, testToken;
beforeEach(function() {