Files
factorio-clusterio/packages/host/src/Host.ts
T
bbassieandClaude Opus 5.5 d7fc3aaec4 Replace bootstrap methods with async factories
Controller.bootstrap and Host.bootstrap returned constructor arguments
that the caller had to spread into new Controller/Host, and
DatastoreProvider.bootstrap did the same for datastores. They are
replaced by Controller.fromConfig, Host.fromConfig and
KeyValueDatastore/SubscribableDatastore.fromProvider, which load the
data and return the finished instance.

Closes #1126

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:51:22 +00:00

1364 lines
44 KiB
TypeScript

import dgram from "node:dgram";
import fs from "node:fs/promises";
import path from "path";
import pidusage from "pidusage";
import semver from "semver";
import setBlocking from "set-blocking";
import stream from "stream";
import { fileURLToPath } from "node:url";
import util from "util";
// internal libraries
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { HostConnector } from "../host.js";
import Instance from "./Instance.js";
import InstanceConnection from "./InstanceConnection.js";
import { BaseHostPlugin, HostHooks } from "./BaseHostPlugin.js";
function checkRequestSaveName(name: string) {
try {
lib.checkFilename(name);
} catch (err: any) {
throw new lib.RequestError(`Save name ${err.message}`);
}
}
/**
* Checks that no socket is bound to the given UDP port, on IPv4 or IPv6
*
* Factorio binds the game port on both 0.0.0.0 and [::].
*
* @param port - UDP port to check.
* @returns true if the port can be bound.
* @internal
*/
async function udpPortFree(port: number) {
for (const type of ["udp4", "udp6"] as const) {
const socket = dgram.createSocket({ type, ipv6Only: type === "udp6" });
try {
await new Promise<void>((resolve, reject) => {
socket.once("error", reject);
socket.bind(port, resolve);
});
} catch (err: any) {
// Other errors, such as no IPv6 support on the host, don't stop Factorio from binding the port.
if (err.code === "EADDRINUSE") {
return false;
}
} finally {
socket.close();
}
}
return true;
}
/**
* Searches for instances in the provided directory
*
* Looks through all sub-dirs of the provided directory for valid
* instance definitions and return a mapping of instance id to
* instance configs and the root instance paths.
*
* @param instancesDir - Directory containing instances
* @returns
* mapping between instance id and information about this instance.
* @internal
*/
async function discoverInstances(instancesDir: string) {
const instances = new Map<number, UnloadedInstance>();
for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
const instancePath = path.join(instancesDir, entry.name);
const configPath = path.join(instancePath, "instance.json");
try {
instanceConfig = await lib.InstanceConfig.fromFile("host", configPath);
instanceConfig.update(Host.instanceConfigWarning, false); // File might be missing the warning
} catch (err: any) {
if (err.code === "ENOENT") {
continue; // Ignore folders without config.json
}
logger.error(`Error occurred while parsing ${configPath}: ${err.message}`);
continue;
}
if (instances.has(instanceConfig.get("instance.id"))) {
logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`);
continue;
}
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
instances.set(instanceConfig.get("instance.id"), {
path: instancePath,
config: instanceConfig,
});
}
}
return instances;
}
// Requests that wake up instances are responsible for making sure the
// instance status is correctly updated even if they fail with an error. This
// means they should typically use try catch on anything that can throw
// and call notifyExit upon any errors.
const instanceStartingMessages = new Set([
lib.InstanceStartRequest.name,
lib.InstanceLoadScenarioRequest.name,
lib.InstanceCreateSaveRequest.name,
lib.InstanceExportDataRequest.name,
]);
export class HostRouter {
constructor(
public host: Host
) { }
/**
* Forward a message to the next hop towards its destination.
*
* @param origin -
* Link the message originated from.
* @param message - Message to process.
* @param entry - message entry if message is a request or an event.
* @param hasFallback - is fallback available
* @returns true if the message was handled, false if fallback
* is requested
*/
forwardMessage(
origin: lib.Link,
message: lib.MessageRoutable,
entry: lib.RequestEntry | lib.EventEntry | undefined,
hasFallback: boolean,
) {
let dst = message.dst;
let nextHop: lib.Link | undefined;
let msg: string | undefined;
if (dst.type === lib.Address.broadcast) {
this.broadcastMessage(origin, message, entry as lib.EventEntry);
return true;
} else if (
dst.type === lib.Address.controller
|| dst.type === lib.Address.control
|| dst.type === lib.Address.host && dst.id !== this.host.config.get("host.id")
|| dst.type === lib.Address.instance && !this.host.assignedInstances.has(dst.id)
) {
nextHop = this.host;
} else if (dst.type === lib.Address.instance && this.host.assignedInstances.has(dst.id)) {
nextHop = this.host.instanceConnections.get(dst.id)!;
}
if (nextHop === origin) {
msg = `Message would return back to sender ${origin.connector.dst}.`;
nextHop = undefined;
}
if (message.type === "request") {
if (
dst.type === lib.Address.instance
&& instanceStartingMessages.has((message as lib.MessageRequest).name)
) {
this.wakeInstance(origin, message, nextHop);
return true;
} else if (!nextHop) {
if (dst.type === lib.Address.instance) {
if (hasFallback) {
return false;
}
origin.connector.sendResponseError(
new lib.ResponseError(msg || "Instance is not running."), message.src
);
}
return true;
}
// XXX What if the session is invalidated and there is no
// response? Need to track pending requests here.
}
if (nextHop) {
this.sendMessage(nextHop, message, origin);
} else {
this.warnUnrouted(message, msg);
}
return true;
}
broadcastMessage(
origin: lib.Link,
message: lib.MessageRoutable,
entry: lib.EventEntry,
) {
let dst = message.dst;
if (message.type !== "event") {
this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`);
return;
}
const plugin = entry.Event.plugin;
if (dst.id === lib.Address.instance) {
for (let instanceConnection of this.host.instanceConnections.values()) {
if (instanceConnection !== origin && (!plugin || instanceConnection.plugins.has(plugin))) {
instanceConnection.connector.forward(message);
}
}
if (this.host !== origin && (!plugin || this.host.serverPlugins.has(plugin))) {
this.host.connector.forward(message);
}
} else if (dst.id === lib.Address.host) {
// Sent up to the controller, which passes it on to the other
// hosts. Nothing left to do when it came from there.
if (this.host !== origin && (!plugin || this.host.serverPlugins.has(plugin))) {
this.host.connector.forward(message);
}
} else if (dst.id === lib.Address.control) {
if (this.host !== origin) {
if (!plugin || this.host.serverPlugins.has(plugin)) {
this.host.connector.forward(message);
}
} else {
logger.warn(`Received control broadcast of ${(message as lib.MessageEvent).name} from controller.`);
}
} else {
this.warnUnrouted(message, `Unexpected broadcast target ${dst.id}`);
}
}
wakeInstance(origin: lib.Link, message: lib.MessageRoutable, nextHop?: lib.Link) {
let dst = message.dst;
if (nextHop) {
origin.connector.sendResponseError(
new lib.ResponseError("Instance is already running."), message.src
);
return;
}
if (this.host._shuttingDown) {
origin.connector.sendResponseError(
new lib.ResponseError("Host is shutting down."), message.src
);
return;
}
this.host._connectInstance(dst.id).then(instanceConnection => {
instanceConnection.connector.forward(message);
}).catch(err => {
logger.error(`Error starting instance:\n${err.stack ?? err.message}`, this.host.instanceLogMeta(dst.id));
origin.connector.sendResponseError(
new lib.ResponseError(err.message, err.code, err.stack), message.src
);
});
}
sendMessage(nextHop: lib.Link, message: lib.MessageRoutable, origin: lib.Link) {
try {
if (message.type === "request") {
nextHop.forwardRequest(message as lib.MessageRequest, origin);
} else {
nextHop.connector.forward(message);
}
} catch (err: any) {
if (message.type === "request") {
origin.connector.sendResponseError(
new lib.ResponseError(err.message, err.code, err.stack), message.src
);
}
logger.warn(`Failed to deliver ${(message as any).name || "message"} (${message.type}): ${err.message}`);
if (err instanceof lib.InvalidMessage && err.errors) {
logger.warn(JSON.stringify(err.errors, null, "\t"));
}
}
}
warnUnrouted(message: lib.MessageRoutable, msg?: string) {
let dst = message.dst;
let baseMsg = `No destination for ${message.constructor.name} routed from ${message.src} to ${dst}`;
logger.warn(msg ? `${baseMsg}: ${msg}.` : `${baseMsg}.`);
}
}
// Instance information required to location and load an instance
type UnloadedInstance = {
path: string,
config: lib.InstanceConfig,
};
/**
* Handles running the host
*
* Connects to the controller over the WebSocket and manages intsances.
*/
export default class Host extends lib.Link {
declare "connector": HostConnector;
router = new HostRouter(this);
pluginInfos: lib.PluginNodeEnvInfo[];
config: lib.HostConfig;
/** Hooks which plugins can attach to */
hooks = new HostHooks(logger);
/** Plugins which are currently loaded */
loadedPlugins: Set<lib.PluginNodeEnvInfo> = new Set();
/** A map from instance id to instance connection. Only present when instance is running. */
instanceConnections = new Map<number, InstanceConnection>();
/** A map of instances that exist in the instance directory of the host */
discoveredInstances = new Map<number, UnloadedInstance>();
/** A map of instances that are assigned to this host */
assignedInstances = new Map<number, UnloadedInstance>();
adminlist = new Set<string>();
banlist = new Map<string, string>();
whitelist = new Set<string>();
serverVersion = "unknown";
serverPlugins = new Map<string, string>();
_startup = true;
_disconnecting = false;
_shuttingDown = false;
startedAtMs = Date.now();
static instanceConfigWarning = {
"_warning": "Changes to this file will be overwritten by the controller's copy.",
};
/** Create a host with its mod store loaded from disk */
static async fromConfig(
connector: HostConnector,
hostConfig: lib.HostConfig,
pluginInfos: lib.PluginNodeEnvInfo[],
canRestart = false,
recoveryMode = false,
) {
const modsDirectory = hostConfig.get("host.mods_directory");
await fs.mkdir(modsDirectory, { recursive: true });
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
return new this(connector, hostConfig, pluginInfos, canRestart, recoveryMode, modStore);
}
constructor(
connector: HostConnector,
hostConfig: lib.HostConfig,
pluginInfos: lib.PluginNodeEnvInfo[],
/**
* If true indicates that there is a process monitor present that
* will restart this host on a non-zero exit codes.
*/
public canRestart = false,
/**
* If true indicates the host is in recovery mode and should
* disable certain actions such as loading plugins or instance autostart
*/
public recoveryMode = false,
public modStore = new lib.ModStore(hostConfig.get("host.mods_directory"), new Map()),
) {
super(connector);
this.pluginInfos = pluginInfos;
this.config = hostConfig;
this.config.on("fieldChanged", (name, curr, prev) => {
if (name === "host.name" || name === "host.public_address") {
this.sendHostUpdate();
}
this.hooks.hostConfigFieldChanged.invoke(name, curr, prev);
});
this.connector.on("hello", data => {
this.serverVersion = data.version;
this.serverPlugins = new Map(Object.entries(data.plugins));
});
this.connector.on("connect", () => {
if (this._shuttingDown) {
return;
}
this.sendHostUpdate();
this.updateInstances().catch((err) => {
if (err instanceof lib.SessionLost) {
return undefined;
}
logger.fatal(`Unexpected error updating instances:\n${err.stack}`);
return this.shutdown();
});
});
this.connector.on("close", () => {
if (this._shuttingDown) {
return;
}
if (this._disconnecting) {
this._disconnecting = false;
this.connector.connect().catch((err) => {
logger.fatal(`Unexpected error reconnecting to controller:\n${err.stack}`);
return this.shutdown();
});
} else {
logger.fatal("Controller connection was unexpectedly closed");
this.shutdown();
}
});
this.connector.on("error", err => {
logger.fatal(err.message);
this.shutdown();
});
for (let event of ["connect", "drop", "resume", "close"] as const) {
this.connector.on(event, () => {
let message = new lib.ControllerConnectionEvent(event);
for (let instanceConnection of this.instanceConnections.values()) {
instanceConnection.send(message);
}
this.hooks.controllerConnectionEvent.invoke(event);
});
}
this.handle(lib.HostStopRequest, this.handleHostStopRequest.bind(this));
this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this));
this.handle(lib.HostMarkRestartRequiredRequest, this.handleHostMarkRestartRequiredRequest.bind(this));
this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this));
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
this.handle(lib.HostConfigSetRequest, this.handleHostConfigSetRequest.bind(this));
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this));
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.handleWhitelistUpdateEvent.bind(this));
this.handle(lib.InstanceAssignInternalRequest, this.handleInstanceAssignInternalRequest.bind(this));
this.handle(lib.InstanceUnassignInternalRequest, this.handleInstanceUnassignInternalRequest.bind(this));
this.fallbackRequest(
lib.InstanceSaveDetailsListRequest, this.fallbackInstanceSaveDetailsListRequest.bind(this),
);
this.handle(lib.InstanceRenameSaveRequest, this.handleInstanceRenameSaveRequest.bind(this));
this.handle(lib.InstanceCopySaveRequest, this.handleInstanceCopySaveRequest.bind(this));
this.handle(lib.InstanceTransferSaveRequest, this.handleInstanceTransferSaveRequest.bind(this));
this.handle(lib.InstanceDeleteSaveRequest, this.handleInstanceDeleteSaveRequest.bind(this));
this.handle(lib.InstancePullSaveRequest, this.handleInstancePullSaveRequest.bind(this));
this.handle(lib.InstancePushSaveRequest, this.handleInstancePushSaveRequest.bind(this));
this.handle(lib.InstanceDeleteInternalRequest, this.handleInstanceDeleteInternalRequest.bind(this));
}
async loadPlugins() {
const context = { logger, host: this };
for (let pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
if (this.recoveryMode) {
logger.warn(`Recovery | force disabled plugin ${pluginInfo.name}`);
continue;
}
try {
await lib.loadPlugin(
pluginInfo,
"host",
context,
"HostPlugin",
BaseHostPlugin,
);
this.loadedPlugins.add(pluginInfo);
} catch (err: any) {
if (err.code === "InstallationError") {
throw err;
}
throw new lib.PluginError(pluginInfo.name, err);
}
logger.info(`Loaded plugin ${pluginInfo.name}`);
}
}
async _createNewInstanceDir(name: string) {
name = lib.cleanFilename(name);
try {
lib.checkFilename(name);
} catch (err: any) {
throw new Error(`Instance folder was unepectedly invalid: name ${err.message}`);
}
let instancesDir = this.config.get("host.instances_directory");
for (let i = 0; i < 10; i++) { // Limit attempts in case this is somehow an infinite loop
let candidateDir = path.join(instancesDir, await lib.findUnusedName(instancesDir, name));
try {
await fs.mkdir(candidateDir);
} catch (err: any) {
if (err.code === "EEXIST") {
continue;
}
throw err;
}
return candidateDir;
}
throw Error("Unable to create instance dir, retry threshold reached");
}
async broadcastEventToInstance<T>(event: lib.Event<T>) {
for (let instanceConnection of this.instanceConnections.values()) {
if (event.constructor.plugin && !instanceConnection.plugins.has(event.constructor.plugin)) {
continue;
}
instanceConnection.send(event);
}
}
async handleHostStopRequest() {
this.shutdown();
}
async handleHostRestartRequest() {
if (!this.canRestart) {
throw new lib.RequestError("Cannot restart, host does not have a process monitor to restart it.");
}
const downgrade = await this.checkRestartDowngrade();
if (downgrade) {
const { installedVersion, runningVersion } = downgrade;
throw new lib.RequestError(
`Cannot restart host with installed Clusterio version ${installedVersion} because it is older than ` +
`running version ${runningVersion}. Stop the host before starting the older version manually.`
);
}
process.exitCode = 1;
this.shutdown();
}
async handleHostMarkRestartRequiredRequest() {
this.config.restartRequired = true;
}
async handleHostConfigGetRequest() {
return this.config.toRemote("control");
}
private static validateHostConfigSetField(fieldName: string) {
if (fieldName === "host.id") {
// Changing host.id at runtime is not worth the effort to
// support and will break a lot of things if it is allowed.
throw new lib.RequestError("Setting 'host.id' while host is running is not supported");
}
}
async handleHostConfigSetRequest(request: lib.HostConfigSetRequest) {
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
Host.validateHostConfigSetField(fieldName);
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
this.config.stageProp(fieldName as keyof lib.HostConfigFields, propName, propValue, "control");
}
} else {
this.config.stage(fieldName as keyof lib.HostConfigFields, fieldValue, "control");
}
}
this.config.commitStaging();
} finally {
this.config.revertStaging();
}
}
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
Host.validateHostConfigSetField(request.field);
this.config.set(request.field as keyof lib.HostConfigFields, request.value, "control");
}
async handleHostConfigSetPropRequest(request: lib.HostConfigSetPropRequest) {
let { field, prop, value } = request;
Host.validateHostConfigSetField(field);
this.config.setProp(field as keyof lib.HostConfigFields, prop, value, "control");
}
async handleSyncUserListsEvent(event: lib.SyncUserListsEvent) {
let updateList = (list: Set<string>, updatedList: Set<string>, Event: lib.EventClass<unknown>) => {
let added = new Set(updatedList);
let removed = new Set(list);
list.forEach(el => added.delete(el));
updatedList.forEach(el => removed.delete(el));
for (let name of added) {
list.add(name);
this.broadcastEventToInstance(new Event(name, true));
}
for (let name of removed) {
list.delete(name);
this.broadcastEventToInstance(new Event(name, false));
}
};
updateList(this.adminlist, event.adminlist, lib.InstanceAdminlistUpdateEvent);
updateList(this.whitelist, event.whitelist, lib.InstanceWhitelistUpdateEvent);
let addedOrChanged = new Map(event.banlist);
let removed = new Set(this.banlist.keys());
addedOrChanged.forEach((_, name) => removed.delete(name));
this.banlist.forEach((reason, name) => {
if (addedOrChanged.get(name) === reason) {
addedOrChanged.delete(name);
}
});
for (let [name, reason] of addedOrChanged) {
this.banlist.set(name, reason);
this.broadcastEventToInstance(new lib.InstanceBanlistUpdateEvent(name, true, reason));
}
for (let name of removed) {
this.banlist.delete(name);
this.broadcastEventToInstance(new lib.InstanceBanlistUpdateEvent(name, false, ""));
}
}
async handleAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent) {
let { name, admin } = event;
if (admin) {
this.adminlist.add(name);
} else {
this.adminlist.delete(name);
}
}
async handleBanlistUpdateEvent(event: lib.InstanceBanlistUpdateEvent) {
let { name, banned, reason } = event;
if (banned) {
this.banlist.set(name, reason);
} else {
this.banlist.delete(name);
}
}
async handleWhitelistUpdateEvent(event: lib.InstanceWhitelistUpdateEvent) {
let { name, whitelisted } = event;
if (whitelisted) {
this.whitelist.add(name);
} else {
this.whitelist.delete(name);
}
}
async downloadMod(mod: lib.ModRecord) {
let streamId = await this.send(new lib.ModDownloadRequest(mod.name, mod.version, mod.sha1));
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
const file = lib.ModInfo.filename(mod.name, mod.version);
const filePath = path.join(this.modStore.modsDirectory, file);
await lib.downloadFile(url, filePath, "overwrite");
const modInfo = await this.modStore.loadFile(file);
if (mod.sha1 && mod.sha1 !== modInfo.sha1) {
throw new Error(`Checksum mismatch downloading ${file} from controller`);
}
logger.info(`Downloaded ${file} from controller`);
return modInfo;
}
private _fetchModQueue = new lib.AsyncSerialCallback(this._fetchModInternal.bind(this));
async fetchMod(mod: lib.ModRecord) {
return await this._fetchModQueue.invoke(mod);
}
private async _fetchModInternal(mod: lib.ModRecord) {
// The mod may have already been downloaded in a previous call
let modInfo = this.modStore.getMod(mod.name, mod.version);
if (modInfo && (!mod.sha1 || mod.sha1 === modInfo.sha1)) {
return modInfo;
}
// The mods folder may be shared with the controller and thus
// already have this mod, try loading it.
try {
modInfo = await this.modStore.loadMod(mod.name, mod.version);
} catch (err: any) {
// A broken zip file may have been left in the mods folder
if (err.code !== "ENOENT") {
logger.error(`Error loading mod ${mod.name} ${mod.version}: ${err.message}`);
}
}
if (modInfo && (!mod.sha1 || mod.sha1 === modInfo.sha1)) {
return modInfo;
}
// Fetch the mod from the controller.
return await this.downloadMod(mod);
}
async fetchMods(mods: Iterable<lib.ModRecord>, factorioVersion: lib.PartialVersion) {
const builtinModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
const modInfos: Promise<lib.ModInfo>[] = [];
for (const mod of mods) {
if (builtinModNames.includes(mod.name)) {
continue;
}
modInfos.push(this.fetchMod(mod));
}
return await Promise.all(modInfos);
}
async handleInstanceAssignInternalRequest(request: lib.InstanceAssignInternalRequest) {
let { instanceId, config } = request;
let instance = this.assignedInstances.get(instanceId);
if (instance) {
instance.config.update(config, true, "controller");
logger.verbose(`Updated config for ${instance.path}`, this.instanceLogMeta(instanceId, instance));
} else {
instance = this.discoveredInstances.get(instanceId);
if (instance) {
await Instance.populate_folders(instance.path);
instance.config.update(config, true, "controller");
} else {
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
instanceConfig.update(config, false, "controller");
let instanceDir = await this._createNewInstanceDir(instanceConfig.get("instance.name"));
instanceConfig.filepath = path.join(instanceDir, "instance.json");
logger.info(`Creating ${instanceDir}`);
await Instance.populate_folders(instanceDir);
instance = {
path: instanceDir,
config: instanceConfig,
};
this.discoveredInstances.set(instanceId, instance);
}
this.assignedInstances.set(instanceId, instance);
logger.verbose(`assigned instance ${instance.config.get("instance.name")}`);
}
// Somewhat hacky, but in the event of a lost session the status is
// resent on assigment since the controller sends an assigment
// request for all the instances it knows should be on this host.
let instanceConnection = this.instanceConnections.get(instanceId);
this.send(
new lib.InstanceStatusChangedEvent(
instanceId,
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection
? instanceConnection.instance.server.version
// TODO: factorio.version is not validated
: instance.config.get("factorio.version") as lib.TargetVersion,
)
);
// Send the new list of saves for this assigned instance to the controller.
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
// save a copy of the instance config
await instance.config.save();
}
async handleInstanceUnassignInternalRequest(request: lib.InstanceUnassignInternalRequest) {
let instanceId = request.instanceId;
let instance = this.assignedInstances.get(instanceId);
if (instance) {
let instanceConnection = this.instanceConnections.get(instanceId);
if (instanceConnection && ["starting", "running"].includes(instanceConnection.instance.status)) {
await instanceConnection.send(new lib.InstanceStopRequest());
}
this.assignedInstances.delete(instanceId);
logger.verbose(`unassigned instance ${instance.config.get("instance.name")}`);
}
}
instanceLogMeta(instanceId: number, instance?: { config: lib.InstanceConfig }) {
instance = instance || this.assignedInstances.get(instanceId);
if (!instance) {
return { instance_id: instanceId, instance_name: String(instanceId) };
}
return { instance_id: instanceId, instance_name: instance.config.get("instance.name") };
}
getRequestInstance(instanceId: number) {
let instance = this.assignedInstances.get(instanceId);
if (!instance) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
/**
* Retrieved assigned port of the given instance
* @param instanceId - ID of the instance to get the assigned game port of:
* @returns Assigned game port or undefined if it does not exist.
*/
gamePort(instanceId: number) {
const instance = this.discoveredInstances.get(instanceId);
if (!instance) {
return undefined;
}
return (
instance.config.get("factorio.game_port")
?? instance.config.get("factorio.host_assigned_game_port")
?? undefined
);
}
assignGamePort(instanceId: number, inUse = new Set<number>()) {
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
for (const port of inUse) {
availablePorts.delete(port);
}
for (const [id, instance] of this.assignedInstances) {
if (id === instanceId) {
continue;
}
const port = (
instance.config.get("factorio.game_port")
?? instance.config.get("factorio.host_assigned_game_port")
);
if (port !== null) {
availablePorts.delete(port);
}
}
const instanceToAssign = this.assignedInstances.get(instanceId)!;
const assignedPort = instanceToAssign.config.get("factorio.host_assigned_game_port");
if (assignedPort !== null && availablePorts.has(assignedPort)) {
return assignedPort;
}
if (!availablePorts.size) {
const others = inUse.size ? ` (${inUse.size} in use by other processes)` : "";
throw new lib.RequestError(`No available port left to assign from host.factorio_port_range${others}`);
}
const [newPort] = availablePorts;
instanceToAssign.config.set("factorio.host_assigned_game_port", newPort);
return newPort;
}
/**
* Assign a game port that no other process is bound to
*
* Replaces the stored port if it is taken, so a port another process
* holds is not reused on every start.
*
* @param instanceId - ID of the instance to assign a game port to.
* @returns the assigned port.
*/
async assignFreeGamePort(instanceId: number) {
const inUse = new Set<number>();
for (;;) {
// Picking and storing the port before checking it keeps concurrent assignments from picking the same one.
const port = this.assignGamePort(instanceId, inUse);
if (await udpPortFree(port)) {
return port;
}
logger.warn(`Game port ${port} is in use by another process`, this.instanceLogMeta(instanceId));
inUse.add(port);
}
}
/**
* Initialize and connect an unloaded instance
*
* @param instanceId - ID of instance to initialize.
* @returns connection to instance.
*/
async _connectInstance(instanceId: number) {
let unloadedInstance = this.getRequestInstance(instanceId);
if (this.instanceConnections.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
}
let hostAddress = new lib.Address(lib.Address.host, this.config.get("host.id"));
let instanceAddress = new lib.Address(lib.Address.instance, instanceId);
let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
let instance = new Instance(
this, connectionClient,
unloadedInstance.path, this.config.get("host.factorio_directory"),
unloadedInstance.config
);
let instanceConnection = new InstanceConnection(connectionServer, this, instance);
this.instanceConnections.set(instanceId, instanceConnection);
await instance.init(this.pluginInfos);
return instanceConnection;
}
async handleSystemInfoRequest() {
const restartRequired = await this.checkRestartRequired();
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs);
}
async checkRestartRequired() {
if (this.config.restartRequired) {
return true;
}
let packageName = "@clusterio/host";
try {
// First check the clusterio version
const runningVersion = this.config.get("host.version");
const packageJsonPath = fileURLToPath(import.meta.resolve("@clusterio/host/package.json"));
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
// Second check plugin versions
for (const pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJson = JSON.parse(await fs.readFile(pluginInfo.packagePath, "utf8"));
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package.json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
async checkRestartDowngrade() {
try {
const runningVersion = this.config.get("host.version");
const packageJsonPath = fileURLToPath(import.meta.resolve("@clusterio/host/package.json"));
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const installedVersion = packageJson.version;
if (!semver.valid(runningVersion) || !semver.valid(installedVersion)) {
logger.warn(
`Unable to compare running host version ${runningVersion} ` +
`with installed version ${installedVersion}.`
);
return null;
}
if (semver.lt(installedVersion, runningVersion)) {
return { installedVersion, runningVersion };
}
} catch (err: any) {
logger.warn(`Failed to check host version before restart:\n${err.stack ?? err.message}`);
}
return null;
}
async handleHostMetricsRequest() {
let requests: Promise<InstanceType<typeof lib.InstanceMetricsRequest["Response"]>>[] = [];
for (let instanceConnection of this.instanceConnections.values()) {
requests.push(instanceConnection.send(new lib.InstanceMetricsRequest()));
}
let results = [];
let pluginResults = await this.hooks.metrics.collect();
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
results.push(metric);
}
}
for (let response of await Promise.all(requests)) {
results.push(...response.results);
}
for await (let result of lib.defaultRegistry.collect()) {
if (result.metric.name.startsWith("process_")) {
results.push(lib.serializeResult(result, {
addLabels: { "host_id": String(this.config.get("host.id")) },
metricName: result.metric.name.replace("process_", "clusterio_host_"),
}));
} else if (result.metric.name.startsWith("system_")) {
results.push(lib.serializeResult(result, {
addLabels: { "host_id": String(this.config.get("host.id")) },
metricName: result.metric.name.replace("system_", "clusterio_host_system_"),
}));
} else {
results.push(lib.serializeResult(result));
}
}
return { results };
}
async fallbackInstanceSaveDetailsListRequest(
request: lib.InstanceSaveDetailsListRequest,
src: lib.Address,
dst: lib.Address
) {
let instance = this.getRequestInstance(dst.id);
return await Instance.listSaves(dst.id, path.join(instance.path, "saves"), null);
}
async handleInstanceRenameSaveRequest(request: lib.InstanceRenameSaveRequest) {
let { instanceId, oldName, newName } = request;
checkRequestSaveName(oldName);
checkRequestSaveName(newName);
let instance = this.getRequestInstance(instanceId);
try {
// Link save in the new location.
await fs.link(
path.join(instance.path, "saves", oldName),
path.join(instance.path, "saves", newName),
);
// Unlink save in old location.
await fs.unlink(path.join(instance.path, "saves", oldName));
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.RequestError(`${newName} already exists`);
}
if (err.code === "ENOENT") {
throw new lib.RequestError(`${oldName} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
}
async handleInstanceCopySaveRequest(request: lib.InstanceCopySaveRequest) {
let { instanceId, source, destination } = request;
checkRequestSaveName(source);
checkRequestSaveName(destination);
let instance = this.getRequestInstance(instanceId);
try {
await fs.copyFile(
path.join(instance.path, "saves", source),
path.join(instance.path, "saves", destination),
fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE
);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${source} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
}
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request;
checkRequestSaveName(sourceName);
checkRequestSaveName(targetName);
let sourceInstance = this.getRequestInstance(sourceInstanceId);
let targetInstance = this.getRequestInstance(targetInstanceId);
// For consistency with remote transfer initiated through pullSave the
// target is renamed if it already exists.
targetName = await lib.findUnusedName(
path.join(targetInstance.path, "saves"), targetName, ".zip"
);
try {
if (copy) {
await fs.copyFile(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
fs.constants.COPYFILE_FICLONE
);
} else {
await fs.rename(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
);
}
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${sourceName} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstance.path, "saves"));
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstance.path, "saves"));
return targetName;
}
async sendSaveListUpdate(instanceId: number, savesDir: string) {
let instanceConnection = this.instanceConnections.get(instanceId);
let saveList: lib.SaveDetails[];
if (instanceConnection) {
saveList = await instanceConnection.send(new lib.InstanceSaveDetailsListRequest());
} else {
saveList = await Instance.listSaves(instanceId, savesDir, null);
}
this.send(new lib.InstanceSaveDetailsUpdatesEvent(saveList, instanceId));
}
async handleInstanceDeleteSaveRequest(request: lib.InstanceDeleteSaveRequest) {
let { instanceId, name } = request;
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
try {
await fs.unlink(path.join(instance.path, "saves", name));
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${name} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
}
async handleInstancePullSaveRequest(request: lib.InstancePullSaveRequest) {
let { instanceId, streamId, name } = request;
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let savesDir = path.join(instance.path, "saves");
const savedPath = await lib.downloadFile(url, path.join(savesDir, name), "rename");
name = path.basename(savedPath);
await this.sendSaveListUpdate(instanceId, savesDir);
return name;
}
async handleInstancePushSaveRequest(request: lib.InstancePushSaveRequest) {
let { instanceId, streamId, name } = request;
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
let content;
try {
content = (await fs.open(path.join(instance.path, "saves", name))).createReadStream();
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${name} does not exist`);
}
throw err;
}
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
fetch(url, {
method: "PUT",
body: content,
duplex: "half",
}).catch(err => {
logger.error(`Error pushing save to controller:\n${err.stack}`, this.instanceLogMeta(instanceId));
});
}
async handleInstanceDeleteInternalRequest(request: lib.InstanceDeleteInternalRequest) {
let instanceId = request.instanceId;
if (this.instanceConnections.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
}
let instance = this.discoveredInstances.get(instanceId);
if (!instance) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
this.discoveredInstances.delete(instanceId);
this.assignedInstances.delete(instanceId);
await fs.rm(instance.path, { recursive: true, maxRetries: 10 });
}
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
if (!this.config.get("host.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
return await lib.updatePackage("@clusterio/host");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
if (!this.config.get("host.allow_plugin_updates")) {
throw new lib.RequestError("Plugin updates are disabled on this machine");
}
return await lib.handlePluginUpdate(request.pluginPackage, this.pluginInfos);
}
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
if (!this.config.get("host.allow_plugin_install")) {
throw new lib.RequestError("Plugin installs are disabled on this machine");
}
await lib.handlePluginInstall(request.pluginPackage);
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
this.config.restartRequired = true;
}
}
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
if (!this.config.get("host.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
if (!this.config.get("host.allow_plugin_updates")) {
throw new lib.RequestError("Plugin updates are disabled on this machine");
}
return await lib.handleUpdateAll("@clusterio/host", this.pluginInfos);
}
async handlePluginListRequest(request: lib.PluginListRequest) {
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this.loadedPlugins.has(pluginInfo),
this.config.get(`${pluginInfo.name}.load_plugin`),
));
}
sendHostUpdate() {
this.send(
new lib.HostInfoUpdateEvent(
new lib.HostInfoUpdate(
this.config.get("host.name"),
this.config.get("host.public_address"),
),
),
);
}
/**
* Discover available instances
*
* Looks through the instances directory for instances and updates
* the host and controller with the new list of instances.
*/
async updateInstances() {
this.discoveredInstances = await discoverInstances(this.config.get("host.instances_directory"));
let list = [];
for (let [instanceId, instance] of this.discoveredInstances) {
let instanceConnection = this.instanceConnections.get(instanceId);
list.push(new lib.HostInstanceUpdate(
instance.config.toRemote("controller"),
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection ? instanceConnection.instance.server.version : undefined,
));
}
await this.send(new lib.InstancesUpdateRequest(list));
// Handle configured auto startup instances
if (this._startup) {
this._startup = false;
for (let [instanceId, instance] of this.assignedInstances) {
if (instance.config.get("instance.auto_start")) {
if (this.recoveryMode) {
logger.warn(`Recovery | skipping auto startup for ${instance.config.get("instance.name")}`);
continue;
}
try {
let instanceConnection = await this._connectInstance(instanceId);
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
} catch (err: any) {
logger.error(
`Error during auto startup for ${instance.config.get("instance.name")}:\n${err.stack}`,
this.instanceLogMeta(instanceId, instance)
);
}
}
}
}
}
async prepareDisconnect() {
await this.hooks.prepareControllerDisconnect.invoke(this);
for (let instanceConnection of this.instanceConnections.values()) {
await instanceConnection.send(new lib.PrepareControllerDisconnectRequest());
}
this._disconnecting = true;
return await super.prepareDisconnect();
}
/**
* Stops all instances and closes the connection
*/
async shutdown() {
if (this._shuttingDown) {
return;
}
this._shuttingDown = true;
await this.hooks.shutdown.invoke();
for (let instanceConnection of this.instanceConnections.values()) {
try {
await instanceConnection.instance.stop();
} catch (err: any) {
logger.error(`Unexpected error stopping instance:\n${err.stack}`);
}
}
try {
await this.connector.disconnect();
} catch (err: any) {
if (!(err instanceof lib.SessionLost)) {
logger.error(`Unexpected error preparing disconnect:\n${err.stack}`);
}
}
logger.info("Saving config");
await this.config.save();
// Save host side in case host_assigned_game_port changed in the config.
await Promise.all(
[...this.assignedInstances.values()].map(instance => instance.config.save())
);
try {
// Clear silly interval in pidfile library.
pidusage.clear();
} catch (err: any) {
setBlocking(true);
logger.error(`
+------------------------------------------------------------+
| Unexpected error occured while shutting down host, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
process.exit(1);
}
}
/**
* True if the connection to the controller is connected, not in the dropped
* state,and not in the process of disconnecting.
*/
get connected() {
return !this._disconnecting && this.connector.connected;
}
}
// For testing only
export const _discoverInstances = discoverInstances;