diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index f3061f29..1da1048d 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -12,6 +12,7 @@ import * as lib from "@clusterio/lib"; import { logger } from "@clusterio/lib"; import type { HostConnector } from "../host"; +import FactorioProxy from "./FactorioProxy"; import Instance from "./Instance"; import InstanceConnection from "./InstanceConnection"; import BaseHostPlugin from "./BaseHostPlugin"; @@ -266,6 +267,7 @@ export default class Host extends lib.Link { instanceConnections = new Map(); discoveredInstanceInfos = new Map(); instanceInfos = new Map(); + factorioProxies = new Map(); adminlist = new Set(); banlist = new Map(); @@ -277,6 +279,7 @@ export default class Host extends lib.Link { _startup = true; _disconnecting = false; _shuttingDown = false; + _proxyStartPromises = new Map>(); static instanceConfigWarning = { "_warning": "Changes to this file will be overwritten by the controller's copy.", @@ -690,7 +693,9 @@ export default class Host extends lib.Link { new lib.InstanceStatusChangedEvent( instanceId, instanceConnection ? instanceConnection.instance.status : "stopped", - instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId), + instanceConnection + ? instanceConnection.instance.config.get("factorio.proxy_port") ?? instanceConnection.instance.server.gamePort + : this.publicGamePort(instanceId, instanceInfo), instanceConnection ? instanceConnection.instance.server.version // TODO: factorio.version is not validated @@ -701,6 +706,8 @@ export default class Host extends lib.Link { // Send the new list of saves for this assigned instance to the controller. await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); + await this._ensureFactorioProxy(instanceId, instanceInfo); + // save a copy of the instance config await instanceInfo.config.save(); } @@ -715,6 +722,10 @@ export default class Host extends lib.Link { } this.instanceInfos.delete(instanceId); + if (this.factorioProxies.has(instanceId)) { + await this.factorioProxies.get(instanceId)!.stop(); + this.factorioProxies.delete(instanceId); + } logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`); } } @@ -735,6 +746,27 @@ export default class Host extends lib.Link { return instanceInfo; } + /** + * Retrieved public port for clients to connect to for the given instance. + * @param instanceId - ID of the instance to get the port for. + * @returns Proxy port if configured, otherwise the game port. + */ + publicGamePort(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) { + instanceInfo = instanceInfo ?? this.discoveredInstanceInfos.get(instanceId); + if (!instanceInfo) { + return undefined; + } + const proxyPort = instanceInfo.config.get("factorio.proxy_port"); + if (proxyPort !== null && proxyPort !== undefined) { + return proxyPort; + } + return ( + instanceInfo.config.get("factorio.game_port") + ?? instanceInfo.config.get("factorio.host_assigned_game_port") + ?? undefined + ); + } + /** * Retrieved assigned port of the given instance * @param instanceId - ID of the instance to get the assigned game port of: @@ -752,6 +784,18 @@ export default class Host extends lib.Link { ); } + ensureGamePortAssigned(instanceId: number, instanceInfo: { config: lib.InstanceConfig }) { + const configuredPort = instanceInfo.config.get("factorio.game_port"); + const assignedPort = instanceInfo.config.get("factorio.host_assigned_game_port"); + if (configuredPort !== null) { + return configuredPort; + } + if (assignedPort !== null) { + return assignedPort; + } + return this.assignGamePort(instanceId); + } + 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) { @@ -779,6 +823,102 @@ export default class Host extends lib.Link { return newPort; } + async startInstanceFromProxy(instanceId: number) { + const existing = this._proxyStartPromises.get(instanceId); + if (existing) { + return existing; + } + + if (this._shuttingDown || this.recoveryMode) { + return; + } + + const promise = (async () => { + let instanceConnection = this.instanceConnections.get(instanceId); + if (instanceConnection && instanceConnection.instance.status === "running") { + return; + } + + if (!instanceConnection) { + instanceConnection = await this._connectInstance(instanceId); + } + + if (instanceConnection.instance.status !== "running") { + await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest()); + } + })().catch(err => { + logger.error( + `Error starting instance from proxy:\n${err.stack ?? err.message ?? err}`, + this.instanceLogMeta(instanceId) + ); + }).finally(() => { + this._proxyStartPromises.delete(instanceId); + }); + + this._proxyStartPromises.set(instanceId, promise); + return promise; + } + + private async _ensureFactorioProxy(instanceId: number, instanceInfo: { config: lib.InstanceConfig }) { + const proxyPort = instanceInfo.config.get("factorio.proxy_port"); + const existingProxy = this.factorioProxies.get(instanceId); + if (proxyPort === null) { + if (existingProxy) { + await existingProxy.stop(); + this.factorioProxies.delete(instanceId); + } + return; + } + + const beforeAssigned = instanceInfo.config.get("factorio.host_assigned_game_port"); + const gamePort = this.ensureGamePortAssigned(instanceId, instanceInfo); + if (beforeAssigned === null && instanceInfo.config.get("factorio.host_assigned_game_port") !== null) { + await instanceInfo.config.save(); + } + + if (proxyPort === gamePort) { + logger.warn( + `Proxy port ${proxyPort} matches game port for instance, proxy disabled.`, + this.instanceLogMeta(instanceId, instanceInfo), + ); + if (existingProxy) { + await existingProxy.stop(); + this.factorioProxies.delete(instanceId); + } + return; + } + + if (existingProxy) { + await existingProxy.updatePorts(proxyPort, gamePort); + return; + } + + const proxyLogger = logger.child(this.instanceLogMeta(instanceId, instanceInfo)); + const proxy = new FactorioProxy(this, instanceId, proxyPort, gamePort, proxyLogger); + this.factorioProxies.set(instanceId, proxy); + try { + await proxy.start(); + } catch (err: any) { + logger.error( + `Failed to start proxy on UDP ${proxyPort}: ${err.message ?? err}`, + this.instanceLogMeta(instanceId, instanceInfo), + ); + this.factorioProxies.delete(instanceId); + } + } + + private async _syncFactorioProxies() { + for (const [instanceId] of this.factorioProxies) { + if (!this.instanceInfos.has(instanceId)) { + await this.factorioProxies.get(instanceId)!.stop(); + this.factorioProxies.delete(instanceId); + } + } + for (const [instanceId, instanceInfo] of this.instanceInfos) { + await this._ensureFactorioProxy(instanceId, instanceInfo); + } + } + /** * Initialize and connect an unloaded instance * @@ -1146,7 +1286,9 @@ export default class Host extends lib.Link { 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.config.get("factorio.proxy_port") ?? instanceConnection.instance.server.gamePort + : this.publicGamePort(instanceId, instanceInfo), instanceConnection ? instanceConnection.instance.server.version : undefined, )); } @@ -1174,6 +1316,8 @@ export default class Host extends lib.Link { } } } + + await this._syncFactorioProxies(); } async prepareDisconnect() { @@ -1204,6 +1348,15 @@ export default class Host extends lib.Link { } } + for (const proxy of this.factorioProxies.values()) { + try { + await proxy.stop(); + } catch (err: any) { + logger.error(`Unexpected error stopping proxy:\n${err.stack ?? err.message ?? err}`); + } + } + this.factorioProxies.clear(); + try { await this.connector.disconnect(); } catch (err: any) { diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index d3480c46..05f97240 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -548,12 +548,13 @@ end`.replace(/\r?\n/g, " "); notifyStatus(status: Instance["_status"]) { this._status = status; + const publicPort = this.config.get("factorio.proxy_port") ?? this.server.gamePort; this.sendTo( "controller", new lib.InstanceStatusChangedEvent( this.id, status, - this.server.gamePort, + publicPort, // TODO: factorio.version is not validated status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion, ), diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index b948a04c..8d1ee9a8 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -407,6 +407,7 @@ export interface InstanceConfigFields { "factorio.executable_path": string | null; "factorio.shutdown_timeout": number; "factorio.game_port": number | null; + "factorio.proxy_port": number | null; "factorio.host_assigned_game_port": number | null; "factorio.rcon_port": number | null; "factorio.rcon_password": string | null; @@ -502,6 +503,13 @@ export class InstanceConfig extends classes.Config { type: "number", optional: true, }, + "factorio.proxy_port": { + description: + "UDP port to accept client connections on. When set, the host will proxy traffic to " + + "the instance game port and start the instance on first packet.", + type: "number", + optional: true, + }, "factorio.host_assigned_game_port": { access: ["host"], type: "number",