mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Add user and permission system
Add users, roles and permissions and rework the authentication to use separate user and slave tokens for control and slave connections respectively. Add commands for listing permissions, roles and users, and creating, editing and deleting roles and users.
This commit is contained in:
1 parent
2acf57d23d
commit
cf6f2cb70a
20 files changed
+1511
-153
No files matched your search
@@ -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
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -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 `<username>` with your Factorio username (do not make
|
||||
up a new username here).
|
||||
|
||||
node master bootstrap create-admin <username>
|
||||
node master bootstrap create-ctl-config <username>
|
||||
|
||||
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 "<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
|
||||
|
||||
|
||||
+231
-47
@@ -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(
|
||||
|
||||
@@ -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.</sub>
|
||||
|
||||
|
||||
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.
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
/**
|
||||
|
||||
+208
-1
@@ -43,6 +43,9 @@ class Request extends Message {
|
||||
* @param {Array<string>} links -
|
||||
* Links this request is valid on. Takes an array of strings
|
||||
* containing "<source>-<target>" 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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
+399
@@ -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<number>}
|
||||
*/
|
||||
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<module:lib/permissions.Role}
|
||||
*/
|
||||
this.roles = new Set();
|
||||
if (roles) {
|
||||
for (let roleId of roles) {
|
||||
let role = loadedRoles.get(roleId);
|
||||
if (role) {
|
||||
this.roles.add(role);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serialize() {
|
||||
let serialized = {
|
||||
name: this.name,
|
||||
roles: [...this.roles].map(role => 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,
|
||||
};
|
||||
@@ -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 <name>", "Create a cluster admin")
|
||||
.command("create-ctl-config <name>", "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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
|
||||
const authenticate = require("lib/authenticate");
|
||||
const prometheus = require("lib/prometheus");
|
||||
|
||||
|
||||
|
||||
@@ -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 <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 <token>. You can generate an auth token using\n"+
|
||||
"using node clusterctl generate-slave-token."
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+13
-12
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user