mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 07:04:53 +09:00
Merge pull request #538 from Hornwitser/host-port-ranges
Host port ranges
This commit is contained in:
7 files changed
+236
-10
No files matched your search
@@ -288,6 +288,7 @@ module.exports = {
|
||||
":": "before",
|
||||
"||": "before",
|
||||
"&&": "before",
|
||||
"??": "before",
|
||||
},
|
||||
}],
|
||||
"padded-blocks": "off",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HostConfigFields> {
|
||||
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<InstanceConfigFields> {
|
||||
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,
|
||||
|
||||
+107
-4
@@ -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<string, string> = {}
|
||||
): 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<number>] {
|
||||
// range = number, [ whitespace ], [ '-', [ whitespace ], number ]
|
||||
const range = new Set<number>();
|
||||
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<number>();
|
||||
let pos = 0;
|
||||
while (pos < input.length) {
|
||||
let range: Set<number>;
|
||||
([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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user