UNPKG

@clusterio/host

Version:

Implementation of Clusterio host server

1,220 lines (1,068 loc) 40.4 kB
import fs from "fs-extra"; import path from "path"; import events from "events"; import pidusage from "pidusage"; import setBlocking from "set-blocking"; import phin from "phin"; import stream from "stream"; import util from "util"; // internal libraries import * as lib from "@clusterio/lib"; import { logger } from "@clusterio/lib"; import type { HostConnector } from "../host"; import Instance from "./Instance"; import InstanceConnection from "./InstanceConnection"; import BaseHostPlugin from "./BaseHostPlugin"; const finished = util.promisify(stream.finished); function checkRequestSaveName(name: string) { try { lib.checkFilename(name); } catch (err: any) { throw new lib.RequestError(`Save name ${err.message}`); } } /** * 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 * instanceInfo objects. * * @param instancesDir - Directory containing instances * @returns * mapping between instance id and information about this instance. * @internal */ async function discoverInstances(instancesDir: string) { let instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>(); 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 (instanceInfos.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}`); instanceInfos.set(instanceConfig.get("instance.id"), { path: instancePath, config: instanceConfig, }); } } return instanceInfos; } 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.instanceInfos.has(dst.id) ) { nextHop = this.host; } else if (dst.type === lib.Address.instance && this.host.instanceInfos.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.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}`, 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}.`); } } /** * 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); /** * Certificate authority used to validate TLS connections to the controller. */ tlsCa?: string; pluginInfos: lib.PluginNodeEnvInfo[]; config: lib.HostConfig; /** Mapping of plugin name to loaded plugin */ plugins: Map<string, BaseHostPlugin> = new Map(); instanceConnections = new Map<number, InstanceConnection>(); discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>(); instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>(); 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; static instanceConfigWarning = { "_warning": "Changes to this file will be overwritten by the controller's copy.", }; static async bootstrap(hostConfig: lib.HostConfig) { const modsDirectory = hostConfig.get("host.mods_directory"); await fs.ensureDir(modsDirectory); return [ await lib.ModStore.fromDirectory(modsDirectory), ] as const; } constructor( connector: HostConnector, hostConfig: lib.HostConfig, tlsCa: string | undefined, 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.tlsCa = tlsCa; this.pluginInfos = pluginInfos; this.config = hostConfig; this.config.on("fieldChanged", (name, curr, prev) => { if (name === "host.name" || name === "host.public_address") { this.sendHostUpdate(); } lib.invokeHook(this.plugins, "onHostConfigFieldChanged", 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); } for (let plugin of this.plugins.values()) { plugin.onControllerConnectionEvent(event); } }); } this.handle(lib.HostStopRequest, this.handleHostStopRequest.bind(this)); this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this)); this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this)); this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.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.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() { for (let pluginInfo of this.pluginInfos) { if ( !pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint || !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields) ) { continue; } if (this.recoveryMode) { logger.warn(`Recovery | force disabled plugin ${pluginInfo.name}`); continue; } let HostPluginClass = BaseHostPlugin; try { if (pluginInfo.hostEntrypoint) { HostPluginClass = await lib.loadPluginClass( pluginInfo.name, path.posix.join(pluginInfo.requirePath, pluginInfo.hostEntrypoint), "HostPlugin", BaseHostPlugin, ); } let hostPlugin = new HostPluginClass(pluginInfo, this, logger); await hostPlugin.init(); this.plugins.set(pluginInfo.name, hostPlugin); } catch (err: any) { 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."); } process.exitCode = 1; this.shutdown(); } async handleHostConfigGetRequest() { return this.config.toRemote("control"); } async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) { if (request.field === "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"); } this.config.set(request.field as keyof lib.HostConfigFields, request.value, "control"); } async handleHostConfigSetPropRequest(request: lib.HostConfigSetPropRequest) { let { field, prop, value } = request; 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}`; let response = await phin({ url, method: "GET", core: { ca: this.tlsCa } as object, stream: true, }); const file = lib.ModInfo.filename(mod.name, mod.version); const filePath = path.join(this.modStore.modsDirectory, file); const tempFilePath = filePath.replace(/(\.zip)?$/, ".tmp.zip"); const writeStream = fs.createWriteStream(tempFilePath, { flags: "w" }); await events.once(writeStream, "open"); response.pipe(writeStream); await finished(writeStream); await fs.rename(tempFilePath, filePath); 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>) { // This is better than the previous hard coded names // But it really shouldn't be a hard coded version either const builtinModNames = lib.ModPack.getBuiltinModNames("2.0"); 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 instanceInfo = this.instanceInfos.get(instanceId); if (instanceInfo) { instanceInfo.config.update(config, true, "controller"); logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instanceId, instanceInfo)); } else { instanceInfo = this.discoveredInstanceInfos.get(instanceId); if (instanceInfo) { await Instance.populate_folders(instanceInfo.path); instanceInfo.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); instanceInfo = { path: instanceDir, config: instanceConfig, }; this.discoveredInstanceInfos.set(instanceId, instanceInfo); } this.instanceInfos.set(instanceId, instanceInfo); logger.verbose(`assigned instance ${instanceInfo.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 : instanceInfo.config.get("factorio.version"), ) ); // Send the new list of saves for this assigned instance to the controller. await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); // save a copy of the instance config await instanceInfo.config.save(); } async handleInstanceUnassignInternalRequest(request: lib.InstanceUnassignInternalRequest) { let instanceId = request.instanceId; let instanceInfo = this.instanceInfos.get(instanceId); if (instanceInfo) { let instanceConnection = this.instanceConnections.get(instanceId); if (instanceConnection && ["starting", "running"].includes(instanceConnection.instance.status)) { await instanceConnection.send(new lib.InstanceStopRequest()); } this.instanceInfos.delete(instanceId); logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`); } } instanceLogMeta(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) { instanceInfo = instanceInfo || this.instanceInfos.get(instanceId); if (!instanceInfo) { return { instance_id: instanceId, instance_name: String(instanceId) }; } return { instance_id: instanceId, instance_name: instanceInfo.config.get("instance.name") }; } getRequestInstanceInfo(instanceId: number) { let instanceInfo = this.instanceInfos.get(instanceId); if (!instanceInfo) { throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`); } return instanceInfo; } /** * 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.discoveredInstanceInfos.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) { const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1); for (const [id, instance] of this.instanceInfos) { 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.instanceInfos.get(instanceId)!; const assignedPort = instanceToAssign.config.get("factorio.host_assigned_game_port"); if (assignedPort !== null && availablePorts.has(assignedPort)) { return assignedPort; } if (!availablePorts.size) { throw new lib.RequestError("No available port left to assign from host.factorio_port_range"); } const [newPort] = availablePorts; instanceToAssign.config.set("factorio.host_assigned_game_port", newPort); return newPort; } /** * Initialize and connect an unloaded instance * * @param instanceId - ID of instance to initialize. * @returns connection to instance. */ async _connectInstance(instanceId: number) { let instanceInfo = this.getRequestInstanceInfo(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, instanceInfo.path, this.config.get("host.factorio_directory"), instanceInfo.config ); let instanceConnection = new InstanceConnection(connectionServer, this, instance); this.instanceConnections.set(instanceId, instanceConnection); await instance.init(this.pluginInfos); return instanceConnection; } async handleSystemInfoRequest() { if (!this.config.restartRequired) { // If a restart isn't already required, then test if a new version is installed try { const runningVersion = this.config.get("host.version"); const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json")); if (runningVersion !== packageJson.version) { this.config.restartRequired = true; } } catch (err: any) { logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`); } } return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, this.config.restartRequired); } 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 lib.invokeHook(this.plugins, "onMetrics"); 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 instanceInfo = this.getRequestInstanceInfo(dst.id); return await Instance.listSaves(dst.id, path.join(instanceInfo.path, "saves"), null); } async handleInstanceRenameSaveRequest(request: lib.InstanceRenameSaveRequest) { let { instanceId, oldName, newName } = request; checkRequestSaveName(oldName); checkRequestSaveName(newName); let instanceInfo = this.getRequestInstanceInfo(instanceId); try { await fs.move( path.join(instanceInfo.path, "saves", oldName), path.join(instanceInfo.path, "saves", newName), { overwrite: false }, ); } catch (err: any) { if (err.code === "ENOENT") { throw new lib.RequestError(`${oldName} does not exist`); } throw err; } await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); } async handleInstanceCopySaveRequest(request: lib.InstanceCopySaveRequest) { let { instanceId, source, destination } = request; checkRequestSaveName(source); checkRequestSaveName(destination); let instanceInfo = this.getRequestInstanceInfo(instanceId); try { await fs.copy( path.join(instanceInfo.path, "saves", source), path.join(instanceInfo.path, "saves", destination), { overwrite: false, errorOnExist: true }, ); } catch (err: any) { if (err.code === "ENOENT") { throw new lib.RequestError(`${source} does not exist`); } throw err; } await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); } async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) { let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request; checkRequestSaveName(sourceName); checkRequestSaveName(targetName); let sourceInstanceInfo = this.getRequestInstanceInfo(sourceInstanceId); let targetInstanceInfo = this.getRequestInstanceInfo(targetInstanceId); // For consistency with remote transfer initiated through pullSave the // target is renamed if it already exists. targetName = await lib.findUnusedName( path.join(targetInstanceInfo.path, "saves"), targetName, ".zip" ); try { if (copy) { await fs.copy( path.join(sourceInstanceInfo.path, "saves", sourceName), path.join(targetInstanceInfo.path, "saves", targetName), { overwrite: true }, ); } else { await fs.move( path.join(sourceInstanceInfo.path, "saves", sourceName), path.join(targetInstanceInfo.path, "saves", targetName), { overwrite: true }, ); } } catch (err: any) { if (err.code === "ENOENT") { throw new lib.RequestError(`${sourceName} does not exist`); } throw err; } await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstanceInfo.path, "saves")); await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstanceInfo.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 instanceInfo = this.getRequestInstanceInfo(instanceId); try { await fs.unlink(path.join(instanceInfo.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(instanceInfo.path, "saves")); } async handleInstancePullSaveRequest(request: lib.InstancePullSaveRequest) { let { instanceId, streamId, name } = request; checkRequestSaveName(name); let instanceInfo = this.getRequestInstanceInfo(instanceId); let url = new URL(this.config.get("host.controller_url")); url.pathname += `api/stream/${streamId}`; let response = await phin({ url, method: "GET", core: { ca: this.tlsCa } as object, stream: true, }); if (response.statusCode !== 200) { let content = await lib.readStream(response); throw new lib.RequestError(`Stream returned ${response.statusCode}: ${content.toString()}`); } let savesDir = path.join(instanceInfo.path, "saves"); let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip"); let writeStream: NodeJS.WritableStream; while (true) { try { writeStream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" }); await events.once(writeStream, "open"); break; } catch (err: any) { if (err.code === "EEXIST") { tempFilename = await lib.findUnusedName(savesDir, tempFilename, ".tmp.zip"); } else { throw err; } } } response.pipe(writeStream); await finished(writeStream); name = await lib.findUnusedName(savesDir, name, ".zip"); await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, name)); await this.sendSaveListUpdate(instanceId, savesDir); return name; } async handleInstancePushSaveRequest(request: lib.InstancePushSaveRequest) { let { instanceId, streamId, name } = request; checkRequestSaveName(name); let instanceInfo = this.getRequestInstanceInfo(instanceId); let content: Buffer; try { // phin doesn't support streaming requests :( content = await fs.readFile(path.join(instanceInfo.path, "saves", name)); } 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}`; phin({ url, method: "PUT", core: { ca: this.tlsCa } as object, data: content, }).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 instanceInfo = this.discoveredInstanceInfos.get(instanceId); if (!instanceInfo) { throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`); } this.discoveredInstanceInfos.delete(instanceId); this.instanceInfos.delete(instanceId); await fs.remove(instanceInfo.path); } 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 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"); } return await lib.handlePluginInstall(request.pluginPackage); } async handlePluginListRequest(request: lib.PluginListRequest) { return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( pluginInfo, this.plugins.has(pluginInfo.name), this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields) as boolean, )); } 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.discoveredInstanceInfos = await discoverInstances(this.config.get("host.instances_directory")); let list = []; for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) { let instanceConnection = this.instanceConnections.get(instanceId); list.push(new lib.HostInstanceUpdate( instanceInfo.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, instanceInfo] of this.instanceInfos) { if (instanceInfo.config.get("instance.auto_start")) { if (this.recoveryMode) { logger.warn(`Recovery | skipping auto startup for ${instanceInfo.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 ${instanceInfo.config.get("instance.name")}:\n${err.stack}`, this.instanceLogMeta(instanceId, instanceInfo) ); } } } } } async prepareDisconnect() { await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect", 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 lib.invokeHook(this.plugins, "onShutdown"); 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.instanceInfos.values()].map(instanceInfo => instanceInfo.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}` ); // eslint-disable-next-line node/no-process-exit 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;