import type Controller from "./Controller.js"; import type WsServerConnector from "./WsServerConnector.js"; import * as lib from "@clusterio/lib"; import { logger } from "@clusterio/lib"; import BaseConnection from "./BaseConnection.js"; import InstanceRecord from "./InstanceRecord.js"; import HostRecord from "./HostRecord.js"; // How long log message warnings are suppressed for after one is logged. const logMessageWarnIntervalMs = 5 * 60 * 1000; /** * Represents the connection to a host * * @extends module:controller/src/BaseConnection * @alias module:controller/src/HostConnection */ export default class HostConnection extends BaseConnection { private info: HostRecord; plugins: Map; // Shared by all log message warnings, a misbehaving host could log in a loop. private _lastLogMessageWarnMs = Number.NEGATIVE_INFINITY; constructor( registerData: lib.RegisterHostData, connector: WsServerConnector, controller: Controller, remoteAddress: string, ) { super(connector, controller); this.plugins = new Map(Object.entries(registerData.plugins)); // Hosts are only created by approving them, WsServer rejects hosts without a record. const record = this._controller.hosts.getMutable(registerData.id); if (!record) { throw new Error(`Host ${registerData.id} is not registered`); } record.version = registerData.version; record.plugins = this.plugins; record.connected = true; record.remoteAddress = remoteAddress; this.info = record; this._controller.hosts.records.set(this.info); this._checkPluginVersions(); for (let event of ["connect", "drop", "resume", "close"] as const) { this.connector.on(event, () => this._controller.hooks.hostConnectionEvent.invoke(this, event)); } this.connector.on("close", () => { // Update status to unknown for instances on this host. const instances: InstanceRecord[] = []; for (let instance of this._controller.instances.valuesMutable()) { if (instance.config.get("instance.assigned_host") !== this.id) { continue; } instances.push(instance); let prev = instance.status; instance.status = "unknown"; this._controller.hooks.instanceStatusChanged.invoke(instance, prev); } this._controller.instances.records.setMany(instances); this.info.connected = false; this.saveRecord(); }); this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this)); this.handle(lib.HostInfoUpdateEvent, this.handleHostInfoUpdateEvent.bind(this)); this.handle(lib.InstanceStatusChangedEvent, this.handleInstanceStatusChangedEvent.bind(this)); this.handle(lib.InstancesUpdateRequest, this.handleInstancesUpdateRequest.bind(this)); this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this)); this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this)); this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this)); } validateIngress(message: lib.MessageRoutable) { let origin = this.connector.dst; switch (message.src.type) { case lib.Address.control: case lib.Address.controller: throw new lib.InvalidMessage(`Received message with invalid src ${message.src} from ${origin}`); case lib.Address.host: if (message.src.id !== origin.id) { throw new lib.InvalidMessage( `Received message with invalid src ${message.src} from ${origin}` ); } break; case lib.Address.instance: let instance = this._controller.instances.get(message.src.id); if (!instance || instance.config.get("instance.assigned_host") !== this.id) { throw new lib.InvalidMessage( `Received message with invalid src ${message.src} from ${origin}` ); } break; default: throw new Error("Should be unreachable"); } } _checkPluginVersions() { let pluginInfos = new Map(this._controller.pluginInfos.map(i => [i.name, i])); for (let [name, version] of this.plugins) { let info = pluginInfos.get(name); if (!info) { logger.warn( `Host ${this.info.name} (${this.id}) has plugin ${name} ${version} which the ` + "controller does not have", ); continue; } if (info.version !== version) { logger.warn( `Host ${this.info.name} (${this.id}) has plugin ${name} ${version} which does not match ` + `the version of this plugin on the controller (${info.version})` ); } } for (let [name, info] of pluginInfos) { if (!this.plugins.has(name)) { logger.warn(`Host ${this.info.name} (${this.id}) is missing plugin ${name} ${info.version}`); } } } async prepareDisconnect() { await this._controller.hooks.prepareHostDisconnect.invoke(this); return await super.prepareDisconnect(); } /** * ID of the host this connection is connected to * * @returns {number} host ID. */ get id() { return this.info.id; } /** * Remote address of the host this connection is connected to * * @returns {string} host remote address. */ get remoteAddress() { return this.info.remoteAddress; } async handleGetFactorioCredentialsRequest() { if (this._controller.config.get("controller.share_factorio_credentials_with_hosts")) { return { username: this._controller.config.get("controller.factorio_username") ?? undefined, token: this._controller.config.get("controller.factorio_token") ?? undefined, }; } return {}; } /** Store changes to the host record, unless the host was revoked */ saveRecord() { if (this._controller.hosts.getMutable(this.id) === this.info) { this._controller.hosts.records.set(this.info); } } async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) { this.info.name = event.update.name; this.info.publicAddress = event.update.publicAddress; this.saveRecord(); } async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) { let instance = this._controller.instances.getMutable(request.instanceId); // It's possible to get updates from an instance that does not exist // or is not assigned to the host it originated from if it was // reassigned or deleted while the connection to the host it was // originally on was down at the time. if (!instance || instance.config.get("instance.assigned_host") !== this.id) { logger.warn(`Got bogus update for instance id ${request.instanceId}`); return; } // We may receive status changed where the status hasn't changed // from our perspective if the connection was down at the time it // changed. Hosts also send status updates on assignInstance which // for hacky reason is also used to push config changes and // restablish status after a connection loss. if ( instance.status === request.status && instance.gamePort === request.gamePort ) { return; } let prev = instance.status; instance.status = request.status; // If the instance has transitioned to running then updated the started at time if (request.status === "running" && prev === "starting") { instance.startedAtMs = Date.now(); } instance.gamePort = request.gamePort; instance.factorioVersion = request.factorioVersion; this._controller.instances.records.set(instance); logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`); await this._controller.hooks.instanceStatusChanged.invoke(instance, prev); } async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) { // Push updated instance configs for (let instance of this._controller.instances.values()) { if (instance.config.get("instance.assigned_host") === this.id) { await this.send( new lib.InstanceAssignInternalRequest(instance.id, instance.config.toRemote("host")) ); } } // Assign instances the host has but controller does not const instanceUpdates: InstanceRecord[] = []; for (let instanceData of request.instances) { const instanceConfig = new lib.InstanceConfig("controller"); instanceConfig.update(instanceData.config, false, "host"); let controllerInstance = this._controller.instances.getMutable(instanceConfig.get("instance.id")); if (controllerInstance) { // Check if this instance is assigned somewhere else. if (controllerInstance.config.get("instance.assigned_host") !== this.id) { await this.send( new lib.InstanceUnassignInternalRequest(instanceConfig.get("instance.id")) ); continue; } // Already have this instance, update state instead if (controllerInstance.status !== instanceData.status) { let prev = controllerInstance.status; controllerInstance.status = instanceData.status; controllerInstance.gamePort = instanceData.gamePort; controllerInstance.factorioVersion = instanceData.factorioVersion; if (instanceData.status === "running" && prev === "starting") { controllerInstance.startedAtMs = Date.now(); } instanceUpdates.push(controllerInstance); logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`); await this._controller.hooks.instanceStatusChanged.invoke(controllerInstance, prev); } continue; } instanceConfig.set("instance.assigned_host", this.id); const newInstance = await this._controller.instances.createInstance(instanceConfig, true); newInstance.factorioVersion = instanceData.factorioVersion; newInstance.gamePort = instanceData.gamePort; newInstance.status = instanceData.status; instanceUpdates.push(newInstance); } // We suppress the changes made by createInstance so we must manually make them here this._controller.instances.records.setMany(instanceUpdates); await Promise.all(instanceUpdates.flatMap(newInstance => [ this._controller.hooks.instanceStatusChanged.invoke(newInstance, undefined), this.send( new lib.InstanceAssignInternalRequest( newInstance.config.get("instance.id"), newInstance.config.toRemote("host") ) ), ])); // Push lists to make sure they are in sync. let adminlist: Set = new Set(); let banlist: Map = new Map(); let whitelist: Set = new Set(); for (let user of this._controller.users.values()) { if (user.isAdmin) { adminlist.add(user.id); } if (user.isBanned) { banlist.set(user.id, user.banReason); } if (user.isWhitelisted) { whitelist.add(user.id); } } await this.send(new lib.SyncUserListsEvent(adminlist, banlist, whitelist)); } // A host may only report on instances assigned to it. Otherwise any host // could speak for an instance on a different host. See // handleInstanceStatusChangedEvent. private _assignedInstance(instanceId: number) { const instance = this._controller.instances.get(instanceId); return instance?.config.get("instance.assigned_host") === this.id ? instance : undefined; } // Host connections live for days, so the suppression expires instead of // latching for the life of the connection. private _warnLogMessage(message: string) { const now = Date.now(); if (now - this._lastLogMessageWarnMs < logMessageWarnIntervalMs) { return; } this._lastLogMessageWarnMs = now; logger.warn(message); } async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) { // Otherwise any host could inject save records for, or wipe the save // list of, an instance on a different host. if (event.instanceId !== undefined && !this._assignedInstance(event.instanceId)) { logger.warn(`Got bogus save updates for instance id ${event.instanceId}`); return; } if (event.updates.some(save => !this._assignedInstance(save.instanceId))) { logger.warn(`Got bogus save updates from host id ${this.id}`); return; } const updates: lib.SaveDetails[] = []; const deletes: lib.SaveDetails[] = []; for (const save of event.updates) { const existingSave = this._controller.saves.get(save.id); if (existingSave && save.equals(existingSave)) { continue; } updates.push(save); } if (event.instanceId !== undefined) { const updatedSaves = new Set(event.updates.map(s => s.id)); for (const save of this._controller.saves.values()) { if (save.instanceId === event.instanceId && !updatedSaves.has(save.id)) { deletes.push(save); } } } // Hosts eagerly send updates, which means we may get an update // where nothing actually changed. this._controller.saves.setMany(updates); this._controller.saves.deleteMany(deletes); } async handleLogMessageEvent(event: lib.LogMessageEvent) { // Fields beyond level and message survive schema validation, so the // instance a log line claims to come from is whatever the host sent. const info: typeof event.info & { instance_id?: number, instance_name?: string } = { ...event.info }; if (info.instance_id !== undefined) { const instance = this._assignedInstance(info.instance_id); if (!instance) { // Demote to a host level line instead of dropping it. The host did // log this, it just does not get to pin it on someone else's // instance, and a host racing a reassignment still gets heard. this._warnLogMessage(`Got bogus log message for instance id ${info.instance_id}`); delete info.instance_id; delete info.instance_name; } else { // The name the host sent may be stale or made up, ours is authoritative. const name = instance.config.get("instance.name"); if (info.instance_name !== name) { this._warnLogMessage( `Got log message for instance id ${info.instance_id} named ` + `${info.instance_name} which is named ${name} on the controller` ); info.instance_name = name; } } } this._controller.clusterLogger.log({ ...info, host_id: this.id, host_name: this.info.name, }); } async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) { let instanceId = src.id; const user = this._controller.users.getOrCreateUser(event.name); if (event.type === "join") { user.notifyJoin(instanceId); } else if (event.type === "leave") { user.notifyLeave(instanceId); } user.instanceStats.set(instanceId, event.stats); user.recalculatePlayerStats(); user.saveRecord(); let instance = this._controller.instances.get(instanceId)!; await this._controller.hooks.playerEvent.invoke(instance, { type: event.type, name: event.name, reason: event.reason, stats: event.stats, }); } async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) { if (src.type !== lib.Address.instance) { return; } const user = this._controller.users.getOrCreateUser(event.name); user.set("isAdmin", event.admin); const instance = this._controller.instances.get(src.id)!; await this._controller.hooks.playerEvent.invoke(instance, { type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"], name: event.name, stats: user.instanceStats.get(src.id)!, }); } async snoopInstanceBanlistUpdateEvent(event: lib.InstanceBanlistUpdateEvent, src: lib.Address) { if (src.type !== lib.Address.instance) { return; } const user = this._controller.users.getOrCreateUser(event.name); user.isBanned = event.banned; user.banReason = event.reason; user.saveRecord(); const instance = this._controller.instances.get(src.id)!; await this._controller.hooks.playerEvent.invoke(instance, { type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"], name: event.name, stats: user.instanceStats.get(src.id)!, reason: event.reason, }); } async snoopInstanceWhitelistUpdateEvent(event: lib.InstanceWhitelistUpdateEvent, src: lib.Address) { if (src.type !== lib.Address.instance) { return; } const user = this._controller.users.getOrCreateUser(event.name); user.set("isWhitelisted", event.whitelisted); const instance = this._controller.instances.get(src.id)!; await this._controller.hooks.playerEvent.invoke(instance, { type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"], name: event.name, stats: user.instanceStats.get(src.id)!, }); } }