diff --git a/.gitignore b/.gitignore index 2aab45d8..32870a46 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,6 @@ Factorio* /config-control.json /config-slave.json package-lock.json -secret-api-token.txt mod_cache.json static/export static/external diff --git a/CHANGELOG.md b/CHANGELOG.md index 47d938b7..11105989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ Version 2.0.0 - Added Factorio server CPU and memory usage statistic. - Added Factorio autosave size statistic. - Added support for extracting locale and item icons from Factorio and mods. +- Added integrated user management for controlling access and storing per + player data. ### Changes @@ -92,8 +94,7 @@ Version 2.0.0 ### Breaking Changes -- lib/authenticate no longer requires config to be passed to it. Breaks - playerManager. +- Removed lib/authenticate. Breaks playerManager. - The masterIP and masterPort config entries has been merged into masterURL. Breaks discordChat, playerManager, serverSelect, trainTeleports and clusterioModel. @@ -166,6 +167,7 @@ Version 2.0.0 - Implemented a new config system that replaces the old. Breaks all plugins. - Removed usage of socket.io entirely in favor of a plain WebSocket connection. - Renamed clusterioMod plugin to subspace_storage. +- Master no longer creates secret-api-token.txt. Version 1.2.4 diff --git a/README.md b/README.md index 9298a408..a35dab63 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,22 @@ After following the installation instructions you can use the following commands to run Clusterio. -### Master Server +### Setting up an admin account + +Before you can manage the cluster you need to bootstrap an admin account +for it. Replace `` with your Factorio username (do not make +up a new username here). + + node master bootstrap create-admin + node master bootstrap create-ctl-config + +The first command creates a user account with the given name and +promotes it to a cluster admin. The second one sets up a +`config-control.json` config for clusterctl to connect to the master +server under the given user account. + + +### Running the master Server It's necessary to run the master server in order for anything to work. Once you've completed the setup run the following command to start it @@ -205,22 +220,21 @@ up: Slaves connect to the master server and are managed remotely from the master server. In order for slaves to connect to the master server they -need an authentication token from the master server. This token is -written to secret-api-token.txt on the master server when it is started -up. +need a valid authentication token, you can create a slave config with +a valid token with the following command. -To set up the configuration for a new local slave run the following. - - node slave config set slave.name "Local" - node slave config set slave.master_token "" + node clusterctl create-slave-config --name Local --generate-token This will write a new `config-slave.json` file in the current directory -(you can change the location with the `--config` option) with the name -and token provided. If you are connecting to a remote master server you -will also need to set the `slave.master_url` option to that url. +(you can change the location with the `--output` option) with the name, +token and url to connect to the master server with. If you are making +the config for a remote slave you will need to have set the +`master.external_address` option to the URL the master server can be +reached on. -You can list the config of a slave with the `node slave config list` -command. Use `node slave config --help` for more information. +You can list the config of a slave on the slave itself with the `node +slave config list` command. Use `node slave config --help` for more +information. Once the config is set up run the slave with diff --git a/clusterctl.js b/clusterctl.js index 3be0d077..989f9445 100644 --- a/clusterctl.js +++ b/clusterctl.js @@ -134,6 +134,47 @@ async function resolveSlave(client, slaveName) { return slaveId; } +/** + * Resolve a string into a role object + * + * Resolves a string with either a role name or an id into an object + * representing the role. + * + * @param client - link to master server to query role on. + * @param roleName - string with name or id of role. + * @returns {Object} Role info. + * @private + */ +async function resolveRole(client, roleName) { + let response = await link.messages.listRoles.send(client); + + let resolvedRole; + if (/^-?\d+$/.test(roleName)) { + let roleId = parseInt(roleName, 10); + for (let role of response.list) { + if (role.id === roleId) { + resolvedRole = role; + break; + } + } + + } else { + for (let role of response.list) { + if (role.name === roleName) { + resolvedRole = role; + break; + } + } + } + + if (!resolvedRole) { + throw new errors.CommandError(`No role named ${roleName}`); + } + + return resolvedRole; +} + + let commands = []; commands.push(new Command({ definition: ["list-slaves", "List slaves connected to the master"], @@ -143,6 +184,50 @@ commands.push(new Command({ }, })); +commands.push(new Command({ + definition: ["generate-slave-token", "Generate token for a slave", (yargs) => { + yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", demandOption: true }); + }], + handler: async function(args, control) { + let response = await link.messages.generateSlaveToken.send(control, { slave_id: args.id }); + console.log(response.token); + }, +})); + +commands.push(new Command({ + definition: ["create-slave-config", "Create slave config", (yargs) => { + yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", default: null }); + yargs.option("name", { type: "string", nargs: 1, describe: "Slave name", default: null }); + yargs.option("generate-token", { + type: "boolean", nargs: 0, describe: "Generate authentication token", default: false + }); + yargs.option("output", { + type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-slave.json" + }); + }], + handler: async function(args, control) { + let response = await link.messages.createSlaveConfig.send(control, { + id: args.id, name: args.name, generate_token: args.generateToken, + }); + + let content = JSON.stringify(response.serialized_config, null, 4); + if (args.output === "-") { + console.log(content); + } else { + console.log(`Writing ${args.output}`); + try { + await fs.outputFile(args.output, content, { flag: "wx" }); + } catch (err) { + if (err.code === "EEXIST") { + throw new errors.CommandError(`File ${args.output} already exists`); + } + throw err; + } + } + }, +})); + + commands.push(new Command({ definition: ["list-instances", "List instances known to the master"], handler: async function(args, control) { @@ -353,6 +438,149 @@ commands.push(new Command({ }, })); +commands.push(new Command({ + definition: ["list-permissions", "List permissions in the cluster"], + handler: async function(args, control) { + let response = await link.messages.listPermissions.send(control); + console.log(asTable(response.list)); + }, +})); + +commands.push(new Command({ + definition: ["list-roles", "List roles in the cluster"], + handler: async function(args, control) { + let response = await link.messages.listRoles.send(control); + console.log(asTable(response.list)); + }, +})); + +commands.push(new Command({ + definition: ["create-role", "Create a new role", (yargs) => { + yargs.options({ + "name": { describe: "Name of role to create", nargs: 1, type: "string", demandOption: true }, + "description": { describe: "Description for role", nargs: 1, type: "string", default: "" }, + "permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] }, + }); + }], + handler: async function(args, control) { + let response = await link.messages.createRole.send(control, { + name: args.name, + description: args.description, + permissions: args.permissions + }); + console.log(`Created role ID ${response.id}`); + }, +})); + +commands.push(new Command({ + definition: ["edit-role", "Edit existing role", (yargs) => { + yargs.options({ + "role": { describe: "Role to edit", nargs: 1, type: "string", demandOption: true }, + "name": { describe: "New name for role", nargs: 1, type: "string" }, + "description": { describe: "New description for role", nargs: 1, type: "string" }, + "permissions": { describe: "New permissions for role", array: true, type: "string" }, + "grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" }, + }); + }], + handler: async function(args, control) { + let role = await resolveRole(control, args.role); + + if (args.name !== undefined) { + role.name = args.name; + } + if (args.description !== undefined) { + role.description = args.description; + } + if (args.permissions !== undefined) { + role.permissions = args.permissions; + } + await link.messages.updateRole.send(control, role); + + if (args.grantDefault) { + await link.messages.grantDefaultRolePermissions.send(control, { id: role.id }); + } + }, +})); + +commands.push(new Command({ + definition: ["delete-role", "Delete role", (yargs) => { + yargs.options({ + "role": { describe: "Role to delete", nargs: 1, type: "string", demandOption: true }, + }); + }], + handler: async function(args, control) { + let role = await resolveRole(control, args.role) + await link.messages.deleteRole.send(control, { id: role.id }); + }, +})); + + +commands.push(new Command({ + definition: ["list-users", "List user in the cluster"], + handler: async function(args, control) { + let response = await link.messages.listUsers.send(control); + console.log(asTable(response.list)); + }, +})); + +commands.push(new Command({ + definition: ["create-user", "Create a user", (yargs) => { + yargs.options({ + "name": { describe: "Name of user to create", nargs: 1, type: "string", demandOption: true }, + }); + }], + handler: async function(args, control) { + await link.messages.createUser.send(control, { name: args.name }); + }, +})); + +commands.push(new Command({ + definition: ["edit-user-roles", "Edit user roles", (yargs) => { + yargs.options({ + "name": { describe: "Name of user to change roles for", nargs: 1, type: "string", demandOption: true }, + "roles": { describe: "roles to assign", array: true, type: "string", demandOption: true }, + }); + }], + handler: async function(args, control) { + let response = await link.messages.listRoles.send(control); + + let resolvedRoles = []; + for (let roleName of args.roles) { + if (/^-?\d+$/.test(roleName)) { + let roleId = parseInt(roleName, 10); + resolvedRoles.push(roleId); + + } else { + let found = false; + for (let role of response.list) { + if (role.name === roleName) { + resolvedRoles.push(role.id); + found = true; + break; + } + } + + if (!found) { + throw new errors.CommandError(`No role named ${roleName}`); + } + } + } + + await link.messages.updateUserRoles.send(control, { name: args.name, roles: resolvedRoles }); + }, +})); + +commands.push(new Command({ + definition: ["delete-user", "Delete user", (yargs) => { + yargs.options({ + "name": { describe: "Name of user to delete", nargs: 1, type: "string", demandOption: true }, + }); + }], + handler: async function(args, control) { + await link.messages.deleteUser.send(control, { name: args.name }); + }, +})); + commands.push(new Command({ definition: ["debug-dump-ws", "Dump WebSocket messages sent and received by master", (yargs) => { }], handler: async function(args, control) { @@ -423,52 +651,6 @@ class Control extends link.Link { } } -async function findCredentials(controlConfig) { - // Try looking in the default master config location - try { - let masterConfig = new config.MasterConfig(); - await masterConfig.load(JSON.parse(await fs.readFile("config-master.json"))); - - let token = jwt.sign({ id: "api" }, masterConfig.get("master.auth_secret"), { expiresIn: 600 }); - controlConfig.set("control.master_token", token); - - let url; - if (masterConfig.get("master.https_port")) { - url = `https://localhost:${masterConfig.get("master.https_port")}`; - } else { - url = `http://localhost:${masterConfig.get("master.http_port")}`; - } - controlConfig.set("control.master_url", url); - - return; - - } catch (err) { - if (err.code !== "ENOENT") { - throw err; - } - } - - // Try looking in the default slave config location - // In the future it's likely that tokens will be assigned more granular - // authorizations, meaning that you cannot use a slave token to control - // the cluster. - try { - let slaveConfig = new config.SlaveConfig(); - await slaveConfig.load(JSON.parse(await fs.readFile("config-slave.json"))); - - controlConfig.set("control.master_token", slaveConfig.get("slave.master_token")); - controlConfig.set("control.master_url", slaveConfig.get("slave.master_url")); - return; - - } catch (err) { - if (err.code !== "ENOENT") { - throw err; - } - } - - throw new errors.StartupError("Couldn't find credentials to connect to the master server with"); -} - async function startControl() { yargs @@ -519,7 +701,9 @@ async function startControl() { // The remaining commands require connecting to the master server. if (!controlConfig.get("control.master_url") || !controlConfig.get("control.master_token")) { - await findCredentials(controlConfig); + console.error("Missing URL and/or token to connect with. See README.md for setting up access."); + process.exitCode = 1; + return; } let controlConnector = new ControlConnector( diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 27cf549c..3e835624 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -13,3 +13,14 @@ it. Mods on the other hand work without modifications. with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with save patching instead. + + +User Accounts +------------- + +The master server creates and stores accounts for all Factorio users +that log into instances under its control. These accounts are used to +store per player data and authorize management of the cluster. By +default users accounts are assigned a Player role that gives a limited +read access to the cluster resources, and admins of the cluster can be +assigned an Admin role to get access to manage it. diff --git a/lib/authenticate.js b/lib/authenticate.js deleted file mode 100644 index c1a02753..00000000 --- a/lib/authenticate.js +++ /dev/null @@ -1,48 +0,0 @@ -const jwt = require('jsonwebtoken'); - -let authSecret = null; -function setAuthSecret(secret) { - authSecret = secret; -} - -function middleware(req, res, next) { - // This should not happen - if (!authSecret) { - return res.status(500).send({ auth: false, message: 'Secret not set', endpoint: req.route.path }); - } - - var token = req.headers['x-access-token']; - if (!token) { - return res.status(401).send({ auth: false, message: 'No token provided.', endpoint: req.route.path }); - } - - jwt.verify(token, authSecret, function(err, decoded) { - if (err) { - return res.status(401).send({ auth: false, message: 'Failed to authenticate token.', endpoint: req.route.path }); - } - next(); - }); -} - -async function check(token) { - return new Promise((resolve, reject) => { - // This should not happen - if (!authSecret) { - resolve({ ok: false, msg: 'Secret not set' }); - } - - jwt.verify(token, authSecret, function(err, decoded) { - if (err) { - resolve({ok:false, msg:err}) - } else { - resolve({ok:true, msg:"Successfully authenticated"}); - } - }); - }); -} - -module.exports = { - setAuthSecret, - middleware, - check, -}; diff --git a/lib/config/definitions.js b/lib/config/definitions.js index 82464f90..212c6813 100644 --- a/lib/config/definitions.js +++ b/lib/config/definitions.js @@ -39,11 +39,11 @@ MasterGroup.define({ initial_value: 8443, }); MasterGroup.define({ - name: "web_root", - title: "Web Root", - description: "Sub-path to where the web interface is hosted. Needed when proxying the web interface.", + name: "external_address", + title: "External Address", + description: "Public facing address the master server is hosted on.", type: "string", - initial_value: "/", + optional: true, }); MasterGroup.define({ name: "tls_certificate", @@ -97,6 +97,14 @@ MasterGroup.define({ type: "number", initial_value: 30, }); +MasterGroup.define({ + name: "default_role_id", + title: "Default role", + description: "ID of role assigned by default to new users", + type: "number", + optional: true, + initial_value: 1, +}); MasterGroup.finalize(); /** diff --git a/lib/link/messages.js b/lib/link/messages.js index effda751..c157dc7e 100644 --- a/lib/link/messages.js +++ b/lib/link/messages.js @@ -43,6 +43,9 @@ class Request extends Message { * @param {Array} links - * Links this request is valid on. Takes an array of strings * containing "-" specifications. + * @param {?string} permission - + * Permission required to send this request. Only applies to + * requests sent from control to master. * @param {?string} forwardTo - * Optional target to forward this request to. 'instance' add * instance_id into the requestProperties and forward to the given @@ -56,16 +59,24 @@ class Request extends Message { * this requst. */ constructor({ - type, links, forwardTo = null, requestProperties = {}, responseProperties = {} + type, permission, links, forwardTo = null, requestProperties = {}, responseProperties = {}, }) { super(); this.type = type; this.links = links; + this.permission = permission || null; this.forwardTo = forwardTo; this.requestType = type + '_request'; this.responseType = type + '_response'; + if (permission === undefined && links.includes("control-master")) { + throw new Error(`permission is required for ${this.type} request over control-master links`); + } + if (permission && !links.includes("control-master")) { + throw new Error(`permission is not allowed on ${this.type} request as it is not over control-master link`); + } + if (forwardTo === 'instance') { requestProperties = Object.assign( {"instance_id": { type: "integer" }}, requestProperties @@ -158,6 +169,15 @@ class Request extends Message { throw new Error(`Missing handler for ${this.requestType} on ${link.source}-${link.target} link`); } + // Check permission if this is a handler for a control connection on the master + if (this.permission !== null && `${link.source}-${link.target}` === "master-control") { + let origHandler = handler; + handler = async function(message, request) { + this.user.checkPermission(request.permission); + return await origHandler.call(this, message, request); + }; + } + link.setHandler(this.requestType, message => { handler.call(link, message, this).then(response => { if (response === undefined) { @@ -213,6 +233,7 @@ let wsLinks = ["master-control", "control-master", "master-slave", "slave-master messages.prepareDisconnect = new Request({ type: "prepare_disconnect", links: wsLinks, + permission: null, }); messages.prepareMasterDisconnect = new Request({ @@ -223,11 +244,13 @@ messages.prepareMasterDisconnect = new Request({ messages.ping = new Request({ type: "ping", links: wsLinks, + permission: null, }); messages.debugDumpWs = new Request({ type: "debug_dump_ws", links: ["control-master"], + permission: "core.debug.dump_ws", }); @@ -235,6 +258,7 @@ messages.debugDumpWs = new Request({ messages.listSlaves = new Request({ type: 'list_slaves', links: ['control-master'], + permission: "core.slave.list", responseProperties: { "list": { type: "array", @@ -256,6 +280,7 @@ messages.listSlaves = new Request({ messages.listInstances = new Request({ type: 'list_instances', links: ['control-master'], + permission: "core.instance.list", responseProperties: { "list": { type: "array", @@ -272,9 +297,36 @@ messages.listInstances = new Request({ }, }); +messages.generateSlaveToken = new Request({ + type: "generate_slave_token", + links: ["control-master"], + permission: "core.slave.generate_token", + requestProperties: { + "slave_id": { type: "integer" }, + }, + responseProperties: { + "token": { type: "string" }, + }, +}); + +messages.createSlaveConfig = new Request({ + type: "create_slave_config", + links: ["control-master"], + permission: "core.slave.create_config", + requestProperties: { + "id": { type: ["integer", "null"] }, + "name": { type: ["string", "null"] }, + "generate_token": { type: "boolean" }, + }, + responseProperties: { + "serialized_config": { type: "object" }, + }, +}); + messages.setInstanceOutputSubscriptions = new Request({ type: 'set_instance_output_subscriptions', links: ['control-master'], + permission: "core.instance.follow_log", requestProperties: { "instance_ids": { type: "array", @@ -286,6 +338,7 @@ messages.setInstanceOutputSubscriptions = new Request({ messages.createInstance = new Request({ type: 'create_instance', links: ['control-master'], + permission: "core.instance.create", requestProperties: { "serialized_config": { type: "object" }, }, @@ -294,6 +347,7 @@ messages.createInstance = new Request({ messages.getInstanceConfig = new Request({ type: "get_instance_config", links: ["control-master"], + permission: "core.instance.get_config", requestProperties: { "instance_id": { type: "integer" }, }, @@ -305,6 +359,7 @@ messages.getInstanceConfig = new Request({ messages.setInstanceConfigField = new Request({ type: "set_instance_config_field", links: ["control-master"], + permission: "core.instance.update_config", requestProperties: { "instance_id": { type: "integer" }, "field": { type: "string" }, @@ -315,6 +370,7 @@ messages.setInstanceConfigField = new Request({ messages.setInstanceConfigProp = new Request({ type: "set_instance_config_prop", links: ["control-master"], + permission: "core.instance.update_config", requestProperties: { "instance_id": { type: "integer" }, "field": { type: "string" }, @@ -326,6 +382,7 @@ messages.setInstanceConfigProp = new Request({ messages.assignInstanceCommand = new Request({ type: 'assign_instance_command', links: ['control-master'], + permission: "core.instance.assign", requestProperties: { "instance_id": { type: "integer" }, "slave_id": { type: "integer" }, @@ -344,6 +401,7 @@ messages.assignInstance = new Request({ messages.startInstance = new Request({ type: 'start_instance', links: ['control-master', 'master-slave', 'slave-instance'], + permission: "core.instance.start", requestProperties: { "save": { type: ["string", "null"] }, }, @@ -353,30 +411,35 @@ messages.startInstance = new Request({ messages.createSave = new Request({ type: 'create_save', links: ['control-master', 'master-slave', 'slave-instance'], + permission: "core.instance.create_save", forwardTo: 'instance', }); messages.exportData = new Request({ type: "export_data", links: ["control-master", "master-slave", "slave-instance"], + permission: "core.instance.export_data", forwardTo: "instance", }); messages.stopInstance = new Request({ type: 'stop_instance', links: ['control-master', 'master-slave', 'slave-instance'], + permission: "core.instance.stop", forwardTo: 'instance', }); messages.deleteInstance = new Request({ type: 'delete_instance', links: ['control-master', 'master-slave'], + permission: "core.instance.delete", forwardTo: 'instance', }); messages.sendRcon = new Request({ type: 'send_rcon', links: ['control-master', 'master-slave', 'slave-instance'], + permission: "core.instance.send_rcon", forwardTo: 'instance', requestProperties: { "command": { type: "string" }, @@ -386,6 +449,139 @@ messages.sendRcon = new Request({ } }); +messages.listPermissions = new Request({ + type: "list_permissions", + links: ["control-master"], + permission: "core.permission.list", + responseProperties: { + "list": { + type: "array", + items: { + additionalProperties: false, + required: ["name", "title", "description"], + properties: { + "name": { type: "string" }, + "title": { type: "string" }, + "description": { type: "string" }, + }, + }, + }, + }, +}); + +messages.listRoles = new Request({ + type: "list_roles", + links: ["control-master"], + permission: "core.role.list", + responseProperties: { + "list": { + type: "array", + items: { + additionalProperties: false, + required: ["id", "name", "description", "permissions"], + properties: { + "id": { type: "integer" }, + "name": { type: "string" }, + "description": { type: "string" }, + "permissions": { type: "array", items: { type: "string" }}, + }, + }, + }, + }, +}); + +messages.createRole = new Request({ + type: "create_role", + links: ["control-master"], + permission: "core.role.create", + requestProperties: { + "name": { type: "string" }, + "description": { type: "string" }, + "permissions": { type: "array", items: { type: "string" }}, + }, + responseProperties: { + "id": { type: "integer" }, + } +}); + +messages.updateRole = new Request({ + type: "update_role", + links: ["control-master"], + permission: "core.role.update", + requestProperties: { + "id": { type: "integer" }, + "name": { type: "string" }, + "description": { type: "string" }, + "permissions": { type: "array", items: { type: "string" }}, + }, +}); + +messages.grantDefaultRolePermissions = new Request({ + type: "grant_default_role_permissions", + links: ["control-master"], + permission: "core.role.update", + requestProperties: { + "id": { type: "integer" }, + }, +}); + +messages.deleteRole = new Request({ + type: "delete_role", + links: ["control-master"], + permission: "core.role.delete", + requestProperties: { + "id": { type: "integer" }, + }, +}); + +messages.listUsers = new Request({ + type: "list_users", + links: ["control-master"], + permission: "core.user.list", + responseProperties: { + "list": { + type: "array", + items: { + additionalProperties: false, + required: ["name", "roles", "instances"], + properties: { + "name": { type: "string" }, + "roles": { type: "array", items: { type: "integer" }}, + "instances": { type: "array", items: { type: "integer" }}, + }, + }, + }, + }, +}); + +messages.createUser = new Request({ + type: "create_user", + links: ["control-master"], + permission: "core.user.create", + requestProperties: { + "name": { type: "string" }, + }, +}); + +messages.updateUserRoles = new Request({ + type: "update_user_roles", + links: ["control-master"], + permission: "core.user.update_roles", + requestProperties: { + "name": { type: "string" }, + "roles": { type: "array", items: { type: "integer" }}, + }, +}); + +messages.deleteUser = new Request({ + type: "delete_user", + links: ["control-master"], + permission: "core.user.delete", + requestProperties: { + "name": { type: "string" }, + }, +}); + messages.getMetrics = new Request({ type: 'statistics_exporter:get_metrics', links: ['master-slave', 'slave-instance'], @@ -628,6 +824,17 @@ messages.masterConnectionEvent = new Event({ }, }); +messages.playerEvent = new Event({ + type: "player_event", + links: ["instance-slave", "slave-master"], + forwardTo: "master", + eventProperties: { + "instance_id": { type: "integer" }, + "type": { type: "string", enum: ["join", "leave"] }, + "name": { type: "string" }, + }, +}); + /** * Attaches all requests and events to the given link * diff --git a/lib/plugin.js b/lib/plugin.js index f2d2a080..20f8fed3 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -158,6 +158,17 @@ class BaseInstancePlugin { * The connection to the slave preparing to disconnect. */ async onPrepareMasterDisconnect(connection) { } + + /** + * Called when a player joins or leaves the game + * + * Invoked when a player either joins or leaves the instance. + * + * @param {Object} event - Information about the event. + * @param {string} event.type - Either "join" or "leave". + * @param {string} event.name - Name of the player that joined/left. + */ + async onPlayerEvent(event) { } } /** @@ -258,6 +269,19 @@ class BaseMasterPlugin { */ async onPrepareSlaveDisconnect(connection) { } + /** + * Called when a player joins or leaves an instance + * + * Invoked when a player either joins or leaves an instance in the + * cluster. + * + * @param {Object} instance - the instance it occured on. + * @param {Object} event - Information about the event. + * @param {string} event.type - Either "join" or "leave". + * @param {string} event.name - Name of the player that joined/left. + */ + async onPlayerEvent(instance, event) { } + /** * Broadcast event to all connected slaves * diff --git a/lib/users.js b/lib/users.js new file mode 100644 index 00000000..4cc855fb --- /dev/null +++ b/lib/users.js @@ -0,0 +1,399 @@ +/** + * User and permissions library + * + * Defines data types for users, roles and permissions. + * + * @author Hornwitser + * @module + */ +"use strict"; +const jwt = require("jsonwebtoken"); + +/** + * Represents a permission that can be granted + * @static + */ +class Permission { + constructor(name, title, description, grantByDefault) { + this.name = name; + this.title = title; + this.description = description; + this.grantByDefault = grantByDefault; + } +} +const permissions = new Map(); + +/** + * Define a new user permission for Clusterio + * + * Defines a permission for use in Clusterio. Defined permissions can be + * granted to roles, and checked on users. Name should be `lower_case` + * style and start with the plugin name followed by a dot. + * + * @param {Object} def - The definition for the permission. + * @param {string} def.name - The internal name for the permission. + * @param {string} def.title - User facing name for permission. + * @param {string} def.description - User facing description. + * @param {boolean} def.grantByDefault - + * If true this role is granted by the generated Player role by default. + * + */ +function definePermission({ name, title, description, grantByDefault=false }) { + if (typeof name !== "string" || !name.length) { + throw new Error("Expected name to be a non-empty string"); + } + if (typeof title !== "string" || !title.length) { + throw new Error("Expected title to be a non-empty string"); + } + if (typeof description !== "string" || !description.length) { + throw new Error("Expected description to be a non-empty string"); + } + if (typeof grantByDefault !== "boolean") { + throw new Error("Expected grantByDefault to be a boolean"); + } + + if (permissions.has(name)) { + throw new Error(`Permission '${name}' is already defined`) + } + + permissions.set(name, new Permission(name, title, description, grantByDefault)); +} + +/** + * Represents a collection of granted permissions + * @static + */ +class Role { + constructor({ id, name, description, permissions }) { + this.id = id; + this.name = name; + this.description = description; + this.permissions = new Set(permissions); + } + + serialize() { + return { + id: this.id, + name: this.name, + description: this.description, + permissions: [...this.permissions], + }; + } + + grantDefaultPermissions() { + for (let permission of permissions.values()) { + if (permission.grantByDefault) { + this.permissions.add(permission.name); + } + } + } +} + + +/** + * Ensure the default admin role has full access + * + * Ensures the role with id 0 exisits and has the core.admin permission. + * + * @returns {module:lib/permission.Role} the default admin role + * @static + */ +function ensureDefaultAdminRole(roles) { + let admin = roles.get(0); + if (!admin) { + admin = new Role({ id: 0, name: "Admin", description: "Cluster wide administrator." }); + roles.set(0, admin); + } + admin.permissions.add("core.admin"); + return admin; +} + +/** + * Ensure the default player role has default accesses + * + * Ensures the role with id 1 exists and has all the permissions that are + * assigned by default. Note that this may not be the same role as the one + * that is assigned to new users by default. + * + * @returns {module:lib/permission.Role} the default player role + * @static + */ +function ensureDefaultPlayerRole(roles) { + let player = roles.get(1); + if (!player) { + player = new Role({ id: 1, name: "Player", description: "Default player role." }); + roles.set(1, player); + } + player.grantDefaultPermissions(); +} + + +/** + * Represeents a user in the cluster + * + * Holds data about a Factorio user in the cluster. + * @static + */ +class User { + constructor({ name, roles, tokenValidAfter }, loadedRoles) { + /** + * Factorio user name. + * @type {string} + */ + this.name = name; + + /** + * Instances this user is online on. + * @type {Set} + */ + this.instances = new Set(); + + /** + * Unix time in seconds the user token must be issued after to be valid. + * @type {number} + */ + this.tokenValidAfter = tokenValidAfter || 0; + + /** + * Roles this user has + * @type {Set role.id), + }; + + if (this.tokenValidAfter) { + serialized.tokenValidAfter = this.tokenValidAfter; + } + + return serialized; + } + + /** + * Generate access token for this user + * + * @param {string} secret - Secret to sign token with. + */ + createToken(secret) { + return jwt.sign({ aud: "user", user: this.name }, secret); + } + + /** + * Invalidate current tokens for the user + * + * Sets the tokenValidAfter property to the current time, which causes + * all currently issued tokens for the user to become invalid. + */ + invalidateToken(secret) { + this.tokenValidAfter = Math.floor(Date.now() / 1000); + } + + /** + * Check if a given permission is granted + * + * Checks the roles the user is member of for one that grants the given + * permission. If the permission is not granted for the user a + * "Permission denied" error is thrown. + * + * @throws {Error} if the user does noh have the given permission. + */ + checkPermission(permission) { + if (!permissions.has(permission)) { + throw new Error(`permission ${permission} does not exist`); + } + + for (let role of this.roles) { + if (role.permissions.has("core.admin") || role.permissions.has(permission)) { + return; + } + } + + throw new Error("Permission denied"); + } + + notifyJoin(instance_id) { + this.instances.add(instance_id); + User.onlineUsers.add(this); + } + + notifyLeave(instance_id) { + this.instances.delete(instance_id); + if (!this.instances.size) { + User.onlineUsers.delete(this); + } + } +} +User.onlineUsers = new Set(); + +// Definitions for the built in permissions used in Clusterio. +// description should answer "this permission allows you to ___" +definePermission({ + name: "core.admin", + title: "Administrator", + description: "Bypass all permission checks.", +}); +definePermission({ + name: "core.control.connect", + title: "Connect to WebSocket", + description: "Connect to the master's WebSocket control interface.", + grantByDefault: true, +}); + +definePermission({ + name: "core.slave.list", + title: "List slaves", + description: "Get the full list of slaves in the cluster.", + grantByDefault: true, +}); +definePermission({ + name: "core.slave.generate_token", + title: "Generate slave token", + description: "Generate tokens for slaves to connect to the cluster with.", +}); +definePermission({ + name: "core.slave.create_config", + title: "Create slave config", + description: "Create slave configs via the master server.", +}); + +definePermission({ + name: "core.instance.list", + title: "List instances", + description: "Get the full list of instances in the cluster.", + grantByDefault: true, +}); +definePermission({ + name: "core.instance.create", + title: "Create instance", + description: "Create new instances on the master server.", +}); +definePermission({ + name: "core.instance.get_config", + title: "Get instance config", + description: "Get the config of instances.", +}); +definePermission({ + name: "core.instance.update_config", + title: "Modify instance config", + description: "Modify the config or entries of the config for instances.", +}); +definePermission({ + name: "core.instance.assign", + title: "Assign instance", + description: "Assign or reassign instances to a slave.", +}); +definePermission({ + name: "core.instance.follow_log", + title: "Follow instance log", + description: "Listen for the stdout log of instances.", +}); +definePermission({ + name: "core.instance.create_save", + title: "Create new instance save", + description: "Create new savegames on instances.", +}); +definePermission({ + name: "core.instance.export_data", + title: "Export locale and icons", + description: "Export the the locale and icons from an instance and upload it to the master.", +}); +definePermission({ + name: "core.instance.start", + title: "Start instance", + description: "Start instances.", +}); +definePermission({ + name: "core.instance.stop", + title: "Stop instance", + description: "Stop instances.", +}); +definePermission({ + name: "core.instance.delete", + title: "Delete instance", + description: "Delete instances (includes config and all files stored like saves and logs).", +}); +definePermission({ + name: "core.instance.send_rcon", + title: "Send RCON", + description: "Send arbitrary RCON commands to instances.", +}); + +definePermission({ + name: "core.permission.list", + title: "List permissions", + description: "Get the full list of permissions in the cluster.", + grantByDefault: true, +}); + +definePermission({ + name: "core.role.list", + title: "List roles", + description: "Get the full list of roles and their permissions in the cluster.", + grantByDefault: true, +}); +definePermission({ + name: "core.role.create", + title: "Create role", + description: "Create new roles with permissions.", +}); +definePermission({ + name: "core.role.update", + title: "Update role", + description: "Edit existing roles including permissions they grant.", +}); +definePermission({ + name: "core.role.delete", + title: "Delete role", + description: "Permanently delete roles.", +}); + +definePermission({ + name: "core.user.list", + title: "List users", + description: "Get the full list of users in the cluster.", +}); +definePermission({ + name: "core.user.create", + title: "Create users", + description: "Create user acounts with a given name.", +}); +definePermission({ + name: "core.user.update_roles", + title: "Update user roles", + description: "Add or remove any role to any user.", +}); +definePermission({ + name: "core.user.delete", + title: "Delete user", + description: "Delete users and all data stored for them.", +}); + +definePermission({ + name: "core.debug.dump_ws", + title: "Dump WebSocket", + description: "Dump all WebSocket communicatation from the master.", +}); + +module.exports = { + Permission, + Role, + User, + + permissions, + definePermission, + ensureDefaultAdminRole, + ensureDefaultPlayerRole, +}; diff --git a/master.js b/master.js index 18de1aa0..f8210c09 100644 --- a/master.js +++ b/master.js @@ -46,9 +46,7 @@ const errors = require("lib/errors"); const plugin = require("lib/plugin"); const prometheus = require("lib/prometheus"); const config = require("lib/config"); - -// homemade express middleware for token auth -const authenticate = require("lib/authenticate"); +const users = require("lib/users"); const express = require("express"); const compression = require('compression'); @@ -78,7 +76,8 @@ app.set('views', ['views', 'plugins']); // give ejs access to some interesting information app.use(function(req, res, next){ - res.locals.root = masterConfig.get("master.web_root"); + let externalAddress = masterConfig.get("master.external_address"); + res.locals.root = externalAddress ? new URL(externalAddress).pathname : "/"; res.locals.res = res; res.locals.req = req; res.locals.masterPlugins = masterPlugins; @@ -173,6 +172,25 @@ async function getMetrics(req, res, next) { } app.get('/metrics', (req, res, next) => getMetrics(req, res, next).catch(next)); + +function validateSlaveToken(req, res, next) { + let token = req.header("x-access-token"); + if (!token) { + res.sendStatus(401); + return; + } + + try { + jwt.verify(token, masterConfig.get("master.auth_secret"), { audience: "slave" }); + + } catch (err) { + res.sendStatus(401); + return; + } + + next(); +} + // Handle an uploaded export package. async function uploadExport(req, res, next) { endpointHitCounter.labels(req.route.path).inc(); @@ -209,7 +227,7 @@ async function uploadExport(req, res, next) { res.sendStatus(200); } app.put("/api/upload-export", - authenticate.middleware, + validateSlaveToken, (req, res, next) => uploadExport(req, res, next).catch(next) ); @@ -257,6 +275,54 @@ async function loadInstances(databaseDirectory, file) { return instances; } +async function loadUsers(databaseDirectory, file) { + let loadedRoles = new Map(); + let loadedUsers = new Map(); + try { + let content = JSON.parse(await fs.readFile(path.join(databaseDirectory, file))); + for (let serializedRole of content.roles) { + let role = new users.Role(serializedRole); + loadedRoles.set(role.id, role); + } + + for (let serializedUser of content.users) { + let user = new users.User(serializedUser, loadedRoles); + loadedUsers.set(user.name, user); + } + + } catch (err) { + if (err.code !== "ENOENT") { + throw err; + } + + // Create default roles if loading failed + users.ensureDefaultAdminRole(loadedRoles); + users.ensureDefaultPlayerRole(loadedRoles); + } + + db.roles = loadedRoles; + db.users = loadedUsers; +} + +/** + * Create a new user + * + * Creates a new user instances and add it to the user database. + * + * @param {string} name - Name of the user to create. + * @returns {module:lib/users.User} newly created user. + */ +function createUser(name) { + if (db.users.has(name)) { + throw new Error(`User '${name}' already exists`) + } + + let defaultRoleId = masterConfig.get("master.default_role_id"); + user = new users.User({ name, roles: [defaultRoleId] }, db.roles); + db.users.set(name, user); + return user; +} + /** * Save Map to JSON file in the database directory. */ @@ -276,6 +342,25 @@ async function saveInstances(databaseDirectory, file, instances) { await fs.outputFile(filePath, JSON.stringify(serialized, null, 4)); } +async function saveUsers(databaseDirectory, file) { + let filePath = path.join(databaseDirectory, file); + let serializedRoles = []; + for (let role of db.roles.values()) { + serializedRoles.push(role.serialize()); + } + + let serializedUsers = []; + for (let user of db.users.values()) { + serializedUsers.push(user.serialize()); + } + + let serialized = { + users: serializedUsers, + roles: serializedRoles, + }; + await fs.outputFile(filePath, JSON.stringify(serialized, null, 4)); +} + /** * Innitiate shutdown of master server */ @@ -288,6 +373,7 @@ async function shutdown() { await saveMap(masterConfig.get('master.database_directory'), "slaves.json", db.slaves); await saveInstances(masterConfig.get('master.database_directory'), "instances.json", db.instances); + await saveUsers(masterConfig.get("master.database_directory"), "users.json"); await plugin.invokeHook(masterPlugins, "onShutdown"); @@ -442,12 +528,18 @@ class BaseConnection extends link.Link { let controlConnections = new Array(); class ControlConnection extends BaseConnection { - constructor(registerData, connector) { + constructor(registerData, connector, user) { super('control', connector) this._agent = registerData.agent; this._version = registerData.version; + /** + * The user making this connection. + * @type {module:lib/user.User} + */ + this.user = user; + this.connector.on("close", () => { let index = controlConnections.indexOf(this); if (index !== -1) { @@ -482,6 +574,32 @@ class ControlConnection extends BaseConnection { return { list }; } + generateSlaveToken(slaveId) { + return jwt.sign({ aud: "slave", slave: slaveId }, masterConfig.get("master.auth_secret")); + } + + async generateSlaveTokenRequestHandler(message) { + return { token: generateSlaveToken(message.data.slave_id) }; + } + + async createSlaveConfigRequestHandler(message) { + let slaveConfig = new config.SlaveConfig(); + await slaveConfig.init(); + + slaveConfig.set("slave.master_url", getMasterUrl()); + if (message.data.id !== null) { + slaveConfig.set("slave.id", message.data.id); + } + if (message.data.name !== null) { + slaveConfig.set("slave.name", message.data.name); + } + if (message.data.generate_token) { + this.user.checkPermission("core.slave.generate_token"); + slaveConfig.set("slave.master_token", this.generateSlaveToken(slaveConfig.get("slave.id"))); + } + return { serialized_config: slaveConfig.serialize() }; + } + async listInstancesRequestHandler(message) { let list = []; for (let instance of db.instances.values()) { @@ -602,6 +720,115 @@ class ControlConnection extends BaseConnection { this.instanceOutputSubscriptions = new Set(message.data.instance_ids); } + async listPermissionsRequestHandler(message) { + let list = []; + for (let permission of users.permissions.values()) { + list.push({ + name: permission.name, + title: permission.title, + description: permission.description, + }); + } + return { list }; + } + + async listRolesRequestHandler(message) { + let list = []; + for (let role of db.roles.values()) { + list.push({ + id: role.id, + name: role.name, + description: role.description, + permissions: [...role.permissions], + }); + } + return { list }; + } + + async createRoleRequestHandler(message) { + let lastId = Math.max.apply(null, [...db.roles.keys()]); + + // Start at 5 to leave space for future default roles + let id = Math.max(5, lastId+1); + db.roles.set(id, new users.Role({ id, ...message.data})); + return { id }; + } + + async updateRoleRequestHandler(message) { + let { id, name, description, permissions } = message.data; + let role = db.roles.get(id); + if (!role) { + throw new errors.RequestError(`Role with ID ${id} does not exist`); + } + + role.name = name; + role.description = description; + role.permissions = new Set(permissions); + } + + async grantDefaultRolePermissionsRequestHandler(message) { + let role = db.roles.get(message.data.id); + if (!role) { + throw new errors.RequestError(`Role with ID ${message.data.id} does not exist`); + } + + role.grantDefaultPermissions(); + } + + async deleteRoleRequestHandler(message) { + let id = message.data.id; + let role = db.roles.get(id); + if (!role) { + throw new errors.RequestError(`Role with ID ${id} does not exist`); + } + + db.roles.delete(id); + for (let user of db.users.values()) { + user.roles.delete(role); + } + } + + async listUsersRequestHandler(message) { + let list = []; + for (let user of db.users.values()) { + list.push({ + name: user.name, + roles: [...user.roles].map(role => role.id), + instances: [...user.instances], + }); + } + return { list }; + } + + async createUserRequestHandler(message) { + createUser(message.data.name); + } + + async updateUserRolesRequestHandler(message) { + let user = db.users.get(message.data.name); + if (!user) { + throw new errors.RequestError(`User '${message.data.name}' does not exist`); + } + + let resolvedRoles = new Set(); + for (let roleId of message.data.roles) { + let role = db.roles.get(roleId); + if (!role) { + throw new errors.RequestError(`Role with ID ${roleId} does not exist`); + } + + resolvedRoles.add(role); + } + + user.roles = resolvedRoles; + } + + async deleteUserRequestHandler(message) { + if (!db.users.delete(message.data.name)) { + throw new errors.RequestError(`User '${message.data.name}' does not exist`); + } + } + async debugDumpWsRequestHandler(message) { this.ws_dumper = data => { if (this.connector.connected) { @@ -722,6 +949,23 @@ class SlaveConnection extends BaseConnection { } } } + + async playerEventEventHandler(message) { + let {instance_id, name, type} = message.data; + let user = db.users.get(name); + if (!user) { + user = createUser(name); + } + + if (type === "join") { + user.notifyJoin(instance_id); + } else if (type === "leave") { + user.notifyLeave(instance_id); + } + + let instance = db.instances.get(instance_id); + await plugin.invokeHook(masterPlugins, "onPlayerEvent", instance, message.data); + } } const wss = new WebSocket.Server({ @@ -1049,12 +1293,41 @@ async function handleHandshake(message, socket, req, attachHandler) { return; } - let result = await authenticate.check(data.token); + // Validate token + let user; + try { + if (type === "register_slave") { + let tokenPayload = jwt.verify( + data.token, + masterConfig.get("master.auth_secret"), + { audience: "slave" } + ); - if (!result.ok) { + if (tokenPayload.slave !== data.id) { + throw new Error("missmatched slave id"); + } + + } else if (type === "register_control") { + let tokenPayload = jwt.verify( + data.token, + masterConfig.get("master.auth_secret"), + { audience: "user" } + ); + + user = db.users.get(tokenPayload.user); + if (!user) { + throw new Error("invalid user"); + } + if (tokenPayload.iat < user.tokenValidAfter) { + throw new Error("invalid token"); + } + user.checkPermission("core.control.connect"); + } + + } catch (err) { console.error(`SOCKET | authentication failed for ${req.socket.remoteAddress}`); wsRejectedConnectionsCounter.inc(); - socket.close(4003, "Authentication failed"); + socket.close(4003, `Authentication failed: ${err.message}`); return; } @@ -1076,7 +1349,7 @@ async function handleHandshake(message, socket, req, attachHandler) { } else if (type === "register_control") { console.log(`SOCKET | registered control from ${req.socket.remoteAddress}`); - controlConnections.push(new ControlConnection(data, connector)); + controlConnections.push(new ControlConnection(data, connector, user)); } connector.ready(sessionToken); @@ -1156,6 +1429,21 @@ function _setConfig(config) { masterConfig = config; } +/** + * Returns the URL needed to connect to the master server. + */ +function getMasterUrl() { + let url = masterConfig.get("master.external_address") + if (!url) { + if (masterConfig.get("master.https_port")) { + url = `https://localhost:${masterConfig.get("master.https_port")}/`; + } else { + url = `http://localhost:${masterConfig.get("master.http_port")}/`; + } + } + return url; +} + async function startServer() { // Set the process title, shows up as the title of the CMD window on windows // and as the process name in ps/top on linux. @@ -1175,6 +1463,17 @@ async function startServer() { type: "string", }) .command("config", "Manage Master config", config.configCommand) + .command("bootstrap", "Bootstrap access to cluster", yargs => { + yargs + .command("create-admin ", "Create a cluster admin") + .command("create-ctl-config ", "Create clusterctl config for the given user", yargs => { + yargs.option("output", { + describe: "Path to output config (- for stdout)", type: "string", + nargs: 1, default: "config-control.json", + }); + }) + .demandCommand(1, "You need to specify a command to run") + }) .command("run", "Run master server") .demandCommand(1, "You need to specify a command to run") .strict() @@ -1206,15 +1505,55 @@ async function startServer() { if (command === "config") { await config.handleConfigCommand(args, masterConfig, masterConfigPath); return; + + } else if (command === "bootstrap") { + let subCommand = args._[1]; + await loadUsers(masterConfig.get("master.database_directory"), "users.json"); + if (subCommand === "create-admin") { + if (!args.name) { + console.error("name cannot be blank"); + process.exitCode = 1; + return; + } + + let admin = db.users.get(args.name); + if (!admin) { + admin = createUser(args.name); + } + + let adminRole = users.ensureDefaultAdminRole(db.roles); + admin.roles.add(adminRole); + await saveUsers(masterConfig.get("master.database_directory"), "users.json"); + + } else if (subCommand === "create-ctl-config") { + let admin = db.users.get(args.name); + if (!admin) { + console.error(`No user named '${args.name}'`); + process.exitCode = 1; + return; + } + let controlConfig = new config.ControlConfig(); + await controlConfig.init(); + + controlConfig.set("control.master_url", getMasterUrl()); + controlConfig.set("control.master_token", admin.createToken(masterConfig.get("master.auth_secret"))); + + let content = JSON.stringify(controlConfig.serialize(), null, 4); + if (args.output === "-") { + console.log(content); + } else { + console.log(`Writing ${args.output}`); + await fs.outputFile(args.output, content); + } + } + + // Save config in case the auth_secret was generated during this invocation. + await fs.outputFile(masterConfigPath, JSON.stringify(masterConfig.serialize(), null, 4)); + return; } // If we get here the command was run - // write an auth token to file - fs.writeFileSync("secret-api-token.txt", jwt.sign({ id: "api" }, masterConfig.get('master.auth_secret'), { - expiresIn: 86400*365 // expires in 1 year - })); - let secondSigint = false process.on('SIGINT', () => { if (secondSigint) { @@ -1238,8 +1577,7 @@ async function startServer() { db.slaves = await loadMap(masterConfig.get('master.database_directory'), "slaves.json"); db.instances = await loadInstances(masterConfig.get('master.database_directory'), "instances.json"); - - authenticate.setAuthSecret(masterConfig.get('master.auth_secret')); + await loadUsers(masterConfig.get("master.database_directory"), "users.json"); // Make sure we're actually going to listen on a port let httpPort = masterConfig.get('master.http_port'); diff --git a/modules/clusterio/impl.lua b/modules/clusterio/impl.lua index 52303963..96164e22 100644 --- a/modules/clusterio/impl.lua +++ b/modules/clusterio/impl.lua @@ -23,6 +23,16 @@ impl.events[api.events.on_server_startup] = function() game.disable_replay() end +impl.events[defines.events.on_player_joined_game] = function(event) + local player = game.players[event.player_index] + api.send_json("player_event", { type = "join", name = player.name }) +end + +impl.events[defines.events.on_player_left_game] = function(event) + local player = game.players[event.player_index] + api.send_json("player_event", { type = "leave", name = player.name }) +end + -- Internal API clusterio_private = {} function clusterio_private.update_instance(new_id, new_name) diff --git a/plugins/subspace_storage/master.js b/plugins/subspace_storage/master.js index 928757b7..b688fb14 100644 --- a/plugins/subspace_storage/master.js +++ b/plugins/subspace_storage/master.js @@ -30,7 +30,8 @@ const masterInventoryGauge = new prometheus.Gauge( class MasterPlugin extends plugin.BaseMasterPlugin { async init() { - let root = this.master.config.get("master.web_root"); + let externalAddress = this.master.config.get("master.external_address"); + let root = externalAddress ? new URL(externalAddress).pathname : "/"; this.ui = { sidebar: [ { diff --git a/plugins/subspace_storage/routes.js b/plugins/subspace_storage/routes.js index de0687c6..301cc371 100644 --- a/plugins/subspace_storage/routes.js +++ b/plugins/subspace_storage/routes.js @@ -1,7 +1,6 @@ const express = require("express"); const path = require("path"); -const authenticate = require("lib/authenticate"); const prometheus = require("lib/prometheus"); diff --git a/slave.js b/slave.js index bbca54f8..8e949460 100644 --- a/slave.js +++ b/slave.js @@ -156,6 +156,14 @@ class Instance extends link.Link{ console.error("Error handling autosave-finished:", err); }); }); + + this.server.on("ipc-player_event", event => { + link.messages.playerEvent.send(this, { + instance_id: this.config.get("instance.id"), + ...event, + }); + plugin.invokeHook(this.plugins, "onPlayerEvent", event); + }); } async _autosave(name) { @@ -1181,10 +1189,10 @@ async function startSlave() { if (slaveConfig.get("slave.master_token") === "enter token here") { console.error("ERROR invalid config!"); console.error( - "Master server now needs an access token for write operations. As clusterio\n"+ + "Master server requires an access token for socket operations. As clusterio\n"+ "slaves depends upon this, please set your token using the command node slave\n"+ - "config set slave.master_token . You can retrieve your auth token from\n"+ - "the master in secret-api-token.txt after running it once." + "config set slave.master_token . You can generate an auth token using\n"+ + "using node clusterctl generate-slave-token." ); process.exitCode = 1; return; diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 6b507dcb..18fe1b80 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -4,7 +4,9 @@ const path = require("path"); const validateHTML = require('html5-validator'); const parallel = require('mocha.parallel'); -const { slowTest, get, exec, sendRcon, controlConfigPath, instancesDir } = require("./index"); +const link = require("lib/link"); + +const { slowTest, get, exec, sendRcon, getControl, controlConfigPath, instancesDir } = require("./index"); describe("Integration of Clusterio", function() { @@ -182,5 +184,77 @@ describe("Integration of Clusterio", function() { assert(!await fs.exists(path.join(instancesDir, "test")), "Instance was not deleted"); }); }); + + describe("list-permissions", function() { + it("runs", async function() { + await exec(`node clusterctl --config ${controlConfigPath} list-permissions`); + }); + }); + + describe("list-roles", function() { + it("runs", async function() { + await exec(`node clusterctl --config ${controlConfigPath} list-roles`); + }); + }); + + describe("create-role", function() { + it("should create the given role", async function() { + await exec(`node clusterctl --config ${controlConfigPath} create-role --name temp --description "A temp role" --permissions core.control.connect`); + 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() { + it("should modify the given role", async function() { + await exec(`node clusterctl --config ${controlConfigPath} edit-role --role temp --name new --description "A new role" --permissions`); + 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() { + it("should modify the given role", async function() { + await exec(`node clusterctl --config ${controlConfigPath} delete-role --role 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() { + it("runs", async function() { + await exec(`node clusterctl --config ${controlConfigPath} list-users`); + }); + }); + + describe("create-user", function() { + it("should create the given user", async function() { + await exec(`node clusterctl --config ${controlConfigPath} create-user --name 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() { + it("should set the roles on the user", async function() { + await exec(`node clusterctl --config ${controlConfigPath} edit-user-roles --name temp --roles 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() { + it("should delete the user", async function() { + await exec(`node clusterctl --config ${controlConfigPath} delete-user --name 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 05bb92f5..e8fe86ce 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -54,7 +54,7 @@ let slaveProcess; let control; let url = "https://localhost:4443/"; -let token = jwt.sign({ id: "api" }, "TestSecretDoNotUse"); +let controlToken = jwt.sign({ aud: "user", user: "test" }, "TestSecretDoNotUse"); let instancesDir = path.join("temp", "test", "instances"); let databaseDir = path.join("temp", "test", "databse"); let masterConfigPath = path.join("temp", "test", "master-integration.json"); @@ -71,6 +71,10 @@ async function sendRcon(instanceId, command) { return response.result; } +function getControl() { + return control; +} + function spawn(name, cmd, waitFor) { return new Promise((resolve, reject) => { console.log(cmd); @@ -111,20 +115,18 @@ before(async function() { await exec(`node master --config ${masterConfigPath} config set master.heartbeat_interval 0.25`); await exec(`node master --config ${masterConfigPath} config set master.connector_shutdown_timeout 2`); - await exec(`node slave --config ${slaveConfigPath} config set slave.id 4`); - await exec(`node slave --config ${slaveConfigPath} config set slave.name slave`); - await exec(`node slave --config ${slaveConfigPath} config set slave.instances_directory ${instancesDir}`); - await exec(`node slave --config ${slaveConfigPath} config set slave.master_url "${url}"`); - await exec(`node slave --config ${slaveConfigPath} config set slave.master_token "${token}"`); - - await exec(`node clusterctl --config ${controlConfigPath} control-config set control.master_url "https://localhost:4443/"`); - await exec(`node clusterctl --config ${controlConfigPath} control-config set control.master_token "${token}"`); + await exec(`node master --config ${masterConfigPath} bootstrap create-admin test`); + await exec(`node master --config ${masterConfigPath} bootstrap create-ctl-config test --output ${controlConfigPath}`); masterProcess = await spawn("master:", `node master --config ${masterConfigPath} run`, "All plugins loaded"); + + await exec(`node clusterctl --config ${controlConfigPath} create-slave-config --id 4 --name slave --generate-token --output ${slaveConfigPath}`); + 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"); let controlConnector = new TestControlConnector(url, 2); - controlConnector.token = token; + controlConnector.token = controlToken; control = new TestControl(controlConnector); await controlConnector.connect(); }); @@ -157,10 +159,9 @@ module.exports = { get, exec, sendRcon, - control, + getControl, url, - token, instancesDir, databaseDir, masterConfigPath, diff --git a/test/integration/link.js b/test/integration/link.js index c160fc30..ed3e5196 100644 --- a/test/integration/link.js +++ b/test/integration/link.js @@ -1,8 +1,10 @@ const assert = require("assert").strict; const events = require("events"); +const jwt = require("jsonwebtoken"); -const { TestControlConnector, TestControl, get, exec, url, token } = require("./index"); +const { TestControlConnector, TestControl, get, exec, url } = require("./index"); +let token = jwt.sign({ aud: "user", user: "test" }, "TestSecretDoNotUse"); describe("Integration of lib/link", function() { let control; diff --git a/test/lib/link/messages.js b/test/lib/link/messages.js index 33997cef..f1e3ebf6 100644 --- a/test/lib/link/messages.js +++ b/test/lib/link/messages.js @@ -24,7 +24,7 @@ describe("lib/link/messages", function() { describe("constructor", function() { it("should throw on invalid forwardTo", function() { assert.throws( - () => new link.Request({ forwardTo: 'invalid'}), + () => new link.Request({ links: [], forwardTo: 'invalid'}), new Error("Invalid forwardTo value invalid") ); }); diff --git a/test/lib/users.js b/test/lib/users.js new file mode 100644 index 00000000..09c37f16 --- /dev/null +++ b/test/lib/users.js @@ -0,0 +1,125 @@ +const assert = require("assert").strict; +const users = require("lib/users"); + + +describe("lib/users", function() { + describe("definePermission()", function() { + it("should validate the types of its arguments", function() { + assert.throws( + () => users.definePermission({ name: 123, title: "Test", description: "A test" }), + new Error("Expected name to be a non-empty string") + ); + assert.throws( + () => users.definePermission({ name: "test", title: 123, description: "A test" }), + new Error("Expected title to be a non-empty string") + ); + assert.throws( + () => users.definePermission({ name: "test", title: "Test", description: 123 }), + new Error("Expected description to be a non-empty string") + ); + assert.throws( + () => users.definePermission({ name: "test", title: "Test", description: "A test", grantByDefault: 1 }), + new Error("Expected grantByDefault to be a boolean") + ); + }); + + it("should define a permission", function() { + users.definePermission({ name: "test", title: "Test", description: "A test" }); + assert(users.permissions.has("test"), "Permission was not defined"); + + let test = users.permissions.get("test"); + assert.equal(test.name, "test"); + assert.equal(test.title, "Test"); + assert.equal(test.description, "A test"); + assert.equal(test.grantByDefault, false); + }); + + it("should throw on already defined permission", function() { + assert.throws( + () => users.definePermission({ name: "test", title: "Test", description: "A test" }), + new Error("Permission 'test' is already defined") + ); + }); + }); + + describe("class Role", function() { + it("should round trip serialize", function() { + let orig = new users.Role({ id: 11, name: "Role", description: "My Role", permissions: ["test"] }); + let copy = new users.Role(orig.serialize()); + assert.deepEqual(copy, orig); + }); + + describe(".grantDefaultRoles()", function() { + it("should only grant permissions with grantByDefault", function() { + let role = new users.Role({ id: 11, name: "Role", description: "My Role" }); + role.grantDefaultPermissions(); + assert(role.permissions.size > 0, "No permissions were granted"); + for (let permission of role.permissions) { + assert( + users.permissions.get(permission).grantByDefault === true, + "Non-default permission granted" + ); + } + }); + }); + }); + + describe("class User", function() { + let roles = new Map([ + [1, new users.Role({ id: 1, name: "a", description: "a role", permissions: ["core.admin"] })], + [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); + + orig = new users.User({ name: "user", roles: [2], tokenValidAfter: 12345 }, roles); + copy = new users.User(orig.serialize(), roles); + assert.deepEqual(copy, orig); + }); + it("should track online users", function() { + let user = new users.User({ name: "admin", roles: [1] }, roles); + assert(!users.User.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set()); + + user.notifyJoin(12); + assert(users.User.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set([12])); + + user.notifyJoin(8); + assert(users.User.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set([12, 8])); + + user.notifyLeave(11); + assert(users.User.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set([12, 8])); + + user.notifyLeave(12); + assert(users.User.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set([8])); + + user.notifyLeave(8); + assert(!users.User.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set()); + }); + + describe(".checkPermission()", function() { + it("should correctly resolve permissions", function() { + let a = new users.User({ name: "admin", roles: [1] }, roles); + let b = new users.User({ name: "user", roles: [2] }, roles); + let c = new users.User({ name: "null", roles: [] }, roles); + + a.checkPermission("core.control.connect"); + assert.throws(() => b.checkPermission("core.control.connect"), new Error("Permission denied")); + assert.throws(() => c.checkPermission("core.control.connect"), new Error("Permission denied")); + + a.checkPermission("test"); + b.checkPermission("test"); + assert.throws(() => c.checkPermission("test"), new Error("Permission denied")); + + assert.throws(() => a.checkPermission("invalid"), new Error("permission invalid does not exist")); + }); + }); + }); +});