mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 15:24:52 +09:00
Merge pull request #299 from Hornwitser/user-management
User management and authentication rework
This commit is contained in:
21 files changed
+1566
-195
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
|
||||
|
||||
|
||||
+232
-76
@@ -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,80 +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");
|
||||
}
|
||||
|
||||
|
||||
class ControlGroup extends config.ConfigGroup {}
|
||||
ControlGroup.groupName = "control";
|
||||
ControlGroup.define({
|
||||
name: "master_url",
|
||||
description: "URL to connect to the master server at",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
ControlGroup.define({
|
||||
name: "master_token",
|
||||
description: "Token to authenticate to master server with.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
ControlGroup.define({
|
||||
name: "reconnect_delay",
|
||||
title: "Reconnect Delay",
|
||||
description: "Maximum delay to wait before attempting to reconnect WebSocket",
|
||||
type: "number",
|
||||
initial_value: 2,
|
||||
});
|
||||
ControlGroup.finalize();
|
||||
|
||||
class ControlConfig extends config.Config { }
|
||||
ControlConfig.registerGroup(ControlGroup);
|
||||
ControlConfig.finalize();
|
||||
|
||||
|
||||
async function startControl() {
|
||||
yargs
|
||||
@@ -525,7 +679,7 @@ async function startControl() {
|
||||
config.finalizeConfigs();
|
||||
|
||||
console.log(`Loading config from ${args.config}`);
|
||||
let controlConfig = new ControlConfig();
|
||||
let controlConfig = new config.ControlConfig();
|
||||
try {
|
||||
await controlConfig.load(JSON.parse(await fs.readFile(args.config)));
|
||||
|
||||
@@ -547,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,
|
||||
};
|
||||
+52
-10
@@ -23,12 +23,6 @@ MasterGroup.define({
|
||||
type: "string",
|
||||
initial_value: "database",
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "factorio_directory",
|
||||
description: "Path to directory to look for factorio installs",
|
||||
type: "string",
|
||||
initial_value: "factorio"
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "http_port",
|
||||
title: "HTTP Port",
|
||||
@@ -45,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",
|
||||
@@ -103,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();
|
||||
|
||||
/**
|
||||
@@ -258,6 +260,43 @@ InstanceConfig.registerGroup(InstanceGroup);
|
||||
InstanceConfig.registerGroup(FactorioGroup);
|
||||
|
||||
|
||||
/**
|
||||
* Control config group for {@link module:lib/config.ControlConfig}
|
||||
* @extends module:lib/config.ConfigGroup
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class ControlGroup extends classes.ConfigGroup {}
|
||||
ControlGroup.groupName = "control";
|
||||
ControlGroup.define({
|
||||
name: "master_url",
|
||||
description: "URL to connect to the master server at",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
ControlGroup.define({
|
||||
name: "master_token",
|
||||
description: "Token to authenticate to master server with.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
ControlGroup.define({
|
||||
name: "reconnect_delay",
|
||||
title: "Reconnect Delay",
|
||||
description: "Maximum delay to wait before attempting to reconnect WebSocket",
|
||||
type: "number",
|
||||
initial_value: 2,
|
||||
});
|
||||
ControlGroup.finalize();
|
||||
|
||||
/**
|
||||
* Control config class
|
||||
* @extends module:lib/config.Config
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class ControlConfig extends classes.Config { }
|
||||
ControlConfig.registerGroup(ControlGroup);
|
||||
|
||||
|
||||
function validateGroup(pluginInfo, groupName) {
|
||||
if (!pluginInfo[groupName] instanceof classes.PluginConfigGroup) {
|
||||
throw new Error(
|
||||
@@ -314,6 +353,7 @@ function finalizeConfigs() {
|
||||
MasterConfig.finalize();
|
||||
SlaveConfig.finalize();
|
||||
InstanceConfig.finalize();
|
||||
ControlConfig.finalize();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,10 +427,12 @@ module.exports = {
|
||||
SlaveGroup,
|
||||
InstanceGroup,
|
||||
FactorioGroup,
|
||||
ControlGroup,
|
||||
|
||||
MasterConfig,
|
||||
SlaveConfig,
|
||||
InstanceConfig,
|
||||
ControlConfig,
|
||||
|
||||
registerPluginConfigGroups,
|
||||
finalizeConfigs,
|
||||
|
||||
@@ -97,7 +97,9 @@ class WebSocketBaseConnector extends events.EventEmitter {
|
||||
* @returns the sequence number of the message sent
|
||||
*/
|
||||
send(type, data) {
|
||||
this._check(["handshake", "connected", "closing"]);
|
||||
if (!["handshake", "connected", "closing"].includes(this._state)) {
|
||||
throw new errors.SessionLost("No session");
|
||||
}
|
||||
let message = { seq: this._seq, type, data };
|
||||
this._sendBuffer.push(message);
|
||||
if (["connected", "closing"].includes(this._state)) {
|
||||
@@ -271,7 +273,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
console.log(`SOCKET | Close (code: ${code}, reason: ${reason})`);
|
||||
// Authentication failed
|
||||
if (code === 4003) {
|
||||
this.emit("error", new errors.AuthenticationFailed("Authentication failed"));
|
||||
this.emit("error", new errors.AuthenticationFailed(reason));
|
||||
this._state = "closing";
|
||||
}
|
||||
|
||||
|
||||
+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();
|
||||
@@ -187,7 +205,7 @@ async function uploadExport(req, res, next) {
|
||||
}
|
||||
data = Buffer.concat(data);
|
||||
let zip = await JSZip.loadAsync(data);
|
||||
delete data;
|
||||
data = null;
|
||||
|
||||
// This is hardcoded to prevent path expansion attacks
|
||||
let exportFiles = [
|
||||
@@ -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;
|
||||
@@ -1275,14 +1283,19 @@ I version of clusterio. Expect things to break. I
|
||||
`
|
||||
);
|
||||
startSlave().catch(err => {
|
||||
console.error(`
|
||||
if (err instanceof errors.AuthenticationFailed) {
|
||||
console.error(err.message);
|
||||
|
||||
} else {
|
||||
console.error(`
|
||||
+--------------------------------------------------------------+
|
||||
| Unexpected error occured while starting slave, please report |
|
||||
| it to https://github.com/clusterio/factorioClusterio/issues |
|
||||
+--------------------------------------------------------------+`
|
||||
);
|
||||
);
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -13,7 +15,7 @@ describe("Integration of lib/link", function() {
|
||||
control = new TestControl(controlConnector);
|
||||
await assert.rejects(
|
||||
controlConnector.connect(),
|
||||
new Error("Authentication failed")
|
||||
new Error("Authentication failed: jwt malformed")
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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