From 43e6dd60b3f6dadd5465ff6c650f003da0a80a24 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 17 Feb 2023 23:41:06 +0100 Subject: [PATCH 1/9] Removed duplicated rejection handling Remove code accidentally left out by the change in 50703f8. --- packages/slave/slave.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/slave/slave.js b/packages/slave/slave.js index d52a3d00..c252ded4 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -136,9 +136,6 @@ async function startSlave() { logger.info(`Starting Clusterio slave ${version}`); } - // add better stack traces on promise rejection - process.on("unhandledRejection", err => logger.error(`Unhandled rejection:\n${err.stack}`)); - logger.info(`Loading available plugins from ${args.pluginList}`); let pluginList = new Map(); try { From c60abdc3494e494c44c6a2a489e9583ac634577e Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 17 Feb 2023 23:41:50 +0100 Subject: [PATCH 2/9] Call fsync on patching saves Similar to 1b2a20c call fsync on the newly written patched save before renaming it to prevent data loss in the event of a system crash. --- packages/lib/factorio/patch.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/lib/factorio/patch.js b/packages/lib/factorio/patch.js index 7f92232f..8e0c6a0f 100644 --- a/packages/lib/factorio/patch.js +++ b/packages/lib/factorio/patch.js @@ -251,8 +251,14 @@ async function patch(savePath, modules) { // Write back the save let tempSavePath = `${savePath}.tmp`; let stream = zip.generateNodeStream({ compression: "DEFLATE" }); - let pipe = stream.pipe(fs.createWriteStream(tempSavePath)); - await events.once(pipe, "finish"); + let fd = await fs.open(tempSavePath, "w"); + try { + let pipe = stream.pipe(fs.createWriteStream("", { fd, autoClose: false })); + await events.once(pipe, "finish"); + await fs.fsync(fd); + } finally { + await fs.close(fd); + } await fs.rename(tempSavePath, savePath); } From 475182efd0a118d54d6a78e891e50fcb0d207a80 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 20 Feb 2023 04:22:59 +0100 Subject: [PATCH 3/9] Ignore techonolgy enabled status for sync Technologies that were disabled were ignored completely by research sync due to a missunderstanding of what disabled means. Disabled does not mean the tech is somehow inaccessible and completely out of play, it only means it can't be selected in the technology screen. Ignore the enabled status of technologies in research_sync to fix a crash that happened when a mod enables a disabled by default technology and the player attempts to research it. --- plugins/research_sync/module/sync.lua | 34 ++++++++++++--------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/plugins/research_sync/module/sync.lua b/plugins/research_sync/module/sync.lua index 5bd9b938..0fdceec9 100644 --- a/plugins/research_sync/module/sync.lua +++ b/plugins/research_sync/module/sync.lua @@ -32,14 +32,12 @@ sync.events[clusterio_api.events.on_server_startup] = function(event) local force = game.forces["player"] for _, tech in pairs(force.technologies) do - if tech.enabled then - local progress = get_technology_progress(tech) - global.research_sync.technologies[tech.name] = { - level = tech.level, - researched = tech.researched, - progress = progress, - } - end + local progress = get_technology_progress(tech) + global.research_sync.technologies[tech.name] = { + level = tech.level, + researched = tech.researched, + progress = progress, + } end end @@ -112,14 +110,12 @@ function research_sync.dump_technologies() local techs = {} for _, tech in pairs(force.technologies) do - if tech.enabled then - table.insert(techs, { - name = tech.name, - level = tech.level, - progress = get_technology_progress(tech), - researched = tech.researched, - }) - end + table.insert(techs, { + name = tech.name, + level = tech.level, + progress = get_technology_progress(tech), + researched = tech.researched, + }) end if #techs == 0 then @@ -140,7 +136,7 @@ function research_sync.sync_technologies(data) global.research_sync.ignore_research_finished = true for _, tech_data in pairs(game.json_to_table(data)) do local tech = force.technologies[tech_data[nameIndex]] - if tech and tech.enabled and tech.level <= tech_data[levelIndex] then + if tech and tech.level <= tech_data[levelIndex] then tech.level = math.min(tech_data[levelIndex], tech.prototype.max_level) local progress @@ -171,7 +167,7 @@ function research_sync.update_progress(data) for _, masterTech in ipairs(techs) do local tech = force.technologies[masterTech.name] - if tech and tech.enabled and tech.level == masterTech.level then + if tech and tech.level == masterTech.level then send_contribution(tech) set_technology_progress(tech, masterTech.progress) global.research_sync.technologies[tech.name] = { @@ -185,7 +181,7 @@ end function research_sync.research_technology(name, level) local force = game.forces["player"] local tech = force.technologies[name] - if not tech or not tech.enabled or tech.level > level then + if not tech or tech.level > level then return end From 17e4b088f1ce2f5890c309d500c8cd78e15d3fa3 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 20 Feb 2023 17:45:10 +0100 Subject: [PATCH 4/9] Fix crash when game starts tick_paused Check patch number in update_instance step to ensure the clusterio_api is properly initialized if the game started tick paused and no ticks to trigger the patch check have happened yet. --- packages/slave/modules/clusterio/impl.lua | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/slave/modules/clusterio/impl.lua b/packages/slave/modules/clusterio/impl.lua index 452a3a30..93175950 100644 --- a/packages/slave/modules/clusterio/impl.lua +++ b/packages/slave/modules/clusterio/impl.lua @@ -1,16 +1,18 @@ local api = require('modules/clusterio/api') -local impl = {} - -impl.events = {} -impl.events[defines.events.on_tick] = function() +local function check_patch() if global.clusterio_patch_number ~= clusterio_patch_number then global.clusterio_patch_number = clusterio_patch_number script.raise_event(api.events.on_server_startup, {}) end end +local impl = {} +impl.events = {} + +impl.events[defines.events.on_tick] = check_patch + impl.events[api.events.on_server_startup] = function() if not global.clusterio then global.clusterio = { @@ -47,6 +49,7 @@ end -- Internal API clusterio_private = {} function clusterio_private.update_instance(new_id, new_name) + check_patch() global.clusterio.instance_id = new_id global.clusterio.instance_name = new_name script.raise_event(api.events.on_instance_updated, { From 5426d9b2bd114201793fc17cb6b09ec82423eefe Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 20 Feb 2023 19:11:21 +0100 Subject: [PATCH 5/9] Log warning on mismatched slave or plugin versions Log warning to the master log if a slave connects with a missmatched version, missmatched plugins or missmatched plugin versions. --- packages/master/src/Master.js | 4 ++-- packages/master/src/SlaveConnection.js | 25 +++++++++++++++++++++++++ packages/master/src/WsServer.js | 11 ++++++++++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/master/src/Master.js b/packages/master/src/Master.js index 6fb10989..e4605896 100644 --- a/packages/master/src/Master.js +++ b/packages/master/src/Master.js @@ -45,8 +45,8 @@ class Master { constructor(clusterLogger, pluginInfos, configPath, config) { this.clusterLogger = clusterLogger; /** - * Mapping of plugin name to info objects for known plugins - * @type {Map} + * Array of plugin info objects for known plugins + * @type {Array} */ this.pluginInfos = pluginInfos; this.configPath = configPath; diff --git a/packages/master/src/SlaveConnection.js b/packages/master/src/SlaveConnection.js index 35564264..c011d01f 100644 --- a/packages/master/src/SlaveConnection.js +++ b/packages/master/src/SlaveConnection.js @@ -23,6 +23,7 @@ class SlaveConnection extends BaseConnection { this._name = registerData.name; this._version = registerData.version; this.plugins = new Map(Object.entries(registerData.plugins)); + this._checkPluginVersions(); this._master.slaves.set(this._id, { agent: this._agent, @@ -57,6 +58,30 @@ class SlaveConnection extends BaseConnection { }); } + _checkPluginVersions() { + let pluginInfos = new Map(this._master.pluginInfos.map(i => [i.name, i])); + for (let [name, version] of this.plugins) { + let info = pluginInfos.get(name); + if (!info) { + logger.warn(`Slave ${this._name} has plugin ${name} ${version} which the master does not have`); + continue; + } + + if (info.version !== version) { + logger.warn( + `Slave ${this._name} has plugin ${name} ${version} which does not match the version of this ` + + `plugin on the master (${info.version})` + ); + } + } + + for (let [name, info] of pluginInfos) { + if (!this.plugins.has(name)) { + logger.warn(`Slave ${this._name} is missing plugin ${name} ${info.version}`); + } + } + } + /** * ID of the slave this connection is connected to * diff --git a/packages/master/src/WsServer.js b/packages/master/src/WsServer.js index 2be97122..2523873c 100644 --- a/packages/master/src/WsServer.js +++ b/packages/master/src/WsServer.js @@ -269,7 +269,16 @@ ${err.stack}` await connection.disconnect(1008, "Registered from another connection"); } - logger.verbose(`WsServer | registered slave ${data.id} version ${data.version}`); + logger.info( + `WsServer | registered slave ${data.name} (${data.id}) using agent ${data.agent} ${data.version}` + ); + if (data.agent === "Clusterio Slave" && data.version !== packageVersion) { + logger.warn( + `Slave ${data.name} (${data.id}) connected using version ${data.version} which does not match ` + + `the version of the master is currently running (${packageVersion}). It may not work as expected.` + ); + } + connection = new SlaveConnection(data, connector, this.master); connector.on("close", () => { if (this.slaveConnections.get(data.id) === connection) { From 2960db2cca373893212985569956378870c61b6c Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 21 Feb 2023 19:38:21 +0100 Subject: [PATCH 6/9] Add revoke user token action Implement revoking of user tokens to allow lost credentials to be invalidated. --- packages/ctl/src/commands.js | 9 ++++++ packages/lib/link/messages.js | 9 ++++++ packages/lib/users.js | 11 +++++++ packages/master/src/ControlConnection.js | 18 +++++++++++ packages/master/src/WsServerConnector.js | 14 +++++++++ .../web_ui/src/components/UserViewPage.jsx | 31 +++++++++++++++---- test/integration/clusterio.js | 21 +++++++++++++ 7 files changed, 107 insertions(+), 6 deletions(-) diff --git a/packages/ctl/src/commands.js b/packages/ctl/src/commands.js index 4f1c03ce..9e0cc35d 100644 --- a/packages/ctl/src/commands.js +++ b/packages/ctl/src/commands.js @@ -1480,6 +1480,15 @@ userCommands.add(new libCommand.Command({ }, })); +userCommands.add(new libCommand.Command({ + definition: ["revoke-token ", "Revoke token for user", (yargs) => { + yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" }); + }], + handler: async function(args, control) { + await libLink.messages.revokeUserToken.send(control, { name: args.name }); + }, +})); + userCommands.add(new libCommand.Command({ definition: ["set-admin ", "Promote or demote a user to admin", (yargs) => { yargs.positional("user", { describe: "Name of user set admin status for", type: "string" }); diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index 21a063db..5fa4707b 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -1036,6 +1036,15 @@ messages.createUser = new Request({ }, }); +messages.revokeUserToken = new Request({ + type: "revoke_user_token", + links: ["control-master"], + permission: "core.user.revoke_token", + requestProperties: { + "name": { type: "string" }, + }, +}); + messages.updateUserRoles = new Request({ type: "update_user_roles", links: ["control-master"], diff --git a/packages/lib/users.js b/packages/lib/users.js index 00b90de9..1e0156cb 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -637,6 +637,17 @@ definePermission({ title: "Create users", description: "Create user acounts with a given name.", }); +definePermission({ + name: "core.user.revoke_token", + title: "Revoke user token", + description: "Revoke access token and terminate all existing control connections for oneself.", + grantByDefault: true, +}); +definePermission({ + name: "core.user.revoke_other_token", + title: "Revoke other user's token", + description: "Allow revoking tokens for other users than oneself. Requires Revoke user token.", +}); definePermission({ name: "core.user.update_roles", title: "Update user roles", diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index f695156e..2a7fbd3e 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -833,6 +833,24 @@ class ControlConnection extends BaseConnection { this._master.userUpdated(user); } + async revokeUserTokenRequestHandler(message) { + let user = this._master.userManager.users.get(message.data.name); + if (!user) { + throw new libErrors.RequestError(`User '${message.data.name}' does not exist`); + } + if (user.name !== this.user.name) { + this.user.checkPermission("core.user.revoke_other_token"); + } + + user.invalidateToken(); + for (let controlConnection of this._master.wsServer.controlConnections) { + if (controlConnection.user.name === user.name) { + controlConnection.connector.terminate(); + } + } + this._master.userUpdated(user); + } + async updateUserRolesRequestHandler(message) { let user = this._master.userManager.users.get(message.data.name); if (!user) { diff --git a/packages/master/src/WsServerConnector.js b/packages/master/src/WsServerConnector.js index 1db846cb..84311284 100644 --- a/packages/master/src/WsServerConnector.js +++ b/packages/master/src/WsServerConnector.js @@ -191,6 +191,20 @@ class WsServerConnector extends libLink.WebSocketBaseConnector { await events.once(this, "close"); } + /** + * Forcefully close the connection immediately + * + * This should only be used if there's a security implication from + * letting the connection carry on with a normal close proceedure. + */ + terminate() { + this.setClosing(); + + if (this._socket) { + this._socket.terminate(); + } + } + /** * Notify the connection is expected to close * diff --git a/packages/web_ui/src/components/UserViewPage.jsx b/packages/web_ui/src/components/UserViewPage.jsx index 7015f6e9..084a7a60 100644 --- a/packages/web_ui/src/components/UserViewPage.jsx +++ b/packages/web_ui/src/components/UserViewPage.jsx @@ -6,7 +6,7 @@ import { } from "antd"; import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; -import { libLink } from "@clusterio/lib"; +import { libErrors, libLink } from "@clusterio/lib"; import { useAccount } from "../model/account"; import { useInstanceList } from "../model/instance"; @@ -14,7 +14,7 @@ import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import SectionHeader from "./SectionHeader"; -import { notifyErrorHandler } from "../util/notify"; +import notify, { notifyErrorHandler } from "../util/notify"; import { formatDuration } from "../util/time_format"; import { formatLastSeen, sortLastSeen, useUser } from "../model/user"; @@ -121,8 +121,27 @@ export default function UserViewPage() { {user["is_banned"] && Banned} } - extra={ - account.hasPermission("core.user.delete") && + {account.hasPermission("core.user.revoke_token") && ( + account.name === userName || account.hasPermission("core.user.revoke_other_token") + ) && } + {account.hasPermission("core.user.delete") && Delete user account and all data associated with it? {(user["is_banned"] && <>
This will remove the user ban!)} @@ -141,8 +160,8 @@ export default function UserViewPage() { -
- } +
} + } />
connector.once("close", resolve)); + + await execCtl("user revoke-token revokee"); + + await closed; + }); + }); + describe("user set-roles", function() { it("should set the roles on the user", async function() { getControl().userUpdates = []; From 57c7e04f04bc343defa2aa9b119963903f937cad Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 21 Feb 2023 19:55:02 +0100 Subject: [PATCH 7/9] Use Summary metric for query log duration --- packages/master/src/ControlConnection.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index 2a7fbd3e..5fe52b8f 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -20,9 +20,9 @@ const routes = require("./routes"); const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare; -const lastQueryLogTime = new libPrometheus.Gauge( - "clusterio_master_last_query_log_duration_seconds", - "Time in seconds the last log query took to execute." +const queryLogTime = new libPrometheus.Summary( + "clusterio_master_query_log_duration_seconds", + "Time in seconds log queries took to execute." ); /** @@ -683,7 +683,7 @@ class ControlConnection extends BaseConnection { } async queryLogRequestHandler(message) { - let setDuration = lastQueryLogTime.startTimer(); + let observeDuration = queryLogTime.startTimer(); let { all, master, slave_ids, instance_ids } = message.data; let log; @@ -693,7 +693,7 @@ class ControlConnection extends BaseConnection { log = await this._master.queryClusterLog(message.data); } - setDuration(); + observeDuration(); return { log }; } From 0af1a25c935161901335dd27b6b0ee12142be3a5 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 21 Feb 2023 19:55:31 +0100 Subject: [PATCH 8/9] Fix bad identifier in copy save code --- packages/slave/src/Slave.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/slave/src/Slave.js b/packages/slave/src/Slave.js index 4bb2f14c..e8dd3cc3 100644 --- a/packages/slave/src/Slave.js +++ b/packages/slave/src/Slave.js @@ -522,7 +522,7 @@ class Slave extends libLink.Link { ); } catch (err) { if (err.code === "ENOENT") { - throw new libErrors.RequestError(`${old_name} does not exist`); + throw new libErrors.RequestError(`${source} does not exist`); } throw err; } From 8662243ed18e0f6e3fab3aa1e58683d37f4933da Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 21 Feb 2023 21:29:41 +0100 Subject: [PATCH 9/9] Show which config entries requires a restart Add a label in the web UI to config entries that require a restart to take effect as this is not obvious to end users. --- packages/lib/config/classes.js | 25 ++++++++++++++++ packages/lib/config/definitions.js | 29 +++++++++++++++++- .../web_ui/src/components/BaseConfigTree.jsx | 30 ++++++++++++++++--- test/lib/config/classes.js | 1 + 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/packages/lib/config/classes.js b/packages/lib/config/classes.js index 0be19776..ac1591b0 100644 --- a/packages/lib/config/classes.js +++ b/packages/lib/config/classes.js @@ -677,6 +677,12 @@ class ConfigGroup { * @param {Array=} item.enum - * Array of all values that are acceptible. Useful for making * enumerated values. + * @param {boolean=} item.restartRequired - + * True if a restart of the entity this config is attached to is + * required for changes to take effect. Informative only, defaults + * to false. + * @param {Array=} item.restartRequiredProps - + * Properties to invert the value of restartRequired for if present. * @param {boolean=} item.optional - * True if this field can be set to null. Defaults to false. * @param {*=} item.initial_value - @@ -706,6 +712,7 @@ class ConfigGroup { // Validate properties let validTypes = ["boolean", "string", "number", "object"]; + // eslint-disable-next-line complexity Object.keys(item).forEach(key => { let value = item[key]; let valueType = basicType(value); @@ -746,6 +753,18 @@ class ConfigGroup { } break; + case "restartRequired": + if (valueType !== "boolean") { + throw new Error("restartRequired must be a boolean"); + } + break; + + case "restartRequiredProps": + if (valueType !== "array") { + throw new Error("restartRequiredProps must be a array"); + } + break; + case "optional": if (valueType !== "boolean") { throw new Error("optional must be a boolean"); @@ -771,9 +790,14 @@ class ConfigGroup { access: this.defaultAccess, optional: false, fullName: `${this.groupName}.${item.name}`, + restartRequired: false, ...item, }; + if (item.restartRequiredProps && item.type !== "object") { + throw new Error("restartRequiredProps is only allowed if type is object"); + } + if (!computed.access) { throw new Error(`access not set and no defaultAccess defined for ${this.name}`); } @@ -815,6 +839,7 @@ class PluginConfigGroup extends ConfigGroup { this.define({ name: "load_plugin", title: "Load Plugin", + restartRequired: true, type: "boolean", initial_value: true, }); diff --git a/packages/lib/config/definitions.js b/packages/lib/config/definitions.js index d6605c85..5165d021 100644 --- a/packages/lib/config/definitions.js +++ b/packages/lib/config/definitions.js @@ -37,6 +37,7 @@ MasterGroup.define({ name: "http_port", title: "HTTP Port", description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.", + restartRequired: true, type: "number", optional: true, initial_value: 8080, @@ -45,6 +46,7 @@ MasterGroup.define({ name: "https_port", title: "HTTPS Port", description: "Port to listen for HTTPS connection on, set to null to not listen for HTTPS connections.", + restartRequired: true, type: "number", optional: true, }); @@ -52,6 +54,7 @@ MasterGroup.define({ name: "bind_address", title: "Bind Address", description: "IP address to bind the HTTP and HTTPS ports on.", + restartRequired: true, type: "string", optional: true, }); @@ -66,6 +69,7 @@ MasterGroup.define({ name: "tls_certificate", title: "TLS Certificate", description: "Path to the certificate to use for HTTPS.", + restartRequired: true, type: "string", optional: true, }); @@ -73,6 +77,7 @@ MasterGroup.define({ name: "tls_private_key", title: "TLS Private Key", description: "Path to the private key to use for HTTPS.", + restartRequired: true, type: "string", optional: true, }); @@ -84,6 +89,7 @@ MasterGroup.define({ "Secret used to generate and verify authentication tokens. " + "Should be a long string of random letters and numbers. " + "Do not share this.", + restartRequired: true, type: "string", optional: true, }); @@ -184,24 +190,28 @@ SlaveGroup.define({ SlaveGroup.define({ name: "instances_directory", description: "Path to directory to store instances in.", + restartRequired: true, type: "string", initial_value: "instances", }); SlaveGroup.define({ name: "master_url", description: "URL to connect to the master server at", + restartRequired: true, type: "string", initial_value: "http://localhost:8080/", }); SlaveGroup.define({ name: "master_token", description: "Token to authenticate to master server with.", + restartRequired: true, type: "string", initial_value: "enter token here", }); SlaveGroup.define({ name: "tls_ca", description: "Path to Certificate Authority to validate TLS connection to master against.", + restartRequired: true, type: "string", optional: true, }); @@ -244,7 +254,7 @@ InstanceGroup.define({ }); InstanceGroup.define({ name: "id", - description: "ID of the slave", + description: "ID of the instance", type: "number", initial_value: () => Math.random() * 2**31 | 0, }); @@ -272,24 +282,28 @@ FactorioGroup.groupName = "factorio"; FactorioGroup.define({ name: "version", description: "Version of the game to run, use latest to run the latest installed version", + restartRequired: true, type: "string", initial_value: "latest", }); FactorioGroup.define({ name: "game_port", description: "UDP port to run game on, uses a random port if null", + restartRequired: true, type: "number", optional: true, }); FactorioGroup.define({ name: "rcon_port", description: "TCP port to run RCON on, uses a random port if null", + restartRequired: true, type: "number", optional: true, }); FactorioGroup.define({ name: "rcon_password", description: "Password for RCON, randomly generated if null", + restartRequired: true, type: "string", optional: true, }); @@ -304,12 +318,14 @@ FactorioGroup.define({ FactorioGroup.define({ name: "mod_pack", description: "Mod pack to use on this server", + restartRequired: true, type: "number", optional: true, }); FactorioGroup.define({ name: "enable_save_patching", description: "Patch saves with Lua code. Required for Clusterio integrations, lua modules, and most plugins.", + restartRequired: true, type: "boolean", initial_value: true, }); @@ -322,24 +338,34 @@ FactorioGroup.define({ FactorioGroup.define({ name: "enable_authserver_bans", description: "Turn on Factorio.com based multiplayer bans.", + restartRequired: true, type: "boolean", initial_value: false, }); FactorioGroup.define({ name: "settings", description: "Settings overridden in server-settings.json", + restartRequired: true, + restartRequiredProps: [ + "afk_autokick_interval", "allow_commands", "autosave_interval", "autosave_only_on_server", "description", + "ignore_player_limit_for_returning_players", "max_players", "max_upload_slots", + "max_upload_in_kilobytes_per_second", "name", "only_admins_can_pause_the_game", "game_password", + "require_user_verification", "tags", "visibility", + ], type: "object", initial_value: {}, // See create instance handler in master. }); FactorioGroup.define({ name: "verbose_logging", description: "Enable verbose logging on the Factorio server", + restartRequired: true, type: "boolean", initial_value: false, }); FactorioGroup.define({ name: "strip_paths", description: "Strip down instance paths in the log", + restartRequired: true, type: "boolean", initial_value: true, }); @@ -364,6 +390,7 @@ FactorioGroup.define({ FactorioGroup.define({ name: "max_concurrent_commands", description: "Maximum number of RCON commands trasmitted in parallel", + restartRequired: true, type: "number", initial_value: 5, }); diff --git a/packages/web_ui/src/components/BaseConfigTree.jsx b/packages/web_ui/src/components/BaseConfigTree.jsx index 1e75338a..68377fff 100644 --- a/packages/web_ui/src/components/BaseConfigTree.jsx +++ b/packages/web_ui/src/components/BaseConfigTree.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Typography } from "antd"; +import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tooltip, Tree, Typography } from "antd"; +import ReloadOutlined from "@ant-design/icons/ReloadOutlined"; const { Title } = Typography; @@ -87,6 +88,11 @@ export default function BaseConfigTree(props) { } } + let restartTip = ; + let treeData = []; let propsMap = new Map(); for (let [name, GroupClass] of props.ConfigClass.groups) { @@ -110,14 +116,27 @@ export default function BaseConfigTree(props) { }; if (def.type === "object") { - childNode.title = ; + let restartRequiredProps = new Set(def.restartRequiredProps || []); + childNode.title = + {def.title || def.name} + {!def.restartRequiredProps && def.restartRequired && restartTip} + } + tooltip={def.description} + />; for (let prop of Object.keys(value)) { let propPath = `${group.name}.${def.name}.${prop}`; + let restart = Boolean( + def.restartRequiredProps && def.restartRequired ^ restartRequiredProps.has(prop) + ); childNode.children.push({ key: propPath, title: + {`"${prop}"`} + {restart && restartTip} + } rules={[{ validator: (_, fieldValue) => { if (!fieldValue.length) { return Promise.reject(new Error("Will be removed")); @@ -184,7 +203,10 @@ export default function BaseConfigTree(props) { } else { childNode.title = + {def.title || def.name} + {def.restartRequired && restartTip} + } validateStatus={errorFields.has(fieldName) ? "error" : undefined} help={errorFields.get(fieldName)} tooltip={def.description} diff --git a/test/lib/config/classes.js b/test/lib/config/classes.js index 49d5d224..23a2a292 100644 --- a/test/lib/config/classes.js +++ b/test/lib/config/classes.js @@ -113,6 +113,7 @@ describe("lib/config/classes", function() { name: "enum", title: "Enum", description: "Enum thingy", + restartRequired: true, enum: ["a", "b", "c"], optional: true, initial_value: "b",