From 87335752556746ee460cd83309bdd13ef3866637 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 10 Jan 2024 22:53:28 +0100 Subject: [PATCH] Assign game port from host configured port ranges Add host.factorio_port_range config to hosts that specify a pool of ports that gets automatically assigned to instances which do not have a game_port set on them. This allows setting up a port range that's forwarded to the host on the router and then configure that port range on the host and have instances automatically get assigned an available port when they start up. Resolves: #448 --- .eslintrc.js | 1 + packages/host/src/Host.ts | 58 +++++++++++++++++++++++++- packages/host/src/Instance.ts | 4 +- packages/lib/src/config/definitions.ts | 17 +++++++- test/host/Instance.js | 2 +- 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 0764886b..3cf36d34 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -288,6 +288,7 @@ module.exports = { ":": "before", "||": "before", "&&": "before", + "??": "before", }, }], "padded-blocks": "off", diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 436efe1b..a975fd09 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -599,7 +599,9 @@ export default class Host extends lib.Link { let instanceConnection = this.instanceConnections.get(instanceId); this.send( new lib.InstanceStatusChangedEvent( - instanceId, instanceConnection ? instanceConnection.instance.status : "stopped", undefined + instanceId, + instanceConnection ? instanceConnection.instance.status : "stopped", + instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId), ) ); @@ -607,6 +609,10 @@ export default class Host extends lib.Link { await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); // save a copy of the instance config + await this.saveInstanceConfig(instanceInfo); + } + + async saveInstanceConfig(instanceInfo: { path: string, config: lib.InstanceConfig }) { let warnedOutput = { _warning: "Changes to this file will be overwritten by the controller's copy.", ...instanceInfo.config.toJSON(), @@ -647,6 +653,50 @@ export default class Host extends lib.Link { return instanceInfo; } + /** + * Retrieved assigned port of the given instance + * @param instanceId - ID of the instance to get the assigned game port of: + * @returns Assigned game port or undefined if it does not exist. + */ + gamePort(instanceId: number) { + const instance = this.discoveredInstanceInfos.get(instanceId); + if (!instance) { + return undefined; + } + return ( + instance.config.get("factorio.game_port") + ?? instance.config.get("factorio.host_assigned_game_port") + ?? undefined + ); + } + + assignGamePort(instanceId: number) { + const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2**16 - 1); + for (const [id, instance] of this.instanceInfos) { + if (id === instanceId) { + continue; + } + const port = ( + instance.config.get("factorio.game_port") + ?? instance.config.get("factorio.host_assigned_game_port") + ); + if (port !== null) { + availablePorts.delete(port); + } + } + const instanceToAssign = this.instanceInfos.get(instanceId)!; + const assignedPort = instanceToAssign.config.get("factorio.host_assigned_game_port"); + if (assignedPort !== null && availablePorts.has(assignedPort)) { + return assignedPort; + } + if (!availablePorts.size) { + throw new lib.RequestError("No available port left to assign from host.factorio_port_range"); + } + const [newPort] = availablePorts; + instanceToAssign.config.set("factorio.host_assigned_game_port", newPort); + return newPort; + } + /** * Initialize and connect an unloaded instance * @@ -923,7 +973,7 @@ export default class Host extends lib.Link { list.push(new lib.HostInstanceUpdate( instanceInfo.config.toRemote("controller"), instanceConnection ? instanceConnection.instance.status : "stopped", - instanceConnection ? instanceConnection.instance.server.gamePort : undefined, + instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId), )); } await this.send(new lib.InstancesUpdateRequest(list)); @@ -986,6 +1036,10 @@ export default class Host extends lib.Link { logger.info("Saving config"); await lib.safeOutputFile(this.configPath, JSON.stringify(this.config, null, "\t")); + for (const instanceInfo of this.instanceInfos.values()) { + // Save in case host_assigned_game_port changed in the config. + await this.saveInstanceConfig(instanceInfo); + } try { // Clear silly interval in pidfile library. diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 09acb34c..3fda98ec 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -169,7 +169,7 @@ export default class Instance extends lib.Link { let serverOptions = { logger: this.logger, version: this.config.get("factorio.version"), - gamePort: this.config.get("factorio.game_port") ?? undefined, + 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, enableWhitelist: this.config.get("factorio.enable_whitelist"), @@ -449,7 +449,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); new lib.InstanceStatusChangedEvent( this.id, status, - status !== "stopped" ? this.server.gamePort : undefined, + this.server.gamePort, ), ); } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index eb5dbb51..fd691557 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -158,6 +158,7 @@ export interface HostConfigFields { "host.controller_token": string; "host.tls_ca": string | null; "host.public_address": string; + "host.factorio_port_range": string; "host.max_reconnect_delay": number; } @@ -220,6 +221,14 @@ export class HostConfig extends classes.Config { type: "string", initialValue: "localhost", }, + "host.factorio_port_range": { + title: "Factorio port range", + description: + "Range of UDP ports to use for game connections. Supports both comma separated values and " + + "ranges separated with a dash.", + type: "string", + initialValue: "34100-34199", + }, "host.max_reconnect_delay": { title: "Max Reconnect Delay", description: "Maximum delay to wait before attempting to reconnect WebSocket", @@ -237,6 +246,7 @@ export interface InstanceConfigFields { "factorio.version": string; "factorio.game_port": number | null; + "factorio.host_assigned_game_port": number | null; "factorio.rcon_port": number | null; "factorio.rcon_password": string | null; "factorio.player_online_autosave_slots": number; @@ -286,11 +296,16 @@ export class InstanceConfig extends classes.Config { initialValue: "latest", }, "factorio.game_port": { - description: "UDP port to run game on, uses a random port if null", + description: "UDP port to run game on, uses a port in host.factorio_port_range if null", restartRequired: true, type: "number", optional: true, }, + "factorio.host_assigned_game_port": { + access: ["host"], + type: "number", + optional: true, + }, "factorio.rcon_port": { description: "TCP port to run RCON on, uses a random port if null", restartRequired: true, diff --git a/test/host/Instance.js b/test/host/Instance.js index 9af3921d..723bd776 100644 --- a/test/host/Instance.js +++ b/test/host/Instance.js @@ -20,7 +20,7 @@ describe("class Instance", function() { instanceConfig.set("instance.name", "foo"); src = addr({ instanceId: instanceConfig.get("instance.id") }); connector = new MockConnector(src, dst); - instance = new Instance({}, connector, "dir", "factorioDir", instanceConfig); + instance = new Instance({ assignGamePort: () => 1 }, connector, "dir", "factorioDir", instanceConfig); instance.server = new MockServer(); });