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/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 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 1ad2a8bf..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); @@ -173,11 +197,30 @@ 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; + 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; @@ -239,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. @@ -383,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 e6a95e4b..e2c260bf 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() { @@ -30,62 +30,62 @@ 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`); + await execCtl("slave list"); }); }); - describe("list-instances", function() { + describe("instance list", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} instance list`); + await execCtl("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`); + await execCtl("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`); + await execCtl("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`); + await execCtl("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`); + await execCtl("instance export-data test"); assert(await fs.exists(path.join("static", "export", "locale.json")), "Export was not created"); }); }); - 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`); + await execCtl("instance start test"); // TODO check that the instance actually started }); }); - 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`); + await execCtl("instance send-rcon test test"); // TODO check that the command was received }); }); - describe("set-instance-config-prop", function() { + describe("instance config set-prop", function() { it("applies factorio settings while running", async function() { slowTest(this); @@ -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 @@ -170,90 +170,134 @@ describe("Integration of Clusterio", function() { }); }); - describe("stop-instance", 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); - await exec(`node clusterctl --config ${controlConfigPath} instance stop test`); + await execCtl("instance stop test"); // TODO check that the instance actually stopped }); }); - 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`); + await execCtl("instance delete test"); assert(!await fs.exists(path.join(instancesDir, "test")), "Instance was not deleted"); }); }); - describe("list-permissions", function() { + describe("permission list", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} permission list`); + await execCtl("permission list"); }); }); - describe("list-roles", function() { + describe("role list", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} role list`); + await execCtl("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}`); + 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"] }); }); }); - 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}`); + 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: [] }); }); }); - 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`); + 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"); }); }); - describe("list-users", function() { + describe("user list", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} user list`); + await execCtl("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`); + 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"); }); }); - 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`); + 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]); }); }); - describe("delete-user", 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, diff --git a/test/lib/users.js b/test/lib/users.js index 901bb53b..35788d1d 100644 --- a/test/lib/users.js +++ b/test/lib/users.js @@ -71,13 +71,22 @@ 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 }); + 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); + 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);