Files
factorio-clusterio/test/integration/routing.js
T
Hornwitser fb3fe09466 Remove tlsCa option
The migration from phin to fetch in #755 was hampred by the lack of an
option to set the CA when using fetch.  This meant places that used phin
to talk to the controller with the tlsCa option could not be migrated to
use fetch.

Remove the tlsCa option to unblock this dependency. The replacement for
this option is to set the NODE_EXTRA_CA_CERTS environment variable to a
path to the certificate file instead, which works with fetch.
2026-03-04 17:43:32 +01:00

444 lines
14 KiB
JavaScript

"use strict";
const assert = require("assert").strict;
const lib = require("@clusterio/lib");
const { Host, Instance, InstanceConnection } = require("@clusterio/host");
const { ControlConnection, Controller, HostConnection, InstanceRecord } = require("@clusterio/controller");
// Although there are no imports, the file has side-effects such as "before" which can effect tests
require("./index");
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);
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", "invalid", instanceConfig);
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; }
// TODO remove when broadcasting from instance to hosts is fixed, see #575
if (srcName === "instanceA1" && dstName === "hostB") { 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}.`)
);
});
}
});
});