From 44e5847a1d439b43da9f6f5ba365a8975eac824f Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 16 Sep 2024 14:42:29 +0200 Subject: [PATCH] Implement shared Factorio credentials Add config entries to controller and host for the Factorio username and token to use when talking to the Factorio APIs. If set an username and token is not set in factorio.settings then these settings are used, making it easy to configure one set of credentials for a cluster/host. For running more public provide your own hardware clusters, there's an option to disable controller sharing the credential with the hosts so that each host have to set their own credentials. --- CHANGELOG.md | 3 + packages/controller/src/Controller.ts | 2 - packages/controller/src/HostConnection.ts | 11 +++ packages/host/src/Instance.ts | 96 ++++++++++++-------- packages/lib/src/config/definitions.ts | 43 +++++++++ packages/lib/src/data/messages_controller.ts | 12 +++ packages/lib/src/link/messages.ts | 1 + 7 files changed, 130 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac866c1..08fc564b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ Many thanks to the following for contributing to this release: - Added instance version display [#573](https://github.com/clusterio/clusterio/pull/573) - Added `factorio.shutdown_timeout` config to set the time the host will wait for a Factorio server to shut down befor killing it. - Changed shutdown logic to prefer sending a /quit command via RCON instead of using diverging logic on Windows and Linux. +- Added `controller.factorio_username` and `controller.factorio_token` config to set Factorio credentials used for the whole cluster. +- Added `controller.share_factorio_credential_with_hosts` config to optionally require hosts to provide their own credentials. +- Added `host.factorio_username` and `host.factorio_token` config to set Factorio credentials used on a given host. ### Breaking Changes diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 4d9c2c80..22b54567 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -788,8 +788,6 @@ export default class Controller { "tags": ["clusterio"], "max_players": 0, "visibility": { "public": true, "lan": true }, - "username": "", - "token": "", "game_password": "", "require_user_verification": true, "max_upload_in_kilobytes_per_second": 0, diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 388014d2..6da8e93c 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -74,6 +74,7 @@ export default class HostConnection extends BaseConnection { this._controller.hosts.set(this.info); }); + 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)); @@ -161,6 +162,16 @@ export default class HostConnection extends BaseConnection { 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 {}; + } + async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) { this.info.name = event.update.name; this.info.publicAddress = event.update.publicAddress; diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 8cbc903b..9cbd3461 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -583,11 +583,23 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); * entries from the given settings object. * * @param overrides - Server settings to override. + * @param includeCredentials - Include Factorio username and token from host/controller config. * @returns * server example settings with the given settings applied over it. */ - async resolveServerSettings(overrides: Record) { + async resolveServerSettings(overrides: Record, includeCredentials: boolean) { let serverSettings = await this.server.exampleSettings(); + if (includeCredentials && !overrides.username && !overrides.token) { + let credentials = { + username: this._host.config.get("host.factorio_username") ?? undefined, + token: this._host.config.get("host.factorio_token") ?? undefined, + }; + if (!credentials.username && !credentials.token) { + Object.assign(credentials, await this.sendTo("controller", new lib.GetFactorioCredentialsRequest())); + } + if (credentials.username) { serverSettings.username = credentials.username; } + if (credentials.token) { serverSettings.token = credentials.token; } + } for (let [key, value] of Object.entries(overrides)) { if (!Object.hasOwnProperty.call(serverSettings, key)) { @@ -605,12 +617,14 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); * Generate the server-settings.json file from the example file in the * data directory and override any settings configured in the instance's * factorio_settings config entry. + * + * @param includeCredentials - Include Factorio username and token from host/controller config. */ - async writeServerSettings() { + async writeServerSettings(includeCredentials: boolean) { const warning = "Changes to this file will be overwitten by the factorio.settings config on the instance."; const serverSettings = { "_comment_warning": warning, - ...await this.resolveServerSettings(this.config.get("factorio.settings")), + ...await this.resolveServerSettings(this.config.get("factorio.settings"), includeCredentials), }; await lib.safeOutputFile( this.server.writePath("server-settings.json"), @@ -714,7 +728,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); */ async prepare() { this.logger.verbose("Writing server-settings.json"); - await this.writeServerSettings(); + await this.writeServerSettings(true); if (this.config.get("factorio.sync_adminlist")) { this.logger.verbose("Writing server-adminlist.json"); @@ -884,8 +898,8 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } async updateFactorioSettings(current: Record, previous: Record) { - current = await this.resolveServerSettings(current); - previous = await this.resolveServerSettings(previous); + current = await this.resolveServerSettings(current, false); + previous = await this.resolveServerSettings(previous, false); for (let [key, action] of Object.entries(serverSettingsActions)) { if (current[key] !== undefined && !util.isDeepStrictEqual(current[key], previous[key])) { @@ -1008,21 +1022,26 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); async handleInstanceStartRequest(request: lib.InstanceStartRequest) { let saveName = request.save; try { - await this.prepare(); - saveName = await this.prepareSave(saveName); - } catch (err: any) { - this.logger.error(`Error preparing instance: ${err.message}`); - this.notifyExit(); - await this.sendSaveListUpdate(); - throw err; - } + try { + await this.prepare(); + saveName = await this.prepareSave(saveName); + } catch (err: any) { + this.logger.error(`Error preparing instance: ${err.message}`); + this.notifyExit(); + await this.sendSaveListUpdate(); + throw err; + } - try { - await this.start(saveName); - } catch (err: any) { - this.logger.error(`Error starting ${saveName}: ${err.message}`); - await this.stop(); - throw err; + try { + await this.start(saveName); + } catch (err: any) { + this.logger.error(`Error starting ${saveName}: ${err.message}`); + await this.stop(); + throw err; + } + } finally { + this.logger.verbose("Wiping credentials from server-settings.json"); + await this.writeServerSettings(false); } } @@ -1033,21 +1052,26 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } try { - await this.prepare(); - } catch (err: any) { - this.logger.error(`Error preparing instance: ${err.message}`); - this.notifyExit(); - await this.sendSaveListUpdate(); - throw err; - } + try { + await this.prepare(); + } catch (err: any) { + this.logger.error(`Error preparing instance: ${err.message}`); + this.notifyExit(); + await this.sendSaveListUpdate(); + throw err; + } - let { scenario, seed, mapGenSettings, mapSettings } = request; - try { - await this.startScenario(scenario, seed, mapGenSettings, mapSettings); - } catch (err: any) { - this.logger.error(`Error starting scenario ${scenario}: ${err.message}`); - await this.stop(); - throw err; + let { scenario, seed, mapGenSettings, mapSettings } = request; + try { + await this.startScenario(scenario, seed, mapGenSettings, mapSettings); + } catch (err: any) { + this.logger.error(`Error starting scenario ${scenario}: ${err.message}`); + await this.stop(); + throw err; + } + } finally { + this.logger.verbose("Wiping credentials from server-settings.json"); + await this.writeServerSettings(false); } } @@ -1059,7 +1083,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); this.notifyStatus("creating_save"); try { this.logger.verbose("Writing server-settings.json"); - await this.writeServerSettings(); + await this.writeServerSettings(false); await this.syncMods(); } catch (err: any) { @@ -1087,7 +1111,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); this.notifyStatus("exporting_data"); try { this.logger.verbose("Writing server-settings.json"); - await this.writeServerSettings(); + await this.writeServerSettings(false); this.logger.info("Exporting data ....."); await this.syncMods(); diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index b50eeaa3..8f38de08 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -20,6 +20,9 @@ export interface ControllerConfigFields { "controller.metrics_timeout": number; "controller.system_metrics_interval": number; "controller.proxy_stream_timeout": number; + "controller.factorio_username": string | null; + "controller.factorio_token": string | null; + "controller.share_factorio_credentials_with_hosts": boolean; "controller.default_mod_pack_id": number | null; "controller.default_role_id": number | null; "controller.autosave_interval": number; @@ -147,6 +150,27 @@ export class ControllerConfig extends classes.Config { type: "number", initialValue: 15, }, + "controller.factorio_username": { + title: "Factorio Username", + description: "Username to authenticate with Factorio API with.", + type: "string", + optional: true, + }, + "controller.factorio_token": { + title: "Factorio Token", + description: "Token to authenticate with Factorio API with.", + type: "string", + credential: ["controller"], + optional: true, + }, + "controller.share_factorio_credentials_with_hosts": { + title: "Share Factorio credentials with Hosts", + description: + "If enabled, the Factorio Username and Token will be shared with hosts and " + + "used in the server settings of instances in this cluster.", + type: "boolean", + initialValue: true, + }, "controller.default_mod_pack_id": { title: "Default Mod Pack", description: "Mod pack used by default for instances.", @@ -175,6 +199,8 @@ export interface HostConfigFields { "host.tls_ca": string | null; "host.public_address": string; "host.factorio_port_range": string; + "host.factorio_username": string | null, + "host.factorio_token": string | null, "host.max_reconnect_delay": number; } @@ -245,6 +271,23 @@ export class HostConfig extends classes.Config { type: "string", initialValue: "34100-34199", }, + "host.factorio_username": { + title: "Factorio Username", + description: + "Username to authenticate with Factorio API with. If set this will be used in the server settings " + + "of all instances on this host.", + type: "string", + optional: true, + }, + "host.factorio_token": { + title: "Factorio Token", + description: + "Token to authenticate with Factorio API with. If set this will be used in the server settings " + + "of all instances on this host.", + type: "string", + credential: ["host"], + optional: true, + }, "host.max_reconnect_delay": { title: "Max Reconnect Delay", description: "Maximum delay to wait before attempting to reconnect WebSocket", diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index e24d0be4..41492305 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -122,6 +122,18 @@ export class HostConfigCreateRequest { static Response = plainJson(HostConfig.jsonSchema); } +export class GetFactorioCredentialsRequest { + declare ["constructor"]: typeof GetFactorioCredentialsRequest; + static type = "request" as const; + static src = ["host", "instance"] as const; + static dst = "controller" as const; + + static Response = plainJson(Type.Object({ + "username": Type.Optional(Type.String()), + "token": Type.Optional(Type.String()), + })); +} + export class LogSetSubscriptionsRequest { declare ["constructor"]: typeof LogSetSubscriptionsRequest; static type = "request" as const; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 7dd7ed99..4182db3a 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -24,6 +24,7 @@ export const dataClasses: (RequestClass | EventClass) controller.ControllerConfigSetPropRequest, controller.HostGenerateTokenRequest, controller.HostConfigCreateRequest, + controller.GetFactorioCredentialsRequest, controller.LogSetSubscriptionsRequest, controller.LogQueryRequest, controller.LogMessageEvent,