mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Handle errors events emitted from the child process created by lib/factorio/server and translate this error if it's EACCES.
1170 lines
33 KiB
JavaScript
1170 lines
33 KiB
JavaScript
// Factorio Server interface
|
||
"use strict";
|
||
const fs = require("fs-extra");
|
||
const child_process = require("child_process");
|
||
const path = require("path");
|
||
const events = require("events");
|
||
const util = require("util");
|
||
const crypto = require("crypto");
|
||
const rconClient = require("rcon-client");
|
||
|
||
const libErrors = require("../errors");
|
||
const libFileOps = require("../file_ops");
|
||
const libIni = require("../ini");
|
||
const { logger } = require("../logging");
|
||
const { escapeRegExp } = require("../helpers");
|
||
const { LineSplitter } = require("../stream");
|
||
|
||
|
||
/**
|
||
* Determines the version of Factorio the datadir is pointing to by
|
||
* reading the changelog.txt in it.
|
||
*
|
||
* @param {string} changelogPath - Path to changelog.txt.
|
||
* @memberof module:lib/factorio
|
||
* @private
|
||
* @inner
|
||
*/
|
||
async function getVersion(changelogPath) {
|
||
let changelog;
|
||
try {
|
||
changelog = await fs.readFile(changelogPath, "utf-8");
|
||
} catch (err) {
|
||
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 {string} a - Version to compare.
|
||
* @param {string} b - Version to compare.
|
||
* @returns {number} 1 if a < b, 0 if a = b and -1 if a > b.
|
||
* @memberof module:lib/factorio
|
||
* @private
|
||
* @inner
|
||
*/
|
||
function versionOrder(a, b) {
|
||
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 {string} factorioDir - Path to Factorio installation dir(s).
|
||
* @param {string} targetVersion -
|
||
* Version to look for, supports the special value "latest" for the
|
||
* latest version available.
|
||
* @returns {Promise<Array>} Array with path to data dir and version found.
|
||
* @memberof module:lib/factorio
|
||
* @private
|
||
* @inner
|
||
*/
|
||
async function findVersion(factorioDir, targetVersion) {
|
||
|
||
// 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 {number} a number in the range 49152 to 65535.
|
||
* @memberof module:lib/factorio
|
||
* @private
|
||
* @inner
|
||
*/
|
||
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 {number} length - the length of the password to generate.
|
||
* @return {Promise<string>} password of the given length
|
||
* @memberof module:lib/factorio
|
||
* @private
|
||
* @inner
|
||
*/
|
||
async function generatePassword(length) {
|
||
function validChar(byte) {
|
||
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 {string} line - A line of output not including the line terminator
|
||
* @param {string} source - Passed into the output structure as source
|
||
*
|
||
* @returns {object} - An object with interpeted data.
|
||
* @memberof module:lib/factorio
|
||
* @private
|
||
* @inner
|
||
*/
|
||
function parseOutput(line, source) {
|
||
let output = {
|
||
source,
|
||
};
|
||
|
||
// 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) {
|
||
output.format = "seconds";
|
||
output.time = secMatch[1];
|
||
let 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) {
|
||
output.type = "log";
|
||
output.level = secLogMatch[1];
|
||
output.file = secLogMatch[2];
|
||
output.message = secLogMatch[5];
|
||
|
||
// The other possibility is that the content is a generic message
|
||
} else {
|
||
output.type = "generic";
|
||
output.message = secContent;
|
||
}
|
||
|
||
// The second category of output starts with a date stamp of the format
|
||
// "yyyy-mm-dd hh:mm:ss message"
|
||
} else {
|
||
const dateRegex = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) (.*)$/;
|
||
let dateMatch = dateRegex.exec(line);
|
||
if (dateMatch) {
|
||
output.format = "date";
|
||
output.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) {
|
||
output.type = "action";
|
||
output.action = dateActionMatch[1];
|
||
output.message = dateActionMatch[2];
|
||
|
||
// The other format is a generic message
|
||
} else {
|
||
output.type = "generic";
|
||
output.message = dateContent;
|
||
}
|
||
|
||
// The last category of output is simply a generic message with no
|
||
// formating.
|
||
} else {
|
||
output.format = "none";
|
||
output.type = "generic";
|
||
output.message = line;
|
||
}
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
// 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 = [
|
||
// 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");
|
||
},
|
||
},
|
||
|
||
// Message indicating the server is shutting down
|
||
{
|
||
filter: {
|
||
type: "generic",
|
||
message: /^Quitting: /,
|
||
},
|
||
action: function(parsed) {
|
||
this.emit("_quitting");
|
||
|
||
// On windows the Factorio server will wait for input on the console input buffer
|
||
// when an error occurs. Since we can't send input there we terminate the server
|
||
// process when this happens.
|
||
if (process.platform === "win32" && parsed.message === "Quitting: multiplayer error.") {
|
||
this._server.kill();
|
||
}
|
||
},
|
||
},
|
||
|
||
// 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}`);
|
||
},
|
||
},
|
||
];
|
||
|
||
|
||
/**
|
||
* 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
|
||
* @memberof module:lib/factorio
|
||
*/
|
||
class FactorioServer extends events.EventEmitter {
|
||
/**
|
||
* Create a Factorio server interface
|
||
*
|
||
* @param {string} factorioDir - Directory of the Factorio install(s).
|
||
* @param {string} writeDir - Directory to write runtime data to.
|
||
* @param {object} options - Optional parameters.
|
||
* @param {Logger} options.logger - Logger to use for reporting errors.
|
||
* @param {string} options.version -
|
||
* Version of Factorio to use. Can also be the string "latest" to
|
||
* use the latest version found in `factorioDir`.
|
||
* @param {number} options.gamePort - UDP port to host game on.
|
||
* @param {number} options.rconPort - TCP port to use for RCON.
|
||
* @param {string} options.rconPassword - Password use for RCON.
|
||
* @param {boolean} options.enableWhitelist - Turn on whitelisting.
|
||
* @param {boolean} options.enableAuthserverBans -
|
||
* Enable Factorio.com based multiplayer bans.
|
||
* @param {boolean} options.verboseLogging - Enable verbose logging.
|
||
* @param {boolean} options.stripPaths - Strip paths in the console.
|
||
* @param {number} options.maxConcurrentCommands -
|
||
* Maximum number of RCON commands transmitted in parallel on the
|
||
* RCON connection.
|
||
* @param {number} options.hangTimeout -
|
||
* Timeout in ms to wait for a response to a shutdown command.
|
||
*/
|
||
constructor(factorioDir, writeDir, options) {
|
||
super();
|
||
|
||
this._factorioDir = factorioDir;
|
||
this._writeDir = writeDir;
|
||
|
||
// Resolved in init
|
||
this._version = null;
|
||
this._dataDir = null;
|
||
|
||
this._logger = options.logger || logger;
|
||
this._targetVersion = options.version || "latest";
|
||
/** 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;
|
||
/** Enable player whitelist */
|
||
this.enableWhitelist = options.enableWhitelist;
|
||
/** Enable Factorio.com based multiplayer bans **/
|
||
this.enableAuthserverBans = options.enableAuthserverBans;
|
||
/** Enable verbose logging */
|
||
this.verboseLogging = options.verboseLogging;
|
||
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
|
||
this.maxConcurrentCommands = options.maxConcurrentCommands || 5;
|
||
/** Timeout in ms to wait for a response to a shutdown command */
|
||
this.hangTimeout = options.hangTimeout || 5000;
|
||
this._state = "new";
|
||
this._server = null;
|
||
this._rconClient = null;
|
||
this._rconReady = false;
|
||
this._gameReady = false;
|
||
|
||
if (options.stripPaths) {
|
||
let chars = new Set(path.resolve(this.writePath("temp")));
|
||
chars.delete(":"); // Having a colon could lead to matching the line number
|
||
chars = [...chars].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.
|
||
`(${escapeRegExp(tempPath.replace(/\\/g, "/"))})|` +
|
||
`(${escapeRegExp(writePath)})|` +
|
||
`(\\.\\.\\.[${escapeRegExp(chars)}/]*?currently-playing/)`,
|
||
"g"
|
||
);
|
||
}
|
||
|
||
// Array of possible causes for an unexpected shutdown
|
||
this._unexpected = [];
|
||
this._killed = false;
|
||
|
||
// Track autosaving
|
||
this._runningAutosave = null;
|
||
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) {
|
||
if (!expectedStates.includes(this._state)) {
|
||
throw new Error(
|
||
`Expected state ${expectedStates} but state is ${this._state}`
|
||
);
|
||
}
|
||
}
|
||
|
||
async _handleIpc(line) {
|
||
let channelEnd = line.indexOf("?");
|
||
if (channelEnd === -1) {
|
||
throw new Error(`Malformed IPC line "${line.toString()}"`);
|
||
}
|
||
|
||
let channel = line
|
||
.slice(6, channelEnd)
|
||
.toString("utf-8")
|
||
.replace(/\\x([0-9a-f]{2})/g, (match, p1) => (
|
||
String.fromCharCode(parseInt(p1, 16))
|
||
))
|
||
;
|
||
|
||
let type = line.slice(channelEnd + 1, channelEnd + 2).toString("utf-8");
|
||
let content;
|
||
if (type === "j") {
|
||
try {
|
||
content = JSON.parse(line.slice(channelEnd + 2).toString("utf-8"));
|
||
} catch (err) {
|
||
throw new Error(`Malformed JSON to ${channel}: ${line.slice(channelEnd + 2).toString("utf-8")}`);
|
||
}
|
||
|
||
} else if (type === "f") {
|
||
let fileName = line.slice(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(line, source) {
|
||
if (line.slice(0, 6).equals(Buffer.from("\f$ipc:"))) {
|
||
this._handleIpc(line).catch(err => this.emit("error", err));
|
||
return;
|
||
}
|
||
|
||
this.emit(source, line);
|
||
|
||
line = line.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[name])) {
|
||
continue heuristicLoop;
|
||
}
|
||
} else if (expected instanceof Array) {
|
||
if (!expected.includes(parsed[name])) {
|
||
continue heuristicLoop;
|
||
}
|
||
} else if (expected !== parsed[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._state === "stopping") {
|
||
return;
|
||
}
|
||
|
||
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 rconClient.Rcon(config);
|
||
this._rconClient.on("error", () => { /* Ignore */ });
|
||
this._rconClient.on("authenticated", () => { this._rconReady = true; this.emit("rcon-ready"); });
|
||
this._rconClient.on("end", () => {
|
||
this._rconClient = null;
|
||
});
|
||
// XXX: Workaround to suppress bogus event listener warning
|
||
if (this._rconClient.emitter instanceof events.EventEmitter) {
|
||
this._rconClient.emitter.setMaxListeners(this.maxConcurrentCommands + 5);
|
||
}
|
||
await this._rconClient.connect();
|
||
}
|
||
|
||
get maxConcurrentCommands() {
|
||
return this._maxConcurrentCommands;
|
||
}
|
||
|
||
set maxConcurrentCommands(value) {
|
||
this._maxConcurrentCommands = value;
|
||
this.setMaxListeners(value + 5);
|
||
if (this._rconClient) {
|
||
if (this._rconClient.sendQueue) {
|
||
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 || 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.
|
||
*/
|
||
get version() {
|
||
return this._version;
|
||
}
|
||
|
||
/**
|
||
* 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 LineSplitter({ readableObjectMode: true });
|
||
stdout.on("data", line => { this._handleOutput(line, "stdout"); });
|
||
this._server.stdout.pipe(stdout);
|
||
let stderr = new 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._runningAutosave = null;
|
||
}
|
||
|
||
_watchExit() {
|
||
this._server.on("exit", (code, signal) => {
|
||
if (this._state !== "stopping") {
|
||
if (signal === "SIGKILL") {
|
||
if (this._killed) {
|
||
this.emit("error", new libErrors.EnvironmentError("Factorio server was killed"));
|
||
} else {
|
||
this.emit("error", new libErrors.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 libErrors.EnvironmentError(msg));
|
||
|
||
} else if (this._unexpected.length === 1) {
|
||
this.emit("error", new libErrors.EnvironmentError(this._unexpected[0]));
|
||
|
||
} else {
|
||
this.emit("error", new libErrors.EnvironmentError(
|
||
"Factorio server unexpectedly shut down. Possible causes:"+
|
||
`\n- ${this._unexpected.join("\n- ")}`
|
||
));
|
||
}
|
||
}
|
||
|
||
if (this._rconClient) {
|
||
this._rconClient.end().catch(() => {});
|
||
}
|
||
|
||
this._resetState();
|
||
this.emit("exit");
|
||
});
|
||
this._server.on("error", err => {
|
||
if (err.code === "EACCES") {
|
||
this.emit("error", new libErrors.EnvironmentError("Unable to run server: Permission denied"));
|
||
} else {
|
||
this.emit("error", new libErrors.EnvironmentError(`Unexpected error:\n${err.stack}`));
|
||
}
|
||
|
||
if (this._rconClient) {
|
||
this._rconClient.end().catch(() => {});
|
||
}
|
||
|
||
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 {string} name -
|
||
* Name of the save to create. Should end with ".zip".
|
||
* @param {?number=} seed - Seed to pass via --map-gen-seed
|
||
* @param {?object=} mapGenSettings -
|
||
* Map get settings to pass via --map-gen-settings.
|
||
* @param {?object=} mapSettings -
|
||
* Map setting to pass via --map-settings.
|
||
*/
|
||
async create(name, seed = null, mapGenSettings = null, mapSettings = null) {
|
||
this._check(["init"]);
|
||
this._state = "create";
|
||
|
||
await this._writeConfigIni();
|
||
await this._writeMapSettings(mapGenSettings, mapSettings);
|
||
this._server = child_process.spawn(
|
||
this.binaryPath(),
|
||
[
|
||
"--config", this.writePath("config.ini"),
|
||
"--create", this.writePath("saves", name),
|
||
...(seed !== null ? ["--map-gen-seed", seed] : []),
|
||
...(mapGenSettings !== null ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []),
|
||
...(mapSettings !== null ? ["--map-settings", this.writePath("map-settings.json")] : []),
|
||
...(this.verboseLogging ? ["--verbose"] : []),
|
||
],
|
||
{
|
||
detached: true,
|
||
stdio: "pipe",
|
||
}
|
||
);
|
||
|
||
this._attachStdio();
|
||
this._server.once("exit", () => this.emit("exit"));
|
||
|
||
try {
|
||
let [code, signal] = await events.once(this._server, "exit");
|
||
void signal;
|
||
if (code !== 0) {
|
||
throw new Error(`Factorio exited with status ${code}`);
|
||
}
|
||
} catch (err) {
|
||
if (err.code === "EACCES") {
|
||
throw new libErrors.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) {
|
||
this._check(["init"]);
|
||
this._state = "running";
|
||
|
||
await this._writeConfigIni();
|
||
this._server = child_process.spawn(
|
||
this.binaryPath(),
|
||
[
|
||
"--config", this.writePath("config.ini"),
|
||
"--start-server", this.writePath("saves", save),
|
||
"--port", this.gamePort,
|
||
"--rcon-port", this.rconPort,
|
||
"--rcon-password", this.rconPassword,
|
||
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
||
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
|
||
...(this.verboseLogging ? ["--verbose"] : []),
|
||
],
|
||
{
|
||
detached: true,
|
||
stdio: "pipe",
|
||
}
|
||
);
|
||
|
||
this._attachStdio();
|
||
this._watchExit();
|
||
await this._waitForReady();
|
||
}
|
||
|
||
/**
|
||
* Start scenario
|
||
*
|
||
* Spawn the Factorio server with the --start-server-load-scenario
|
||
* argument to start the given scenario.
|
||
*
|
||
* @param {string} scenario - Name of the scenario to run.
|
||
* @param {?number=} seed - Seed to pass via --map-gen-seed
|
||
* @param {?object=} mapGenSettings -
|
||
* Map get settings to pass via --map-gen-settings.
|
||
* @param {?object=} mapSettings -
|
||
* Map setting to pass via --map-settings.
|
||
*/
|
||
async startScenario(scenario, seed = null, mapGenSettings = null, mapSettings = null) {
|
||
this._check(["init"]);
|
||
this._state = "running";
|
||
|
||
await this._writeConfigIni();
|
||
await this._writeMapSettings(mapGenSettings, mapSettings);
|
||
this._server = child_process.spawn(
|
||
this.binaryPath(),
|
||
[
|
||
"--config", this.writePath("config.ini"),
|
||
"--start-server-load-scenario", scenario,
|
||
...(seed !== null ? ["--map-gen-seed", seed] : []),
|
||
...(mapGenSettings !== null ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []),
|
||
...(mapSettings !== null ? ["--map-settings", this.writePath("map-settings.json")] : []),
|
||
"--port", this.gamePort,
|
||
"--rcon-port", this.rconPort,
|
||
"--rcon-password", this.rconPassword,
|
||
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
||
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
|
||
...(this.verboseLogging ? ["--verbose"] : []),
|
||
],
|
||
{
|
||
detached: true,
|
||
stdio: "pipe",
|
||
}
|
||
);
|
||
|
||
this._attachStdio();
|
||
this._watchExit();
|
||
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 {string} message - message to send to server over RCON.
|
||
* @param {boolean} expectEmpty -
|
||
* if true throw if the response is not empty. Useful for detecting
|
||
* errors that might have been sent in response.
|
||
* @returns {Promise<string>} response from server.
|
||
*/
|
||
async sendRcon(message, expectEmpty) {
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Stop the server
|
||
*
|
||
* Send stop signal to the server process and wait for it to shut
|
||
* down.
|
||
*/
|
||
async stop() {
|
||
this._check(["running"]);
|
||
|
||
let hanged = true;
|
||
let stopped = false;
|
||
function setAlive() { hanged = false; }
|
||
|
||
let timeoutId = setTimeout(() => {
|
||
if (hanged) {
|
||
this._logger.error("Factorio appears to have hanged, sending SIGKILL");
|
||
if (this._rconClient) {
|
||
this._rconClient.end().catch(() => {});
|
||
}
|
||
this._server.kill("SIGKILL");
|
||
}
|
||
}, this.hangTimeout);
|
||
|
||
// It's possible the server decides to exit on its own, in which
|
||
// case the stop operation has to be cancelled.
|
||
this._server.once("exit", () => {
|
||
stopped = true;
|
||
clearTimeout(timeoutId);
|
||
});
|
||
|
||
this._state = "stopping";
|
||
// On Windows we need to save the map as there's no graceful shutdown.
|
||
if (this._rconClient && process.platform === "win32") {
|
||
// Not using events.once here as that will reject on error.
|
||
let saved = new Promise(resolve => this.once("_saved", resolve));
|
||
this.on("_saving", setAlive);
|
||
|
||
try {
|
||
await this.sendRcon("/server-save");
|
||
await saved;
|
||
|
||
} catch (err) {
|
||
// Ignore
|
||
}
|
||
|
||
clearTimeout(timeoutId);
|
||
this.off("_saving", setAlive);
|
||
}
|
||
|
||
if (this._rconClient) {
|
||
|
||
// If RCON is not yet fully connected that operation needs to
|
||
// complete before the RCON connection can be closed cleanly.
|
||
if (!this._rconReady) {
|
||
await events.once(this, "rcon-ready");
|
||
}
|
||
|
||
await this._rconClient.end();
|
||
}
|
||
|
||
// The Factorio server may have decided to get ahead of us and
|
||
// stop by itself before we had the chance to signal it.
|
||
if (stopped) {
|
||
return;
|
||
}
|
||
|
||
// On linux this sends SIGTERM to the process. On windows this
|
||
// will terminate the process with no cleanup. You can send your
|
||
// complaint for the lack of graceful shutdown to the Factorio
|
||
// developers. Rcon does not recognize /quit, stdin is not
|
||
// recognized, and there's no "send CTRL+C to process" on Windows.
|
||
this._server.kill();
|
||
|
||
// There appears to be an race condition where sending SIGTERM
|
||
// immediatly before the RCON interface comes online causes the
|
||
// Factorio server to hang. It's also possible the server doesn't
|
||
// stop if it's stuck with an infinite lua code loop. In either
|
||
// case there's no recovering from it.
|
||
if (process.platform !== "win32") {
|
||
this.on("_quitting", setAlive);
|
||
await events.once(this._server, "exit");
|
||
|
||
clearTimeout(timeoutId);
|
||
this.off("_quitting", setAlive);
|
||
|
||
// On windows the process is terminated immediately, but to keep
|
||
// ordering wait until after the exit event here.
|
||
} else {
|
||
await events.once(this._server, "exit");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Kill the server
|
||
*
|
||
* Terminates the server without any cleanup or saving.
|
||
* @param {boolean} [unexpected=false] -
|
||
* If true raise an error event as a result of killing the Factorio
|
||
* process.
|
||
*/
|
||
async kill(unexpected = false) {
|
||
this._check(["running", "stopping"]);
|
||
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 {Promise<boolean>}
|
||
* 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 {...string} parts - Extra parts to add to the data path.
|
||
* @returns {string} Data directory path.
|
||
*/
|
||
dataPath(...parts) {
|
||
return path.join(this._dataDir, ...parts);
|
||
}
|
||
|
||
/**
|
||
* Get Factorio binary path
|
||
*
|
||
* Get the path to the factorio binary depending on the platform (MacOS support)
|
||
*
|
||
* @returns {string} Path to factorio binary
|
||
*/
|
||
binaryPath() {
|
||
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 {...string} parts - Extra parts to add to the write path.
|
||
* @returns {string} Write directory path.
|
||
*/
|
||
writePath(...parts) {
|
||
return path.join(this._writeDir, ...parts);
|
||
}
|
||
|
||
/**
|
||
* Get example server settings
|
||
*
|
||
* Loads server-settings.example.json from the data dir.
|
||
*
|
||
* @returns {Promise<object>} the parsed server-settings.
|
||
*/
|
||
async exampleSettings() {
|
||
return JSON.parse(await fs.readFile(this.dataPath("server-settings.example.json"), "utf-8"));
|
||
}
|
||
|
||
async _writeConfigIni() {
|
||
let content = libIni.stringify({
|
||
path: {
|
||
"read-data": this.dataPath(),
|
||
"write-data": this.writePath(),
|
||
},
|
||
});
|
||
await libFileOps.safeOutputFile(this.writePath("config.ini"), content);
|
||
}
|
||
|
||
async _writeMapSettings(mapGenSettings, mapSettings) {
|
||
if (mapGenSettings) {
|
||
await libFileOps.safeOutputFile(
|
||
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, 4)
|
||
);
|
||
}
|
||
if (mapSettings) {
|
||
await libFileOps.safeOutputFile(
|
||
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, 4)
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
module.exports = {
|
||
FactorioServer,
|
||
|
||
// For testing only
|
||
_getVersion: getVersion,
|
||
_versionOrder: versionOrder,
|
||
_findVersion: findVersion,
|
||
_randomDynamicPort: randomDynamicPort,
|
||
_generatePassword: generatePassword,
|
||
_parseOutput: parseOutput,
|
||
};
|