Files
factorio-clusterio/packages/host/src/Instance.ts
T
BastiaanandClaude Opus 4.8 fc73433ffb Stop host crash on malformed action log lines (#1034)
_watchServerLogActions dereferenced two regex results with ! and
threw on null. The name regex /^([^ ]+)/ returns null when a line
classified as an action starts with a space, and the BAN reason
regex /\. Reason: (.+)\.$/ returns null for a [BAN] line with no
". Reason: X." tail. The output listener runs synchronously off the
stdout LineSplitter, so the throw becomes an uncaughtException and
takes the whole host down with every instance on it.

Repro: instance with enable_save_patching off and enable_script_commands
on; a control client sends /sc print("2000-01-01 00:00:00 [BAN] evil").
The raw print reaches stdout, parses as a dated BAN action, and the
reason deref throws.

Skip the line when the name regex misses and default the ban reason
to empty when the tail is absent. Apply the same name guard to
_watchPlayerPromote. The autosave ![1] in server.ts is safe because
its filter regex already guarantees the capture.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 14:53:49 +01:00

1433 lines
45 KiB
TypeScript

import fs from "node:fs/promises";
import path from "path";
import pidusage from "pidusage";
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.js";
import { SaveModule, patch } from "./patch.js";
import { exportData } from "./export.js";
import type Host from "./Host.js";
import { BaseInstancePlugin, InstanceHooks } from "./BaseInstancePlugin.js";
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;
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();
/** Hooks which plugins can attach to */
hooks: InstanceHooks;
/** Plugins which are currently loaded */
loadedPlugins: Set<lib.PluginNodeEnvInfo> = 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.config = instanceConfig;
this.id = this.config.get("instance.id");
this.logger = lib.logger.child({
instance_id: this.id,
instance_name: this.name,
});
this.hooks = new InstanceHooks(this.logger);
this._configFieldChanged = (field: string, curr: unknown, prev: unknown) => {
let hook = () => this.hooks.instanceConfigFieldChanged.invoke(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,
// TODO: factorio.version is not validated
version: this.config.get("factorio.version") as lib.TargetVersion,
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 });
this.hooks.output.invoke(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.InstanceSaveGameRequest, this.handleInstanceSaveGameRequest.bind(this));
this.handle(lib.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this));
this.handle(lib.InstanceRestartRequest, this.handleInstanceRestartRequest.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;
}
// Guard against lines that classify as actions but lack a name
let nameMatch = /^([^ ]+)/.exec(parsed.message);
if (!nameMatch) {
return;
}
let name = nameMatch[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") {
// Ban lines without a ". Reason: X." tail default to no reason
const reasonMatch = /\. Reason: (.+)\.$/.exec(parsed.message);
const reason = reasonMatch ? reasonMatch[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 nameMatch = /^([^ ]+)/.exec(parsed.message);
if (!nameMatch) {
return;
}
this._recordUserUpdate(parsed.action as "PROMOTE" | "DEMOTE", nameMatch[1]);
});
}
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));
this.hooks.playerEvent.invoke(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));
this.hooks.playerEvent.invoke(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));
this.hooks.playerEvent.invoke(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,
// TODO: factorio.version is not validated
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
),
);
}
/**
* 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
this.hooks.exit.invoke();
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 _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[]) {
// After changing the status this function is responsible for calling notifyExit if it throws.
this.notifyStatus("starting");
try {
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
await this._loadStats();
// Resolve a release channel target (e.g. "stable") to a concrete
// version using the controller's cached latest-releases data, so the
// existing download/lookup logic can fetch and run it.
const targetVersion = this.config.get("factorio.version") as string;
if (lib.isReleaseChannel(targetVersion)) {
const releases = await this.sendTo("controller", new lib.LatestReleasesRequest());
const resolved = lib.resolveReleaseChannel(releases, targetVersion);
if (!resolved) {
throw new Error(
`Cannot resolve Factorio release channel '${targetVersion}': ` +
"latest-releases unavailable and no cached copy on the controller."
);
}
this.server.setTargetVersion(resolved);
}
await this.server.checkForUpdates(factorioVersions);
await this.server.init();
} catch (err) {
this.notifyExit();
await this.sendSaveListUpdate();
throw err;
}
// load plugins
const plugins: Record<string, string> = {};
for (let pluginInfo of pluginInfos) {
if (
!pluginInfo.instanceEntrypoint
|| !this._host.serverPlugins.has(pluginInfo.name)
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
if (this._host.recoveryMode) {
this.logger.warn(`Recovery | force disabled plugin ${pluginInfo.name}`);
continue;
}
try {
let pluginLoadStartedMs = Date.now();
await lib.loadPlugin(
pluginInfo,
"instance",
{ logger: this.logger, host: this._host, instance: this },
"InstancePlugin",
BaseInstancePlugin,
);
this.loadedPlugins.add(pluginInfo);
plugins[pluginInfo.name] = pluginInfo.version;
this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStartedMs}ms`);
} catch (err) {
this.notifyExit();
await this.sendSaveListUpdate();
throw err;
}
}
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.mkdir(path.join(instanceDir, "script-output"), { recursive: true });
await fs.mkdir(path.join(instanceDir, "saves"), { recursive: true });
}
/**
* 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.checkModPackVersion(modPack);
this.activeModPack = modPack;
const mods = await this._host.fetchMods(modPack.mods.values(), modPack.factorioVersion);
await fs.mkdir(this.path("mods"), { recursive: true });
// 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.writeFile(this.path("mods", "mod-list.json"), JSON.stringify({
mods: [...this.activeModPack.mods.values()],
}, null, "\t"));
// Write mod-settings.dat
await fs.writeFile(this.path("mods", "mod-settings.dat"), this.activeModPack.toModSettingsDat());
}
/**
* Check that the mod pack does not target a newer Factorio than the server
*
* The mod pack's Factorio version is written into mod-settings.dat and
* Factorio refuses to read it if it is newer than the game. It then
* loads default settings instead, which can corrupt the save.
*
* @throws {lib.RequestError} if the mod pack version is higher than the server version.
*/
checkModPackVersion(modPack: lib.ModPack) {
const serverVersion = this.server.version;
if (serverVersion && modPack.integerFactorioVersion > lib.integerFullVersion(serverVersion)) {
throw new lib.RequestError(
`Mod pack ${modPack.name} is for Factorio ${modPack.factorioVersion} ` +
`which is newer than the Factorio ${serverVersion} this instance runs`
);
}
}
/**
* 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");
await 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");
await 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");
await lib.safeOutputFile(
this.server.writePath("server-whitelist.json"),
JSON.stringify([...this._host.whitelist], null, "\t")
);
} else {
// Always create the whitelist even if unused
// This allows it to be watched for changes without a server restart
try {
// wx will throw an error if the file already exists to prevent overwrite
await fs.writeFile(this.server.writePath("server-whitelist.json"), "\n[]\n", { flag: "wx" });
} catch (err: any) {
if (err.code !== "EEXIST") {
throw err;
}
}
}
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,
);
// This will overwrite the save if this runs twice in the same minute.
await fs.copyFile(
this.path("saves", saveName),
this.path("saves", newName),
fs.constants.COPYFILE_FICLONE,
);
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 (const pluginInfo of this.loadedPlugins) {
let module = await SaveModule.fromPluginInfo(pluginInfo);
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(import.meta.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_script_commands")) {
await this.server.disableAchievements();
}
if (this.config.get("factorio.enable_save_patching")) {
this._watchPlayerPromote();
if (this.config.get("factorio.enable_script_commands")) {
await this.updateInstanceData();
}
} else {
this._watchServerLogActions();
}
await this.sendSaveListUpdate();
await this.hooks.start.invoke();
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 this.hooks.start.invoke();
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 this.hooks.stop.invoke();
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 this.hooks.controllerConnectionEvent.invoke(event.event);
}
async handlePrepareControllerDisconnectRequest() {
await this.hooks.prepareControllerDisconnect.invoke(this);
}
async handleInstanceMetricsRequest() {
let results: ReturnType<typeof lib.serializeResult>[] = [];
if (!["stopped", "stopping"].includes(this._status)) {
let pluginResults = await this.hooks.metrics.collect();
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 handleInstanceSaveGameRequest() {
if (this._status !== "running") {
throw new lib.RequestError("Instance is not running");
}
const saved = new Promise<void>((resolve, reject) => {
const onSaved = () => {
this.server.off("exit", onExit);
resolve();
};
const onExit = () => {
this.server.off("save-finished", onSaved);
reject(new lib.RequestError("Instance stopped before the save finished"));
};
this.server.once("save-finished", onSaved);
this.server.once("exit", onExit);
});
await this.sendRcon("/server-save");
await saved;
}
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 fetch(url, {
method: "PUT",
body: content,
duplex: "half",
headers: {
"Content-Type": "application/zip",
"x-access-token": this._host.config.get("host.controller_token"),
},
});
if (response.status !== 200) {
const body = Buffer.from(await response.arrayBuffer());
throw Error(`Upload failed: ${response.status} ${response.statusText}: ${body.toString()}`);
}
} finally {
this.notifyExit();
}
}
async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) {
await this.stop();
// Stopping invalidates this instance object, a new one must be created by the host
// To do this we simulate a waking an instance and sending a start request
const instanceConnection = await this._host._connectInstance(this.id);
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest(request.save));
}
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);
}
}