Merge pull request #480 from Hornwitser/small-things-10

Small things 10
This commit is contained in:
Hornwitser authored and GitHub committed 2023-02-22 15:28:34 +01:00
commit 6ae4c8ef46
19 files changed
+260 -48

No files matched your search

+9
View File
@@ -1480,6 +1480,15 @@ userCommands.add(new libCommand.Command({
},
}));
userCommands.add(new libCommand.Command({
definition: ["revoke-token <name>", "Revoke token for user", (yargs) => {
yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" });
}],
handler: async function(args, control) {
await libLink.messages.revokeUserToken.send(control, { name: args.name });
},
}));
userCommands.add(new libCommand.Command({
definition: ["set-admin <user>", "Promote or demote a user to admin", (yargs) => {
yargs.positional("user", { describe: "Name of user set admin status for", type: "string" });
+25
View File
@@ -677,6 +677,12 @@ class ConfigGroup {
* @param {Array=} item.enum -
* Array of all values that are acceptible. Useful for making
* enumerated values.
* @param {boolean=} item.restartRequired -
* True if a restart of the entity this config is attached to is
* required for changes to take effect. Informative only, defaults
* to false.
* @param {Array=} item.restartRequiredProps -
* Properties to invert the value of restartRequired for if present.
* @param {boolean=} item.optional -
* True if this field can be set to null. Defaults to false.
* @param {*=} item.initial_value -
@@ -706,6 +712,7 @@ class ConfigGroup {
// Validate properties
let validTypes = ["boolean", "string", "number", "object"];
// eslint-disable-next-line complexity
Object.keys(item).forEach(key => {
let value = item[key];
let valueType = basicType(value);
@@ -746,6 +753,18 @@ class ConfigGroup {
}
break;
case "restartRequired":
if (valueType !== "boolean") {
throw new Error("restartRequired must be a boolean");
}
break;
case "restartRequiredProps":
if (valueType !== "array") {
throw new Error("restartRequiredProps must be a array");
}
break;
case "optional":
if (valueType !== "boolean") {
throw new Error("optional must be a boolean");
@@ -771,9 +790,14 @@ class ConfigGroup {
access: this.defaultAccess,
optional: false,
fullName: `${this.groupName}.${item.name}`,
restartRequired: false,
...item,
};
if (item.restartRequiredProps && item.type !== "object") {
throw new Error("restartRequiredProps is only allowed if type is object");
}
if (!computed.access) {
throw new Error(`access not set and no defaultAccess defined for ${this.name}`);
}
@@ -815,6 +839,7 @@ class PluginConfigGroup extends ConfigGroup {
this.define({
name: "load_plugin",
title: "Load Plugin",
restartRequired: true,
type: "boolean",
initial_value: true,
});
+28 -1
View File
@@ -37,6 +37,7 @@ MasterGroup.define({
name: "http_port",
title: "HTTP Port",
description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.",
restartRequired: true,
type: "number",
optional: true,
initial_value: 8080,
@@ -45,6 +46,7 @@ MasterGroup.define({
name: "https_port",
title: "HTTPS Port",
description: "Port to listen for HTTPS connection on, set to null to not listen for HTTPS connections.",
restartRequired: true,
type: "number",
optional: true,
});
@@ -52,6 +54,7 @@ MasterGroup.define({
name: "bind_address",
title: "Bind Address",
description: "IP address to bind the HTTP and HTTPS ports on.",
restartRequired: true,
type: "string",
optional: true,
});
@@ -66,6 +69,7 @@ MasterGroup.define({
name: "tls_certificate",
title: "TLS Certificate",
description: "Path to the certificate to use for HTTPS.",
restartRequired: true,
type: "string",
optional: true,
});
@@ -73,6 +77,7 @@ MasterGroup.define({
name: "tls_private_key",
title: "TLS Private Key",
description: "Path to the private key to use for HTTPS.",
restartRequired: true,
type: "string",
optional: true,
});
@@ -84,6 +89,7 @@ MasterGroup.define({
"Secret used to generate and verify authentication tokens. " +
"Should be a long string of random letters and numbers. " +
"Do not share this.",
restartRequired: true,
type: "string",
optional: true,
});
@@ -184,24 +190,28 @@ SlaveGroup.define({
SlaveGroup.define({
name: "instances_directory",
description: "Path to directory to store instances in.",
restartRequired: true,
type: "string",
initial_value: "instances",
});
SlaveGroup.define({
name: "master_url",
description: "URL to connect to the master server at",
restartRequired: true,
type: "string",
initial_value: "http://localhost:8080/",
});
SlaveGroup.define({
name: "master_token",
description: "Token to authenticate to master server with.",
restartRequired: true,
type: "string",
initial_value: "enter token here",
});
SlaveGroup.define({
name: "tls_ca",
description: "Path to Certificate Authority to validate TLS connection to master against.",
restartRequired: true,
type: "string",
optional: true,
});
@@ -244,7 +254,7 @@ InstanceGroup.define({
});
InstanceGroup.define({
name: "id",
description: "ID of the slave",
description: "ID of the instance",
type: "number",
initial_value: () => Math.random() * 2**31 | 0,
});
@@ -272,24 +282,28 @@ FactorioGroup.groupName = "factorio";
FactorioGroup.define({
name: "version",
description: "Version of the game to run, use latest to run the latest installed version",
restartRequired: true,
type: "string",
initial_value: "latest",
});
FactorioGroup.define({
name: "game_port",
description: "UDP port to run game on, uses a random port if null",
restartRequired: true,
type: "number",
optional: true,
});
FactorioGroup.define({
name: "rcon_port",
description: "TCP port to run RCON on, uses a random port if null",
restartRequired: true,
type: "number",
optional: true,
});
FactorioGroup.define({
name: "rcon_password",
description: "Password for RCON, randomly generated if null",
restartRequired: true,
type: "string",
optional: true,
});
@@ -304,12 +318,14 @@ FactorioGroup.define({
FactorioGroup.define({
name: "mod_pack",
description: "Mod pack to use on this server",
restartRequired: true,
type: "number",
optional: true,
});
FactorioGroup.define({
name: "enable_save_patching",
description: "Patch saves with Lua code. Required for Clusterio integrations, lua modules, and most plugins.",
restartRequired: true,
type: "boolean",
initial_value: true,
});
@@ -322,24 +338,34 @@ FactorioGroup.define({
FactorioGroup.define({
name: "enable_authserver_bans",
description: "Turn on Factorio.com based multiplayer bans.",
restartRequired: true,
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "settings",
description: "Settings overridden in server-settings.json",
restartRequired: true,
restartRequiredProps: [
"afk_autokick_interval", "allow_commands", "autosave_interval", "autosave_only_on_server", "description",
"ignore_player_limit_for_returning_players", "max_players", "max_upload_slots",
"max_upload_in_kilobytes_per_second", "name", "only_admins_can_pause_the_game", "game_password",
"require_user_verification", "tags", "visibility",
],
type: "object",
initial_value: {}, // See create instance handler in master.
});
FactorioGroup.define({
name: "verbose_logging",
description: "Enable verbose logging on the Factorio server",
restartRequired: true,
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "strip_paths",
description: "Strip down instance paths in the log",
restartRequired: true,
type: "boolean",
initial_value: true,
});
@@ -364,6 +390,7 @@ FactorioGroup.define({
FactorioGroup.define({
name: "max_concurrent_commands",
description: "Maximum number of RCON commands trasmitted in parallel",
restartRequired: true,
type: "number",
initial_value: 5,
});
+8 -2
View File
@@ -251,8 +251,14 @@ async function patch(savePath, modules) {
// Write back the save
let tempSavePath = `${savePath}.tmp`;
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
let pipe = stream.pipe(fs.createWriteStream(tempSavePath));
await events.once(pipe, "finish");
let fd = await fs.open(tempSavePath, "w");
try {
let pipe = stream.pipe(fs.createWriteStream("", { fd, autoClose: false }));
await events.once(pipe, "finish");
await fs.fsync(fd);
} finally {
await fs.close(fd);
}
await fs.rename(tempSavePath, savePath);
}
+9
View File
@@ -1036,6 +1036,15 @@ messages.createUser = new Request({
},
});
messages.revokeUserToken = new Request({
type: "revoke_user_token",
links: ["control-master"],
permission: "core.user.revoke_token",
requestProperties: {
"name": { type: "string" },
},
});
messages.updateUserRoles = new Request({
type: "update_user_roles",
links: ["control-master"],
+11
View File
@@ -637,6 +637,17 @@ definePermission({
title: "Create users",
description: "Create user acounts with a given name.",
});
definePermission({
name: "core.user.revoke_token",
title: "Revoke user token",
description: "Revoke access token and terminate all existing control connections for oneself.",
grantByDefault: true,
});
definePermission({
name: "core.user.revoke_other_token",
title: "Revoke other user's token",
description: "Allow revoking tokens for other users than oneself. Requires Revoke user token.",
});
definePermission({
name: "core.user.update_roles",
title: "Update user roles",
+23 -5
View File
@@ -20,9 +20,9 @@ const routes = require("./routes");
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
const lastQueryLogTime = new libPrometheus.Gauge(
"clusterio_master_last_query_log_duration_seconds",
"Time in seconds the last log query took to execute."
const queryLogTime = new libPrometheus.Summary(
"clusterio_master_query_log_duration_seconds",
"Time in seconds log queries took to execute."
);
/**
@@ -683,7 +683,7 @@ class ControlConnection extends BaseConnection {
}
async queryLogRequestHandler(message) {
let setDuration = lastQueryLogTime.startTimer();
let observeDuration = queryLogTime.startTimer();
let { all, master, slave_ids, instance_ids } = message.data;
let log;
@@ -693,7 +693,7 @@ class ControlConnection extends BaseConnection {
log = await this._master.queryClusterLog(message.data);
}
setDuration();
observeDuration();
return { log };
}
@@ -833,6 +833,24 @@ class ControlConnection extends BaseConnection {
this._master.userUpdated(user);
}
async revokeUserTokenRequestHandler(message) {
let user = this._master.userManager.users.get(message.data.name);
if (!user) {
throw new libErrors.RequestError(`User '${message.data.name}' does not exist`);
}
if (user.name !== this.user.name) {
this.user.checkPermission("core.user.revoke_other_token");
}
user.invalidateToken();
for (let controlConnection of this._master.wsServer.controlConnections) {
if (controlConnection.user.name === user.name) {
controlConnection.connector.terminate();
}
}
this._master.userUpdated(user);
}
async updateUserRolesRequestHandler(message) {
let user = this._master.userManager.users.get(message.data.name);
if (!user) {
+2 -2
View File
@@ -45,8 +45,8 @@ class Master {
constructor(clusterLogger, pluginInfos, configPath, config) {
this.clusterLogger = clusterLogger;
/**
* Mapping of plugin name to info objects for known plugins
* @type {Map<string, Object>}
* Array of plugin info objects for known plugins
* @type {Array<object>}
*/
this.pluginInfos = pluginInfos;
this.configPath = configPath;
+25
View File
@@ -23,6 +23,7 @@ class SlaveConnection extends BaseConnection {
this._name = registerData.name;
this._version = registerData.version;
this.plugins = new Map(Object.entries(registerData.plugins));
this._checkPluginVersions();
this._master.slaves.set(this._id, {
agent: this._agent,
@@ -57,6 +58,30 @@ class SlaveConnection extends BaseConnection {
});
}
_checkPluginVersions() {
let pluginInfos = new Map(this._master.pluginInfos.map(i => [i.name, i]));
for (let [name, version] of this.plugins) {
let info = pluginInfos.get(name);
if (!info) {
logger.warn(`Slave ${this._name} has plugin ${name} ${version} which the master does not have`);
continue;
}
if (info.version !== version) {
logger.warn(
`Slave ${this._name} has plugin ${name} ${version} which does not match the version of this ` +
`plugin on the master (${info.version})`
);
}
}
for (let [name, info] of pluginInfos) {
if (!this.plugins.has(name)) {
logger.warn(`Slave ${this._name} is missing plugin ${name} ${info.version}`);
}
}
}
/**
* ID of the slave this connection is connected to
*
+10 -1
View File
@@ -269,7 +269,16 @@ ${err.stack}`
await connection.disconnect(1008, "Registered from another connection");
}
logger.verbose(`WsServer | registered slave ${data.id} version ${data.version}`);
logger.info(
`WsServer | registered slave ${data.name} (${data.id}) using agent ${data.agent} ${data.version}`
);
if (data.agent === "Clusterio Slave" && data.version !== packageVersion) {
logger.warn(
`Slave ${data.name} (${data.id}) connected using version ${data.version} which does not match ` +
`the version of the master is currently running (${packageVersion}). It may not work as expected.`
);
}
connection = new SlaveConnection(data, connector, this.master);
connector.on("close", () => {
if (this.slaveConnections.get(data.id) === connection) {
+14
View File
@@ -191,6 +191,20 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
await events.once(this, "close");
}
/**
* Forcefully close the connection immediately
*
* This should only be used if there's a security implication from
* letting the connection carry on with a normal close proceedure.
*/
terminate() {
this.setClosing();
if (this._socket) {
this._socket.terminate();
}
}
/**
* Notify the connection is expected to close
*
+7 -4
View File
@@ -1,16 +1,18 @@
local api = require('modules/clusterio/api')
local impl = {}
impl.events = {}
impl.events[defines.events.on_tick] = function()
local function check_patch()
if global.clusterio_patch_number ~= clusterio_patch_number then
global.clusterio_patch_number = clusterio_patch_number
script.raise_event(api.events.on_server_startup, {})
end
end
local impl = {}
impl.events = {}
impl.events[defines.events.on_tick] = check_patch
impl.events[api.events.on_server_startup] = function()
if not global.clusterio then
global.clusterio = {
@@ -47,6 +49,7 @@ end
-- Internal API
clusterio_private = {}
function clusterio_private.update_instance(new_id, new_name)
check_patch()
global.clusterio.instance_id = new_id
global.clusterio.instance_name = new_name
script.raise_event(api.events.on_instance_updated, {
-3
View File
@@ -136,9 +136,6 @@ async function startSlave() {
logger.info(`Starting Clusterio slave ${version}`);
}
// add better stack traces on promise rejection
process.on("unhandledRejection", err => logger.error(`Unhandled rejection:\n${err.stack}`));
logger.info(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
+1 -1
View File
@@ -522,7 +522,7 @@ class Slave extends libLink.Link {
);
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${old_name} does not exist`);
throw new libErrors.RequestError(`${source} does not exist`);
}
throw err;
}
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Typography } from "antd";
import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tooltip, Tree, Typography } from "antd";
import ReloadOutlined from "@ant-design/icons/ReloadOutlined";
const { Title } = Typography;
@@ -87,6 +88,11 @@ export default function BaseConfigTree(props) {
}
}
let restartTip = <Tooltip
className="ant-form-item-tooltip"
title="A restart is required for this setting to take effect"
><ReloadOutlined/></Tooltip>;
let treeData = [];
let propsMap = new Map();
for (let [name, GroupClass] of props.ConfigClass.groups) {
@@ -110,14 +116,27 @@ export default function BaseConfigTree(props) {
};
if (def.type === "object") {
childNode.title = <Form.Item label={def.title || def.name} tooltip={def.description} />;
let restartRequiredProps = new Set(def.restartRequiredProps || []);
childNode.title = <Form.Item
label={<>
{def.title || def.name}
{!def.restartRequiredProps && def.restartRequired && restartTip}
</>}
tooltip={def.description}
/>;
for (let prop of Object.keys(value)) {
let propPath = `${group.name}.${def.name}.${prop}`;
let restart = Boolean(
def.restartRequiredProps && def.restartRequired ^ restartRequiredProps.has(prop)
);
childNode.children.push({
key: propPath,
title: <Form.Item
name={propPath}
label={`"${prop}"`}
label={<>
{`"${prop}"`}
{restart && restartTip}
</>}
rules={[{ validator: (_, fieldValue) => {
if (!fieldValue.length) {
return Promise.reject(new Error("Will be removed"));
@@ -184,7 +203,10 @@ export default function BaseConfigTree(props) {
} else {
childNode.title = <Form.Item
name={fieldName}
label={def.title || def.name}
label={<>
{def.title || def.name}
{def.restartRequired && restartTip}
</>}
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
help={errorFields.get(fieldName)}
tooltip={def.description}
@@ -6,7 +6,7 @@ import {
} from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import { libLink } from "@clusterio/lib";
import { libErrors, libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import { useInstanceList } from "../model/instance";
@@ -14,7 +14,7 @@ import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
import { notifyErrorHandler } from "../util/notify";
import notify, { notifyErrorHandler } from "../util/notify";
import { formatDuration } from "../util/time_format";
import { formatLastSeen, sortLastSeen, useUser } from "../model/user";
@@ -121,8 +121,27 @@ export default function UserViewPage() {
{user["is_banned"] && <Tag color="red">Banned</Tag>}
</span>
</Space>}
extra={
account.hasPermission("core.user.delete") && <Popconfirm
extra={<>
{account.hasPermission("core.user.revoke_token") && (
account.name === userName || account.hasPermission("core.user.revoke_other_token")
) && <Button
danger
onClick={() => {
libLink.messages.revokeUserToken.send(
control, { name: userName }
).then(() => {
notify("User token revoked");
}, err => {
if (err instanceof libErrors.SessionLost && userName === account.name) {
// Got kicked out after revoking our own token
notify("User token revoked");
return;
}
throw err;
}).catch(notifyErrorHandler("Error revoking token"));
}}
>Revoke token</Button>}
{account.hasPermission("core.user.delete") && <Popconfirm
title={<>
Delete user account and all data associated with it?
{(user["is_banned"] && <><br/>This will remove the user ban!</>)}
@@ -141,8 +160,8 @@ export default function UserViewPage() {
<Button danger >
<DeleteOutlined />
</Button>
</Popconfirm>
}
</Popconfirm>}
</>}
/>
<Form
form={form}
+15 -19
View File
@@ -32,14 +32,12 @@ sync.events[clusterio_api.events.on_server_startup] = function(event)
local force = game.forces["player"]
for _, tech in pairs(force.technologies) do
if tech.enabled then
local progress = get_technology_progress(tech)
global.research_sync.technologies[tech.name] = {
level = tech.level,
researched = tech.researched,
progress = progress,
}
end
local progress = get_technology_progress(tech)
global.research_sync.technologies[tech.name] = {
level = tech.level,
researched = tech.researched,
progress = progress,
}
end
end
@@ -112,14 +110,12 @@ function research_sync.dump_technologies()
local techs = {}
for _, tech in pairs(force.technologies) do
if tech.enabled then
table.insert(techs, {
name = tech.name,
level = tech.level,
progress = get_technology_progress(tech),
researched = tech.researched,
})
end
table.insert(techs, {
name = tech.name,
level = tech.level,
progress = get_technology_progress(tech),
researched = tech.researched,
})
end
if #techs == 0 then
@@ -140,7 +136,7 @@ function research_sync.sync_technologies(data)
global.research_sync.ignore_research_finished = true
for _, tech_data in pairs(game.json_to_table(data)) do
local tech = force.technologies[tech_data[nameIndex]]
if tech and tech.enabled and tech.level <= tech_data[levelIndex] then
if tech and tech.level <= tech_data[levelIndex] then
tech.level = math.min(tech_data[levelIndex], tech.prototype.max_level)
local progress
@@ -171,7 +167,7 @@ function research_sync.update_progress(data)
for _, masterTech in ipairs(techs) do
local tech = force.technologies[masterTech.name]
if tech and tech.enabled and tech.level == masterTech.level then
if tech and tech.level == masterTech.level then
send_contribution(tech)
set_technology_progress(tech, masterTech.progress)
global.research_sync.technologies[tech.name] = {
@@ -185,7 +181,7 @@ end
function research_sync.research_technology(name, level)
local force = game.forces["player"]
local tech = force.technologies[name]
if not tech or not tech.enabled or tech.level > level then
if not tech or tech.level > level then
return
end
+21
View File
@@ -1,6 +1,7 @@
"use strict";
const assert = require("assert").strict;
const fs = require("fs-extra");
const jwt = require("jsonwebtoken");
const path = require("path");
const events = require("events");
const phin = require("phin");
@@ -1087,6 +1088,26 @@ describe("Integration of Clusterio", function() {
});
});
describe("user revoke-token", function() {
it("should kick existing sessions for the user", async function() {
slowTest(this);
await libLink.messages.createUser.send(getControl(), { name: "revokee" });
let tlsCa = await fs.readFile("test/file/tls/cert.pem");
let connector = new TestControlConnector(url, 2, tlsCa);
connector.token = jwt.sign(
{ aud: "user", user: "revokee" }, Buffer.from("TestSecretDoNotUse", "base64")
);
let revokeeControl = new TestControl(connector);
await connector.connect();
connector.setClosing();
let closed = new Promise(resolve => connector.once("close", resolve));
await execCtl("user revoke-token revokee");
await closed;
});
});
describe("user set-roles", function() {
it("should set the roles on the user", async function() {
getControl().userUpdates = [];
+1
View File
@@ -113,6 +113,7 @@ describe("lib/config/classes", function() {
name: "enum",
title: "Enum",
description: "Enum thingy",
restartRequired: true,
enum: ["a", "b", "c"],
optional: true,
initial_value: "b",