mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Add opt-in UDP messaging between the host and the Factorio server using the --enable-lua-udp option of Factorio 2.0.59+. When factorio.enable_lua_udp is set the instance binds a localhost UDP socket, starts Factorio with --enable-lua-udp on factorio.lua_udp_port (random if null, like the RCON port) and tells the clusterio module the host port via clusterio_private.update_instance. Data from Factorio is sent with clusterio_api.send_udp(channel, data) in the same IPC format as send_json, so plugins receive it through the existing ipc-<channel> events. Packets not sent from the Lua UDP port are ignored, as that is the only unauthenticated way into the IPC handler. Data to Factorio is sent with the new sendUdp method on instances and instance plugins, and is dispatched in-game as on_udp_packet_received events by polling helpers.recv_udp every tick from the clusterio module. Plugins requiring UDP declare the new LuaUdp feature flag, which like SavePatching and ScriptCommands refuses to load the plugin on instances without the option enabled. Verified against a headless 2.1.17 server: send_udp and recv_udp are silent no-ops when the option is off, so a stale port in a save that is moved to an instance without UDP does no harm. The integration test matrix now starts the instance with UDP enabled on Factorio 2.0.59+ and round trips a player event over it. Closes #969 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
424 lines
13 KiB
JavaScript
424 lines
13 KiB
JavaScript
/* eslint-disable no-console */
|
|
import path from "node:path";
|
|
import fs from "node:fs/promises";
|
|
import { readFileSync } from "node:fs";
|
|
import child_process from "node:child_process";
|
|
import jwt from "jsonwebtoken";
|
|
import util from "node:util";
|
|
import events from "node:events";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
import { LineSplitter, logger } from "@clusterio/lib";
|
|
|
|
import { selectTargetCommand, initialize as initializeCtl } from "@clusterio/ctl";
|
|
import { _listFactorioVersions } from "@clusterio/host/dist/node/src/server.js";
|
|
|
|
import "../setup_logging.js";
|
|
|
|
// Make sure permissions from plugins are loaded
|
|
import "../../plugins/global_chat/dist/node/index.js";
|
|
import "../../plugins/player_auth/dist/node/index.js";
|
|
import "../../plugins/research_sync/dist/node/index.js";
|
|
import "../../plugins/statistics_exporter/dist/node/index.js";
|
|
import "../../plugins/subspace_storage/dist/node/index.js";
|
|
|
|
export class TestControl extends lib.Link {
|
|
constructor(connector, subscribe = true) {
|
|
super(connector);
|
|
this.hostUpdates = [];
|
|
this.instanceUpdates = [];
|
|
this.saveUpdates = [];
|
|
this.modUpdates = [];
|
|
this.modPackUpdates = [];
|
|
this.userUpdates = [];
|
|
|
|
this.connector.on("connect", () => {
|
|
if (!subscribe) {
|
|
return;
|
|
}
|
|
this.send(
|
|
new lib.SubscriptionRequest(lib.HostUpdatesEvent.name, true)
|
|
).catch(err => logger.error(`Error setting host subscriptions:\n${err.stack}`));
|
|
this.send(
|
|
new lib.SubscriptionRequest(lib.InstanceDetailsUpdatesEvent.name, true)
|
|
).catch(err => logger.error(`Error setting instance subscriptions:\n${err.stack}`));
|
|
this.send(
|
|
new lib.SubscriptionRequest(lib.InstanceSaveDetailsUpdatesEvent.name, true)
|
|
).catch(err => logger.error(`Error setting save subscriptions:\n${err.stack}`));
|
|
this.send(
|
|
new lib.SubscriptionRequest(lib.ModUpdatesEvent.name, true)
|
|
).catch(err => logger.error(`Error setting mod subscriptions:\n${err.stack}`));
|
|
this.send(
|
|
new lib.SubscriptionRequest(lib.ModPackUpdatesEvent.name, true)
|
|
).catch(err => logger.error(`Error setting mod pack subscriptions:\n${err.stack}`));
|
|
this.send(
|
|
new lib.SubscriptionRequest(lib.UserUpdatesEvent.name, true)
|
|
).catch(err => logger.error(`Error setting user subscriptions:\n${err.stack}`));
|
|
});
|
|
|
|
this.handle(lib.AccountUpdateEvent);
|
|
this.handle(lib.HostUpdatesEvent, this.handleHostUpdatesEvent.bind(this));
|
|
this.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsUpdatesEvent.bind(this));
|
|
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
|
|
this.handle(lib.ModUpdatesEvent, this.handleModUpdatesEvent.bind(this));
|
|
this.handle(lib.ModPackUpdatesEvent, this.handleModPackUpdatesEvent.bind(this));
|
|
this.handle(lib.UserUpdatesEvent, this.handleUserUpdatesEvent.bind(this));
|
|
}
|
|
|
|
async handleHostUpdatesEvent(event) {
|
|
this.hostUpdates.push(...event.updates);
|
|
}
|
|
|
|
async handleInstanceDetailsUpdatesEvent(event) {
|
|
this.instanceUpdates.push(...event.updates);
|
|
}
|
|
|
|
async handleInstanceSaveDetailsUpdatesEvent(event) {
|
|
this.saveUpdates.push(event);
|
|
}
|
|
|
|
async handleModUpdatesEvent(event) {
|
|
this.modUpdates.push(...event.updates);
|
|
}
|
|
|
|
async handleModPackUpdatesEvent(event) {
|
|
this.modPackUpdates.push(...event.updates);
|
|
}
|
|
|
|
async handleUserUpdatesEvent(event) {
|
|
this.userUpdates.push(...event.updates);
|
|
}
|
|
|
|
async setLogSubscriptions(args) {
|
|
// Do nothing
|
|
}
|
|
}
|
|
|
|
export class TestControlConnector extends lib.WebSocketClientConnector {
|
|
register() {
|
|
this.sendHandshake(
|
|
new lib.MessageRegisterControl(
|
|
new lib.RegisterControlData(
|
|
this.token, "test",
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
export class TestHostConnector extends lib.WebSocketClientConnector {
|
|
register() {
|
|
this.sendHandshake(
|
|
new lib.MessageRegisterHost(
|
|
new lib.RegisterHostData(
|
|
this.token, "test",
|
|
this.hostId, {},
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Mark that this test or suite of tests requires a factorio install to run.
|
|
export function requiresFactorio(testOrSuite) {
|
|
if (testOrSuite.skip) {
|
|
if (!haveFactorioInstall) {
|
|
testOrSuite.skip();
|
|
}
|
|
} else {
|
|
before(function() {
|
|
if (!haveFactorioInstall) {
|
|
this.skip();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export function hasFactorio() {
|
|
return haveFactorioInstall;
|
|
}
|
|
|
|
export function getFactorioVersion() {
|
|
return latestFactorioVersion;
|
|
}
|
|
|
|
export async function get(urlPath) {
|
|
const url = new URL("https://localhost:4443");
|
|
url.pathname = urlPath;
|
|
let res = await fetch(url);
|
|
if (res.status !== 200) {
|
|
const body = Buffer.from(await res.arrayBuffer());
|
|
throw new Error(`Got response code ${res.status}, content: ${body.toString()}`);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
function loadJSON(filePath) {
|
|
try {
|
|
// eslint-disable-next-line node/no-sync
|
|
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
} catch (err) {
|
|
if (err.code === "ENOENT") {
|
|
return undefined;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function getFactorioDir(hostConfig) {
|
|
const factorioDir = hostConfig?.["host.factorio_directory"] ?? "factorio";
|
|
return path.join(factorioDir);
|
|
}
|
|
|
|
let controllerProcess;
|
|
let hostProcess;
|
|
let control;
|
|
let haveFactorioInstall;
|
|
let latestFactorioVersion;
|
|
|
|
const baseHostConfig = loadJSON("config-host.json");
|
|
|
|
export let url = "https://localhost:4443/";
|
|
// Signed in the root before hook, as the user has to exist before the token is issued
|
|
export let controlToken;
|
|
export let instancesDir = path.join("temp", "test", "instances");
|
|
export let modsDir = path.join("temp", "test", "mods");
|
|
export let databaseDir = path.join("temp", "test", "database");
|
|
export let factorioDir = getFactorioDir(baseHostConfig);
|
|
let pluginListPath = path.join("temp", "test", "plugin-list.json");
|
|
export let controllerConfigPath = path.join("temp", "test", "config-controller.json");
|
|
export let hostConfigPath = path.join("temp", "test", "config-host.json");
|
|
export let ctlConfigPath = path.join("temp", "test", "config-ctl.json");
|
|
|
|
function childOptions(options) {
|
|
return {
|
|
cwd: path.join("temp", "test"),
|
|
env: { ...process.env },
|
|
...options,
|
|
};
|
|
}
|
|
|
|
export function exec(command, options = {}) {
|
|
// Uncomment to show commands run in tests
|
|
// console.log(command);
|
|
return util.promisify(child_process.exec)(command, childOptions(options));
|
|
}
|
|
|
|
export function execController(...args) {
|
|
args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`;
|
|
return exec(...args);
|
|
}
|
|
|
|
export function execHost(...args) {
|
|
args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`;
|
|
return exec(...args);
|
|
}
|
|
|
|
export function execCtlProcess(...args) {
|
|
args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`;
|
|
return exec(...args);
|
|
}
|
|
|
|
let inExecCtl = false;
|
|
export async function execCtl(command) {
|
|
inExecCtl = true;
|
|
process.chdir("temp/test");
|
|
try {
|
|
const initArgs = await initializeCtl(command, control.hooks, true);
|
|
if (!initArgs.shouldRun) {
|
|
return;
|
|
}
|
|
|
|
control.config = initArgs.ctlConfig;
|
|
const targetCommand = selectTargetCommand(initArgs.args, initArgs.rootCommands);
|
|
await targetCommand.run(initArgs.args, control);
|
|
} finally {
|
|
process.chdir("../..");
|
|
inExecCtl = false;
|
|
}
|
|
}
|
|
|
|
afterEach(function() {
|
|
if (inExecCtl) {
|
|
// This is needed in case a test times out and never runs the finally block
|
|
process.chdir("../..");
|
|
}
|
|
});
|
|
|
|
export async function sendRcon(instanceId, command) {
|
|
return await control.sendTo({ instanceId }, new lib.InstanceSendRconRequest(command));
|
|
}
|
|
|
|
export function getControl() {
|
|
return control;
|
|
}
|
|
|
|
/** @returns {Promise<child_process.ChildProcess>} */
|
|
export function spawn(name, cmd, waitFor, options = {}) {
|
|
|
|
const silent = process.env.SILENT_TEST;
|
|
const bootstrap = !controllerProcess || !hostProcess;
|
|
function log(...args) {
|
|
if (!silent || bootstrap) {
|
|
console.log(...args);
|
|
}
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
log(cmd);
|
|
let parts = cmd.split(" ");
|
|
let process = child_process.spawn(parts[0], parts.slice(1), childOptions(options));
|
|
let stdout = new LineSplitter({ readableObjectMode: true });
|
|
let stderr = new LineSplitter({ readableObjectMode: true });
|
|
let onDataOut = line => {
|
|
line = line.toString("utf8");
|
|
if (waitFor.test(line)) {
|
|
if (silent) {
|
|
stdout.off("data", onDataOut);
|
|
stderr.off("data", onDataErr);
|
|
}
|
|
resolve(process);
|
|
}
|
|
log(name, line);
|
|
};
|
|
let onDataErr = line => {
|
|
log(name, line.toString("utf8"));
|
|
};
|
|
stdout.on("data", onDataOut);
|
|
stderr.on("data", onDataErr);
|
|
process.stdout.pipe(stdout);
|
|
process.stderr.pipe(stderr);
|
|
});
|
|
}
|
|
|
|
export function spawnNode(name, cmd, waitFor, options) {
|
|
return spawn(name, `node --enable-source-maps ${cmd}`, waitFor, options);
|
|
}
|
|
|
|
before(async function() {
|
|
this.timeout(40000);
|
|
|
|
|
|
const silent = process.env.SILENT_TEST;
|
|
if (silent) {
|
|
console.log("SILENT_TEST is present in env, loggers after bootstrap will be muted.");
|
|
}
|
|
|
|
// If fast test is enabled then output that it is
|
|
|
|
if (process.env.FAST_TEST) {
|
|
console.log("FAST_TEST is present in env, slow tests will be skipped.");
|
|
}
|
|
|
|
if (process.env.NO_EXTERNAL_TEST) {
|
|
console.log("NO_EXTERNAL_TEST is present in env, tests depending on external APIs will be skipped.");
|
|
}
|
|
|
|
await fs.rm(instancesDir, { force: true, recursive: true, maxRetries: 10 });
|
|
await fs.rm(modsDir, { force: true, recursive: true, maxRetries: 10 });
|
|
await fs.rm(databaseDir, { force: true, recursive: true, maxRetries: 10 });
|
|
|
|
await fs.rm(pluginListPath, { force: true });
|
|
await fs.rm(controllerConfigPath, { force: true });
|
|
await fs.rm(hostConfigPath, { force: true });
|
|
await fs.rm(ctlConfigPath, { force: true });
|
|
|
|
await fs.mkdir(path.join("temp", "test"), { recursive: true });
|
|
|
|
console.log("Building Mods");
|
|
await fs.mkdir(modsDir, { recursive: true });
|
|
await lib.build({
|
|
build: true,
|
|
pack: true,
|
|
sourceDir: "packages/host/lua/clusterio_lib",
|
|
outputDir: modsDir,
|
|
});
|
|
|
|
console.log("Setting Controller Config");
|
|
await execController("config create");
|
|
await execController("config set controller.auth_secret TestSecretDoNotUse");
|
|
await execController("config set controller.http_port 8880");
|
|
await execController("config set controller.https_port 4443");
|
|
await execController("config set controller.heartbeat_interval 0.25");
|
|
await execController("config set controller.session_timeout 2");
|
|
await execController("config set controller.tls_certificate ../../test/file/tls/cert.pem");
|
|
await execController("config set controller.tls_private_key ../../test/file/tls/key.pem");
|
|
|
|
console.log("Setting Controller Plugins");
|
|
await execCtlProcess("plugin add ../../test/file/test_plugin");
|
|
await execCtlProcess("plugin add ../../plugins/global_chat");
|
|
await execCtlProcess("plugin add ../../plugins/research_sync");
|
|
await execCtlProcess("plugin add ../../plugins/statistics_exporter");
|
|
await execCtlProcess("plugin add ../../plugins/subspace_storage");
|
|
await execCtlProcess("plugin add ../../plugins/player_sync");
|
|
await execCtlProcess("plugin add ../../plugins/player_auth");
|
|
|
|
console.log("Bootstrapping");
|
|
await execController("bootstrap create-admin test");
|
|
await execController("bootstrap create-ctl-config test");
|
|
controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64"));
|
|
|
|
controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/);
|
|
|
|
const factorioVersions = (await _listFactorioVersions(factorioDir)).versions;
|
|
latestFactorioVersion = [...factorioVersions.values()]
|
|
.sort((a, b) => lib.integerFullVersion(b) - lib.integerFullVersion(a))[0];
|
|
haveFactorioInstall = factorioVersions.size > 0;
|
|
|
|
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
|
|
await execCtlProcess("host create-config --id 4 --name host --generate-token");
|
|
await execHost("config create");
|
|
await execHost(`config set host.factorio_directory ${relativeFactorioDir}`);
|
|
|
|
hostProcess = await spawnNode("host:", "../../packages/host run", /Started host/);
|
|
|
|
const controlConnector = new TestControlConnector(url, 2);
|
|
controlConnector.token = controlToken;
|
|
control = new TestControl(controlConnector);
|
|
await controlConnector.connect();
|
|
|
|
const initArgs = await initializeCtl(`--plugin-list ${pluginListPath} config create`, undefined, true);
|
|
control.config = initArgs.ctlConfig;
|
|
control.hooks = initArgs.ctlHooks;
|
|
|
|
const testPack = lib.ModPack.fromJSON({});
|
|
testPack.id = 12;
|
|
testPack.name = "subspace_storage-pack";
|
|
testPack.factorioVersion = latestFactorioVersion;
|
|
|
|
const [major, minor] = lib.normaliseMajorMinorVersion(latestFactorioVersion).split(".");
|
|
testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: `2.1.${major}${minor}` });
|
|
|
|
await control.sendTo("controller", new lib.ModPackCreateRequest(testPack));
|
|
await control.sendTo(
|
|
"controller",
|
|
new lib.ControllerConfigSetFieldRequest("controller.default_mod_pack_id", "12"),
|
|
);
|
|
});
|
|
|
|
after(async function() {
|
|
this.timeout(20000);
|
|
if (hostProcess) {
|
|
console.log("Shutting down host");
|
|
hostProcess.kill("SIGINT");
|
|
await events.once(hostProcess, "exit");
|
|
}
|
|
if (controllerProcess) {
|
|
console.log("Shutting down controller");
|
|
controllerProcess.kill("SIGINT");
|
|
await events.once(controllerProcess, "exit");
|
|
}
|
|
if (control) {
|
|
await control.connector.close();
|
|
}
|
|
if (haveFactorioInstall === false) {
|
|
console.warn("Many tests may have been skipped due to no factorio install being detected");
|
|
}
|
|
});
|
|
|
|
// Ensure the test processes are stopped.
|
|
process.on("exit", () => {
|
|
if (hostProcess) { hostProcess.kill(); }
|
|
if (controllerProcess) { controllerProcess.kill(); }
|
|
});
|