From 9742eb1645cacd2dc41f4872dec6bb20a38af179 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 9 Jan 2024 19:17:43 +0100 Subject: [PATCH 1/2] Add parser for simple range expression Implement parsing of ranges of the form "1, 3-5" into a set of all the numbers it represents. --- packages/lib/src/helpers.ts | 111 ++++++++++++++++++++++++++++++++++-- test/lib/helpers.js | 53 +++++++++++++++++ 2 files changed, 160 insertions(+), 4 deletions(-) diff --git a/packages/lib/src/helpers.ts b/packages/lib/src/helpers.ts index b322cf7c..34d5ba28 100644 --- a/packages/lib/src/helpers.ts +++ b/packages/lib/src/helpers.ts @@ -230,6 +230,14 @@ export function formatBytes(bytes: number) { return (power > 0 ? (bytes / factor ** power).toFixed(2) : bytes) + units[power]; } +function skipWhitespace(pos: number, input: string) { + // whitespace = 1*" " + while (pos < input.length && input.charAt(pos) === " ") { + pos += 1; + } + return pos; +} + function parseSearchIdentifier(pos: number, input: string): [number, string] { // character = ? all characters excluding space : " ? // identifier = 1*character @@ -393,7 +401,6 @@ export function parseSearchString( input: string, attributes: Record = {} ): ParsedSearch { - // whitespace = 1*" " // search = [ term, *( [ whitespace ], term ) ] input = input.trim(); let parsed = { @@ -407,9 +414,7 @@ export function parseSearchString( if (term) { parsed.terms.push(term); } - while (pos < input.length && input.charAt(pos) === " ") { - pos += 1; - } + pos = skipWhitespace(pos, input); } // istanbul ignore if (should not be possible) if (pos !== input.length) { @@ -417,3 +422,101 @@ export function parseSearchString( } return parsed; } + +function isDigit(pos: number, input: string) { + const code = input.charCodeAt(pos); + return "0".charCodeAt(0) <= code && code <= "9".charCodeAt(0); +} + +function parseNumber(pos: number, input: string): [number, number] { + // number = 1*('0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9') + let startPos = pos; + let endPos = pos; + while (pos < input.length && isDigit(pos, input)) { + pos += 1; + endPos += 1; + } + return [pos, Number.parseInt(input.slice(startPos, endPos), 10)]; +} + +function parseRange(pos: number, input: string, min: number, max: number): [number, Set] { + // range = number, [ whitespace ], [ '-', [ whitespace ], number ] + const range = new Set(); + if (!isDigit(pos, input)) { + throw new Error(`Expected digit but got '${input[pos]}' at pos ${pos} while parsing "${input}"`); + } + let start: number; + ([pos, start] = parseNumber(pos, input)); + pos = skipWhitespace(pos, input); + if (pos < input.length && input[pos] === "-") { + pos += 1; // Skip dash + pos = skipWhitespace(pos, input); + if (pos === input.length) { + throw new Error(`Expected digit but got end of input while parsing "${input}"`); + } + if (!isDigit(pos, input)) { + throw new Error(`Expected digit but got '${input[pos]}' at pos ${pos} while parsing "${input}"`); + } + let end: number; + ([pos, end] = parseNumber(pos, input)); + + if (start > end) { + ([start, end] = [end, start]); + } + if (start < min) { + throw new Error(`start of range ${start}-${end} is below the minimum value ${min}`); + } + if (end > max) { + throw new Error(`end of range ${start}-${end} is above the maximum value ${max}`); + } + for (let i = start; i <= end; i++) { + range.add(i); + } + } else { // Single value range + if (start < min) { + throw new Error(`value ${start} is below the minimum value ${min}`); + } + if (start > max) { + throw new Error(`value ${start} is above the maximum value ${max}`); + } + range.add(start); + } + return [pos, range]; +} + +/** + * Parse a comma separated range expression + * + * Parses the given input as a series of comma sepparated number ranges + * where each range can consist of either a whole number or a whole number + * of where the range starts, a dash and then a whole number of where the + * range ends inclusively. + * + * @param input - Range expression to parse + * @param min - Minimum accepted input value. + * @param max - Maximum accepted input value. + * @returns Set of all numbers in the parsed range. + */ +export function parseRanges( + input: string, + min: number, + max: number, +) { + // ranges = [ range, *( [ whitespace ], [ ',', [ whitespace ] ], range ) ] + input = input.trim(); + const parsed = new Set(); + let pos = 0; + while (pos < input.length) { + let range: Set; + ([pos, range] = parseRange(pos, input, min, max)); + for (const i of range) { + parsed.add(i); + } + pos = skipWhitespace(pos, input); + if (pos < input.length && input[pos] === ",") { + pos += 1; + pos = skipWhitespace(pos, input); + } + } + return parsed; +} diff --git a/test/lib/helpers.js b/test/lib/helpers.js index 25b0270c..7b898215 100644 --- a/test/lib/helpers.js +++ b/test/lib/helpers.js @@ -300,4 +300,57 @@ describe("lib/helpers", function() { ); }); }); + describe("parseRanges()", function() { + function range(input, min=1, max=10) { + return [...lib.parseRanges(input, min, max)]; + } + function expectedRange(start, end) { + const items = new Set(); + for (let i = start; i <= end; i++) { + items.add(i); + } + return [...items]; + } + it("should parse numbers separated by spaces and or commas", function() { + assert.deepEqual(range("1"), [1]); + assert.deepEqual(range(" 1"), [1]); + assert.deepEqual(range("1 "), [1]); + assert.deepEqual(range(" 1 "), [1]); + assert.deepEqual(range("1 2"), [1, 2]); + assert.deepEqual(range("1, 2"), [1, 2]); + assert.deepEqual(range("1 , 2"), [1, 2]); + assert.deepEqual(range(" 1 2 "), [1, 2]); + }); + it("should parse ranges", function() { + assert.deepEqual(range("1-4"), [1, 2, 3, 4]); + assert.deepEqual(range(" 1-4 "), [1, 2, 3, 4]); + assert.deepEqual(range(" 1 - 4 "), [1, 2, 3, 4]); + assert.deepEqual(range("1- 4"), [1, 2, 3, 4]); + assert.deepEqual(range("1 -4"), [1, 2, 3, 4]); + assert.deepEqual(range(" 1, 2-4"), [1, 2, 3, 4]); + assert.deepEqual(range("4-1"), [1, 2, 3, 4]); + }); + it("should ignore duplicates in ranges", function() { + assert.deepEqual(range("1, 2, 2, 2"), [1, 2]); + assert.deepEqual(range("1-4, 3, 4"), [1, 2, 3, 4]); + assert.deepEqual(range("3, 1-4"), [3, 1, 2, 4]); + }); + it("should throw on bad input", function() { + assert.throws(() => range("1-"), new Error('Expected digit but got end of input while parsing "1-"')); + assert.throws(() => range("a1-4"), new Error('Expected digit but got \'a\' at pos 0 while parsing "a1-4"')); + assert.throws(() => range("1a-4"), new Error('Expected digit but got \'a\' at pos 1 while parsing "1a-4"')); + assert.throws(() => range("1-a4"), new Error('Expected digit but got \'a\' at pos 2 while parsing "1-a4"')); + assert.throws(() => range("1-4a"), new Error('Expected digit but got \'a\' at pos 3 while parsing "1-4a"')); + }); + it("should throw if out of range", function() { + assert.throws(() => range("0"), new Error("value 0 is below the minimum value 1")); + assert.throws(() => range("110"), new Error("value 110 is above the maximum value 10")); + assert.throws(() => range("1-999"), new Error("end of range 1-999 is above the maximum value 10")); + assert.throws(() => range("0-1"), new Error("start of range 0-1 is below the minimum value 1")); + }); + it("should handle 16-bit ranges", function() { + assert.deepEqual(range("1-65535", 1, 2**16 - 1), expectedRange(1, 2**16 - 1)); + assert.deepEqual(range("20000-20002", 1, 2**16 - 1), [20000, 20001, 20002]); + }); + }); }); From 87335752556746ee460cd83309bdd13ef3866637 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 10 Jan 2024 22:53:28 +0100 Subject: [PATCH 2/2] 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(); });