From 9761fbb71c40d8387a763c39b830f5312040a5c3 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Thu, 27 Aug 2020 14:19:45 +0200 Subject: [PATCH 1/6] Fix test names for clusterctl Rename the test cases named after the old clusterctl commands for the integrations to the new clusterctl commands. --- test/integration/clusterio.js | 40 +++++++++++++++++------------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index e6a95e4b..937a534b 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -30,38 +30,38 @@ describe("Integration of Clusterio", function() { describe("clusterctl", function() { - describe("list-slaves", function() { + describe("slave list", function() { it("runs", async function() { await exec(`node clusterctl --config ${controlConfigPath} slave list`); }); }); - describe("list-instances", function() { + describe("instance list", function() { it("runs", async function() { await exec(`node clusterctl --config ${controlConfigPath} instance list`); }); }); - describe("create-instances", function() { + describe("instance create", function() { it("runs", async function() { await exec(`node clusterctl --config ${controlConfigPath} instance create test --id 44`); }); }); - describe("assign-instance", function() { + describe("instance assign", function() { it("creates the instance files", async function() { await exec(`node clusterctl --config ${controlConfigPath} instance assign test 4`); assert(await fs.exists(path.join(instancesDir, "test")), "Instance was not created"); }); }); - describe("create-save", function() { + describe("instance create-save", function() { it("creates a save", async function() { slowTest(this); await exec(`node clusterctl --config ${controlConfigPath} instance create-save test`); }); }); - describe("export-data", function() { + describe("instance export-data", function() { it("exports the data", async function() { slowTest(this); await exec(`node clusterctl --config ${controlConfigPath} instance export-data test`); @@ -69,7 +69,7 @@ describe("Integration of Clusterio", function() { }); }); - describe("start-instance", function() { + describe("instance start", function() { it("starts the instance", async function() { slowTest(this); await exec(`node clusterctl --config ${controlConfigPath} instance start test`); @@ -77,7 +77,7 @@ describe("Integration of Clusterio", function() { }); }); - describe("send-rcon", function() { + describe("instance send-rcon", function() { it("sends the command", async function() { slowTest(this); await exec(`node clusterctl --config ${controlConfigPath} instance send-rcon test test`); @@ -85,7 +85,7 @@ describe("Integration of Clusterio", function() { }); }); - describe("set-instance-config-prop", function() { + describe("instance config set-prop", function() { it("applies factorio settings while running", async function() { slowTest(this); @@ -170,7 +170,7 @@ describe("Integration of Clusterio", function() { }); }); - describe("stop-instance", function() { + describe("instance stop", function() { it("stops the instance", async function() { slowTest(this); await exec(`node clusterctl --config ${controlConfigPath} instance stop test`); @@ -178,7 +178,7 @@ describe("Integration of Clusterio", function() { }); }); - describe("delete-instance", function() { + describe("instance delete", function() { it("deletes the instance", async function() { slowTest(this); await exec(`node clusterctl --config ${controlConfigPath} instance delete test`); @@ -186,19 +186,19 @@ describe("Integration of Clusterio", function() { }); }); - describe("list-permissions", function() { + describe("permission list", function() { it("runs", async function() { await exec(`node clusterctl --config ${controlConfigPath} permission list`); }); }); - describe("list-roles", function() { + describe("role list", function() { it("runs", async function() { await exec(`node clusterctl --config ${controlConfigPath} role list`); }); }); - describe("create-role", function() { + describe("role create", function() { it("should create the given role", async function() { let args = "--description \"A temp role\" --permissions core.control.connect"; await exec(`node clusterctl --config ${controlConfigPath} role create temp ${args}`); @@ -208,7 +208,7 @@ describe("Integration of Clusterio", function() { }); }); - describe("edit-role", function() { + describe("role edit", function() { it("should modify the given role", async function() { let args = "--name new --description \"A new role\" --permissions"; await exec(`node clusterctl --config ${controlConfigPath} role edit temp ${args}`); @@ -218,7 +218,7 @@ describe("Integration of Clusterio", function() { }); }); - describe("delete-role", function() { + describe("role delete", function() { it("should modify the given role", async function() { await exec(`node clusterctl --config ${controlConfigPath} role delete new`); let result = await link.messages.listRoles.send(getControl()); @@ -227,13 +227,13 @@ describe("Integration of Clusterio", function() { }); }); - describe("list-users", function() { + describe("user list", function() { it("runs", async function() { await exec(`node clusterctl --config ${controlConfigPath} user list`); }); }); - describe("create-user", function() { + describe("user create", function() { it("should create the given user", async function() { await exec(`node clusterctl --config ${controlConfigPath} user create temp`); let result = await link.messages.listUsers.send(getControl()); @@ -242,7 +242,7 @@ describe("Integration of Clusterio", function() { }); }); - describe("edit-user-roles", function() { + describe("user set-roles", function() { it("should set the roles on the user", async function() { await exec(`node clusterctl --config ${controlConfigPath} user set-roles temp Admin`); let result = await link.messages.listUsers.send(getControl()); @@ -251,7 +251,7 @@ describe("Integration of Clusterio", function() { }); }); - describe("delete-user", function() { + describe("user delete", function() { it("should delete the user", async function() { await exec(`node clusterctl --config ${controlConfigPath} user delete temp`); let result = await link.messages.listUsers.send(getControl()); From 5e5a47ce02edb62617898f5503306904c32491c7 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sat, 29 Aug 2020 11:03:12 +0200 Subject: [PATCH 2/6] Leave out roles array in serialized user Leave out the roles property containing the array of role ids if the user does not have any roles. --- lib/users.js | 5 ++++- test/lib/users.js | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/lib/users.js b/lib/users.js index 1ad2a8bf..4ba85e96 100644 --- a/lib/users.js +++ b/lib/users.js @@ -173,9 +173,12 @@ class User { serialize() { let serialized = { name: this.name, - roles: [...this.roles].map(role => role.id), }; + if (this.roles.size) { + serialized.roles = [...this.roles].map(role => role.id); + } + if (this.tokenValidAfter) { serialized.tokenValidAfter = this.tokenValidAfter; } diff --git a/test/lib/users.js b/test/lib/users.js index 901bb53b..2e577135 100644 --- a/test/lib/users.js +++ b/test/lib/users.js @@ -71,13 +71,20 @@ describe("lib/users", function() { [2, new users.Role({ id: 2, name: "b", description: "b role", permissions: ["test"] })], ]); it("should round trip serialize", function() { - let orig = new users.User({ name: "admin", roles: [1] }, roles); - let copy = new users.User(orig.serialize(), roles); - assert.deepEqual(copy, orig); + function test_roundtrip(serialized) { + let user = new users.User(serialized, roles); + let user_serialized = user.serialize(); + assert.deepEqual(user_serialized, serialized); + let user_deserialized = new users.User(user_serialized, roles); + assert.deepEqual(user_deserialized, user); + } - orig = new users.User({ name: "user", roles: [2], tokenValidAfter: 12345 }, roles); - copy = new users.User(orig.serialize(), roles); - assert.deepEqual(copy, orig); + test_roundtrip({ name: "admin", roles: [1] }); + test_roundtrip({ name: "user", roles: [2], token_valid_after: 12345 }); + }); + it("should ignore invalid roles", function() { + let user = new users.User({ name: "test", roles: [1, 4, 55] }, roles); + assert.equal(user.roles.size, 1, "Unexpected count of roles"); }); it("should track online users", function() { let user = new users.User({ name: "admin", roles: [1] }, roles); From f08774bd4a061afab9e255f8dc8bc2732e2f1d8d Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 31 Aug 2020 18:50:15 +0200 Subject: [PATCH 3/6] Add execCtl shortcut for clusterctl tests Add execCtl which is a shortcut to execute node clusterctl with the test config set. --- test/integration/clusterio.js | 42 +++++++++++++++++------------------ test/integration/index.js | 8 ++++++- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 937a534b..2370dc6b 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -7,7 +7,7 @@ const parallel = require("mocha.parallel"); const link = require("lib/link"); -const { slowTest, get, exec, sendRcon, getControl, controlConfigPath, instancesDir } = require("./index"); +const { slowTest, get, execCtl, sendRcon, getControl, instancesDir } = require("./index"); describe("Integration of Clusterio", function() { @@ -32,24 +32,24 @@ describe("Integration of Clusterio", function() { describe("clusterctl", function() { describe("slave list", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} slave list`); + await execCtl("slave list"); }); }); describe("instance list", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} instance list`); + await execCtl("instance list"); }); }); describe("instance create", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} instance create test --id 44`); + await execCtl("instance create test --id 44"); }); }); describe("instance assign", function() { it("creates the instance files", async function() { - await exec(`node clusterctl --config ${controlConfigPath} instance assign test 4`); + await execCtl("instance assign test 4"); assert(await fs.exists(path.join(instancesDir, "test")), "Instance was not created"); }); }); @@ -57,14 +57,14 @@ describe("Integration of Clusterio", function() { describe("instance create-save", function() { it("creates a save", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} instance create-save test`); + await execCtl("instance create-save test"); }); }); describe("instance export-data", function() { it("exports the data", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} instance export-data test`); + await execCtl("instance export-data test"); assert(await fs.exists(path.join("static", "export", "locale.json")), "Export was not created"); }); }); @@ -72,7 +72,7 @@ describe("Integration of Clusterio", function() { describe("instance start", function() { it("starts the instance", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} instance start test`); + await execCtl("instance start test"); // TODO check that the instance actually started }); }); @@ -80,7 +80,7 @@ describe("Integration of Clusterio", function() { describe("instance send-rcon", function() { it("sends the command", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} instance send-rcon test test`); + await execCtl("instance send-rcon test test"); // TODO check that the command was received }); }); @@ -159,7 +159,7 @@ describe("Integration of Clusterio", function() { for (let [prop, value, ,] of testConfigs) { value = `"'${JSON.stringify(value).replace(/"/g, process.platform === "win32" ? '""' : '\\"')}'"`; let args = `test factorio.settings ${prop} ${value}`; - await exec(`node clusterctl --config ${controlConfigPath} instance config set-prop ${args}`); + await execCtl(`instance config set-prop ${args}`); } // Do this afterwards to leave enough to time for the @@ -173,7 +173,7 @@ describe("Integration of Clusterio", function() { describe("instance stop", function() { it("stops the instance", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} instance stop test`); + await execCtl("instance stop test"); // TODO check that the instance actually stopped }); }); @@ -181,27 +181,27 @@ describe("Integration of Clusterio", function() { describe("instance delete", function() { it("deletes the instance", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} instance delete test`); + await execCtl("instance delete test"); assert(!await fs.exists(path.join(instancesDir, "test")), "Instance was not deleted"); }); }); describe("permission list", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} permission list`); + await execCtl("permission list"); }); }); describe("role list", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} role list`); + await execCtl("role list"); }); }); describe("role create", function() { it("should create the given role", async function() { let args = "--description \"A temp role\" --permissions core.control.connect"; - await exec(`node clusterctl --config ${controlConfigPath} role create temp ${args}`); + await execCtl(`role create temp ${args}`); let result = await link.messages.listRoles.send(getControl()); let role = result.list.find(role => role.name === "temp"); assert.deepEqual(role, { id: 5, name: "temp", description: "A temp role", permissions: ["core.control.connect"] }); @@ -211,7 +211,7 @@ describe("Integration of Clusterio", function() { describe("role edit", function() { it("should modify the given role", async function() { let args = "--name new --description \"A new role\" --permissions"; - await exec(`node clusterctl --config ${controlConfigPath} role edit temp ${args}`); + await execCtl(`role edit temp ${args}`); let result = await link.messages.listRoles.send(getControl()); let role = result.list.find(role => role.name === "new"); assert.deepEqual(role, { id: 5, name: "new", description: "A new role", permissions: [] }); @@ -220,7 +220,7 @@ describe("Integration of Clusterio", function() { describe("role delete", function() { it("should modify the given role", async function() { - await exec(`node clusterctl --config ${controlConfigPath} role delete new`); + await execCtl("role delete new"); let result = await link.messages.listRoles.send(getControl()); let role = result.list.find(role => role.name === "new"); assert(!role, "Role was not deleted"); @@ -229,13 +229,13 @@ describe("Integration of Clusterio", function() { describe("user list", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} user list`); + await execCtl("user list"); }); }); describe("user create", function() { it("should create the given user", async function() { - await exec(`node clusterctl --config ${controlConfigPath} user create temp`); + await execCtl("user create temp"); let result = await link.messages.listUsers.send(getControl()); let user = result.list.find(user => user.name === "temp"); assert(user, "user was not created"); @@ -244,7 +244,7 @@ describe("Integration of Clusterio", function() { describe("user set-roles", function() { it("should set the roles on the user", async function() { - await exec(`node clusterctl --config ${controlConfigPath} user set-roles temp Admin`); + await execCtl(`user set-roles temp Admin`); let result = await link.messages.listUsers.send(getControl()); let user = result.list.find(user => user.name === "temp"); assert.deepEqual(user.roles, [0]); @@ -253,7 +253,7 @@ describe("Integration of Clusterio", function() { describe("user delete", function() { it("should delete the user", async function() { - await exec(`node clusterctl --config ${controlConfigPath} user delete temp`); + await execCtl("user delete temp"); let result = await link.messages.listUsers.send(getControl()); let user = result.list.find(user => user.name === "temp"); assert(!user, "user was note deleted"); diff --git a/test/integration/index.js b/test/integration/index.js index 1c9fdf64..21230fcf 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -69,6 +69,11 @@ async function exec(...args) { return await util.promisify(child_process.exec)(...args); } +async function execCtl(...args) { + args[0] = `node clusterctl --config ${controlConfigPath} ${args[0]}`; + return await exec(...args); +} + async function sendRcon(instanceId, command) { let response = await link.messages.sendRcon.send(control, { instance_id: instanceId, command }); return response.result; @@ -124,7 +129,7 @@ before(async function() { masterProcess = await spawn("master:", `node master --config ${masterConfigPath} run`, "All plugins loaded"); let createArgs = `--id 4 --name slave --generate-token --output ${slaveConfigPath}`; - await exec(`node clusterctl --config ${controlConfigPath} slave create-config ${createArgs}`); + await execCtl(`slave create-config ${createArgs}`); await exec(`node slave --config ${slaveConfigPath} config set slave.instances_directory ${instancesDir}`); slaveProcess = await spawn("slave:", `node slave --config ${slaveConfigPath} run`, "SOCKET | received ready from master"); @@ -162,6 +167,7 @@ module.exports = { slowTest, get, exec, + execCtl, sendRcon, getControl, From 84450b83b4f013fc03c3ca39dea45650be448957 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sat, 15 Aug 2020 11:34:28 +0200 Subject: [PATCH 4/6] Implement adminlist, whitelist and banlist sync Add integration of the server adminlist, whitelist and banlist into clusterio. Membership is stored as booleans in the user acconuts and changes are synchronized live to slaves which pass it on to instances which in turn will update the running server's lists with RCON commands. --- .eslintrc.js | 3 +- clusterctl.js | 46 ++++++++++ docs/maniging-a-cluster.md | 132 +++++++++++++++++++++++++++ lib/config/definitions.js | 24 +++++ lib/factorio/server.js | 4 + lib/link/messages.js | 91 +++++++++++++++++++ lib/users.js | 71 +++++++++++++-- master.js | 92 ++++++++++++++++++- slave.js | 166 +++++++++++++++++++++++++++++++++- test/integration/clusterio.js | 46 +++++++++- test/lib/users.js | 2 + 11 files changed, 666 insertions(+), 11 deletions(-) create mode 100644 docs/maniging-a-cluster.md diff --git a/.eslintrc.js b/.eslintrc.js index e518c29a..8cc36d30 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,6 +18,7 @@ module.exports = { }, "rules": { "prefer-arrow-callback": "off", + "no-invalid-this": "off", }, }, ], @@ -96,7 +97,7 @@ module.exports = { "max-statements": "off", "max-statements-per-line": "off", "multiline-comment-style": ["warn", "separate-lines"], - "multiline-ternary": "warn", + "multiline-ternary": "off", "new-cap": "warn", "new-parens": "error", "newline-after-var": "off", diff --git a/clusterctl.js b/clusterctl.js index bf8eca3b..310d5265 100644 --- a/clusterctl.js +++ b/clusterctl.js @@ -407,6 +407,52 @@ userCommands.add(new command.Command({ }, })); +userCommands.add(new command.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" }); + yargs.options({ + "revoke": { describe: "Revoke admin status", nargs: 0, type: "boolean", default: false }, + "create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args, control) { + await link.messages.setUserAdmin.send(control, { + name: args.user, create: args.create, admin: !args.revoke, + }); + }, +})); + +userCommands.add(new command.Command({ + definition: ["set-whitelisted ", "Add or remove user from the whitelist", (yargs) => { + yargs.positional("user", { describe: "Name of user to set whitelist status for", type: "string" }); + yargs.options({ + "remove": { describe: "Remove from whitelist", nargs: 0, type: "boolean", default: false }, + "create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args, control) { + await link.messages.setUserWhitelisted.send(control, { + name: args.user, create: args.create, whitelisted: !args.remove, + }); + }, +})); + +userCommands.add(new command.Command({ + definition: ["set-banned ", "Ban or pardon user from banlist", (yargs) => { + yargs.positional("user", { describe: "Name of user to set ban status for", type: "string" }); + yargs.options({ + "pardon": { describe: "Remove from banlist", nargs: 0, type: "boolean", default: false }, + "reason": { describe: "Ban reason", nargs: 1, type: "string", default: "" }, + "create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args, control) { + await link.messages.setUserBanned.send(control, { + name: args.user, create: args.create, banned: !args.pardon, reason: args.reason, + }); + }, +})); + userCommands.add(new command.Command({ definition: ["set-roles [roles...]", "Replace user roles", (yargs) => { yargs.positional("user", { describe: "Name of user to change roles for", type: "string" }); diff --git a/docs/maniging-a-cluster.md b/docs/maniging-a-cluster.md new file mode 100644 index 00000000..4db36968 --- /dev/null +++ b/docs/maniging-a-cluster.md @@ -0,0 +1,132 @@ +Managing a Cluster +================== + +Clusterio clusers are managed through the master server by using the +`clusterctl` command line interface which is invoked by running +`node clusterctl ` in the clusterio directory. This document +uses the shorthand `ctl> foo` to indicate `node clusterctl foo` should +be executed. Mandatory parameters are shown in `` and +optional pameters are in `[square brackets]`. + +Before `clusterctl` can be used it needs to be configured for the +cluster it will connect to. The easiest way to do this is to run +`node master bootstrap create-ctl-config ` on the master +server, which creates the necessary `config-control.json` for managing +the cluster as the given user. + + +Slaves +------ + +To be written. + + +Instances +--------- + +To be written. + + +Users +----- + +Clusterio automatically creates user accounts for all players that join +an instance when save patching is enabled. These accounts are used to +store per player data shared between instances, like if the player +should be whitelisted, admin or banned. + +### List users + + ctl> user list + +Lists all user accounts in the cluster along with some data for each. + + +### Create User + + ctl> user create + +Creates a new empty user account for the given Factorio player. Note +that case matters here. This is usually not required as user accounts +are created automatically for players that join instances with save +patching enabled. + + +### Promote User to Server Admin + + ctl> user set-admin [--revoke] [--create] + +Promotes the user given to in-game admin on instances with the +`sync_adminlist` option enabled. If the `--revoke` switch is used the +user is removed from the adminlist. + +Since admin status is a part of the account data the account must exist +for this to succeed, passing `--create` will create the account if it +does not exist. + +**Note:** Being a server admin does not grant any access to manage the +cluster. See [set-roles](#set-cluster-roles) for adding roles which +grant access to managing the clusetr. + + +### Whitelist User + + ctl> user set-whitelisted [--remove] [--create] + +Add the user given to the whitelist on instances with the +`sync_whitelist` option enabled. If the `--remove` switch is used the +user is removed from the whitelist. + +Since whitelisted status is a part of the account data the account must +exist for this to succeed, passing `--create` will create the account if +it does not exist. + + +### Ban User + + ctl> user set-banned [--reason ] [--pardon] [--create] + +Ban user in-game from instances with the `sync_banlist` option enabled. +Reason is a message that will be shown to the user when attemption to +log in. If the `--pardon` switch is used it removes the ban. + +Since ban status is a part of the account data the account must exist +for this to succeed, passing `--create` will create the account if it +does not exist. + +Note: This bans the user from logging in to Factorio servers in the +cluster, it does not revoke access to any cluster management they might +have, see next section on setting roles for revoking tha. + + +### Set Cluster Roles + + ctl> user set-roles [roles...] + +Replaces the roles the user has in the cluster with a new list of roles. +Calling with an empty roles argument will remove all roles from the +user. + +By default there's a roled named Admin which grants access to +everything and a role named Player which grants a limited read access to +the cluster, see [the section on roles](#roles) for more information +about setting up roles and permissions. + + +### Delete user + + ctl> user delete + +Deletes everything stored on the master server for this user. + +Note: If the player is banned from the cluster this will effectively +unban them, as the ban status is stored with the user account. + +Note: If the player joins the cluster again a new account will be made +for them automatically. + + +Roles +----- + +To be written. diff --git a/lib/config/definitions.js b/lib/config/definitions.js index e098c66b..7e4b25d6 100644 --- a/lib/config/definitions.js +++ b/lib/config/definitions.js @@ -245,6 +245,12 @@ FactorioGroup.define({ type: "boolean", initial_value: true, }); +FactorioGroup.define({ + name: "enable_whitelist", + description: "Turn on whitelist for joining the server.", + type: "boolean", + initial_value: false, +}); FactorioGroup.define({ name: "settings", description: "Settings overridden in server-settings.json", @@ -254,6 +260,24 @@ FactorioGroup.define({ "auto_pause": false, }, }); +FactorioGroup.define({ + name: "sync_adminlist", + description: "Synchronize adminlist with master server", + type: "boolean", + initial_value: true, +}); +FactorioGroup.define({ + name: "sync_whitelist", + description: "Synchronize whitelist with master server", + type: "boolean", + initial_value: true, +}); +FactorioGroup.define({ + name: "sync_banlist", + description: "Synchronize banlist with master server", + type: "boolean", + initial_value: true, +}); FactorioGroup.finalize(); /** diff --git a/lib/factorio/server.js b/lib/factorio/server.js index 9b2a033b..77f0cc70 100644 --- a/lib/factorio/server.js +++ b/lib/factorio/server.js @@ -515,6 +515,8 @@ class FactorioServer extends events.EventEmitter { this.rconPort = options.rconPort || randomDynamicPort(); /** Password used for RCON on the Factorio game server */ this.rconPassword = options.rconPassword; + /** Enable player whitelist */ + this.enableWhitelist = options.enableWhitelist; this._state = "new"; this._server = null; this._rconClient = null; @@ -813,6 +815,7 @@ class FactorioServer extends events.EventEmitter { "--port", this.gamePort, "--rcon-port", this.rconPort, "--rcon-password", this.rconPassword, + ...(this.enableWhitelist ? ["--use-server-whitelist"] : []), ], { detached: true, @@ -846,6 +849,7 @@ class FactorioServer extends events.EventEmitter { "--port", this.gamePort, "--rcon-port", this.rconPort, "--rcon-password", this.rconPassword, + ...(this.enableWhitelist ? ["--use-server-whitelist"] : []), ], { detached: true, diff --git a/lib/link/messages.js b/lib/link/messages.js index 9a795317..648aef20 100644 --- a/lib/link/messages.js +++ b/lib/link/messages.js @@ -560,6 +560,9 @@ messages.listUsers = new Request({ properties: { "name": { type: "string" }, "roles": { type: "array", items: { type: "integer" }}, + "is_admin": { type: "boolean" }, + "is_banned": { type: "boolean" }, + "is_whitelisted": { type: "boolean" }, "instances": { type: "array", items: { type: "integer" }}, }, }, @@ -586,6 +589,40 @@ messages.updateUserRoles = new Request({ }, }); +messages.setUserAdmin = new Request({ + type: "set_user_admin", + links: ["control-master"], + permission: "core.user.set_admin", + requestProperties: { + "name": { type: "string" }, + "create": { type: "boolean" }, + "admin": { type: "boolean" }, + }, +}); + +messages.setUserWhitelisted = new Request({ + type: "set_user_whitelisted", + links: ["control-master"], + permission: "core.user.set_whitelisted", + requestProperties: { + "name": { type: "string" }, + "create": { type: "boolean" }, + "whitelisted": { type: "boolean" }, + }, +}); + +messages.setUserBanned = new Request({ + type: "set_user_banned", + links: ["control-master"], + permission: "core.user.set_banned", + requestProperties: { + "name": { type: "string" }, + "create": { type: "boolean" }, + "banned": { type: "boolean" }, + "reason": { type: "string" }, + }, +}); + messages.deleteUser = new Request({ type: "delete_user", links: ["control-master"], @@ -841,6 +878,60 @@ messages.masterConnectionEvent = new Event({ }, }); +messages.syncUserLists = new Event({ + type: "sync_user_lists", + links: ["master-slave"], + eventProperties: { + "adminlist": { + type: "array", + items: { type: "string" }, + }, + "banlist": { + type: "array", + items: { + type: "array", + additionalItems: false, + items: [{ type: "string" }, { type: "string" }], + }, + }, + "whitelist": { + type: "array", + items: { type: "string" }, + }, + }, +}); + +messages.banlistUpdate = new Event({ + type: "banlist_update", + links: ["master-slave", "slave-instance"], + broadcastTo: "instance", + eventProperties: { + "name": { type: "string" }, + "banned": { type: "boolean" }, + "reason": { type: "string" }, + }, +}); + +messages.adminlistUpdate = new Event({ + type: "adminlist_update", + links: ["master-slave", "slave-instance"], + eventProperties: { + "name": { type: "string" }, + "admin": { type: "boolean" }, + }, + broadcastTo: "instance", +}); + +messages.whitelistUpdate = new Event({ + type: "whitelist_update", + links: ["master-slave", "slave-instance"], + eventProperties: { + "name": { type: "string" }, + "whitelisted": { type: "boolean" }, + }, + broadcastTo: "instance", +}); + messages.playerEvent = new Event({ type: "player_event", links: ["instance-slave", "slave-master"], diff --git a/lib/users.js b/lib/users.js index 4ba85e96..aa2803fb 100644 --- a/lib/users.js +++ b/lib/users.js @@ -136,12 +136,12 @@ function ensureDefaultPlayerRole(roles) { * @static */ class User { - constructor({ name, roles, tokenValidAfter }, loadedRoles) { + constructor(serializedUser, loadedRoles) { /** * Factorio user name. * @type {string} */ - this.name = name; + this.name = serializedUser.name; /** * Instances this user is online on. @@ -153,15 +153,39 @@ class User { * Unix time in seconds the user token must be issued after to be valid. * @type {number} */ - this.tokenValidAfter = tokenValidAfter || 0; + this.tokenValidAfter = serializedUser.token_valid_after || 0; + + /** + * True if the user is promoted to admin on the Factorio instances. + * @type {boolean} + */ + this.isAdmin = Boolean(serializedUser.is_admin); + + /** + * True if the user is whitelisted on the Factorio instances. + * @type {boolean} + */ + this.isWhitelisted = Boolean(serializedUser.is_whitelisted); + + /** + * True if the user is banned from Factorio instances. + * @type {boolean} + */ + this.isBanned = Boolean(serializedUser.is_banned); + + /** + * Reason for being banned. Ignored if isBanned is false. + * @type {string} + */ + this.banReason = serializedUser.ban_reason || ""; /** * Roles this user has * @type {Set} */ this.roles = new Set(); - if (roles) { - for (let roleId of roles) { + if (serializedUser.roles) { + for (let roleId of serializedUser.roles) { let role = loadedRoles.get(roleId); if (role) { this.roles.add(role); @@ -180,7 +204,23 @@ class User { } if (this.tokenValidAfter) { - serialized.tokenValidAfter = this.tokenValidAfter; + serialized.token_valid_after = this.tokenValidAfter; + } + + if (this.isAdmin) { + serialized.is_admin = true; + } + + if (this.isWhitelisted) { + serialized.is_whitelisted = true; + } + + if (this.isBanned) { + serialized.is_banned = true; + } + + if (this.banReason) { + serialized.ban_reason = this.banReason; } return serialized; @@ -242,6 +282,10 @@ class User { } } } +/** + * Set of users currently online in the cluster. + * @type {module:lib/users.User} + */ User.onlineUsers = new Set(); // Definitions for the built in permissions used in Clusterio. @@ -386,6 +430,21 @@ definePermission({ title: "Update user roles", description: "Add or remove any role to any user.", }); +definePermission({ + name: "core.user.set_admin", + title: "Set user admin status", + description: "Promote or demote any user to Factorio admin.", +}); +definePermission({ + name: "core.user.set_banned", + title: "Set user ban status", + description: "Ban or unban any user.", +}); +definePermission({ + name: "core.user.set_whitelisted", + title: "Set user whitelist status", + description: "Add or remove any user to/from the whitelist.", +}); definePermission({ name: "core.user.delete", title: "Delete user", diff --git a/master.js b/master.js index 234bd8bc..b5f8d427 100644 --- a/master.js +++ b/master.js @@ -504,7 +504,7 @@ class BaseConnection extends link.Link { event.send(connection, message.data); } - async broadcastEventToInstance(message, event) { + async broadcastEventToSlaves(message, event) { for (let slaveConnection of slaveConnections.values()) { // Do not broadcast back to the source if (slaveConnection === this) { continue; } @@ -515,6 +515,10 @@ class BaseConnection extends link.Link { } } + async broadcastEventToInstance(message, event) { + await this.broadcastEventToSlaves(message, event); + } + async prepareDisconnectRequestHandler(message, request) { await plugin.invokeHook(masterPlugins, "onPrepareSlaveDisconnect", this); this.connector.setClosing(); @@ -803,6 +807,9 @@ class ControlConnection extends BaseConnection { list.push({ name: user.name, roles: [...user.roles].map(role => role.id), + is_admin: user.isAdmin, + is_banned: user.isBanned, + is_whitelisted: user.isWhitelisted, instances: [...user.instances], }); } @@ -832,10 +839,71 @@ class ControlConnection extends BaseConnection { user.roles = resolvedRoles; } + async setUserAdminRequestHandler(message) { + let { name, create, admin } = message.data; + let user = db.users.get(name); + if (!user) { + if (create) { + this.user.checkPermission("core.user.create"); + user = createUser(name); + } else { + throw new errors.RequestError(`User '${name}' does not exist`); + } + } + + user.isAdmin = admin; + this.broadcastEventToSlaves({ data: { name, admin }}, link.messages.adminlistUpdate); + } + + async setUserBannedRequestHandler(message) { + let { name, create, banned, reason } = message.data; + let user = db.users.get(name); + if (!user) { + if (create) { + this.user.checkPermission("core.user.create"); + user = createUser(name); + } else { + throw new errors.RequestError(`User '${name}' does not exist`); + } + } + + user.isBanned = banned; + user.banReason = reason; + this.broadcastEventToSlaves({ data: { name, banned, reason }}, link.messages.banlistUpdate); + } + + async setUserWhitelistedRequestHandler(message) { + let { name, create, whitelisted } = message.data; + let user = db.users.get(name); + if (!user) { + if (create) { + this.user.checkPermission("core.user.create"); + user = createUser(name); + } else { + throw new errors.RequestError(`User '${name}' does not exist`); + } + } + + user.isWhitelisted = whitelisted; + this.broadcastEventToSlaves({ data: { name, whitelisted }}, link.messages.whitelistUpdate); + } + async deleteUserRequestHandler(message) { - if (!db.users.delete(message.data.name)) { + let user = db.users.get(message.data.name); + if (!user) { throw new errors.RequestError(`User '${message.data.name}' does not exist`); } + + if (user.is_admin) { + this.broadcastEventToSlaves({ data: { name, admin: false }}, link.messages.adminlistUpdate); + } + if (user.is_whitelisted) { + this.broadcastEventToSlaves({ data: { name, whitelisted: false }}, link.messages.whitelistUpdate); + } + if (user.is_banned) { + this.broadcastEventToSlaves({ data: { name, banned: false, reason: "" }}, link.messages.banlistUpdate); + } + db.users.delete(message.data.name); } async debugDumpWsRequestHandler(message) { @@ -949,6 +1017,25 @@ class SlaveConnection extends BaseConnection { serialized_config: instanceConfig.serialize(), }); } + + // Push lists to make sure they are in sync. + let adminlist = []; + let banlist = []; + let whitelist = []; + + for (let user of db.users.values()) { + if (user.isAdmin) { + adminlist.push(user.name); + } + if (user.isBanned) { + banlist.push([user.name, user.banReason]); + } + if (user.isWhitelisted) { + whitelist.push(user.name); + } + } + + link.messages.syncUserLists.send(this, { adminlist, banlist, whitelist }); } async instanceOutputEventHandler(message) { @@ -1548,6 +1635,7 @@ async function startServer() { let adminRole = users.ensureDefaultAdminRole(db.roles); admin.roles.add(adminRole); + admin.isAdmin = true; await saveUsers(masterConfig.get("master.database_directory"), "users.json"); } else if (subCommand === "create-ctl-config") { diff --git a/slave.js b/slave.js index 062cecff..7852aa65 100644 --- a/slave.js +++ b/slave.js @@ -117,6 +117,8 @@ class Instance extends link.Link{ if (group.name === "factorio" && field === "settings") { this.updateFactorioSettings(group.get(field), prev).finally(hook); + } else if (group.name === "factorio" && field === "enable_whitelist") { + this.updateFactorioWhitelist(group.get(field)).finally(hook); } else { hook(); } @@ -128,6 +130,7 @@ class Instance extends link.Link{ gamePort: this.config.get("factorio.game_port"), rconPort: this.config.get("factorio.rcon_port"), rconPassword: this.config.get("factorio.rcon_password"), + enableWhitelist: this.config.get("factorio.enable_whitelist"), }; this._running = false; @@ -290,12 +293,38 @@ class Instance extends link.Link{ /** * Prepare instance for starting * - * Writes server settings and links mods. + * Writes server settings, admin/ban/white-lists and links mods. */ async prepare() { console.log("Clusterio | Writing server-settings.json"); await this.writeServerSettings(); + if (this.config.get("factorio.sync_adminlist")) { + console.log("Clusterio | Writing server-adminlist.json"); + fs.outputFile( + this.server.writePath("server-adminlist.json"), + JSON.stringify([...this._slave.adminlist], null, 4) + ); + } + + if (this.config.get("factorio.sync_banlist")) { + console.log("Clusterio | Writing server-banlist.json"); + fs.outputFile( + this.server.writePath("server-banlist.json"), + JSON.stringify([...this._slave.banlist].map( + ([username, reason]) => ({ username, reason }) + ), null, 4), + ); + } + + if (this.config.get("factorio.sync_whitelist")) { + console.log("Clusterio | Writing server-whitelist.json"); + fs.outputFile( + this.server.writePath("server-whitelist.json"), + JSON.stringify([...this._slave.whitelist], null, 4) + ); + } + console.log("Clusterio | Rotating old logs..."); // clean old log file to avoid crash try{ @@ -473,6 +502,60 @@ class Instance extends link.Link{ } } + /** + * Enable or disable the player whitelist + * + * @param {boolean} enable - + * True to enable the whitelist, False to disable the whitelist. + */ + async updateFactorioWhitelist(enable) { + if (!enable) { + await this.server.sendRcon("/whitelist disable"); + } + + if (this.config.get("factorio.sync_whitelist")) { + await this.server.sendRcon("/whitelist clear"); + for (let player of this._slave.whitelist) { + await this.server.sendRcon(`/whitelist ${player}`); + } + } + + if (enable) { + await this.server.sendRcon("/whitelist enable"); + } + } + + async adminlistUpdateEventHandler(message) { + if (!this.config.get("factorio.sync_adminlist")) { + return; + } + + let { name, admin } = message.data; + let command = admin ? `/promote ${name}` : `/demote ${name}`; + await this.server.sendRcon(command); + } + + async banlistUpdateEventHandler(message) { + if (!this.config.get("factorio.sync_banlist")) { + return; + } + + let { name, banned, reason } = message.data; + let command = banned ? `/ban ${name} ${reason}` : `/unban ${name}`; + await this.server.sendRcon(command); + } + + async whitelistUpdateEventHandler(message) { + if (!this.config.get("factorio.sync_whitelist")) { + return; + } + + let { name, whitelisted } = message.data; + let command = whitelisted ? `/whitelist add ${name}` : `/whiteliste remove ${name}`; + await this.server.sendRcon(command); + } + + /** * Stop the instance */ @@ -790,6 +873,10 @@ class Slave extends link.Link { this.instanceConnections = new Map(); this.instanceInfos = new Map(); + this.adminlist = new Set(); + this.banlist = new Map(); + this.whitelist = new Set(); + this.connector.on("hello", data => { this.serverVersion = data.version; this.serverPlugins = new Map(Object.entries(data.plugins)); @@ -906,6 +993,83 @@ class Slave extends link.Link { } } + async syncUserListsEventHandler(message) { + let updateList = (list, updatedList, prop, event) => { + let added = new Set(updatedList); + let removed = new Set(list); + list.forEach(Set.prototype.delete.bind(added)); + updatedList.forEach(Set.prototype.delete.bind(removed)); + + for (let name of added) { + list.add(name); + this.broadcastEventToInstance( + { data: { name, [prop]: true }}, event + ); + } + + for (let name of removed) { + list.delete(name); + this.broadcastEventToInstance( + { data: { name, [prop]: false }}, event + ); + } + }; + + updateList(this.adminlist, message.data.adminlist, "admin", link.messages.adminlistUpdate); + updateList(this.whitelist, message.data.whitelist, "whitelisted", link.messages.whitelistUpdate); + + let updatedBanlist = new Map(message.data.banlist); + let addedOrChanged = new Map(updatedBanlist); + let removed = new Set(this.banlist.keys()); + removed.forEach((name, reason) => { + if (addedOrChanged.get(name) === reason) { + addedOrChanged.delete(name); + } + }); + updatedBanlist.forEach(Set.prototype.delete.bind(removed)); + + for (let [name, reason] of addedOrChanged) { + this.banlist.set(name, reason); + this.broadcastEventToInstance( + { data: { name, banned: true, reason }}, link.messages.banlistUpdate + ); + } + + for (let name of removed) { + this.banlist.delete(name); + this.broadcastEventToInstance( + { data: { name, banned: false, reason: "" }}, link.messages.banlistUpdate + ); + } + } + + async adminlistUpdateEventHandler(message) { + let { name, admin } = message.data; + if (admin) { + this.adminlist.add(name); + } else { + this.adminlist.delete(name); + } + } + + async banlistUpdateEventHandler(message) { + let { name, banned, reason } = message.data; + if (banned) { + this.banlist.set(name, reason); + } else { + this.banlist.delete(name); + } + } + + async whitelistUpdateEventHandler(message) { + let { name, whitelisted } = message.data; + if (whitelisted) { + this.whitelist.add(name); + } else { + this.whitelist.delete(name); + } + } + async assignInstanceRequestHandler(message) { let { instance_id, serialized_config } = message.data; let instanceInfo = this.instanceInfos.get(instance_id); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 2370dc6b..be5be862 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -242,9 +242,53 @@ describe("Integration of Clusterio", function() { }); }); + async function getUsers() { + let result = await link.messages.listUsers.send(getControl()); + return new Map(result.list.map(user => [user.name, user])); + } + + describe("user set-admin/whitelisted/banned", function() { + let lists = ["admin", "whitelisted", "banned"]; + it("should add and remove the given user to the list", async function() { + slowTest(this); + await link.messages.createUser.send(getControl(), { name: "list_test" }); + let user = (await getUsers()).get("list_test"); + for (let list of lists) { + assert.equal(user[`is_${list}`], false, `unexpected ${list} status`); + await execCtl(`user set-${list} list_test`); + } + user = (await getUsers()).get("list_test"); + for (let list of lists) { + assert.equal(user[`is_${list}`], true, `unexpected ${list} status`); + let remove = { admin: "--revoke", whitelisted: "--remove", banned: "--pardon" }[list]; + await execCtl(`user set-${list} ${remove} list_test`); + } + user = (await getUsers()).get("list_test"); + for (let list of lists) { + assert.equal(user[`is_${list}`], false, `unexpected ${list} status`); + } + }); + it("should not create the user if not instructed to", async function() { + for (let list of lists) { + try { + await execCtl(`user set-${list} no_create_test`); + } catch (err) { /* ignore */ } + } + let user = (await getUsers()).get("no_create_test"); + assert.equal(user, undefined, "user was unexpectedly created"); + }); + it("should create the user if instructed to", async function() { + for (let list of lists) { + await execCtl(`user set-${list} --create test_create_${list}`); + let user = (await getUsers()).get(`test_create_${list}`); + assert.equal(user && user[`is_${list}`], true, `user not created and added to ${list}`); + } + }); + }); + describe("user set-roles", function() { it("should set the roles on the user", async function() { - await execCtl(`user set-roles temp Admin`); + await execCtl("user set-roles temp Admin"); let result = await link.messages.listUsers.send(getControl()); let user = result.list.find(user => user.name === "temp"); assert.deepEqual(user.roles, [0]); diff --git a/test/lib/users.js b/test/lib/users.js index 2e577135..35788d1d 100644 --- a/test/lib/users.js +++ b/test/lib/users.js @@ -81,6 +81,8 @@ describe("lib/users", function() { test_roundtrip({ name: "admin", roles: [1] }); test_roundtrip({ name: "user", roles: [2], token_valid_after: 12345 }); + test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true }); + test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" }); }); it("should ignore invalid roles", function() { let user = new users.User({ name: "test", roles: [1, 4, 55] }, roles); From 08ec54290713c7066502a9f604da635eb3fddaec Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 1 Sep 2020 20:14:10 +0200 Subject: [PATCH 5/6] Move integration tests for lists sync Move the integration tests for the admin, user, and ban list sync to be done while an instance is running in order to hit more lines in the coverage with them. --- test/integration/clusterio.js | 88 +++++++++++++++++------------------ 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index be5be862..e2c260bf 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -170,6 +170,50 @@ describe("Integration of Clusterio", function() { }); }); + describe("user set-admin/whitelisted/banned", function() { + async function getUsers() { + let result = await link.messages.listUsers.send(getControl()); + return new Map(result.list.map(user => [user.name, user])); + } + + let lists = ["admin", "whitelisted", "banned"]; + it("should add and remove the given user to the list", async function() { + slowTest(this); + await link.messages.createUser.send(getControl(), { name: "list_test" }); + let user = (await getUsers()).get("list_test"); + for (let list of lists) { + assert.equal(user[`is_${list}`], false, `unexpected ${list} status`); + await execCtl(`user set-${list} list_test`); + } + user = (await getUsers()).get("list_test"); + for (let list of lists) { + assert.equal(user[`is_${list}`], true, `unexpected ${list} status`); + let remove = { admin: "--revoke", whitelisted: "--remove", banned: "--pardon" }[list]; + await execCtl(`user set-${list} ${remove} list_test`); + } + user = (await getUsers()).get("list_test"); + for (let list of lists) { + assert.equal(user[`is_${list}`], false, `unexpected ${list} status`); + } + }); + it("should not create the user if not instructed to", async function() { + for (let list of lists) { + try { + await execCtl(`user set-${list} no_create_test`); + } catch (err) { /* ignore */ } + } + let user = (await getUsers()).get("no_create_test"); + assert.equal(user, undefined, "user was unexpectedly created"); + }); + it("should create the user if instructed to", async function() { + for (let list of lists) { + await execCtl(`user set-${list} --create test_create_${list}`); + let user = (await getUsers()).get(`test_create_${list}`); + assert.equal(user && user[`is_${list}`], true, `user not created and added to ${list}`); + } + }); + }); + describe("instance stop", function() { it("stops the instance", async function() { slowTest(this); @@ -242,50 +286,6 @@ describe("Integration of Clusterio", function() { }); }); - async function getUsers() { - let result = await link.messages.listUsers.send(getControl()); - return new Map(result.list.map(user => [user.name, user])); - } - - describe("user set-admin/whitelisted/banned", function() { - let lists = ["admin", "whitelisted", "banned"]; - it("should add and remove the given user to the list", async function() { - slowTest(this); - await link.messages.createUser.send(getControl(), { name: "list_test" }); - let user = (await getUsers()).get("list_test"); - for (let list of lists) { - assert.equal(user[`is_${list}`], false, `unexpected ${list} status`); - await execCtl(`user set-${list} list_test`); - } - user = (await getUsers()).get("list_test"); - for (let list of lists) { - assert.equal(user[`is_${list}`], true, `unexpected ${list} status`); - let remove = { admin: "--revoke", whitelisted: "--remove", banned: "--pardon" }[list]; - await execCtl(`user set-${list} ${remove} list_test`); - } - user = (await getUsers()).get("list_test"); - for (let list of lists) { - assert.equal(user[`is_${list}`], false, `unexpected ${list} status`); - } - }); - it("should not create the user if not instructed to", async function() { - for (let list of lists) { - try { - await execCtl(`user set-${list} no_create_test`); - } catch (err) { /* ignore */ } - } - let user = (await getUsers()).get("no_create_test"); - assert.equal(user, undefined, "user was unexpectedly created"); - }); - it("should create the user if instructed to", async function() { - for (let list of lists) { - await execCtl(`user set-${list} --create test_create_${list}`); - let user = (await getUsers()).get(`test_create_${list}`); - assert.equal(user && user[`is_${list}`], true, `user not created and added to ${list}`); - } - }); - }); - describe("user set-roles", function() { it("should set the roles on the user", async function() { await execCtl("user set-roles temp Admin"); From 3753c303dd9e3197a97022f7da16054cc9f66a82 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 1 Sep 2020 20:26:30 +0200 Subject: [PATCH 6/6] Add changelog entry for list synchronization [skip travis] --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11105989..e99a2e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ Version 2.0.0 - Added support for extracting locale and item icons from Factorio and mods. - Added integrated user management for controlling access and storing per player data. +- Added synchronization of in-game adminlist, banlist and whitelist to + Clusterio core. Previously this was handled by the playerManager plugin. ### Changes