Files
bbassieandClaude Opus 5.5 e6554e09c7 Add HostManager for host records and host tokens
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>
2026-11-04 00:37:37 +00:00

445 lines
15 KiB
JavaScript

import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import { Host, Instance, InstanceConnection } from "@clusterio/host";
import { ControlConnection, Controller, HostConnection, HostRecord, InstanceRecord } from "@clusterio/controller";
import { MockServer } from "../mock.js";
// Although there are no imports, the file has side-effects such as "before" which can effect tests
import "./index.js";
const addr = lib.Address.fromShorthand;
class Control extends lib.Link { }
/**
* @param {Controller} controller
* @param {number} hostId
*/
function connectHost(controller, hostId, plugins) {
const [controllerSide, hostSide] = lib.VirtualConnector.makePair(addr("controller"), addr({ hostId }));
const registerData = new lib.RegisterHostData("", "0.0.0", hostId, plugins);
// Hosts can only connect once approved, which creates their record.
controller.hosts.records.set(new HostRecord(hostId, `host-${hostId}`, "", new Map()));
const hostConnection = new HostConnection(registerData, controllerSide, controller, "host-a.test");
controller.wsServer.hostConnections.set(hostId, hostConnection);
const hostConfig = new lib.HostConfig("host");
hostConfig.set("host.id", hostId);
return new Host(hostSide, hostConfig, []);
}
/**
* @param {Controller} controller
* @param {Host} host
* @param {number} instanceId
*/
function connectInstance(controller, host, instanceId) {
const hostAddress = addr({ hostId: host.config.get("host.id")});
const instanceAddress = addr({ instanceId });
const [instanceSide, hostSide] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
const instanceConfig = new lib.InstanceConfig("host");
instanceConfig.set("instance.id", instanceId);
instanceConfig.set("instance.assigned_host", host.config.get("host.id"));
host.assignedInstances.set(instanceId, { path: "invalid", config: instanceConfig });
const controllerConfig = new lib.InstanceConfig("controller", instanceConfig.toRemote("controller"));
controller.instances.records.set(new InstanceRecord(controllerConfig, "stopped"));
const instance = new Instance(host, instanceSide, "invalid", instanceConfig, new MockServer());
const instanceConnection = new InstanceConnection(hostSide, host, instance);
host.instanceConnections.set(instanceId, instanceConnection);
return instance;
}
/**
* @param {Controller} controller
* @param {number} hostId
*/
function connectControl(controller, controlId) {
const [controllerSide, controlSide] = lib.VirtualConnector.makePair(addr("controller"), addr({ controlId }));
const registerData = new lib.RegisterControlData("", "0.0.0");
const user = controller.users.getOrCreateUser("test");
const controlConnection = new ControlConnection(registerData, controllerSide, controller, user, controlId);
controller.wsServer.controlConnections.set(controlId, controlConnection);
return new Control(controlSide);
}
class TestRequest {
static type = "request";
static src = ["controller", "host", "instance", "control"];
static dst = ["controller", "host", "instance", "control"];
static permission = null;
}
class TestEvent {
static type = "event";
static src = ["controller", "host", "instance", "control"];
static dst = ["controller", "host", "instance", "control"];
static permission = null;
}
class PluginEvent {
static type = "event";
static src = ["controller", "host", "instance", "control"];
static dst = ["controller", "host", "instance", "control"];
static permission = null;
static plugin = "test";
}
lib.Link.register(TestRequest);
lib.Link.register(TestEvent);
lib.Link.register(PluginEvent);
describe("Integration of link routing", function() {
/** @type {Controller} */
let controller;
/** @type {Host} */
let hostA;
/** @type {Host} */
let hostB;
/** @type {Instance} */
let instanceA1;
/** @type {Instance} */
let instanceA2;
/** @type {Instance} */
let instanceB1;
/** @type {Control} */
let controlA;
/** @type {Control} */
let controlB;
beforeEach(function() {
controller = new Controller({}, [], new lib.ControllerConfig("controller"));
hostA = connectHost(controller, 10, []);
hostB = connectHost(controller, 20, []);
instanceA1 = connectInstance(controller, hostA, 11);
instanceA2 = connectInstance(controller, hostA, 12);
instanceB1 = connectInstance(controller, hostB, 21);
controlA = connectControl(controller, 100);
controlB = connectControl(controller, 101);
});
/** @returns {Controller | Host | Instance} */
function get(name) {
return {
controller,
hostA,
hostB,
instanceA1,
instanceA2,
instanceB1,
controlA,
controlB,
}[name];
}
// Combinations to try. Equivalent paths have been removed.
const parties = [
["controller", ["hostA", "instanceA1", "controlA"]],
["hostA", ["controller", "hostB", "instanceA1", "instanceB1", "controlA"]],
["instanceA1", ["controller", "hostA", "hostB", "instanceA2", "instanceB1", "controlA"]],
["controlA", ["controller", "hostA", "instanceA1", "controlB"]],
];
function handle(dst, Message, handler) {
if (dst instanceof Controller) {
for (const host of dst.wsServer.hostConnections.values()) {
host.handle(Message, handler);
}
for (const control of dst.wsServer.controlConnections.values()) {
control.handle(Message, handler);
}
} else if (dst instanceof Host) {
for (const instance of dst.instanceConnections.values()) {
instance.handle(Message, handler);
}
dst.handle(Message, handler);
} else {
dst.handle(Message, handler);
}
}
/**
* @param {Controller | Host | Instance} link
* @param {Address} dst
*/
function send(link, dst, message) {
// This logic exists becasue .sendTo doesn't forward all kinds of
// messages to the correct location from all places. Idealy this
// would not be neccessary and .sendTo would do the right thing.
if (link instanceof Host) {
const instance = link.instanceConnections.get(dst.id);
if (instance) {
return instance.sendTo(dst, message);
}
}
return link.sendTo(dst, message);
}
describe("request routing", function() {
for (const [srcName, dstNames] of parties) {
it(`should route from ${srcName}`, async function() {
for (const dstName of dstNames) {
let called;
const dst = get(dstName);
handle(dst, TestRequest, async () => { called = true; });
const dstAddr = dst instanceof Controller ? addr("controller") : dst.connector.src;
await send(get(srcName), dstAddr, new TestRequest());
assert(called, `${dstName} handler was not called`);
}
});
}
});
describe("event routing", function() {
for (const [srcName, dstNames] of parties) {
it(`should route from ${srcName}`, async function() {
for (const dstName of dstNames) {
let called;
const dst = get(dstName);
handle(dst, TestEvent, async () => { called = true; });
const dstAddr = dst instanceof Controller ? addr("controller") : dst.connector.src;
send(get(srcName), dstAddr, new TestEvent());
assert(called, `${dstName} handler was not called`);
}
});
}
});
/**
* @param {Controller | Host | Instance | Control} link
* @param {"allHosts" | "allInstances" | "allControls"} dst
*/
function bcast(link, dst, message) {
// This logic exists becasue .sendTo doesn't forward all kinds of
// messages to the correct location from all places. Idealy this
// would not be neccessary and .sendTo would do the right thing.
if (link instanceof Host) {
const plugin = message.constructor.plugin;
if (dst === "allInstances") {
for (const instance of link.instanceConnections.values()) {
if (plugin && !instance.plugins.has(plugin)) {
continue;
}
instance.sendTo(dst, message);
}
}
if (plugin && !link.serverPlugins.has(plugin)) {
return;
}
}
link.sendTo(dst, message);
}
function bhandle(srcName, dst, Event, canReach = (dstB) => true) {
const asserts = [];
const dsts = {
allHosts: ["hostA", "hostB"],
allInstances: ["instanceA1", "instanceA2", "instanceB1"],
allControls: ["controlA", "controlB"],
};
for (const dstName of dsts[dst]) {
if (srcName === dstName) { continue; }
let called;
handle(get(dstName), Event, async () => { called = true; });
const should = canReach(dstName);
asserts.push(() => assert(
!should ^ called,
`${dstName} handler was ${should ? "not " : ""}called from ${srcName}`,
));
}
return asserts;
}
describe("event broadcast", function() {
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
it(`should broadcast from ${srcName}`, async function() {
for (const dstName of ["allHosts", "allInstances", "allControls"]) {
const dstAsserts = bhandle(srcName, dstName, TestEvent);
bcast(get(srcName), dstName, new TestEvent());
for (const dstAssert of dstAsserts) {
dstAssert();
}
}
});
}
});
const graph = {
controller: ["hostA", "hostB", "controlA", "controlB"],
hostA: ["controller", "instanceA1", "instanceA2"],
hostB: ["controller", "instanceB1"],
instanceA1: ["hostA"],
instanceA2: ["hostA"],
instanceB1: ["hostB"],
controlA: ["controller"],
controlB: ["controller"],
};
// Returns true if the path from src to dst goes through via
function goesVia(src, dst, via) {
// Places we've been to
const checked = new Set();
// Places left to check
const paths = new Set([src]);
while (paths.size) {
const [candidate] = paths;
paths.delete(candidate);
if (candidate === via) {
continue;
}
if (checked.has(candidate)) {
continue;
}
if (candidate === dst) {
return false;
}
checked.add(candidate);
for (const newCandidate of graph[candidate]) {
paths.add(newCandidate);
}
}
return true;
}
assert(goesVia("instanceA1", "instanceB1", "controller"));
assert(!goesVia("instanceA1", "instanceA2", "controller"));
assert(goesVia("instanceA1", "hostB", "hostA"));
assert(!goesVia("instanceA1", "hostB", "controlA"));
assert(goesVia("controlA", "controlB", "controller"));
assert(goesVia("controlA", "hostA", "controller"));
function addPluginExceptFor(exName) {
const ex = get(exName);
const exAddr = ex instanceof Controller ? addr("controller") : ex.connector.src;
for (const name of [
"controller", "hostA", "hostB", "instanceA1", "instanceA2", "instanceB1", "controlA", "controlB",
]) {
const party = get(name);
if (party instanceof Controller) {
for (const host of party.wsServer.hostConnections.values()) {
if (!host.connector.dst.equals(exAddr)) {
host.plugins.set("test", "0.0.0");
}
}
for (const control of party.wsServer.controlConnections.values()) {
if (!control.connector.dst.equals(exAddr)) {
// No way to set plugins
}
}
} else if (party instanceof Host) {
for (const instance of party.instanceConnections.values()) {
if (!instance.connector.dst.equals(exAddr)) {
instance.plugins.set("test", "0.0.0");
}
}
if (!party.connector.dst.equals(exAddr)) {
party.serverPlugins.set("test", "0.0.0");
}
} else if (party instanceof Instance) {
// Currently does not handle plugins
} else if (party instanceof Control) {
// Currently does not handle plugins
}
}
}
// When plugin events are broadcast the party sending the event should
// filter it out if the next hop it is forwarded to does not have plugin
// the event was defined in loaded.
describe("event broadcast with controller missing plugin", function() {
for (const srcName of ["hostA", "instanceA1", "controlA"]) {
it(`should broadcast from ${srcName}`, async function() {
if (srcName === "controlA") {
this.skip(); // Not implemented
}
addPluginExceptFor("controller");
for (const dstName of ["allHosts", "allInstances", "allControls"]) {
const dstAsserts = bhandle(
srcName, dstName, PluginEvent, dstB => !goesVia(srcName, dstB, "controller"),
);
bcast(get(srcName), dstName, new PluginEvent());
for (const dstAssert of dstAsserts) {
dstAssert();
}
}
});
}
});
describe("event broadcast with host missing plugin", function() {
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
it(`should broadcast from ${srcName}`, async function() {
addPluginExceptFor("hostB");
for (const dstName of ["allHosts", "allInstances", "allControls"]) {
const dstAsserts = bhandle(
srcName, dstName, PluginEvent, dstB => !goesVia(srcName, dstB, "hostB"),
);
bcast(get(srcName), dstName, new PluginEvent());
for (const dstAssert of dstAsserts) {
dstAssert();
}
}
});
}
});
describe("event broadcast with instance missing plugin", function() {
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
it(`should broadcast from ${srcName}`, async function() {
addPluginExceptFor("instanceA2");
for (const dstName of ["allHosts", "allInstances", "allControls"]) {
const dstAsserts = bhandle(
srcName, dstName, PluginEvent, dstB => !goesVia(srcName, dstB, "instanceA2"),
);
bcast(get(srcName), dstName, new PluginEvent());
for (const dstAssert of dstAsserts) {
dstAssert();
}
}
});
}
});
describe("event broadcast with control missing plugin", function() {
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
it(`should broadcast from ${srcName}`, async function() {
this.skip(); // Currently not implemented.
addPluginExceptFor("controlB");
for (const dstName of ["allHosts", "allInstances", "allControls"]) {
const dstAsserts = bhandle(
srcName, dstName, PluginEvent, dstB => !goesVia(srcName, dstB, "controlB"),
);
bcast(get(srcName), dstName, new PluginEvent());
for (const dstAssert of dstAsserts) {
dstAssert();
}
}
});
}
});
describe("event should not be routable on loopback", function() {
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
it(`should not loopback on ${srcName}`, async function() {
const src = get(srcName);
const dstAddr = src instanceof Controller ? addr("controller") : src.connector.src;
assert.throws(
() => send(src, dstAddr, new TestEvent()),
new Error(`Message would return back to sender ${dstAddr}.`)
);
});
}
});
describe("request should not be routable on loopback", function() {
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
it(`should not loopback on ${srcName}`, async function() {
const src = get(srcName);
const dstAddr = src instanceof Controller ? addr("controller") : src.connector.src;
assert.throws(
() => send(src, dstAddr, new TestRequest()),
new Error(`Message would return back to sender ${dstAddr}.`)
);
});
}
});
});