diff --git a/docs/configuration.md b/docs/configuration.md index 8bf2ed82..a2ee116e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -606,6 +606,23 @@ Like factorio.enable_save_patching, plugins that need script commands can't be l Defaults to true. +### factorio.enable_lua_udp + +Enable UDP messaging between the host and the Factorio server by passing `--enable-lua-udp` to Factorio. +This lets plugins send UDP packets to the server and receive data sent with `clusterio_api.send_udp` in-game. +Requires Factorio 2.1.10 or later, earlier versions crash when a packet is received on a headless server. + +Defaults to false. + + +### factorio.lua_udp_port + +UDP port on localhost the Factorio server receives Lua UDP messages on when `factorio.enable_lua_udp` is enabled. +If null a random port in the dynamic range is assigned each time the instance starts. + +Defaults to null. + + ### factorio.enable_whitelist Turn on the whitelist on the server. diff --git a/docs/developing-for-clusterio.md b/docs/developing-for-clusterio.md index cb172164..ecdf041e 100644 --- a/docs/developing-for-clusterio.md +++ b/docs/developing-for-clusterio.md @@ -292,6 +292,19 @@ Parameters: - `channel`: string identifying which channel to send it on. - `data`: table that can be converted to JSON with `compat.table_to_json` +#### clusterio_api.send_udp(channel, data) + +Send data to Clusterio over the given channel using UDP instead of stdout. +Clusterio plugins receive it as `udp-channel_name` events, see the [Communicating over UDP section](writing-plugins.md#communicating-over-udp) in the Writing Plugins document. +Requires `factorio.enable_lua_udp` to be enabled on the instance and Factorio 2.1.10 or later, otherwise the data is silently dropped. +The port to send to is passed to the module with a script command on startup, so `factorio.enable_script_commands` must be enabled as well. + +**Note**: Delivery is not guaranteed and payloads should be kept well below 32 kB. + +Parameters: +- `channel`: string identifying which channel to send it on. +- `data`: string with the payload to send, any bytes are allowed. + ### serialize library diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 502b1813..a690bfe2 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -147,7 +147,7 @@ The following properties are recognized: See [Migrating Stored Data](#migrating-stored-data) **features**: - Array of features the plugin needs from the instances it is loaded on, `"SavePatching"` and/or `"ScriptCommands"`. + Array of features the plugin needs from the instances it is loaded on, any of `"SavePatching"`, `"ScriptCommands"` and `"LuaUdp"`. See [Handling Invalid Configuration](#handling-invalid-configuration) **messages**: @@ -303,7 +303,7 @@ See [Configuration System](config-system.md) for more details on how this system ### Handling Invalid Configuration -If the plugin requires save patching or script commands to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export: +If the plugin requires save patching, script commands or Lua UDP to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export: ```js export const plugin = { @@ -501,6 +501,43 @@ Data out from Factorio does not have the same limits as data into Factorio, RCON For `send_json` it's possible that payloads greater than 4kB are received after payloads that were sent at a later point in time. For RCON, commands longer than 50 characters may end up being executed after shorter commands sent after it. +### Communicating over UDP + +Instances can optionally exchange UDP packets with the Factorio server for low latency fire-and-forget communication, using the `--enable-lua-udp` option of Factorio. +This needs Factorio 2.1.10 or later, earlier versions crash when a packet is received on a headless server. +This is disabled by default and enabled by setting `factorio.enable_lua_udp` on the instance. +Sending data out of Factorio also needs `factorio.enable_script_commands`, as the port to send to is passed to the module with a script command on startup. +Plugins requiring it should declare the `LuaUdp` feature. +Note that UDP does not guarantee delivery or ordering, and packets should be kept well below 32 kB. + +Data out from Factorio is sent with the `send_udp` API of the Clusterio module. +Unlike `send_json` the payload is an arbitrary string, and it is delivered to plugins as a `udp-channel_name` event with the payload as a Buffer: + +```lua +clusterio_api.send_udp("my_plugin_foo", "123") +``` + +```js +export default async function(context) { + const { instance, logger } = context; + instance.server.handleUdp("my_plugin_foo", async data => { + logger.info(`got ${data.toString()}`); + }); +} +``` + +Like `handle` for `send_json`, `handleUdp` logs errors thrown by the handler. +Use `instance.server.on("udp-my_plugin_foo", ...)` for custom error handling. + +Data into Factorio is sent with the `sendUdp` method on the instance and delivered in-game as `defines.events.on_udp_packet_received` events with the packet in `event.payload`. +The Clusterio module polls for received packets every tick, all modules and mods receive every packet so make sure the payload identifies your plugin. + +```js +instance.hooks.start.attach(plugin.name, async () => { + await instance.sendUdp("my_plugin_foo:123"); +}); +``` + ## Defining Link Messages diff --git a/packages/host/lua/clusterio_lib/api.lua b/packages/host/lua/clusterio_lib/api.lua index 6af5463b..8aee448d 100644 --- a/packages/host/lua/clusterio_lib/api.lua +++ b/packages/host/lua/clusterio_lib/api.lua @@ -38,17 +38,20 @@ function api.get_instance_id() end +local function escape_channel(channel) + -- Escape bad characters. The question mark is used for separating the + -- channel name from the payload. + return channel:gsub("([\x00-\x1f?\\])", function(match) + return "\\x" .. string.format("%02x", match:byte()) + end) +end + function api.send_json(channel, data) if not remote.interfaces.clusterio_api then return end - -- Escape bad characters. The question mark is used for separating the - -- channel name from the payload. - channel = channel:gsub("([\x00-\x1f?\\])", function(match) - return "\\x" .. string.format("%02x", match:byte()) - end) - + channel = escape_channel(channel) data = compat.table_to_json(data) -- If there's more than about 4kB of data users running with the Windows @@ -64,5 +67,14 @@ function api.send_json(channel, data) end end +function api.send_udp(channel, data) + local port = call_api("get_host_udp_port") + if not port then + return -- Lua UDP is not enabled for this instance + end + + helpers.send_udp(port, escape_channel(channel) .. "?" .. data, 0) +end + return api diff --git a/packages/host/modules/clusterio/api.lua b/packages/host/modules/clusterio/api.lua index 49ffda06..36ab00cc 100644 --- a/packages/host/modules/clusterio/api.lua +++ b/packages/host/modules/clusterio/api.lua @@ -17,14 +17,16 @@ function api.get_instance_id() return compat.script_data.clusterio.instance_id end -function api.send_json(channel, data) - +local function escape_channel(channel) -- Escape bad characters. The question mark is used for separating the -- channel name from the payload. - channel = channel:gsub("([\x00-\x1f?\\])", function(match) + return channel:gsub("([\x00-\x1f?\\])", function(match) return "\\x" .. string.format("%02x", match:byte()) end) +end +function api.send_json(channel, data) + channel = escape_channel(channel) data = compat.table_to_json(data) -- If there's more than about 4kB of data users running with the Windows @@ -41,5 +43,15 @@ function api.send_json(channel, data) end end +function api.send_udp(channel, data) + local clusterio = compat.script_data.clusterio + local port = clusterio and clusterio.host_udp_port + if not port then + return -- Lua UDP is not enabled for this instance + end + + helpers.send_udp(port, escape_channel(channel) .. "?" .. data, 0) +end + return api diff --git a/packages/host/modules/clusterio/impl.lua b/packages/host/modules/clusterio/impl.lua index 37ecf81e..344a1754 100644 --- a/packages/host/modules/clusterio/impl.lua +++ b/packages/host/modules/clusterio/impl.lua @@ -12,16 +12,27 @@ local function check_patch() end end +-- Added in Factorio 2.0.59, indexing a missing key on a LuaObject errors +local has_recv_udp = helpers ~= nil and pcall(function() return helpers.recv_udp end) + local impl = {} impl.events = {} -impl.events[defines.events.on_tick] = check_patch +impl.events[defines.events.on_tick] = function() + check_patch() + -- Dispatch on_udp_packet_received for packets sent by the host, this is + -- a no-op when the server was started without --enable-lua-udp + if has_recv_udp then + helpers.recv_udp(0) + end +end impl.events[api.events.on_server_startup] = function() if not compat.script_data.clusterio then compat.script_data.clusterio = { instance_id = nil, instance_name = nil, + host_udp_port = nil, } end @@ -74,11 +85,12 @@ end -- Internal API clusterio_private = {} -function clusterio_private.update_instance(new_id, new_name) +function clusterio_private.update_instance(new_id, new_name, host_udp_port) check_patch() local script_data = compat.script_data script_data.clusterio.instance_id = new_id script_data.clusterio.instance_name = new_name + script_data.clusterio.host_udp_port = host_udp_port script.raise_event(api.events.on_instance_updated, { instance_id = new_id, instance_name = new_name, @@ -105,6 +117,12 @@ remote.add_interface('clusterio_api', { return compat.script_data.clusterio.instance_name end, + get_host_udp_port = function() + -- Not initialised until the first tick after the save is patched + local clusterio = compat.script_data.clusterio + return clusterio and clusterio.host_udp_port + end, + get_file_no = function() local script_data = compat.script_data script_data.clusterio_file_no = (script_data.clusterio_file_no or 0) + 1 diff --git a/packages/host/src/BaseInstancePlugin.ts b/packages/host/src/BaseInstancePlugin.ts index 457c4bbe..dd06c114 100644 --- a/packages/host/src/BaseInstancePlugin.ts +++ b/packages/host/src/BaseInstancePlugin.ts @@ -259,6 +259,22 @@ export class BaseInstancePlugin { return await this.instance.sendRcon(message, expectEmpty, this.info.name); } + /** + * Send UDP message to instance + * + * Send a UDP packet to the Factorio server, which is delivered in-game + * as an on_udp_packet_received event. Delivery is not guaranteed and + * the payload should be kept well below 64 kB. Requires the LuaUdp + * plugin feature. + * + * This should not be called before onStart or after onStop. + * + * @param message - payload of the packet to send. + */ + async sendUdp(message: string | Buffer): Promise { + await this.instance.sendUdp(message); + } + /** * Send serially ordered RCON message to instance * diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index e2507ab9..4402c268 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -233,6 +233,8 @@ export default class Instance extends lib.Link { gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id), rconPort: this.config.get("factorio.rcon_port") ?? undefined, rconPassword: this.config.get("factorio.rcon_password") ?? undefined, + enableLuaUdp: this.config.get("factorio.enable_lua_udp"), + luaUdpPort: this.config.get("factorio.lua_udp_port") ?? undefined, enableWhitelist: this.config.get("factorio.enable_whitelist"), enableAuthserverBans: this.config.get("factorio.enable_authserver_bans"), verboseLogging: this.config.get("factorio.verbose_logging"), @@ -506,6 +508,23 @@ end`.replace(/\r?\n/g, " "); } } + /** + * Send UDP message to the Factorio server + * + * Delivered in-game as an on_udp_packet_received event. Requires + * factorio.enable_lua_udp to be enabled on the instance. + * + * @param message - payload of the packet to send. + */ + async sendUdp(message: string | Buffer) { + if (!this.config.get("factorio.enable_lua_udp")) { + throw new Error( + "Attempted to send UDP message while Lua UDP is disabled. See config factorio.enable_lua_udp." + ); + } + await this.server.sendUdp(message); + } + static async listSaves(instanceId: number, savesDir: string, loadedSave: string | null) { let defaultSave = null; if (loadedSave === null) { @@ -1090,7 +1109,8 @@ end`.replace(/\r?\n/g, " "); */ async updateInstanceData() { let name = lib.escapeString(this.name); - await this.sendRcon(`/sc clusterio_private.update_instance(${this.id}, "${name}")`, true); + let hostUdpPort = this.server.hostUdpPort ?? "nil"; + await this.sendRcon(`/sc clusterio_private.update_instance(${this.id}, "${name}", ${hostUdpPort})`, true); } async updateFactorioSettings(current: Record, previous: Record) { diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index dc46a0ff..5cedf332 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import { type FSWatcher, createWriteStream, watch as fsWatch, writeFileSync } from "node:fs"; import child_process from "child_process"; +import dgram from "dgram"; import path from "path"; import stream from "node:stream"; import { pipeline } from "node:stream/promises"; @@ -303,6 +304,15 @@ async function generatePassword(length: number) { } } +function unescapeChannel(channel: Buffer) { + return channel + .toString("utf-8") + .replace(/\\x([0-9a-f]{2})/g, (match, p1) => ( + String.fromCharCode(parseInt(p1, 16)) + )) + ; +} + /** * Interpret lines of output from Factorio * @@ -425,6 +435,9 @@ const outputHeuristics: Heuristic[] = [ filter: { type: "log", message: /^Starting RCON interface/ }, action: function() { this._startRcon().catch((err) => { this.emit("error", err); }); + if (this.enableLuaUdp) { + this._startUdp().catch((err) => { this.emit("error", err); }); + } }, }, @@ -556,6 +569,10 @@ export interface FactorioServerOptions { rconPort?: number, /** Password use for RCON. */ rconPassword?: string, + /** Enable UDP messaging with the Factorio server. */ + enableLuaUdp?: boolean, + /** UDP port the Factorio server receives Lua UDP messages on. */ + luaUdpPort?: number, /** Turn on whitelisting. */ enableWhitelist?: boolean, /** Enable Factorio.com based multiplayer bans. */ @@ -600,8 +617,9 @@ type FactorioServerEvents = { "_saving": [], "_saved": [], - // IPS events + // IPC events [ipcEvent: `ipc-${string}`]: [ event: any ], + [udpEvent: `udp-${string}`]: [ data: Buffer ], }; /** @@ -621,6 +639,8 @@ type FactorioServerEvents = { * - autosave-fnished - invoked when the autosave finished * - save-finished - invoked when the server has finished a manual save * - exit - invoked when the sterver has exited + * - ipc- - invoked with data sent with send_json in-game + * - udp- - invoked with data sent with send_udp in-game * @extends events.EventEmitter */ export class FactorioServer extends events.EventEmitter { @@ -632,6 +652,10 @@ export class FactorioServer extends events.EventEmitter { rconPort: number; /** Password used for RCON on the Factorio game server */ rconPassword: string; + /** Enable UDP messaging with the Factorio game server */ + enableLuaUdp: boolean; + /** UDP port the Factorio game server receives Lua UDP messages on */ + luaUdpPort: number; /** Enable player whitelist */ enableWhitelist: boolean; /** Enable Factorio.com based multiplayer bans **/ @@ -661,6 +685,7 @@ export class FactorioServer extends events.EventEmitter { _server: child_process.ChildProcessWithoutNullStreams | null = null; _rconClient: Rcon | null= null; _rconReady = false; + _udpSocket: dgram.Socket | null = null; _gameReady = false; _stripRegExp?: RegExp; _maxConcurrentCommands = 5; @@ -693,6 +718,10 @@ export class FactorioServer extends events.EventEmitter { this.rconPort = options.rconPort || randomDynamicPort(); /** Password used for RCON on the Factorio game server */ this.rconPassword = options.rconPassword as string; // init will generate one if not there + /** Enable UDP messaging with the Factorio game server */ + this.enableLuaUdp = options.enableLuaUdp || false; + /** UDP port the Factorio game server receives Lua UDP messages on */ + this.luaUdpPort = options.luaUdpPort || randomDynamicPort(); /** Enable player whitelist */ this.enableWhitelist = options.enableWhitelist || false; /** Enable Factorio.com based multiplayer bans **/ @@ -755,19 +784,24 @@ export class FactorioServer extends events.EventEmitter { })); } + handleUdp(channel: string, handler: (data: Buffer) => Promise) { + // Runs from the socket callback, so synchronous throws must be caught here too + this.on(`udp-${channel}`, async (data) => { + try { + await handler(data); + } catch (err: any) { + this._logger.error(`Error handling udp event:\n${err.stack ?? err.message}`); + } + }); + } + async _handleIpc(line: Buffer) { let channelEnd = line.indexOf("?"); if (channelEnd === -1) { throw new Error(`Malformed IPC line "${line.toString()}"`); } - let channel = line - .subarray(6, channelEnd) - .toString("utf-8") - .replace(/\\x([0-9a-f]{2})/g, (match, p1) => ( - String.fromCharCode(parseInt(p1, 16)) - )) - ; + let channel = unescapeChannel(line.subarray(6, channelEnd)); let type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8"); let content; @@ -805,6 +839,74 @@ export class FactorioServer extends events.EventEmitter { } } + _handleUdpMessage(message: Buffer, rinfo: dgram.RemoteInfo) { + // Factorio sends from the socket bound to the Lua UDP port, anything + // else is some other local process. + if (rinfo.port !== this.luaUdpPort) { + this._logger.warn(`Ignoring UDP packet from unexpected source ${rinfo.address}:${rinfo.port}`); + return; + } + + let channelEnd = message.indexOf("?"); + if (channelEnd === -1) { + this._logger.warn(`Ignoring malformed UDP packet "${message.subarray(0, 100).toString("utf-8")}"`); + return; + } + + let channel = unescapeChannel(message.subarray(0, channelEnd)); + let data = message.subarray(channelEnd + 1); + if (!this.emit(`udp-${channel}`, data)) { + this._logger.warn(`Warning: Unhandled udp-${channel}`); + } + } + + async _startUdp() { + if (this._udpSocket !== null) { + throw Error("UDP socket is already open"); + } + + const socket = dgram.createSocket("udp4"); + socket.on("message", (message, rinfo) => this._handleUdpMessage(message, rinfo)); + try { + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(0, "127.0.0.1", () => { + socket.off("error", reject); + resolve(); + }); + }); + } catch (err: any) { + this._logger.error(`Failed to open UDP socket:\n${err?.stack ?? err?.message ?? err}`); + socket.close(); + return; + } + socket.on("error", err => this.emit("error", err)); + this._udpSocket = socket; + } + + // Factorio before 2.1.10 crashes when a packet is received on a headless server + _checkLuaUdpVersion() { + if (this.enableLuaUdp && lib.integerFullVersion(this._version!) < lib.integerFullVersion("2.1.10")) { + throw new Error("Lua UDP requires Factorio 2.1.10 or later"); + } + } + + _stopUdp() { + if (this._udpSocket) { + this._udpSocket.close(); + this._udpSocket = null; + } + } + + /** + * UDP port on localhost the host receives Lua UDP messages on + * + * Only available while the server is running with Lua UDP enabled. + */ + get hostUdpPort(): number | undefined { + return this._udpSocket?.address().port; + } + _handleOutput(rawLine: Buffer, source: "stdout" | "stderr") { if (rawLine.subarray(0, 6).equals(Buffer.from("\f$ipc:"))) { this._handleIpc(rawLine).catch(err => this.emit("error", err)); @@ -1087,6 +1189,7 @@ export class FactorioServer extends events.EventEmitter { this._server = null; this._rconClient = null; this._rconReady = false; + this._udpSocket = null; this._gameReady = false; this._unexpected = []; this._killed = false; @@ -1132,6 +1235,7 @@ export class FactorioServer extends events.EventEmitter { if (this._rconClient) { this._rconClient.end().catch(() => {}); } + this._stopUdp(); if (this._whitelistWatcher) { this._whitelistWatcher.close(); @@ -1150,6 +1254,7 @@ export class FactorioServer extends events.EventEmitter { if (this._rconClient) { this._rconClient.end().catch(() => {}); } + this._stopUdp(); if (this._whitelistWatcher) { this._whitelistWatcher.close(); @@ -1248,6 +1353,7 @@ export class FactorioServer extends events.EventEmitter { */ async start(save: string) { this._check(["init"]); + this._checkLuaUdpVersion(); this._state = "running"; try { @@ -1263,6 +1369,7 @@ export class FactorioServer extends events.EventEmitter { "--port", String(this.gamePort), "--rcon-port", String(this.rconPort), "--rcon-password", this.rconPassword, + ...(this.enableLuaUdp ? ["--enable-lua-udp", String(this.luaUdpPort)] : []), ...(this.enableWhitelist ? ["--use-server-whitelist"] : []), ...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []), ...(this.verboseLogging ? ["--verbose"] : []), @@ -1300,6 +1407,7 @@ export class FactorioServer extends events.EventEmitter { */ async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) { this._check(["init"]); + this._checkLuaUdpVersion(); this._state = "running"; try { @@ -1321,6 +1429,7 @@ export class FactorioServer extends events.EventEmitter { "--port", String(this.gamePort), "--rcon-port", String(this.rconPort), "--rcon-password", this.rconPassword, + ...(this.enableLuaUdp ? ["--enable-lua-udp", String(this.luaUdpPort)] : []), ...(this.enableWhitelist ? ["--use-server-whitelist"] : []), ...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []), ...(this.verboseLogging ? ["--verbose"] : []), @@ -1401,6 +1510,27 @@ export class FactorioServer extends events.EventEmitter { return response; } + /** + * Send UDP message to the server + * + * Sends a UDP packet to the Lua UDP port of the Factorio server, which + * is delivered in-game as an on_udp_packet_received event. Delivery is + * not guaranteed. Requires Lua UDP to be enabled. + * + * @param message - payload of the packet to send. + */ + async sendUdp(message: string | Buffer) { + this._check(["running", "stopping"]); + if (!this._udpSocket) { + throw new Error("UDP socket is not open"); + } + + const socket = this._udpSocket; + await new Promise((resolve, reject) => { + socket.send(message, this.luaUdpPort, "127.0.0.1", err => (err ? reject(err) : resolve())); + }); + } + /** * Timeout in ms to wait after a shutdown is requested before killing the * process. Defaults to 0 meaning no timeout @@ -1436,6 +1566,7 @@ export class FactorioServer extends events.EventEmitter { if (this._rconClient) { this._rconClient.end().catch(() => {}); } + this._stopUdp(); if (this._whitelistWatcher) { this._whitelistWatcher.close(); } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index cfed9f21..c0464588 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -482,6 +482,8 @@ export interface InstanceConfigFields { "factorio.host_assigned_game_port": number | null; "factorio.rcon_port": number | null; "factorio.rcon_password": string | null; + "factorio.enable_lua_udp": boolean; + "factorio.lua_udp_port": number | null; "factorio.player_online_autosave_slots": number; "factorio.mod_pack_id": number | null; "factorio.enable_save_patching": boolean; @@ -606,6 +608,21 @@ export class InstanceConfig extends classes.Config { type: "string", optional: true, }, + "factorio.enable_lua_udp": { + description: + "Enable UDP messaging between the Factorio server and the host. " + + "Required by plugins using UDP. Requires Factorio 2.1.10 or later.", + restartRequired: true, // Because of the cli option "--enable-lua-udp" + type: "boolean", + initialValue: false, + }, + "factorio.lua_udp_port": { + description: "UDP port for the Factorio server to receive Lua UDP messages on, uses a random port if null", + restartRequired: true, + type: "number", + optional: true, + validator: validators.optional(validators.greaterThanEqualZero), + }, "factorio.player_online_autosave_slots": { description: "Rename autosaves where players have been online since the previous autosave into a separate " + @@ -795,7 +812,12 @@ export function addPluginFieldDefinitions( fieldDef.dependsOn.push("factorio.enable_script_commands"); } - if (scriptCommands || savePatching) { + const luaUdp = pluginInfo.features.includes("LuaUdp"); + if (luaUdp) { + fieldDef.dependsOn.push("factorio.enable_lua_udp"); + } + + if (scriptCommands || savePatching || luaUdp) { fieldDef.validator = (enabled, config) => { if (!enabled) { return; @@ -806,6 +828,9 @@ export function addPluginFieldDefinitions( if (scriptCommands && !config.get("factorio.enable_script_commands")) { throw new Error(`Plugin ${pluginInfo.name} requires script commands`); } + if (luaUdp && !config.get("factorio.enable_lua_udp")) { + throw new Error(`Plugin ${pluginInfo.name} requires Lua UDP`); + } }; } } diff --git a/packages/lib/src/plugin.ts b/packages/lib/src/plugin.ts index 9ff338ab..18d581aa 100644 --- a/packages/lib/src/plugin.ts +++ b/packages/lib/src/plugin.ts @@ -14,6 +14,8 @@ export const PluginFeatureFlags = [ "SavePatching", /** The plugin requires access to script commands over rcon */ "ScriptCommands", + /** The plugin requires UDP messaging with the Factorio server */ + "LuaUdp", ] as const; /** Used to define the plugin export in plugins */ diff --git a/test/host/BaseInstancePlugin.js b/test/host/BaseInstancePlugin.js index 10808b40..9e7dbd30 100644 --- a/test/host/BaseInstancePlugin.js +++ b/test/host/BaseInstancePlugin.js @@ -66,6 +66,12 @@ describe("host/src/BaseInstancePlugin", function() { await assert.rejects(instancePlugin.sendRcon("a"), new Error("ref")); }); }); + describe("sendUdp", function() { + it("should send message to the instance", async function() { + await instancePlugin.sendUdp("a"); + assert.deepEqual(instancePlugin.instance.server.udpMessages, ["a"]); + }); + }); describe("sendOrderedRcon", function() { it("should send commands in order", async function() { instancePlugin.instance.server.rconCommandResults.set("a", { time: 100, response: "a" }); diff --git a/test/host/Instance.js b/test/host/Instance.js index f396c105..9d4562a5 100644 --- a/test/host/Instance.js +++ b/test/host/Instance.js @@ -240,6 +240,18 @@ describe("class Instance", function() { }); }); + describe(".sendUdp()", function() { + it("should throw when Lua UDP is disabled", async function() { + await assert.rejects(instance.sendUdp("data"), /Lua UDP is disabled/); + assert.deepEqual(instance.server.udpMessages, []); + }); + it("should send to the server when enabled", async function() { + instance.config.set("factorio.enable_lua_udp", true); + await instance.sendUdp("data"); + assert.deepEqual(instance.server.udpMessages, ["data"]); + }); + }); + describe("._recordPlayerJoin()", function() { it("should add player to playersOnline", function() { instance._recordPlayerJoin("player"); diff --git a/test/host/server.js b/test/host/server.js index 1b14e200..9934b575 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import dgram from "node:dgram"; import events from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; @@ -336,6 +337,213 @@ describe("host/server", function() { }); }); + describe("Lua UDP", function() { + let udpServer; + let received; + before(async function() { + // Stand-in for the Factorio server's --enable-lua-udp socket + udpServer = dgram.createSocket("udp4"); + received = []; + udpServer.on("message", (msg, rinfo) => received.push([msg, rinfo])); + await events.once(udpServer.bind(0, "127.0.0.1"), "listening"); + server.luaUdpPort = udpServer.address().port; + }); + after(function() { + udpServer.close(); + }); + afterEach(function() { + server._stopUdp(); + server._state = "init"; + }); + function sendFromGame(message) { + udpServer.send(Buffer.from(message), server.hostUdpPort, "127.0.0.1"); + } + function captureLogger(level) { + const logger = server._logger; + const lines = []; + server._logger = { [level]: msg => lines.push(msg) }; + return { lines, restore: () => { server._logger = logger; } }; + } + + it("should refuse to start on Factorio before 2.1.10", async function() { + const version = server._version; + server._version = "2.1.9"; + server.enableLuaUdp = true; + try { + const error = new Error("Lua UDP requires Factorio 2.1.10 or later"); + await assert.rejects(server.start("save.zip"), error); + await assert.rejects(server.startScenario("test"), error); + assert.equal(server._state, "init"); + } finally { + server._version = version; + server.enableLuaUdp = false; + } + }); + it("should open the socket when RCON starts", async function() { + const startRcon = server._startRcon; + server._startRcon = async () => {}; + server.enableLuaUdp = true; + try { + server._handleOutput(Buffer.from( + " 0.500 Info RemoteCommandProcessor.cpp:133: Starting RCON interface" + ), "stdout"); + while (!server._udpSocket) { + await wait(1); + } + } finally { + server._startRcon = startRcon; + server.enableLuaUdp = false; + } + assert(server.hostUdpPort > 0, "socket was not opened"); + }); + it("should not have a host port when not running", function() { + assert.equal(server.hostUdpPort, undefined); + }); + it("should reject sendUdp when the socket is not open", async function() { + server._state = "running"; + await assert.rejects(server.sendUdp("data"), new Error("UDP socket is not open")); + }); + it("should send packets to the Lua UDP port", async function() { + await server._startUdp(); + server._state = "running"; + assert(server.hostUdpPort > 0, "hostUdpPort not set"); + + await server.sendUdp("hello"); + await server.sendUdp(Buffer.from("world")); + while (received.length < 2) { + await wait(1); + } + assert.equal(received[0][0].toString(), "hello"); + assert.equal(received[1][0].toString(), "world"); + assert.equal(received[0][1].port, server.hostUdpPort); + }); + it("should emit udp events for packets from the game", async function() { + await server._startUdp(); + let udpReceived = []; + let onUdp = data => udpReceived.push(data); + // Escaped characters in the channel name are unescaped + server.on("udp-udp?channel", onUdp); + let waiter = events.once(server, "udp-udp?channel"); + // Packets not from the Factorio server's port are ignored + let other = dgram.createSocket("udp4"); + try { + await events.once(other.bind(0, "127.0.0.1"), "listening"); + other.send(Buffer.from("udp\\x3fchannel?bad"), server.hostUdpPort, "127.0.0.1"); + sendFromGame("udp\\x3fchannel?spam?eggs"); + await waiter; + } finally { + other.close(); + server.off("udp-udp?channel", onUdp); + } + assert(Buffer.isBuffer(udpReceived[0]), "data is not a Buffer"); + assert.deepEqual(udpReceived.map(String), ["spam?eggs"]); + }); + it("should warn on malformed packets and unhandled channels", async function() { + await server._startUdp(); + const { lines, restore } = captureLogger("warn"); + try { + sendFromGame("no separator"); + sendFromGame("unhandled?data"); + while (lines.length < 2) { + await wait(1); + } + } finally { + restore(); + } + assert.deepEqual(lines, [ + 'Ignoring malformed UDP packet "no separator"', + "Warning: Unhandled udp-unhandled", + ]); + }); + it("should invoke handlers registered with handleUdp", async function() { + let handled = []; + server.handleUdp("handled", async data => { handled.push(data.toString()); }); + server.handleUdp("failing", async () => { throw new Error("boom"); }); + const { lines, restore } = captureLogger("error"); + try { + server.emit("udp-handled", Buffer.from("data")); + server.emit("udp-failing", Buffer.from("data")); + await new Promise(resolve => setImmediate(resolve)); + } finally { + restore(); + } + assert.deepEqual(handled, ["data"]); + assert.equal(lines.length, 1); + assert(lines[0].startsWith("Error handling udp event:\nError: boom"), lines[0]); + }); + it("should log errors thrown synchronously by handleUdp handlers", async function() { + await server._startUdp(); + server.handleUdp("sync", () => { throw new Error("sync boom"); }); + const { lines, restore } = captureLogger("error"); + try { + sendFromGame("sync?data"); + while (lines.length < 1) { + await wait(1); + } + } finally { + restore(); + } + assert(lines[0].startsWith("Error handling udp event:\nError: sync boom"), lines[0]); + }); + it("should forward socket errors", async function() { + await server._startUdp(); + let waiter = events.once(server, "error"); + server._udpSocket.emit("error", new Error("socket failed")); + let [err] = await waiter; + assert.equal(err.message, "socket failed"); + }); + it("should reject sendUdp when sending fails", async function() { + await server._startUdp(); + server._state = "running"; + // Larger than the maximum size of a UDP datagram + await assert.rejects(server.sendUdp(Buffer.alloc(70000)), { code: "EMSGSIZE" }); + }); + it("should throw if the socket is already open", async function() { + await server._startUdp(); + await assert.rejects(server._startUdp(), new Error("UDP socket is already open")); + }); + it("should log an error if opening the socket fails", async function() { + const createSocket = dgram.createSocket; + dgram.createSocket = (...args) => { + const socket = createSocket(...args); + socket.bind = () => process.nextTick(() => socket.emit("error", new Error("bind failed"))); + return socket; + }; + const { lines, restore } = captureLogger("error"); + try { + await server._startUdp(); + } finally { + dgram.createSocket = createSocket; + restore(); + } + assert.equal(server._udpSocket, null); + assert(lines[0].startsWith("Failed to open UDP socket:\nError: bind failed"), lines[0]); + }); + it("should close the socket when the server exits", async function() { + await server._startUdp(); + server._server = new events.EventEmitter(); + server._state = "stopping"; + server._watchExit(); + server._server.emit("exit", 0, null); + assert.equal(server._udpSocket, null); + assert.equal(server._state, "init"); + }); + it("should close the socket when the server is killed for hanging", async function() { + await server._startUdp(); + let killed = false; + server._server = { kill: () => { killed = true; } }; + const { restore } = captureLogger("error"); + try { + server.onStopTimeout(); + } finally { + restore(); + server._server = null; + } + assert(killed, "server was not killed"); + assert.equal(server._udpSocket, null); + }); + }); + describe(".stop()", function() { it("should handle server quitting on its own during stop", async function() { server.shutdownTimeoutMs = 20; diff --git a/test/integration/index.js b/test/integration/index.js index 5793a5d3..ccb977e7 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -138,6 +138,10 @@ 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; @@ -170,6 +174,7 @@ let controllerProcess; let hostProcess; let control; let haveFactorioInstall; +let latestFactorioVersion; const baseHostConfig = loadJSON("config-host.json"); @@ -356,7 +361,7 @@ before(async function() { controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/); const factorioVersions = (await _listFactorioVersions(factorioDir)).versions; - const latestFactorioVersion = [...factorioVersions.values()] + latestFactorioVersion = [...factorioVersions.values()] .sort((a, b) => lib.integerFullVersion(b) - lib.integerFullVersion(a))[0]; haveFactorioInstall = factorioVersions.size > 0; diff --git a/test/integration/instance.js b/test/integration/instance.js index b46529a1..ff856717 100644 --- a/test/integration/instance.js +++ b/test/integration/instance.js @@ -1,4 +1,6 @@ import path from "node:path"; +import dgram from "node:dgram"; +import events from "node:events"; import assert from "node:assert/strict"; import * as lib from "@clusterio/lib"; import fs from "node:fs/promises"; @@ -6,7 +8,7 @@ import fs from "node:fs/promises"; import { slowTest, testMatrix } from "../common.js"; import { execCtl, execCtlProcess, sendRcon, getControl, - requiresFactorio, hasFactorio, instancesDir, + requiresFactorio, hasFactorio, getFactorioVersion, instancesDir, } from "./index.js"; const instId = 48; @@ -21,6 +23,19 @@ const requireApi = [ "or package.loaded['__level__/modules/clusterio/api.lua']", // 2.0.0 ].join(" "); +// Lua UDP needs Factorio 2.1.10, see docs/configuration.md +function hasLuaUdp() { + return lib.integerFullVersion(getFactorioVersion()) >= lib.integerFullVersion("2.1.10"); +} + +async function freeUdpPort() { + const socket = dgram.createSocket("udp4"); + await events.once(socket.bind(0, "127.0.0.1"), "listening"); + const port = socket.address().port; + socket.close(); + return port; +} + function getUser(name) { return getControl().send(new lib.UserGetRequest(name)); } @@ -105,12 +120,16 @@ describe("Clusterio Instance", function() { } } describe(`Integration - ${strSavePatching}, ${strScriptCommands}`, function() { + let luaUdpPort; before(async function() { this.timeout(20000); // Create and start a patched save await execCtl(`${instSetConfig} factorio.enable_whitelist true`); await execCtl(`${instSetConfig} factorio.enable_save_patching ${savePatchingEnabled}`); await execCtl(`${instSetConfig} factorio.enable_script_commands ${scriptCommandsEnabled}`); + await execCtl(`${instSetConfig} factorio.enable_lua_udp ${hasLuaUdp()}`); + luaUdpPort = await freeUdpPort(); + await execCtl(`${instSetConfig} factorio.lua_udp_port ${luaUdpPort}`); await execCtl(`instance save create ${instName}`); await execCtl(`instance start ${instName}`); }); @@ -125,6 +144,46 @@ describe("Clusterio Instance", function() { assert.equal(res === "Disabled\n", scriptCommandsEnabled); }); }); + if (savePatchingEnabled) { + describe("Lua UDP", function() { + it("should drop send_udp before the module is initialised", async function() { + // Mods can call send_udp from on_init, before the first tick sets up script data + await allowSendingScriptCommands(); + const res = await sendRcon(instId, + `/sc ${requireApi} local data = storage or global ` + + "local saved = data.clusterio data.clusterio = nil " + + "local module_ok = pcall(api.send_udp, 'early', 'data') " + + "local mod_ok, port = pcall(remote.call, 'clusterio_api', 'get_host_udp_port') " + + "data.clusterio = saved " + + "rcon.print(tostring(module_ok) .. ' ' .. tostring(mod_ok) .. ' ' .. tostring(port))" + ); + assert.equal(res, "true true nil\n"); + }); + it("should dispatch packets sent to the game", async function() { + if (!hasLuaUdp()) { + this.skip(); + } + await allowSendingScriptCommands(); + await sendRcon(instId, + "/sc script.on_event(defines.events.on_udp_packet_received, " + + "function(event) storage.udp_test = event.payload end)" + ); + const socket = dgram.createSocket("udp4"); + let payload; + try { + for (let i = 0; i < 10 && payload !== "hello\n"; i++) { + socket.send("hello", luaUdpPort, "127.0.0.1"); + await lib.wait(10); + // The server does not tick without players, commands make it tick + payload = await sendRcon(instId, "/sc rcon.print(tostring(storage.udp_test))"); + } + } finally { + socket.close(); + } + assert.equal(payload, "hello\n"); + }); + }); + } describe("Player events", function() { if (savePatchingEnabled) { // IPC expects save patching to be enabled diff --git a/test/integration/server.js b/test/integration/server.js index b74af0e9..be1c70a6 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -125,6 +125,8 @@ describe("Integration of host/src/server", function() { let mapPath = server.writePath("saves", "test.zip"); await assert.doesNotReject(fs.access(mapPath), "save is missing"); + // Lua UDP needs Factorio 2.1.10, see docs/configuration.md + server.enableLuaUdp = lib.integerFullVersion(server.version) >= lib.integerFullVersion("2.1.10"); await server.start("test.zip"); }); }); @@ -159,6 +161,56 @@ describe("Integration of host/src/server", function() { }); }); + describe("Lua UDP", function() { + it("emits udp events for packets sent from the game", async function() { + slowTest(this); + if (!server.enableLuaUdp) { + this.skip(); + } + log("Lua UDP from game"); + + assert(server.hostUdpPort > 0, "host UDP socket is not open"); + let waiter = events.once(server, "udp-test_channel"); + await server.sendRcon(`/sc helpers.send_udp(${server.hostUdpPort}, "test_channel?hello", 0)`); + let [data] = await waiter; + assert.equal(data.toString(), "hello"); + }); + it("delivers packets sent with sendUdp to the game", async function() { + slowTest(this); + if (!server.enableLuaUdp) { + this.skip(); + } + log("Lua UDP to game"); + + // Poll from on_tick like the clusterio module does + await server.sendRcon( + "/sc script.on_event(defines.events.on_udp_packet_received, " + + "function(event) print('udp:' .. event.payload) end) " + + "script.on_nth_tick(1, function() helpers.recv_udp(0) end)" + ); + let pass = false; + function filter(output) { + if (output.message === "udp:hello") { + pass = true; + } + } + server.on("output", filter); + + await server.sendUdp("hello"); + // The server does not tick without players, commands make it tick + for (let i = 0; i < 10; i++) { + await server.sendRcon("/sc rcon.print('tick')"); + await lib.wait(10); + if (pass) { + break; + } + } + + server.off("output", filter); + assert(pass, "server did not output the packet payload"); + }); + }); + describe(".stop()", function() { it("stops the server", async function() { slowTest(this); diff --git a/test/lib/config/validators.test.js b/test/lib/config/validators.test.js index 1ae9de05..db5113f4 100644 --- a/test/lib/config/validators.test.js +++ b/test/lib/config/validators.test.js @@ -340,6 +340,35 @@ describe("lib/config/definitions/validators", function() { /requires script commands/ ); }); + it("should validate against factorio.enable_lua_udp", function() { + class TestInstanceConfig extends InstanceConfig { + static fieldDefinitions = { ...InstanceConfig.fieldDefinitions }; + } + + lib.addPluginFieldDefinitions( + { name: "lua_udp", features: ["LuaUdp"] }, + "instanceConfigFields", TestInstanceConfig + ); + + const config = new TestInstanceConfig("controller", initialConfigFields); + + // valid: does not require save patching or script commands + assert.doesNotThrow(() => config.set("factorio.enable_save_patching", false)); + assert.doesNotThrow(() => config.set("factorio.enable_script_commands", false)); + + // valid: can have load disabled + assert.doesNotThrow(() => config.set("lua_udp.load_plugin", false)); + + // invalid: requires lua udp when loaded (disabled by default) + assert.throws( + () => config.set("lua_udp.load_plugin", true), + /requires Lua UDP/ + ); + + // valid: requirement satisfied + assert.doesNotThrow(() => config.set("factorio.enable_lua_udp", true)); + assert.doesNotThrow(() => config.set("lua_udp.load_plugin", true)); + }); it("should allow when no feature flags are present", function() { class TestInstanceConfig extends InstanceConfig { static fieldDefinitions = { ...InstanceConfig.fieldDefinitions }; diff --git a/test/mock.js b/test/mock.js index 265e869a..9b2d9ef6 100644 --- a/test/mock.js +++ b/test/mock.js @@ -95,6 +95,11 @@ export class MockServer extends events.EventEmitter { reset() { this.rconCommands = []; this.rconCommandResults = new Map(); + this.udpMessages = []; + } + + async sendUdp(message) { + this.udpMessages.push(message); } async sendRcon(command) { @@ -144,6 +149,10 @@ export class MockInstance extends lib.Link { async sendRcon(command, expectEmpty, plugin) { return await this.server.sendRcon(command); } + + async sendUdp(message) { + await this.server.sendUdp(message); + } } export class MockHost extends lib.Link {