diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index a72ab6cd..7ca51d56 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -31,6 +31,79 @@ function print(...content) { console.log(...content); } + +const masterCommands = new libCommand.CommandTree({ name: "master", description: "Master management" }); +const masterConfigCommands = new libCommand.CommandTree({ + name: "config", alias: ["c"], description: "master config management", +}); +masterConfigCommands.add(new libCommand.Command({ + definition: ["list", "List master configuration"], + handler: async function(args, control) { + let response = await libLink.messages.getMasterConfig.send(control); + + for (let group of response.serialized_config.groups) { + for (let [name, value] of Object.entries(group.fields)) { + print(`${group.name}.${name} ${JSON.stringify(value)}`); + } + } + }, +})); + +masterConfigCommands.add(new libCommand.Command({ + definition: ["set [value]", "Set field in master config", (yargs) => { + yargs.positional("field", { describe: "Field to set", type: "string" }); + yargs.positional("value", { describe: "Value to set", type: "string" }); + yargs.options({ + "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, + }); + }], + handler: async function(args, control) { + if (args.stdin) { + args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } else if (args.value === undefined) { + args.value = ""; + } + await libLink.messages.setMasterConfigField.send(control, { + field: args.field, + value: args.value, + }); + }, +})); + +masterConfigCommands.add(new libCommand.Command({ + definition: ["set-prop [value]", "Set property of field in master config", (yargs) => { + yargs.positional("field", { describe: "Field to set", type: "string" }); + yargs.positional("prop", { describe: "Property to set", type: "string" }); + yargs.positional("value", { describe: "JSON parsed value to set", type: "string" }); + yargs.options({ + "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, + }); + }], + handler: async function(args, control) { + if (args.stdin) { + args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } + let request = { + field: args.field, + prop: args.prop, + }; + try { + if (args.value !== undefined) { + request.value = JSON.parse(args.value); + } + } catch (err) { + // See note for the instance version of set-prop + if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) { + throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`); + } + request.value = args.value; + } + await libLink.messages.setMasterConfigProp.send(control, request); + }, +})); +masterCommands.add(masterConfigCommands); + + const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" }); slaveCommands.add(new libCommand.Command({ definition: [["list", "l"], "List slaves connected to the master"], @@ -701,6 +774,7 @@ async function loadPlugins(pluginList) { async function registerCommands(controlPlugins, yargs) { const rootCommands = new libCommand.CommandTree({ name: "clusterioctl", description: "Manage cluster" }); + rootCommands.add(masterCommands); rootCommands.add(slaveCommands); rootCommands.add(instanceCommands); rootCommands.add(permissionCommands); diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index a68ae538..89a6cbdd 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -300,6 +300,37 @@ messages.debugDumpWs = new Request({ // Management requests +messages.getMasterConfig = new Request({ + type: "get_master_config", + links: ["control-master"], + permission: "core.master.get_config", + responseProperties: { + "serialized_config": { type: "object" }, + }, +}); + +messages.setMasterConfigField = new Request({ + type: "set_master_config_field", + links: ["control-master"], + permission: "core.master.update_config", + requestProperties: { + "field": { type: "string" }, + "value": { type: "string" }, + }, +}); + +messages.setMasterConfigProp = new Request({ + type: "set_master_config_prop", + links: ["control-master"], + permission: "core.master.update_config", + requestRequired: ["field", "prop"], + requestProperties: { + "field": { type: "string" }, + "prop": { type: "string" }, + "value": {}, + }, +}); + messages.listSlaves = new Request({ type: "list_slaves", links: ["control-master"], diff --git a/packages/lib/users.js b/packages/lib/users.js index a55ef972..3bb8f60a 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -302,6 +302,16 @@ definePermission({ grantByDefault: true, }); +definePermission({ + name: "core.master.get_config", + title: "Get master config", + description: "Get the config of master.", +}); +definePermission({ + name: "core.master.update_config", + title: "Modify master config", + description: "Modify the master config or entries of the master config.", +}); definePermission({ name: "core.slave.list", title: "List slaves", diff --git a/packages/master/master.js b/packages/master/master.js index 9bb49643..2474c0d6 100755 --- a/packages/master/master.js +++ b/packages/master/master.js @@ -684,6 +684,19 @@ class ControlConnection extends BaseConnection { }); } + async getMasterConfigRequestHandler() { + return { serialized_config: masterConfig.serialize("control") }; + } + + async setMasterConfigFieldRequestHandler(message) { + masterConfig.set(message.data.field, message.data.value, "control"); + } + + async setMasterConfigPropRequestHandler(message) { + let { field, prop, value } = message.data; + masterConfig.setProp(field, prop, value, "control"); + } + async listSlavesRequestHandler(message) { let list = []; for (let slave of db.slaves.values()) { diff --git a/packages/master/src/routes.js b/packages/master/src/routes.js index 7e0ca52a..bcfde4ee 100644 --- a/packages/master/src/routes.js +++ b/packages/master/src/routes.js @@ -2,15 +2,14 @@ // Routes used in the web interface and served by the master server module.exports = [ "/", - "/instances", - "/instances/:id/view", + "/master", "/slaves", "/slaves/:id/view", + "/instances", + "/instances/:id/view", "/users", "/users/:id/view", "/roles", "/roles/:id/view", - "/permissions", - "/permissions/:id/view", "/settings", ]; diff --git a/packages/web_ui/src/components/MasterConfigTree.jsx b/packages/web_ui/src/components/MasterConfigTree.jsx new file mode 100644 index 00000000..fc817b90 --- /dev/null +++ b/packages/web_ui/src/components/MasterConfigTree.jsx @@ -0,0 +1,44 @@ +import React, { useContext } from "react"; +import { Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; +import libConfig from "@clusterio/lib/config"; + +import BaseConfigTree from "./BaseConfigTree"; +import ControlContext from "./ControlContext"; + + +export default function MasterConfigTree(props) { + let control = useContext(ControlContext); + + async function retrieveConfig() { + let result = await libLink.messages.getMasterConfig.send(control); + return result.serialized_config; + } + + async function setField(field, value) { + await libLink.messages.setMasterConfigField.send(control, { + field, + value: String(value), + }); + } + + async function setProp(field, prop, value) { + let data = { field, prop }; + if (value) { + try { + data.value = JSON.parse(value); + } catch (err) { + return; + } + } + await libLink.messages.setMasterConfigProp.send(control, data); + } + + return ; +} diff --git a/packages/web_ui/src/components/MasterPage.jsx b/packages/web_ui/src/components/MasterPage.jsx new file mode 100644 index 00000000..3f14b548 --- /dev/null +++ b/packages/web_ui/src/components/MasterPage.jsx @@ -0,0 +1,12 @@ +import React, { useContext } from "react"; + +import MasterConfigTree from "./MasterConfigTree"; +import PageLayout from "./PageLayout"; + + +export default function SlavesPage() { + return +

Master

+ +
; +}; diff --git a/packages/web_ui/src/components/SiteLayout.jsx b/packages/web_ui/src/components/SiteLayout.jsx index 751a56e4..f2ef9358 100644 --- a/packages/web_ui/src/components/SiteLayout.jsx +++ b/packages/web_ui/src/components/SiteLayout.jsx @@ -9,6 +9,7 @@ import ErrorPage from "./ErrorPage"; const { Header, Sider } = Layout; +import MasterPage from "./MasterPage"; import SlavesPage from "./SlavesPage"; import InstancesPage from "./InstancesPage"; import InstanceViewPage from "./InstanceViewPage"; @@ -24,6 +25,7 @@ const pages = [ ]; const sidebar = [ + { name: "Master", path: "/master", content: }, { name: "Slaves", path: "/slaves", content: }, { name: "Instances", path: "/instances", content: }, { name: "Users", path: "/users", content: }, diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 3ab0ffb4..8d3be325 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -35,6 +35,42 @@ describe("Integration of Clusterio", function() { }); describe("clusterioctl", function() { + describe("master config list", function() { + it("runs", async function() { + await execCtl("master config list"); + }); + it("should not leak auth_secret", async function() { + let result = await libLink.messages.getMasterConfig.send(getControl()); + let done = false; + for (let group of result.serialized_config.groups) { + if (group.name === "master") { + assert.equal(Object.prototype.hasOwnProperty.call(group.fields, "auth_secret"), false); + done = true; + break; + } + } + assert(done, "master group not found"); + }); + }); + describe("master config set", function() { + it("sets given config option", async function() { + await execCtl('master config set master.name "Test Cluster"'); + let result = await libLink.messages.getMasterConfig.send(getControl()); + let done = false; + for (let group of result.serialized_config.groups) { + if (group.name === "master") { + assert.equal(group.fields.name, "Test Cluster"); + done = true; + break; + } + } + assert(done, "master group not found"); + }); + it("should not allow setting auth_secret", async function() { + await assert.rejects(execCtl("master config set master.auth_secret root")); + }); + }); + describe("slave list", function() { it("runs", async function() { await execCtl("slave list");