UNPKG

@clusterio/host

Version:

Implementation of Clusterio host server

1,338 lines (1,183 loc) 42.8 kB
import fs from "fs-extra"; import path from "path"; import pidusage from "pidusage"; import phin from "phin"; import util from "util"; import type { Static } from "@sinclair/typebox"; import { exec } from "child_process"; const execAsync = util.promisify(exec); // internal libraries import * as lib from "@clusterio/lib"; import { FactorioServer } from "./server"; import { SaveModule, patch } from "./patch"; import { exportData } from "./export"; import type Host from "./Host"; import BaseInstancePlugin from "./BaseInstancePlugin"; const scriptCommands = [ "/cheat", "/editor", "/command", "/c", "/measured-command", "/mc", "/silent-command", "/sc", ]; const instanceRconCommandDuration = new lib.Histogram( "clusterio_instance_rcon_command_duration_seconds", "Histogram of the RCON command duration from request to response.", { labels: ["instance_id"] } ); const instanceRconCommandSize = new lib.Histogram( "clusterio_instance_rcon_command_size_bytes", "Histogram of the RCON command sizes that are sent.", { labels: ["instance_id", "plugin"], buckets: lib.Histogram.exponential(16, 2, 12), } ); const instanceFactorioCpuTime = new lib.Gauge( "clusterio_instance_factorio_cpu_time_total", "Factorio CPU time spent in seconds.", { labels: ["instance_id"] } ); const instanceFactorioMemoryUsage = new lib.Gauge( "clusterio_instance_factorio_resident_memory_bytes", "Factorio resident memory size in bytes.", { labels: ["instance_id"] } ); const instanceFactorioAutosaveSize = new lib.Gauge( "clusterio_instance_factorio_autosave_bytes", "Size of Factorio server autosave in bytes.", { labels: ["instance_id"] } ); function applyAsConfig(name: string) { return async function action(instance: Instance, value: unknown, logger: lib.Logger) { if (name === "tags" && value instanceof Array) { // Replace spaces with non-break spaces and delimit by spaces. // This does change the defined tags, but there doesn't seem to // be a way to include a space into a tag from the console. value = value.map(tag => tag.replace(/ /g, "\u00a0")).join(" "); } try { await instance.sendRcon(`/config set ${name} ${value}`); } catch (err: any) { logger.error(`Error applying server setting ${name} ${err.message}`); } }; } const serverSettingsActions = { "afk_autokick_interval": applyAsConfig("afk-auto-kick"), "allow_commands": applyAsConfig("allow-commands"), "autosave_interval": applyAsConfig("autosave-interval"), "autosave_only_on_server": applyAsConfig("autosave-only-on-server"), "description": applyAsConfig("description"), "ignore_player_limit_for_returning_players": applyAsConfig("ignore-player-limit-for-returning-players"), "max_players": applyAsConfig("max-players"), "max_upload_slots": applyAsConfig("max-upload-slots"), "max_upload_in_kilobytes_per_second": applyAsConfig("max-upload-speed"), "name": applyAsConfig("name"), "only_admins_can_pause_the_game": applyAsConfig("only-admins-can-pause"), "game_password": applyAsConfig("password"), "require_user_verification": applyAsConfig("require-user-verification"), "tags": applyAsConfig("tags"), "visibility": async (instance: Instance, value: unknown, logger: lib.Logger) => { for (let scope of ["lan", "public", "steam"]) { try { let enabled = Boolean((value as Record<string, string>)[scope]); await instance.sendRcon(`/config set visibility-${scope} ${enabled}`); } catch (err) { logger.error(`Error applying visibility ${scope} ${err}`); } } }, }; /** * Get process stats for a Windows process * Due to wmic being removed in later versions of Windows, pidusage does not work * until this PR is merged: https://github.com/soyuka/pidusage/pull/143 * @param pid - Process ID * @param logger - Logger instance * @returns Process stats */ async function getWindowsProcessStats(pid: number, logger: lib.Logger) { try { const { stdout } = await execAsync( `powershell "Get-Process -Id ${pid} | Select-Object TotalProcessorTime,WorkingSet | ConvertTo-Json"` ); const stats = JSON.parse(stdout); // Convert ticks (100-nanosecond intervals) to milliseconds const totalTimeMs = stats.TotalProcessorTime.Ticks / 10000; return { ctime: totalTimeMs, memory: stats.WorkingSet, }; } catch (err) { logger.warn("Failed to get process stats:", err); return { ctime: 0, memory: 0 }; } } async function getProcessStats(pid: number, logger: lib.Logger) { if (process.platform === "win32") { return getWindowsProcessStats(pid, logger); } return pidusage(pid); } /** * Keeps track of the runtime parameters of an instance * @alias module:host/src/Instance */ export default class Instance extends lib.Link { /** * ID of this instance, equivalenet to `instance.config.get("instance.id")`. */ readonly id: number; plugins: Map<string, BaseInstancePlugin>; config: lib.InstanceConfig; logger: lib.Logger; server: FactorioServer; /** * Mod pack currently running on this instance */ activeModPack!: lib.ModPack; // This is set in syncMods /** * Per player statistics recorded by this instance. */ playerStats: Map<string, lib.PlayerStats> = new Map(); /** * Players currently online on the instance. */ playersOnline: Set<string> = new Set(); _host: Host; _dir: string; _configFieldChanged: (field: string, curr: unknown, prev: unknown) => void; _status: "stopped" | "starting" | "running" | "stopping" | "creating_save" | "exporting_data" = "stopped"; _loadedSave: string | null = null; _playerCheckInterval: ReturnType<typeof setInterval> | undefined; _hadPlayersOnline = false; _playerAutosaveSlot = 1; _expectedUserUpdates: { action: string, name: string, reason: string }[] = []; constructor( host: Host, connector: lib.VirtualConnector, dir: string, factorioDir: string, instanceConfig: lib.InstanceConfig ) { super(connector); this._host = host; this._dir = dir; this.plugins = new Map(); this.config = instanceConfig; this.id = this.config.get("instance.id"); this.logger = lib.logger.child({ instance_id: this.id, instance_name: this.name, }); this._configFieldChanged = (field: string, curr: unknown, prev: unknown) => { let hook = () => lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", field, curr, prev); if (field === "factorio.shutdown_timeout") { this.server.shutdownTimeoutMs = curr as number * 1000; } else if (field === "factorio.settings") { this.updateFactorioSettings(curr as any, prev as any).finally(hook); } else if (field === "factorio.enable_whitelist") { this.updateFactorioWhitelist(curr as any).finally(hook); } else { if (field === "factorio.max_concurrent_commands") { this.server.maxConcurrentCommands = curr as number; } hook(); } }; this.config.on("fieldChanged", this._configFieldChanged); let serverOptions = { logger: this.logger, version: this.config.get("factorio.version"), executablePath: this.config.get("factorio.executable_path") ?? 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"), enableAuthserverBans: this.config.get("factorio.enable_authserver_bans"), verboseLogging: this.config.get("factorio.verbose_logging"), consoleLogging: this.config.get("factorio.console_logging"), stripPaths: this.config.get("factorio.strip_paths"), maxConcurrentCommands: this.config.get("factorio.max_concurrent_commands"), shutdownTimeoutMs: this.config.get("factorio.shutdown_timeout") * 1000, }; this.server = new FactorioServer( factorioDir, this._dir, serverOptions ); this.server.on("output", (parsed, line) => { this.logger.log("server", { message: line, instance_id: this.id, parsed }); lib.invokeHook(this.plugins, "onOutput", parsed, line); }); this.server.on("error", err => { if (err instanceof lib.EnvironmentError) { this.logger.error(err.message); } else { this.logger.error(`${this.name}:\n${err.stack}`); } }); this.server.on("autosave-finished", name => { this._autosave(name).catch(err => { this.logger.error(`Error handling autosave-finished in instance ${this.name}:\n${err.stack}`); }); }); this.server.on("save-finished", () => { this.sendSaveListUpdate().catch(err => { this.logger.error(`Error handling save-finished in instance ${this.name}:\n${err.stack}`); }); }); this.server.on("ipc-player_event", event => { if (event.type === "join") { this._recordPlayerJoin(event.name); } else if (event.type === "leave") { this._recordPlayerLeave(event.name, event.reason); } else if (["BAN", "UNBANNED", "PROMOTE", "DEMOTE"].includes(event.type)) { this._recordUserUpdate(event.type, event.name, event.reason); } else { this.logger.warn(`Unknown type from player event ipc: ${event.type}`); } }); this.server.on("whitelist-change", (added: string[], removed: string[]) => { for (const player of added) { this._recordUserUpdate("WHITELISTED", player); } for (const player of removed) { this._recordUserUpdate("UNWHITELISTED", player); } }); this.handle(lib.InstanceExtractPlayersRequest, this.handleInstanceExtractPlayersRequest.bind(this)); this.handle(lib.InstanceAdminlistUpdateEvent, this.handleInstanceAdminlistUpdateEvent.bind(this)); this.handle(lib.InstanceBanlistUpdateEvent, this.handleInstanceBanlistUpdateEvent.bind(this)); this.handle(lib.InstanceWhitelistUpdateEvent, this.handleInstanceWhitelistUpdateEvent.bind(this)); this.handle(lib.ControllerConnectionEvent, this.handleControllerConnectionEvent.bind(this)); this.handle( lib.PrepareControllerDisconnectRequest, this.handlePrepareControllerDisconnectRequest.bind(this) ); this.handle(lib.InstanceMetricsRequest, this.handleInstanceMetricsRequest.bind(this)); this.handle(lib.InstanceStartRequest, this.handleInstanceStartRequest.bind(this)); this.handle(lib.InstanceLoadScenarioRequest, this.handleInstanceLoadScenarioRequest.bind(this)); this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this)); this.handle(lib.InstanceCreateSaveRequest, this.handleInstanceCreateSaveRequest.bind(this)); this.handle(lib.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this)); this.handle(lib.InstanceStopRequest, this.handleInstanceStopRequest.bind(this)); this.handle(lib.InstanceKillRequest, this.handleInstanceKillRequest.bind(this)); this.handle(lib.InstanceSendRconRequest, this.handleInstanceSendRconRequest.bind(this)); } _watchServerLogActions() { this.server.on("output", (parsed: lib.ParsedFactorioOutput, line: string) => { if (parsed.type !== "action") { return; } let name = /^([^ ]+)/.exec(parsed.message)![1]; // Detect player leave and join if (parsed.action === "JOIN") { this._recordPlayerJoin(name); } else if (["LEAVE", "KICK", "BAN"].includes(parsed.action)) { let reason = { "LEAVE": "quit", "KICK": "kicked", "BAN": "banned", }[parsed.action]!; this._recordPlayerLeave(name, reason); } // Detect banned and admin state change, whitelist changes are not logged if (parsed.action === "BAN") { const reason = /\. Reason: (.+)\.$/.exec(parsed.message)![1]; this._recordUserUpdate(parsed.action, name, reason !== "unspecified" ? reason : ""); } else if (["UNBANNED", "PROMOTE", "DEMOTE"].includes(parsed.action)) { this._recordUserUpdate(parsed.action as "UNBANNED" | "PROMOTE" | "DEMOTE", name); } }); // Leave log entries are unreliable and sometimes don't show up. this._playerCheckInterval = setInterval(() => { this._checkOnlinePlayers().catch(err => { this.logger.error(`Error checking online players:\n${err.stack}`); }); }, 60e3); } // Needed to cover promote/demote when player is not on map, not required when _watchServerLogActions is used _watchPlayerPromote() { this.server.on("output", (parsed: lib.ParsedFactorioOutput, line: string) => { if ( parsed.type !== "action" || !["PROMOTE", "DEMOTE"].includes(parsed.action) || !parsed.message.includes("be promoted upon joining the game.") ) { return; } const name = /^([^ ]+)/.exec(parsed.message)![1]; this._recordUserUpdate(parsed.action as "PROMOTE" | "DEMOTE", name); }); } async _checkOnlinePlayers() { if (this.playersOnline.size) { let actualPlayers = (await this.sendRcon("/players online")) .split("\n") .slice(1, -1) // Remove header and trailing newline .map(s => s.slice(2, -" (online)".length)) ; let left = new Set(this.playersOnline); actualPlayers.map(player => left.delete(player)); let joined = new Set(actualPlayers); this.playersOnline.forEach(player => joined.delete(player)); for (let player of left) { this._recordPlayerLeave(player, "quit"); } // Missing join messages is not supposed to happen. if (joined.size) { this.logger.warn(`Missed join message for ${[...joined].join(", ")}`); for (let player of joined) { this._recordPlayerJoin(player); } } } } _recordPlayerJoin(name: string) { if (this.playersOnline.has(name)) { return; } this.playersOnline.add(name); let stats = this.playerStats.get(name); if (!stats) { stats = new lib.PlayerStats(); this.playerStats.set(name, stats); } stats.lastJoinAt = new Date(); if (!stats.firstJoinAt) { stats.firstJoinAt = stats.lastJoinAt; } stats.joinCount += 1; let event: lib.PlayerEvent = { type: "join", name, stats, }; this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats)); lib.invokeHook(this.plugins, "onPlayerEvent", event); } _recordPlayerLeave(name: string, reason: string) { if (!this.playersOnline.delete(name)) { return; } let stats = this.playerStats.get(name)!; stats.lastLeaveAt = new Date(); stats.lastLeaveReason = reason; stats.onlineTimeMs += stats.lastLeaveAt.getTime() - stats.lastJoinAt!.getTime(); this._hadPlayersOnline = true; let event: lib.PlayerEvent = { type: "leave", name, reason, stats, }; this.sendTo("controller", new lib.InstancePlayerUpdateEvent("leave", name, stats, reason)); lib.invokeHook(this.plugins, "onPlayerEvent", event); } async handleInstanceExtractPlayersRequest() { const exportPlayerTimes = `/sc local players = {} for _, p in pairs(game.players) do players[p.name] = p.online_time end if helpers ~= nil then rcon.print(helpers.table_to_json(players)) else rcon.print(game.table_to_json(players)) end`.replace(/\r?\n/g, " "); let playerTimes: Record<string, number> = JSON.parse(await this.sendRcon(exportPlayerTimes)); let count = 0; for (let [name, onlineTimeTicks] of Object.entries(playerTimes)) { let stats = this.playerStats.get(name); if (!stats) { stats = new lib.PlayerStats(); this.playerStats.set(name, stats); } stats.onlineTimeMs = onlineTimeTicks * 1000 / 60; let event: lib.PlayerEvent = { type: "import", name, stats, }; this.sendTo("controller", new lib.InstancePlayerUpdateEvent("import", name, stats)); lib.invokeHook(this.plugins, "onPlayerEvent", event); count += 1; } this.logger.info(`Extracted data for ${count} player(s)`); } async sendRcon(message: string, expectEmpty = false, plugin = "") { const trimmedMessage = message.trim(); if ( !this.config.get("factorio.enable_script_commands") && scriptCommands.find(cmd => trimmedMessage.startsWith(cmd)) ) { throw new Error( "Attempted to use script command while disabled. See config factorio.enable_script_commands.\n" + `Command: ${message}` ); } let instanceId = String(this.id); let observeDuration = instanceRconCommandDuration.labels(instanceId).startTimer(); try { return await this.server.sendRcon(message, expectEmpty); } finally { observeDuration(); instanceRconCommandSize.labels(instanceId, plugin).observe(Buffer.byteLength(message, "utf8")); } } static async listSaves(instanceId: number, savesDir: string, loadedSave: string | null) { let defaultSave = null; if (loadedSave === null) { defaultSave = await lib.getNewestFile( savesDir, (name) => !name.endsWith(".tmp.zip") ); } let list: lib.SaveDetails[] = []; for (let name of await fs.readdir(savesDir)) { let type: "file" | "directory" | "special"; let stat = await fs.stat(path.join(savesDir, name)); if (stat.isFile()) { type = "file"; } else if (stat.isDirectory()) { type = "directory"; } else { type = "special"; } list.push(new lib.SaveDetails( instanceId, type, name, stat.size, stat.mtimeMs, name === loadedSave, name === defaultSave, 0, // Set by controller false, )); } return list; } async sendSaveListUpdate() { this.sendTo( "controller", new lib.InstanceSaveDetailsUpdatesEvent( await Instance.listSaves(this.id, this.path("saves"), this._loadedSave), this.id, ), ); } async _autosave(name: string) { let stat = await fs.stat(this.path("saves", `${name}.zip`)); instanceFactorioAutosaveSize.labels(String(this.id)).set(stat.size); if ( this.config.get("factorio.player_online_autosave_slots") > 0 && (this._hadPlayersOnline || this.playersOnline.size) ) { if (this._playerAutosaveSlot > this.config.get("factorio.player_online_autosave_slots")) { this._playerAutosaveSlot = 1; } await fs.rename( this.path("saves", `${name}.zip`), this.path("saves", `_autosave_po${this._playerAutosaveSlot}.zip`), ); this._playerAutosaveSlot += 1; this._hadPlayersOnline = false; } await this.sendSaveListUpdate(); } notifyStatus(status: Instance["_status"]) { this._status = status; this.sendTo( "controller", new lib.InstanceStatusChangedEvent( this.id, status, this.server.gamePort, status === "running"? this.server.version : this.config.get("factorio.version"), ), ); } /** * Current state of the instance * * One of stopped, starting, running, stopping, creating_save and exporting_data */ get status() { return this._status; } notifyExit() { this._loadedSave = null; this.notifyStatus("stopped"); this.connector.emit("close"); this.config.off("fieldChanged", this._configFieldChanged); clearTimeout(this._playerCheckInterval); // Clear metrics this instance is exporting for (let collector of lib.defaultRegistry.collectors) { if ( collector instanceof lib.ValueCollector && collector.metric.labels.includes("instance_id") ) { collector.removeAll({ instance_id: String(this.id) }); } } // Notify plugins of exit for (let pluginInstance of this.plugins.values()) { pluginInstance.onExit(); } for (let player of this.playersOnline) { this._recordPlayerLeave(player, "server_quit"); } this._saveStats().catch(err => this.logger.error(`Error saving stats:\n${err.stack}`)); } async _loadPlugin(pluginInfo: lib.PluginNodeEnvInfo, host: Host) { let pluginLoadStartedMs = Date.now(); let InstancePluginClass = await lib.loadPluginClass( pluginInfo.name, path.posix.join(pluginInfo.requirePath, pluginInfo.instanceEntrypoint!), "InstancePlugin", BaseInstancePlugin, ); let instancePlugin = new InstancePluginClass(pluginInfo, this, host); this.plugins.set(pluginInfo.name, instancePlugin); await instancePlugin.init(); this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStartedMs}ms`); } async _loadStats() { let instanceStats; try { instanceStats = JSON.parse(await fs.readFile(this.path("instance-stats.json"), "utf8")); } catch (err: any) { if (err.code === "ENOENT") { return; } throw err; } this.playerStats = new Map(instanceStats["players"].map( ([id, stats]: [number, Static<typeof lib.PlayerStats.jsonSchema>]) => [id, new lib.PlayerStats(stats)]) ); this._playerAutosaveSlot = instanceStats["player_autosave_slot"] || 1; } async _saveStats() { let content = JSON.stringify({ players: [...this.playerStats], player_autosave_slot: this._playerAutosaveSlot, }, null, "\t"); await lib.safeOutputFile(this.path("instance-stats.json"), content); } async init(pluginInfos: lib.PluginNodeEnvInfo[]) { this.notifyStatus("starting"); try { await this._loadStats(); await this.server.init(); } catch (err) { this.notifyExit(); await this.sendSaveListUpdate(); throw err; } // load plugins for (let pluginInfo of pluginInfos) { if ( !pluginInfo.instanceEntrypoint || !this._host.serverPlugins.has(pluginInfo.name) || !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.InstanceConfigFields) ) { continue; } if (this._host.recoveryMode) { this.logger.warn(`Recovery | force disabled plugin ${pluginInfo.name}`); continue; } try { await this._loadPlugin(pluginInfo, this._host); } catch (err) { this.notifyExit(); await this.sendSaveListUpdate(); throw err; } } let plugins: Record<string, string> = {}; for (let [name, plugin] of this.plugins) { plugins[name] = plugin.info.version; } this.send(new lib.InstanceInitialisedEvent(plugins)); } /** * Resolve the effective Factorio server settings * * Use the example settings as the basis and override it with all the * entries from the given settings object. * * @param overrides - Server settings to override. * @param includeCredentials - Include Factorio username and token from host/controller config. * @returns * server example settings with the given settings applied over it. */ async resolveServerSettings(overrides: Record<string, unknown>, includeCredentials: boolean) { let serverSettings = await this.server.exampleSettings(); if (includeCredentials && overrides.username === undefined && overrides.token === undefined) { let credentials = { username: this._host.config.get("host.factorio_username") ?? undefined, token: this._host.config.get("host.factorio_token") ?? undefined, }; if (!credentials.username && !credentials.token) { Object.assign(credentials, await this.sendTo("controller", new lib.GetFactorioCredentialsRequest())); if (credentials.username || credentials.token) { this.logger.info("Using Factorio credentials from controller config"); } else { this.logger.warn("No Factorio credentials found"); } } else { this.logger.info("Using Factorio credentials from host config"); } if (credentials.username) { serverSettings.username = credentials.username; } if (credentials.token) { serverSettings.token = credentials.token; } } else if (includeCredentials) { this.logger.info("Using Factorio credentials from instance config"); } for (let [key, value] of Object.entries(overrides)) { if (!Object.hasOwnProperty.call(serverSettings, key)) { this.logger.warn(`Server settings does not have the property '${key}'`); } serverSettings[key] = value; } return serverSettings; } /** * Write the server-settings.json file * * Generate the server-settings.json file from the example file in the * data directory and override any settings configured in the instance's * factorio_settings config entry. * * @param includeCredentials - Include Factorio username and token from host/controller config. */ async writeServerSettings(includeCredentials: boolean) { const warning = "Changes to this file will be overwitten by the factorio.settings config on the instance."; const serverSettings = { "_comment_warning": warning, ...await this.resolveServerSettings(this.config.get("factorio.settings"), includeCredentials), }; await lib.safeOutputFile( this.server.writePath("server-settings.json"), JSON.stringify(serverSettings, null, "\t") ); } /** * Creates a new empty instance directory * * Ensures the neccessary files for starting up a new instance into the * provided instance directory are present. * * @param instanceDir - */ static async populate_folders(instanceDir: string) { await fs.ensureDir(path.join(instanceDir, "script-output")); await fs.ensureDir(path.join(instanceDir, "saves")); } /** * Sync instance mods directory with configured mod pack * * Adds, deletes, and updates all files in the instance mods folder to * match up with the mod pack that's configured. If no mod pack is * configured then an empty mod pack is used, esentially turning it into * a vanilla mods folder. If the instance mods directory does't exist * then it will be created. * * On Linux this creates symlinks to mods in the host's mods folder, on * Windows hard links are used instead due to symlinks being privileged. */ async syncMods() { this.logger.info("Syncing mods"); const modPackId = this.config.get("factorio.mod_pack_id"); let modPack; if (modPackId === null) { modPack = await this.sendTo("controller", new lib.ModPackGetDefaultRequest()); } else { modPack = await this.sendTo("controller", new lib.ModPackGetRequest(modPackId)); } this.activeModPack = modPack; // TODO validate factorioVersion const mods = await this._host.fetchMods(modPack.mods.values()); await fs.ensureDir(this.path("mods")); // Remove all files for (let entry of await fs.readdir(this.path("mods"), { withFileTypes: true })) { if (entry.isDirectory()) { this.logger.warn( `Found unexpected directory ${entry.name} in mods folder, it may break Clusterio's mod syncing` ); continue; } if (entry.isFile() || entry.isSymbolicLink()) { await fs.unlink(this.path("mods", entry.name)); } } // Add mods from mod the pack const modsDir = this._host.config.get("host.mods_directory"); for (let mod of mods) { const modFile = mod.filename; const target = path.join(modsDir, modFile); const link = this.path("mods", modFile); if (process.platform !== "win32") { await fs.symlink(path.relative(path.dirname(link), target), link); // On Windows symlinks require elevated privileges, which is // not something we want to have. For this reason the mods // are hard linked instead. } else { try { await fs.link(target, link); } catch (err) { this.logger.warn(`Failed to link mod ${modFile}.`); } } } // Write mod-list.json await fs.outputFile(this.path("mods", "mod-list.json"), JSON.stringify({ mods: [...this.activeModPack.mods.values()], }, null, "\t")); // Write mod-settings.dat await fs.outputFile(this.path("mods", "mod-settings.dat"), this.activeModPack.toModSettingsDat()); } /** * Prepare instance for starting * * Writes server settings, admin/ban/white-lists and links mods. */ async prepare() { this.logger.verbose("Writing server-settings.json"); await this.writeServerSettings(true); this._expectedUserUpdates = []; if (this.config.get("factorio.sync_adminlist") !== "disabled") { this.logger.verbose("Writing server-adminlist.json"); lib.safeOutputFile( this.server.writePath("server-adminlist.json"), JSON.stringify([...this._host.adminlist], null, "\t") ); } if (this.config.get("factorio.sync_banlist") !== "disabled") { this.logger.verbose("Writing server-banlist.json"); lib.safeOutputFile( this.server.writePath("server-banlist.json"), JSON.stringify([...this._host.banlist].map( ([username, reason]) => ({ username, reason }) ), null, "\t"), ); } if (this.config.get("factorio.sync_whitelist") !== "disabled") { this.logger.verbose("Writing server-whitelist.json"); lib.safeOutputFile( this.server.writePath("server-whitelist.json"), JSON.stringify([...this._host.whitelist], null, "\t") ); } await this.syncMods(); } /** * Prepare a save for starting * * Creates a new save if no save is passed and patches it with modules. * * @param saveName - * Save to prepare from the instance saves directory. Creates a new * save if null. * @returns Name of the save prepared. */ async prepareSave(saveName?: string) { // Use latest save if no save was specified if (saveName === undefined) { saveName = await lib.getNewestFile( this.path("saves"), (name) => !name.endsWith(".tmp.zip") ); } // Create save if no save was found. if (saveName === undefined) { this.logger.info("Creating new save"); await this.server.create("world.zip"); saveName = "world.zip"; } // Load a copy if it's autosave to prevent overwriting the autosave if (saveName.startsWith("_autosave")) { this.logger.info("Copying autosave"); let now = new Date(); let newName = util.format( "%s-%s-%s %s%s %s", now.getUTCFullYear(), (now.getUTCMonth() + 1).toLocaleString("en", { minimumIntegerDigits: 2 }), now.getUTCDate().toLocaleString("en", { minimumIntegerDigits: 2 }), now.getUTCHours().toLocaleString("en", { minimumIntegerDigits: 2 }), now.getUTCMinutes().toLocaleString("en", { minimumIntegerDigits: 2 }), saveName, ); await fs.copy(this.path("saves", saveName), this.path("saves", newName)); saveName = newName; } if (!this.config.get("factorio.enable_save_patching")) { return saveName; } // Patch save with lua modules from plugins this.logger.verbose("Patching save"); // Find plugin modules to patch in let modules: Map<string, SaveModule> = new Map(); for (let plugin of this.plugins.values()) { let module = await SaveModule.fromPlugin(plugin); if (!module) { continue; } modules.set(module.info.name, module); } // Find stand alone modules to load // XXX for now only the included clusterio module is loaded let modulesDirectory = path.join(__dirname, "..", "..", "..", "modules"); for (let entry of await fs.readdir(modulesDirectory, { withFileTypes: true })) { if (entry.isDirectory()) { if (modules.has(entry.name)) { throw new Error(`Module with name ${entry.name} already exists in a plugin`); } let module = await SaveModule.fromDirectory(path.join(modulesDirectory, entry.name)); modules.set(module.info.name, module); } } await patch(this.path("saves", saveName), [...modules.values()]); return saveName; } /** * Start Factorio server * * Launches the Factorio server for this instance with the given save. * * @param saveName - Name of save game to load. */ async start(saveName: string) { this.server.on("rcon-ready", () => { this.logger.verbose("RCON connection established"); }); this.server.on("exit", () => this.notifyExit()); this._loadedSave = saveName; await this.server.start(saveName); if (this.config.get("factorio.enable_save_patching") && this.config.get("factorio.enable_script_commands")) { await this.server.disableAchievements(); await this.updateInstanceData(); this._watchPlayerPromote(); } else { this._watchServerLogActions(); } await this.sendSaveListUpdate(); await lib.invokeHook(this.plugins, "onStart"); this.notifyStatus("running"); } /** * Start Factorio server by loading a scenario * * Launches the Factorio server for this instance with the given * scenario. * * @param scenario - Name of scenario to load. * @param seed - seed to use. * @param mapGenSettings - MapGenSettings to use. * @param mapSettings - MapSettings to use. */ async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) { this.server.on("rcon-ready", () => { this.logger.verbose("RCON connection established"); }); this.server.on("exit", () => this.notifyExit()); await this.server.startScenario(scenario, seed, mapGenSettings, mapSettings); this._watchServerLogActions(); await lib.invokeHook(this.plugins, "onStart"); this.notifyStatus("running"); } /** * Update instance information on the Factorio side */ async updateInstanceData() { let name = lib.escapeString(this.name); await this.sendRcon(`/sc clusterio_private.update_instance(${this.id}, "${name}")`, true); } async updateFactorioSettings(current: Record<string, unknown>, previous: Record<string, unknown>) { current = await this.resolveServerSettings(current, false); previous = await this.resolveServerSettings(previous, false); for (let [key, action] of Object.entries(serverSettingsActions)) { if (current[key] !== undefined && !util.isDeepStrictEqual(current[key], previous[key])) { await action(this, current[key], this.logger); } } } /** * Enable or disable the player whitelist * * @param enable - * True to enable the whitelist, False to disable the whitelist. */ async updateFactorioWhitelist(enable: boolean) { if (!enable) { await this.sendRcon("/whitelist disable"); } if (this.config.get("factorio.sync_whitelist") !== "disabled") { await this.sendRcon("/whitelist clear"); for (let player of this._host.whitelist) { await this.sendRcon(`/whitelist ${player}`); } } if (enable) { await this.sendRcon("/whitelist enable"); } } _recordUserUpdate( action: "BAN" | "UNBANNED" | "PROMOTE" | "DEMOTE" | "WHITELISTED" | "UNWHITELISTED", name: string, reason: string = "" ) { const addr = lib.Address.fromShorthand("allInstances"); const expectedIndex = this._expectedUserUpdates.findIndex( expected => expected.name === name && expected.action === action && expected.reason === reason ); if (expectedIndex >= 0) { this._expectedUserUpdates.splice(expectedIndex, 1); } else if (action === "BAN") { if (this.config.get("factorio.sync_banlist") !== "bidirectional") { return; } this.sendTo(addr, new lib.InstanceBanlistUpdateEvent(name, true, reason ?? "")); } else if (action === "UNBANNED") { if (this.config.get("factorio.sync_banlist") !== "bidirectional") { return; } this.sendTo(addr, new lib.InstanceBanlistUpdateEvent(name, false, "")); } else if (action === "PROMOTE") { if (this.config.get("factorio.sync_adminlist") !== "bidirectional") { return; } this.sendTo(addr, new lib.InstanceAdminlistUpdateEvent(name, true)); } else if (action === "DEMOTE") { if (this.config.get("factorio.sync_adminlist") !== "bidirectional") { return; } this.sendTo(addr, new lib.InstanceAdminlistUpdateEvent(name, false)); } else if (action === "WHITELISTED") { if (this.config.get("factorio.sync_whitelist") !== "bidirectional") { return; } this.sendTo(addr, new lib.InstanceWhitelistUpdateEvent(name, true)); } else if (action === "UNWHITELISTED") { if (this.config.get("factorio.sync_whitelist") !== "bidirectional") { return; } this.sendTo(addr, new lib.InstanceWhitelistUpdateEvent(name, false)); } else { throw new Error(`Unexpected Action: ${action} for ${name}`); } } async handleInstanceAdminlistUpdateEvent(request: lib.InstanceAdminlistUpdateEvent) { const { name, admin } = request; const sync = this.config.get("factorio.sync_adminlist"); if (sync === "disabled") { return; } else if (sync === "bidirectional") { this._expectedUserUpdates.push({action: admin ? "PROMOTE" : "DEMOTE", name: name, reason: "" }); } let command = admin ? `/promote ${name}` : `/demote ${name}`; await this.sendRcon(command); } async handleInstanceBanlistUpdateEvent(request: lib.InstanceBanlistUpdateEvent) { const { name, banned, reason } = request; const sync = this.config.get("factorio.sync_banlist"); if (sync === "disabled") { return; } else if (sync === "bidirectional") { this._expectedUserUpdates.push({action: banned ? "BAN" : "UNBANNED", name: name, reason: reason}); } let command = banned ? `/ban ${name} ${reason}` : `/unban ${name}`; await this.sendRcon(command); } async handleInstanceWhitelistUpdateEvent(request: lib.InstanceWhitelistUpdateEvent) { const { name, whitelisted } = request; const sync = this.config.get("factorio.sync_whitelist"); if (this.config.get("factorio.sync_whitelist") === "disabled") { return; } else if (sync === "bidirectional") { // Factorio does not contain log events for whitelist so these values currently have no special meaning this._expectedUserUpdates.push({ action: whitelisted ? "WHITELISTED" : "UNWHITELISTED", name: name, reason: "", }); } let command = whitelisted ? `/whitelist add ${name}` : `/whitelist remove ${name}`; await this.sendRcon(command); } /** * Stop the instance */ async stop() { if (this._status === "stopped") { return; } this.notifyStatus("stopping"); // XXX this needs more thought to it if (this.server._state === "running") { await lib.invokeHook(this.plugins, "onStop"); await this.server.stop(); await this.sendSaveListUpdate(); } else if ( this.server._state === "stopping" || this.server._state === "create" ) { await this.server.kill(); } } async kill() { if (this._status === "stopped") { return; } await this.server.kill(true); } async handleControllerConnectionEvent(event: lib.ControllerConnectionEvent) { await lib.invokeHook(this.plugins, "onControllerConnectionEvent", event.event); } async handlePrepareControllerDisconnectRequest() { await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect", this); } async handleInstanceMetricsRequest() { let results: ReturnType<typeof lib.serializeResult>[] = []; if (!["stopped", "stopping"].includes(this._status)) { let pluginResults = await lib.invokeHook(this.plugins, "onMetrics"); for (let metricIterator of pluginResults) { for await (let metric of metricIterator) { results.push(lib.serializeResult(metric)); } } } let pid = this.server.pid; if (pid) { try { let stats = await getProcessStats(pid, this.logger); instanceFactorioCpuTime.labels(String(this.id)).set(stats.ctime / 1000); instanceFactorioMemoryUsage.labels(String(this.id)).set(stats.memory); } catch (err) { this.logger.warn("Failed to get process stats:", err); } } return new lib.InstanceMetricsRequest.Response(results); } async handleInstanceStartRequest(request: lib.InstanceStartRequest) { let saveName = request.save; try { try { await this.prepare(); saveName = await this.prepareSave(saveName); } catch (err: any) { this.logger.error(`Error preparing instance: ${err.message}`); this.notifyExit(); await this.sendSaveListUpdate(); throw err; } try { await this.start(saveName); } catch (err: any) { this.logger.error(`Error starting ${saveName}: ${err.message}`); await this.stop(); throw err; } } finally { this.logger.verbose("Wiping credentials from server-settings.json"); await this.writeServerSettings(false); } } async handleInstanceLoadScenarioRequest(request: lib.InstanceLoadScenarioRequest) { if (this.config.get("factorio.enable_save_patching")) { this.notifyExit(); throw new lib.RequestError("Load scenario cannot be used with save patching enabled"); } try { try { await this.prepare(); } catch (err: any) { this.logger.error(`Error preparing instance: ${err.message}`); this.notifyExit(); await this.sendSaveListUpdate(); throw err; } let { scenario, seed, mapGenSettings, mapSettings } = request; try { await this.startScenario(scenario, seed, mapGenSettings, mapSettings); } catch (err: any) { this.logger.error(`Error starting scenario ${scenario}: ${err.message}`); await this.stop(); throw err; } } finally { this.logger.verbose("Wiping credentials from server-settings.json"); await this.writeServerSettings(false); } } async handleInstanceSaveDetailsListRequest() { return await Instance.listSaves(this.id, this.path("saves"), this._loadedSave); } async handleInstanceCreateSaveRequest(request: lib.InstanceCreateSaveRequest) { this.notifyStatus("creating_save"); try { this.logger.verbose("Writing server-settings.json"); await this.writeServerSettings(false); await this.syncMods(); } catch (err: any) { this.logger.error(`Error preparing instance: ${err.message}`); this.notifyExit(); await this.sendSaveListUpdate(); throw err; } this.server.on("exit", () => this.notifyExit()); let { name, seed, mapGenSettings, mapSettings } = request; try { this.logger.info("Creating save"); await this.server.create(name, seed, mapGenSettings, mapSettings); } catch (err: any) { this.logger.error(`Error creating save ${name}: ${err.message}`); throw err; } await this.sendSaveListUpdate(); this.logger.info("Successfully created save"); } async handleInstanceExportDataRequest() { this.notifyStatus("exporting_data"); try { this.logger.verbose("Writing server-settings.json"); await this.writeServerSettings(false); this.logger.info("Exporting data ....."); await this.syncMods(); let zip = await exportData(this.server); let content = await zip.generateAsync({ type: "nodebuffer" }); let url = new URL(this._host.config.get("host.controller_url")); url.pathname += "api/upload-export"; url.searchParams.set("mod_pack_id", String(this.activeModPack.id)); let response = await phin({ url, method: "PUT", data: content, core: { ca: this._host.tlsCa } as object, headers: { "Content-Type": "application/zip", "x-access-token": this._host.config.get("host.controller_token"), }, }); if (response.statusCode !== 200) { throw Error(`Upload failed: ${response.statusCode} ${response.statusMessage}: ${response.body}`); } } finally { this.notifyExit(); } } async handleInstanceStopRequest() { await this.stop(); } async handleInstanceKillRequest() { await this.kill(); } async handleInstanceSendRconRequest(request: lib.InstanceSendRconRequest) { return await this.sendRcon(request.command); } /** * Name of the instance * * This should not be used for filesystem paths. See .path() for that. */ get name() { return this.config.get("instance.name"); } /** * Return path in instance * * Creates a path using path.join with the given parts that's relative to * the directory of the instance. For example instance.path("mods") * returns a path to the mods directory of the instance. If no parts are * given it returns a path to the directory of the instance. * * @returns path in instance directory. */ path(...parts: string[]) { return path.join(this._dir, ...parts); } }