UNPKG

@clusterio/host

Version:

Implementation of Clusterio host server

1,394 lines (1,247 loc) 41 kB
// Factorio Server interface import fs from "fs-extra"; import child_process from "child_process"; import path from "path"; import events from "events"; import util from "util"; import crypto from "crypto"; import { Rcon } from "rcon-client"; import * as lib from "@clusterio/lib"; /** * Determines the version of Factorio the datadir is pointing to by * reading the changelog.txt in it. * * @param changelogPath - Path to changelog.txt. * @internal */ async function getVersion(changelogPath: string) { let changelog; try { changelog = await fs.readFile(changelogPath, "utf-8"); } catch (err: any) { if (err.code === "ENOENT") { return null; } throw err; } for (let line of changelog.split(/[\r\n]+/)) { let index = line.indexOf(":"); if (index !== -1) { let name = line.slice(0, index).trim(); if (name.toLowerCase() === "version") { return line.slice(index + 1).trim(); } } } return null; } /** * Comparison function for sorting Factorio version strings * * @param a - Version to compare. * @param b - Version to compare. * @returns 1 if a < b, 0 if a = b and -1 if a > b. * @internal */ function versionOrder(a: string, b: string) { let aParts = a.split(".").map(s => Number.parseInt(s, 10)); let bParts = b.split(".").map(s => Number.parseInt(s, 10)); for (let i = 0; i < aParts.length; i++) { if (aParts[i] === bParts[i]) { continue; } else if (aParts[i] < bParts[i]) { return 1; } else { return -1; } } return 0; } /** * Find factorio data directory of the given version * * Searches the given factorio dir for an installation of Factorio of * the target version. * * @param factorioDir - Path to Factorio installation dir(s). * @param targetVersion - * Version to look for, supports the special value "latest" for the * latest version available. * @returns Array with path to data dir and version found. * @internal */ async function findVersion(factorioDir: string, targetVersion: string) { // There are two supported setups: having the factorio dir be the actual // install directory, and having the factorio dir be a folder containing // multiple install directories let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt")); if (simpleVersion !== null) { if (simpleVersion === targetVersion || targetVersion === "latest") { return [path.join(factorioDir, "data"), simpleVersion]; } throw new Error( `Factorio version ${targetVersion} was requested, but install directory contains ${simpleVersion}` ); } let versions = new Map(); for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) { if (!entry.isDirectory()) { continue; } let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt")); if (version === null) { continue; } if (version === targetVersion) { return [path.join(factorioDir, entry.name, "data"), version]; } versions.set(version, entry.name); } if (!versions.size) { throw new Error(`Unable to find any Factorio install in ${factorioDir}`); } if (targetVersion === "latest") { let latest = [...versions.keys()].sort(versionOrder)[0]; return [path.join(factorioDir, versions.get(latest), "data"), latest]; } throw new Error(`Unable to find Factorio version ${targetVersion}`); } /** * Give a random dynamic port * * Returns a random port number in the Dynamic Ports range as defined by * RFC 6335. * * @return a number in the range 49152 to 65535. * @internal */ function randomDynamicPort() { const start = 49152; const end = 65535 + 1; return Math.floor(Math.random() * (end - start) + start); } /** * Generate a secure random password of the given length * * Uses crypto.randomBytes to generate a secure alphanumeric password of * the given length. * * @param length - the length of the password to generate. * @return password of the given length * @internal */ async function generatePassword(length: number) { function validChar(byte: number) { const ranges = ["az", "AZ", "09"]; return ranges.some(range => ( range.codePointAt(0)! <= byte && byte <= range.codePointAt(1)! )); } let randomBytesAsync = util.promisify(crypto.randomBytes); let password = ""; while (true) { let bytes = await randomBytesAsync((length - password.length) * 3); for (let byte of bytes) { // Crop to ASCII values only byte = byte & 0x7f; if (validChar(byte)) { password += String.fromCharCode(byte); if (password.length === length) { return password; } } } } } /** * Interpret lines of output from Factorio * * Parses a line of output from Factorio and interprets the content * based on the broad categories of output that it produces. * * TODO document output format. * * @param line - A line of output not including the line terminator * @param source - Passed into the output structure as source * * @returns - An object with interpeted data. * @internal */ function parseOutput(line: string, source: "stdout" | "stderr"): lib.ParsedFactorioOutput { // There are three broad categories of output from Factorio, the first kind // starts with the seconds since the server started and has a format of // " 0.704 message" const secRegex = /^ {0,3}(\d+\.\d+) (.*)$/; let secMatch = secRegex.exec(line); if (secMatch) { const format = "seconds"; const time = secMatch[1]; const secContent = secMatch[2]; // A seconds output has two different kinds of formats: The first is a // log level and source location and has a format of // "Level File.cpp:123: message" // "Script @/path/to/file.lua:123: message" // "Script =(command):123: message" const secLogRegex = /^(\w+) ((\w+\.cpp:\d+)|([@=].*?:\d+)): (.*)$/; let secLogMatch = secLogRegex.exec(secContent); if (secLogMatch) { return { source, format, time, type: "log", level: secLogMatch[1], file: secLogMatch[2], message: secLogMatch[5], }; } // The other possibility is that the content is a generic message return { source, format, time, type: "generic", message: secContent, }; } // The second category of output starts with a date stamp of the format // "yyyy-mm-dd hh:mm:ss message" const dateRegex = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) (.*)$/; let dateMatch = dateRegex.exec(line); if (dateMatch) { const format = "date"; const time = dateMatch[1]; let dateContent = dateMatch[2]; // A date output has two general formats. The first is an action // followed by a message and has a format of "[ACTION] message" const dateActionRegex = /^\[(\w+)\] (.*)$/; let dateActionMatch = dateActionRegex.exec(dateContent); if (dateActionMatch) { return { source, format, time, type: "action", action: dateActionMatch[1], message: dateActionMatch[2], }; } // The other format is a generic message return { source, format, time, type: "generic", message: dateContent, }; } // The last category of output is simply a generic message with no // formating. return { source, format: "none", type: "generic", message: line, }; } // https://stackoverflow.com/a/49402091 type KeysOfUnion<T> = T extends T ? keyof T : never; interface Heuristic { filter: { [P in KeysOfUnion<lib.ParsedFactorioOutput>]?: string | string[] | RegExp; }, action(this: FactorioServer, parsed: lib.ParsedFactorioOutput): void; } // These are filters applied to the output from Factorio. The filter // syntax is a set of properties to test for in the parsed output. If // the property is a RegExp it's tested using the RegExp. If it's an // array it's tested using the includes method of the array. Otherwise // the property must compare strictly equal. // // If the filter matches the action is called with the FactorioServer // instance bound as this. const outputHeuristics: Heuristic[] = [ // Message indicating the RCON interface has started { filter: { type: "log", message: /^Starting RCON interface/ }, action: function() { this._startRcon().catch((err) => { this.emit("error", err); }); }, }, // Message indicating the server is done starting up { filter: { type: "log", message: /^(update|map)Tick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ }, action: function() { this._notifyGameReady().catch((err) => { this.emit("error", err); }); }, }, // Message indicating the server has started an autosave { filter: { type: "log", message: /^Saving to _autosave\d+ \((non-)?blocking\)\.$/, }, action: function(parsed) { let name = /^Saving to (_autosave\d+) /.exec(parsed.message)![1]; this.emit("_autosave", name); }, }, // Message indicating the server has started saving { filter: { type: "log", message: /^Saving game as /, }, action: function() { this.emit("_saving"); }, }, // Message indicating the server has finished saving { filter: { type: "log", message: [ "Saving finished", // Outputted when saving failed from --server-start-load-scenario "Can't save to default location: Default location not known", ], }, action: function() { this.emit("_saved"); }, }, // Last messages before the server hangs during shutdown waiting for console input on Windows. { filter: { type: "generic", message: "Quitting: multiplayer error.", }, action: function(parsed) { // On windows the Factorio server will wait for input on the console input buffer // before shutting down. Since we can't send input there we terminate the server // process when this happens. if (process.platform === "win32") { this._server!.kill(); // Notify that the server is being killed after the last message // is logged so that it doesn't look like it was killed unnecessarily. process.nextTick(() => { this._logger.info("Killing Factorio process to avoid it hanging forever."); }); } }, }, { filter: { type: "log", message: /^updateTick\(\d+\) changing state from\(Disconnected\) to\(Closed\)$/, }, action: function() { if (process.platform === "win32") { this._server!.kill(); process.nextTick(() => { this._logger.info("Killing Factorio process to avoid it hanging forever."); }); } }, }, // Messages that might be tha cause of an unexpected shutdown. { filter: { type: "log", level: "Error", message: [ "MultiplayerManager failed: \"Binding IPv4 socket failed: Permission denied\"", "MultiplayerManager failed: Host address is already in use.", ], }, action: function(parsed) { this._unexpected.push(`Factorio failed to bind to game port: ${parsed.message}`); }, }, { filter: { type: "log", level: "Error", message: [ "Can't bind socket: Address already in use", "Can't bind socket: Permission denied", ], }, action: function(parsed) { this._unexpected.push(`Factorio failed to bind to RCON port: ${parsed.message}`); }, }, ]; export interface FactorioServerOptions { /** Logger to use for reporting errors. */ logger?: lib.Logger, /** * Version of Factorio to use. Can also be the string "latest" to use * the latest version found in `factorioDir`. */ version?: string, /** Path to executable to invoke when starting the server */ executablePath?: string; /** UDP port to host game on. */ gamePort?: number, /** TCP port to use for RCON. */ rconPort?: number, /** Password use for RCON. */ rconPassword?: string, /** Turn on whitelisting. */ enableWhitelist?: boolean, /** Enable Factorio.com based multiplayer bans. */ enableAuthserverBans?: boolean, /** Enable verbose logging. */ verboseLogging?: boolean, /** Enable console logging. */ consoleLogging?: boolean, /** Strip paths in the console. */ stripPaths?: boolean, /** * Maximum number of RCON commands transmitted in parallel on the RCON * connection. */ maxConcurrentCommands?: number /** * Timeout in ms to wait after a shutdown is requested before killing the * process. Defaults to 0 meaning no timeout */ shutdownTimeoutMs?: number, } /** * Factorio Server interface * * Handles the interactions with a Factorio server, including running, * stopping and sending commands to the server. It does not deal with * creating or managing servers, or downloading Factorio. * * This is an EventEmitter with the following events: * - stdout - invoked when Factorio outputs a line to stdout * - stderr - invoked when Factorio outputs a line to stderr * - output - invoked with parsed output from Factorio * - rcon-ready - invoked when the RCON client has connected * - game-ready - invoked when the server is finished starting up * - autosave-start - invoked when the server starts an autosave * - 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 * @extends events.EventEmitter */ export class FactorioServer extends events.EventEmitter { /** Path to executable to invoke when starting the server */ executablePath?: string; /** UDP port used for hosting the Factorio game server on */ gamePort: number; /** TCP port used for RCON on the Factorio game server */ rconPort: number; /** Password used for RCON on the Factorio game server */ rconPassword: string; /** Enable player whitelist */ enableWhitelist: boolean; /** Enable Factorio.com based multiplayer bans **/ enableAuthserverBans: boolean; /** Enable verbose logging */ verboseLogging: boolean; /** Enable console logging */ consoleLogging: boolean; _shutdownTimeoutMs = 0; _stopTimeoutId?: ReturnType<typeof setTimeout>; _factorioDir: string; _writeDir: string; // Resolved in init _version: string | null = null; _dataDir: string | null = null; // Due to inconsistencies in the factorio api, we must manually watch the whitelist // https://forums.factorio.com/viewtopic.php?t=123673 _whitelistWatcher: fs.FSWatcher | null = null; _whitelist = new Set<string>(); _logger: lib.Logger; _targetVersion: string; _state: "new" | "init" | "create" | "running" | "stopping" = "new"; _server: child_process.ChildProcessWithoutNullStreams | null = null; _rconClient: Rcon | null= null; _rconReady = false; _gameReady = false; _stripRegExp?: RegExp; _maxConcurrentCommands = 5; // Array of possible causes for an unexpected shutdown _unexpected: string[] = []; _killed = false; _runningAutosave: string | null = null; /** * Create a Factorio server interface * * @param factorioDir - Directory of the Factorio install(s). * @param writeDir - Directory to write runtime data to. * @param options - Optional parameters. */ constructor(factorioDir: string, writeDir: string, options: FactorioServerOptions) { super(); this._factorioDir = factorioDir; this._writeDir = writeDir; this._logger = options.logger || lib.logger; this._targetVersion = options.version || "latest"; this.executablePath = options.executablePath; /** UDP port used for hosting the Factorio game server on */ this.gamePort = options.gamePort || randomDynamicPort(); /** TCP port used for RCON on the Factorio game server */ 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 player whitelist */ this.enableWhitelist = options.enableWhitelist || false; /** Enable Factorio.com based multiplayer bans **/ this.enableAuthserverBans = options.enableAuthserverBans || false; /** Enable verbose logging */ this.verboseLogging = options.verboseLogging || false; /** Enable console logging */ this.consoleLogging = options.consoleLogging || false; /** Maximum number of RCON commands transmitted in parallel on the RCON connection */ this.maxConcurrentCommands = options.maxConcurrentCommands || 5; if (options.shutdownTimeoutMs !== undefined) { this.shutdownTimeoutMs = options.shutdownTimeoutMs; } if (options.stripPaths) { let charSet = new Set(path.resolve(this.writePath("temp"))); charSet.delete(":"); // Having a colon could lead to matching the line number let chars = [...charSet].join(""); let tempPath = `${path.resolve(this.writePath("temp", "currently-playing"))}${path.sep}`; let writePath = `${path.resolve(this.writePath())}${path.sep}`; this._stripRegExp = new RegExp( // The script printer formats paths using / on both windows and linux. // But the save path is printed with \ on windows and / on linux. `(${lib.escapeRegExp(tempPath.replace(/\\/g, "/"))})|` + `(${lib.escapeRegExp(writePath)})|` + `(\\.\\.\\.[${lib.escapeRegExp(chars)}/]*?currently-playing/)`, "g" ); } // Track autosaving this.on("_autosave", name => { this.emit("autosave-start", name); this._runningAutosave = name; }); this.on("_saved", () => { if (this._runningAutosave) { this.emit("autosave-finished", this._runningAutosave); this._runningAutosave = null; } else { this.emit("save-finished"); } }); } _check(expectedStates: FactorioServer["_state"][]) { if (!expectedStates.includes(this._state)) { throw new Error( `Expected state ${expectedStates} but state is ${this._state}` ); } } // TODO at the moment this is just a helper function, it has the potential for schema checking handle<Event>(eventName: string, handler: (event: Event) => Promise<void>) { this.on(`ipc-${eventName}`, (event) => handler(event).catch((err: Error) => { this._logger.error(`Error handling ipc 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 type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8"); let content; if (type === "j") { try { content = JSON.parse(line.subarray(channelEnd + 2).toString("utf-8")); } catch (err) { throw new Error(`Malformed JSON to ${channel}: ${line.subarray(channelEnd + 2).toString("utf-8")}`); } } else if (type === "f") { let fileName = line.subarray(channelEnd + 2).toString("utf-8"); let filePath = this.writePath("script-output", fileName); // Prevent malicious names if (/[\\/\0]/.test(fileName)) { throw new Error(`Invalid IPC file name '${fileName}'`); } if (fileName.endsWith(".json")) { content = JSON.parse(await fs.readFile(filePath, "utf-8")); await fs.unlink(filePath); } else { let format = fileName.slice(fileName.lastIndexOf(".") + 1); throw new Error(`Unknown IPC file format '${format}'`); } } else { throw new Error(`Unknown IPC type '${type}'`); } if (!this.emit(`ipc-${channel}`, content)) { this._logger.warn(`Warning: Unhandled ipc-${channel}`, { content }); } } _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)); return; } this.emit(source, rawLine); let line = rawLine.toString("utf-8"); if (this._stripRegExp) { line = line.replace(this._stripRegExp, ""); } let parsed = parseOutput(line, source); heuristicLoop: for (let heuristic of outputHeuristics) { for (let [name, expected] of Object.entries(heuristic.filter)) { if (!Object.hasOwnProperty.call(parsed, name)) { continue heuristicLoop; } if (expected instanceof RegExp) { if (!expected.test((parsed as any)[name])) { continue heuristicLoop; } } else if (expected instanceof Array) { if (!expected.includes((parsed as any)[name])) { continue heuristicLoop; } } else if (expected !== (parsed as any)[name]) { continue heuristicLoop; } } // If we get here the filter matched the output heuristic.action.call(this, parsed); } this.emit("output", parsed, line); } async _startRcon() { if (this._rconClient !== null) { throw Error("RCON client is already started"); } let config = { host: "127.0.0.1", // localhost may resolve to ::1 while Factorio listen to only IPv4 by default. port: this.rconPort, password: this.rconPassword, timeout: 200000, // 200s, should allow for commands up to 1250kB in length maxPending: this.maxConcurrentCommands, }; this._rconClient = new Rcon(config); this._rconClient.on("error", () => { // Errors before being authenticated might not emit end this._rconClient = null; if (!this._rconReady) { this._rconReady = true; this.emit("rcon-ready"); } }); this._rconClient.on("authenticated", () => { this._rconReady = true; this.emit("rcon-ready"); }); this._rconClient.on("end", () => { this._rconClient = null; if (!this._rconReady) { this._rconReady = true; this.emit("rcon-ready"); } }); // XXX: Workaround to suppress bogus event listener warning if (this._rconClient.emitter instanceof events.EventEmitter) { this._rconClient.emitter.setMaxListeners(this.maxConcurrentCommands + 5); } try { await this._rconClient.connect(); } catch (err: any) { this._logger.error(`Failed to start RCON connection:\n${err?.stack ?? err?.message ?? err}`); this._rconClient = null; if (!this._rconReady) { this._rconReady = true; this.emit("rcon-ready"); } } } _watchWhitelist() { if (this._whitelistWatcher !== null) { throw new Error("Whitelist watcher already started"); } if (!this.enableWhitelist) { return; // The file is not updated if the whitelist is disabled } const filePath = this.writePath("server-whitelist.json"); try { this._whitelistWatcher = fs.watch(filePath); } catch (err: any) { this._logger.error(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`); return; } this._whitelistWatcher.unref(); this._whitelistWatcher.on("error", (err) => { this._whitelistWatcher = null; this._logger.error(`Whitelist Watcher has errored:\n${err?.stack ?? err?.message ?? err}`); }); this._whitelistWatcher.on("close", () => { this._whitelistWatcher = null; }); this._whitelistWatcher.on("change", async (eventType) => { if (eventType !== "change") { this._logger.warn(`Unexpected file watcher event: ${eventType}`); return; } let newWhitelistJson; try { newWhitelistJson = await fs.readJSON(filePath); } catch (err: any) { this._logger.error(`Unable to read whitelist, bidirectional sync will not be available:\n${err}`); return; } if (!(newWhitelistJson instanceof Array) || !newWhitelistJson.every(e => typeof e === "string")) { // The whitelist must be an array of strings this._logger.error("Unable to read whitelist, bidirectional sync will not be available:" + "Unexpected whitelist format"); return; } const newWhitelist = new Set(newWhitelistJson); // Set.difference added in node v22 and so we can not use this due to v20 support const added = newWhitelistJson.filter(e => !this._whitelist.has(e)); const removed = [...this._whitelist.values()].filter(e => !newWhitelist.has(e)); this._whitelist = newWhitelist; this.emit("whitelist-change", added, removed); }); } /** Maximum number of RCON commands transmitted in parallel on the RCON connection */ get maxConcurrentCommands() { return this._maxConcurrentCommands; } set maxConcurrentCommands(value: number) { this._maxConcurrentCommands = value; this.setMaxListeners(value + 5); if (this._rconClient) { // @ts-expect-error sendQueue is private if (this._rconClient.sendQueue) { // @ts-expect-error sendQueue is private this._rconClient.sendQueue.maxConcurrent = value; } // XXX: Workaround to suppress bogus event listener warning if (this._rconClient.emitter instanceof events.EventEmitter) { this._rconClient.emitter.setMaxListeners(value + 5); } } } /** * Initialize class instance * * Must be called before instances of this class can be used. * @throws {Error} if the requested version of Factorio was not found. */ async init() { this._check(["new"]); [this._dataDir, this._version] = await findVersion(this._factorioDir, this._targetVersion); this.rconPassword = this.rconPassword as string | undefined ?? await generatePassword(10); this._state = "init"; } /** * The version of Factorio in use. This will be the actual version if * "latest" (the default) was specified as `factorioVersion` to the constructor. * This will be undefined before the server is initialized, or if init failed. */ get version(): string | undefined { return this._version ?? undefined; } /** * The pid of the server process, or null if not running */ get pid() { if (!this._server) { return null; } return this._server.pid; } _attachStdio() { let stdout = new lib.LineSplitter({ readableObjectMode: true }); stdout.on("data", line => { this._handleOutput(line, "stdout"); }); this._server!.stdout.pipe(stdout); let stderr = new lib.LineSplitter({ readableObjectMode: true }); stderr.on("data", line => { this._handleOutput(line, "stderr"); }); this._server!.stderr.pipe(stderr); } _resetState() { this._state = "init"; this._server = null; this._rconClient = null; this._rconReady = false; this._gameReady = false; this._unexpected = []; this._killed = false; this._runningAutosave = null; } _watchExit() { this._server!.on("exit", (code, signal) => { if (this._state !== "stopping") { if (signal === "SIGKILL") { if (this._killed) { this.emit("error", new lib.EnvironmentError("Factorio server was killed")); } else { this.emit("error", new lib.EnvironmentError( "Factorio server was unexpectedly killed, is the system low on memory?" )); } } else if (this._unexpected.length === 0) { let msg; if (code !== null) { msg = `Factorio server unexpectedly shut down with code ${code}`; } else { msg = `Factorio server was unexpectedly shut by down by signal ${signal}`; } this.emit("error", new lib.EnvironmentError(msg)); } else if (this._unexpected.length === 1) { this.emit("error", new lib.EnvironmentError(this._unexpected[0])); } else { this.emit("error", new lib.EnvironmentError( "Factorio server unexpectedly shut down. Possible causes:"+ `\n- ${this._unexpected.join("\n- ")}` )); } } else { // if state === "stopping" this._clearStopTimeout(); } if (this._rconClient) { this._rconClient.end().catch(() => {}); } if (this._whitelistWatcher) { this._whitelistWatcher.close(); } this._resetState(); this.emit("exit"); }); this._server!.on("error", (err: any) => { if (err.code === "EACCES") { this.emit("error", new lib.EnvironmentError("Unable to run server: Permission denied")); } else { this.emit("error", new lib.EnvironmentError(`Unexpected error:\n${err.stack}`)); } if (this._rconClient) { this._rconClient.end().catch(() => {}); } if (this._whitelistWatcher) { this._whitelistWatcher.close(); } this._resetState(); this.emit("exit"); }); } async _notifyGameReady() { this._gameReady = true; this.emit("game-ready"); } async _waitForReady() { if (!this._gameReady) { await events.once(this, "game-ready"); } } /** * Create a new save * * Spawns the Factorio server with the --create argument to create a new * map save with the given name. * * @param name - * Name of the save to create. Should end with ".zip". * @param seed - Seed to pass via --map-gen-seed * @param mapGenSettings - * Map get settings to pass via --map-gen-settings. * @param mapSettings - * Map setting to pass via --map-settings. */ async create(name: string, seed?: number, mapGenSettings?: object, mapSettings?: object) { this._check(["init"]); this._state = "create"; try { // Everything between _state and once exit must be sync and call _resetState on error // If this this thread yields then the server will be left in an invalid transient state /* eslint-disable node/no-sync */ this._writeConfigIniSync(); this._writeMapSettingsSync(mapGenSettings, mapSettings); this._server = child_process.spawn( this.binaryPath(), [ "--config", this.writePath("config.ini"), "--create", this.writePath("saves", name), ...(seed !== undefined ? ["--map-gen-seed", String(seed)] : []), ...(mapGenSettings !== undefined ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : [] ), ...(mapSettings !== undefined ? ["--map-settings", this.writePath("map-settings.json")] : []), ...(this.verboseLogging ? ["--verbose"] : []), ], { detached: true, stdio: "pipe", } ); this._attachStdio(); this._server.once("exit", () => this.emit("exit")); /* eslint-enable node/no-sync */ } catch (err: any) { this._resetState(); throw new lib.EnvironmentError(`Unexpected error:\n${err.stack}`); } try { let [code, signal] = await events.once(this._server, "exit"); if (signal) { throw new Error(`Factorio exited with signal ${signal}`); } if (code !== 0) { throw new Error(`Factorio exited with status ${code}`); } } catch (err: any) { if (err.code === "EACCES") { throw new lib.EnvironmentError("Unable to run server: Permission denied"); } throw err; } finally { this._state = "init"; } } /** * Start server * * Spawn the Factorio server with the --start-server argument to * start the given save * * @param {string} save - Name of the save to run. */ async start(save: string) { this._check(["init"]); this._state = "running"; try { // Everything between _state and _watchExist must be sync and call _resetState on error // If this this thread yields then the server will be left in an invalid transient state /* eslint-disable node/no-sync */ this._writeConfigIniSync(); this._server = child_process.spawn( this.binaryPath(), [ "--config", this.writePath("config.ini"), "--start-server", this.writePath("saves", save), "--port", String(this.gamePort), "--rcon-port", String(this.rconPort), "--rcon-password", this.rconPassword, ...(this.enableWhitelist ? ["--use-server-whitelist"] : []), ...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []), ...(this.verboseLogging ? ["--verbose"] : []), ...(this.consoleLogging ? ["--console-log", this.writePath("console.log")] : []), ], { detached: true, stdio: "pipe", } ); this._watchWhitelist(); this._attachStdio(); this._watchExit(); /* eslint-enable node/no-sync */ } catch (err: any) { this.emit("error", new lib.EnvironmentError(`Unexpected error:\n${err.stack}`)); this._resetState(); } await this._waitForReady(); } /** * Start scenario * * Spawn the Factorio server with the --start-server-load-scenario * argument to start the given scenario. * * @param scenario - Name of the scenario to run. * @param seed - Seed to pass via --map-gen-seed * @param mapGenSettings - * Map get settings to pass via --map-gen-settings. * @param mapSettings - * Map setting to pass via --map-settings. */ async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) { this._check(["init"]); this._state = "running"; try { // Everything between _state and _watchExist must be sync and call _resetState on error // If this this thread yields then the server will be left in an invalid transient state /* eslint-disable node/no-sync */ this._writeConfigIniSync(); this._writeMapSettingsSync(mapGenSettings, mapSettings); this._server = child_process.spawn( this.binaryPath(), [ "--config", this.writePath("config.ini"), "--start-server-load-scenario", scenario, ...(seed !== undefined ? ["--map-gen-seed", String(seed)] : []), ...(mapGenSettings !== undefined ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : [] ), ...(mapSettings !== undefined ? ["--map-settings", this.writePath("map-settings.json")] : []), "--port", String(this.gamePort), "--rcon-port", String(this.rconPort), "--rcon-password", this.rconPassword, ...(this.enableWhitelist ? ["--use-server-whitelist"] : []), ...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []), ...(this.verboseLogging ? ["--verbose"] : []), ...(this.consoleLogging ? ["--console-log", this.writePath("console.log")] : []), ], { detached: true, stdio: "pipe", } ); this._watchWhitelist(); this._attachStdio(); this._watchExit(); /* eslint-enable node/no-sync */ } catch (err: any) { this.emit("error", new lib.EnvironmentError(`Unexpected error:\n${err.stack}`)); this._resetState(); } await this._waitForReady(); } /** * Send message over RCON * * If the rcon connection hasn't been established yet, this will * wait until it is establied and then send the message. * * @param message - message to send to server over RCON. * @param expectEmpty - * if true throw if the response is not empty. Useful for detecting * errors that might have been sent in response. * @returns response from server. */ async sendRcon(message: string, expectEmpty?: boolean) { this._check(["running", "stopping"]); if (!this._rconReady) { await events.once(this, "rcon-ready"); } if (!this._rconClient) { throw new Error("RCON connection lost"); } let response = await this._rconClient.send(message); if (expectEmpty && response !== "") { throw new Error(`Expected empty response but got "${response}"`); } return response; } /** * Timeout in ms to wait after a shutdown is requested before killing the * process. Defaults to 0 meaning no timeout */ get shutdownTimeoutMs() { return this._shutdownTimeoutMs; } set shutdownTimeoutMs(newTimeoutMs: number) { this._shutdownTimeoutMs = newTimeoutMs; if (this._state === "stopping") { this._clearStopTimeout(); this._setStopTimeout(); } } _setStopTimeout() { if (this._shutdownTimeoutMs !== 0) { this._stopTimeoutId = setTimeout( this.onStopTimeout.bind(this), this.shutdownTimeoutMs ); } } _clearStopTimeout() { clearTimeout(this._stopTimeoutId); this._stopTimeoutId = undefined; } onStopTimeout() { this._logger.error("Factorio appears to have hanged, killing process"); if (this._rconClient) { this._rconClient.end().catch(() => {}); } if (this._whitelistWatcher) { this._whitelistWatcher.close(); } this._server!.kill("SIGKILL"); } /** * Stop the server * * Send stop signal to the server process and wait for it to shut * down. */ async stop() { this._check(["running"]); this._state = "stopping"; // It's possible the server doesn't stop if it's stuck in an infinite // loop or overloaded, kill it if the shutdown takes too long. this._setStopTimeout(); // If RCON is not yet fully connected that operation needs to // complete before the RCON connection can be used if (!this._rconReady) { // Not using events.once here to avoid throwing on error events. await new Promise(resolve => this.once("rcon-ready", resolve)); } // The Factorio server may have decided to get ahead of us and // stop by itself before we had the chance to signal it. if (this._state !== "stopping") { return; } const waitForExit = events.once(this._server!, "exit"); waitForExit.catch(() => {}); // Prevent unhandled promise rejection // Stop the server if (this._rconClient) { // Use RCON by default to stop the the server as it's available on // all platforms and simplifies the code. try { await this.sendRcon("/quit"); } catch (err: any) { // Ignore RCON connection being closed while sending the quit command. if (err.message !== "Connection closed") { throw err; } } } else { // No rcon connection is available, fallback to signal. On linux // this sends SIGTERM to the process. On windows this // will terminate the process without saving. You can send your // complaint for the lack of graceful shutdown options to the // Factorio developers. I haven't found any way to make stdin work // and there's no "send CTRL+C to process" API on Windows. if (process.platform === "win32") { this._logger.warn("No RCON connection, falling back to killing the server."); } this._server!.kill(); } await waitForExit; } /** * Kill the server * * Terminates the server without any cleanup or saving. * @param unexpected - * If true raise an error event as a result of killing the Factorio * process. */ async kill(unexpected = false) { this._check(["running", "stopping", "create"]); this._killed = true; if (!unexpected) { this._state = "stopping"; } this._server!.kill("SIGKILL"); await new Promise(resolve => this._server!.once("exit", resolve)); } /** * Disable achievements on the running server * * Ensures achievements are disabled on the save that's running. This is * necessary in order to run any commands at all. * * @returns * True if acheivements got disabled and false if they already where * disabled. */ async disableAchievements() { this._check(["running"]); let check = await this.sendRcon("/sc rcon.print('disabled')"); if (check === "disabled\n") { return false; } // Factorio will print a warning to the console and ask for the command // to be runned again before disabling achievements and allowing commands. check = await this.sendRcon("/sc rcon.print('disabled')"); if (check === "disabled\n") { return true; } throw new Error("An error occured trying to disable acheivements"); } /** * Return path in data directory * * Creates a path using path.join with the given parts that's relative to * the data directory of the Factorio server. Not valid before init has * been called. * * @param parts - Extra parts to add to the data path. * @returns Data directory path. */ dataPath(...parts: string[]) { return path.join(this._dataDir!, ...parts); } /** * Get Factorio binary path * * Get the path to the factorio binary depending on the configuration * and or the platform (MacOS support) * * @returns Path to factorio binary */ binaryPath() { if (this.executablePath) { return this.dataPath("..", this.executablePath); } if (process.platform === "darwin") { return this.dataPath("..", "MacOS", "factorio"); } return this.dataPath("..", "bin", "x64", "factorio"); } /** * Return path in write directory * * Creates a path using path.join with the given parts that's relative to * the write directory of the Factorio server. * * @param parts - Extra parts to add to the write path. * @returns Write directory path. */ writePath(...parts: string[]) { return path.join(this._writeDir, ...parts); } /** * Get example server settings * * Loads server-settings.example.json from the data dir. * * @returns the parsed server-settings. */ async exampleSettings() { return JSON.parse(await fs.readFile(this.dataPath("server-settings.example.json"), "utf-8")); } _writeConfigIniSync() { let content = lib.stringify({ path: { "read-data": this.dataPath(), "write-data": this.writePath(), }, }); // Must be sync to allow process spawn without awaiting // eslint-disable-next-line node/no-sync fs.writeFileSync(this.writePath("config.ini"), content); } _writeMapSettingsSync(mapGenSettings?: object, mapSettings?: object) { // Must be sync to allow process spawn without awaiting if (mapGenSettings) { // eslint-disable-next-line node/no-sync fs.writeFileSync( this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, "\t") ); } if (mapSettings) { // eslint-disable-next-line node/no-sync fs.writeFileSync( this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, "\t") ); } } } // For testing only export const _getVersion = getVersion; export const _versionOrder = versionOrder; export const _findVersion = findVersion; export const _randomDynamicPort = randomDynamicPort; export const _generatePassword = generatePassword; export const _parseOutput = parseOutput;