mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +09:00
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.
444 lines
14 KiB
JavaScript
444 lines
14 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.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 });
|
|
controller.instances.records.set(new InstanceRecord(instanceConfig, "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}.`)
|
|
);
|
|
});
|
|
}
|
|
});
|
|
});
|