From 707c0bb45331dcab6b8a1babf11fda7858a09dac Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 15 Jan 2024 20:28:44 +0100 Subject: [PATCH] Add remote configuration of hosts Implement remote editing of the host config in a similar fasion to how the remote editing of the controller config works. --- packages/ctl/src/commands.ts | 145 +++++++++++++++++- packages/host/src/Host.ts | 22 +++ packages/lib/src/data/messages_host.ts | 58 ++++++- packages/lib/src/link/messages.ts | 3 + packages/lib/src/permissions.ts | 10 ++ .../web_ui/src/components/BaseConfigTree.tsx | 30 +++- .../web_ui/src/components/HostConfigTree.tsx | 39 +++++ .../web_ui/src/components/HostViewPage.tsx | 2 + packages/web_ui/src/style.css | 2 +- test/integration/clusterio.js | 23 +++ 10 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 packages/web_ui/src/components/HostConfigTree.tsx diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 215c67b3..683588e5 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -58,7 +58,7 @@ async function configToKeyVal(data: string) { function serializedConfigToString( serializedConfig: lib.ConfigSchema, - configGroup: typeof lib.ControllerConfig | typeof lib.InstanceConfig, + configGroup: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig, disallowedList: Record, ) { let allConfigElements = ""; @@ -289,6 +289,149 @@ hostCommands.add(new lib.Command({ }, })); +const hostConfigCommands = new lib.CommandTree({ + name: "config", alias: ["c"], description: "Host config management", +}); +hostConfigCommands.add(new lib.Command({ + definition: ["list ", "List configuration for a host", (yargs) => { + yargs.positional("host", { describe: "Host to list config for", type: "string" }); + }], + handler: async function(args: { host: string }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest()); + + for (let [name, value] of Object.entries(config)) { + print(`${name} ${JSON.stringify(value)}`); + } + }, +})); + +hostConfigCommands.add(new lib.Command({ + definition: ["set [value]", "Set field in host config", (yargs) => { + yargs.positional("host", { describe: "Host to set config on", type: "string" }); + 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: { host: string, field: string, value?: string, stdin?: boolean }, + control: Control, + ) { + let hostId = await lib.resolveHost(control, args.host); + if (args.stdin) { + args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } else if (args.value === undefined) { + args.value = ""; + } + await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value)); + }, +})); + +hostConfigCommands.add(new lib.Command({ + definition: ["set-prop [value]", "Set property of field in host config", (yargs) => { + yargs.positional("host", { describe: "Host to set config on", type: "string" }); + 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: { host: string, field: string, prop: string, value?: string, stdin?: boolean }, + control: Control + ) { + let hostId = await lib.resolveHost(control, args.host); + if (args.stdin) { + args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } + let value; + try { + if (args.value !== undefined) { + value = JSON.parse(args.value); + } + } catch (err: any) { + // See note for the instance version of set-prop + if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) { + throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`); + } + value = args.value; + } + await control.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value)); + }, +})); + +hostConfigCommands.add(new lib.Command({ + definition: ["edit [editor]", "Edit host configuration", (yargs) => { + yargs.positional("host", { describe: "Host to set config on", type: "string" }); + yargs.positional("editor", { + describe: "Editor to use", + type: "string", + default: "", + }); + }], + handler: async function(args: { host: string, editor: string }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest()); + let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir()); + let editor = await getEditor(args.editor); + if (editor === undefined) { + throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars + Try "ctl controller config edit "`); + } + let disallowedList = {"host.id": 0}; + let allConfigElements = serializedConfigToString( + config, + lib.HostConfig, + disallowedList + ); + await fs.writeFile(tmpFile, allConfigElements); + let editorSpawn = child_process.spawn(editor, [tmpFile], { + stdio: "inherit", + detached: false, + }); + editorSpawn.on("data", (data) => { + process.stdout.pipe(data); + }); + let doneEmitter = new events.EventEmitter(); + editorSpawn.on("exit", async (exit) => { + const data = await fs.readFile(tmpFile, "utf8"); + const final = await configToKeyVal(data); + for (let index in final) { + if (index in final) { + try { + await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest( + index, + final[index], + )); + } catch (err) { + // eslint-disable-next-line + print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`); + // If the string is empty, it's better to just print "" instead of nothing + print("This message shouldn't normally appear; if the below message does not indicate it"); + print("was a user mistake, please report it to the clustorio devs."); + // added this because it could be a missed entry in disallowedList + // i've added all the vanilla clustorio configs, but there may be + // some modded ones that need to be added. + print(err); + } + } + } + doneEmitter.emit("dot_on_done"); + }); + await events.once(doneEmitter, "dot_on_done"); + try { + await fs.unlink(tmpFile); + } catch (err) { + print("err: temporary file", tmpFile, "could not be deleted."); + print("This is not fatal, but they may build up over time if the issue persists."); + } + }, +})); +hostCommands.add(hostConfigCommands); + const instanceCommands = new lib.CommandTree({ name: "instance", alias: ["i"], description: "Instance management", diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 5e9297f9..971f82f3 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -341,6 +341,9 @@ export default class Host extends lib.Link { }); } + this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this)); + this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this)); + this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this)); this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this)); this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this)); @@ -423,6 +426,25 @@ export default class Host extends lib.Link { } } + async handleHostConfigGetRequest() { + return this.config.toRemote("control"); + } + + async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) { + if (request.field === "host.id") { + // Changing host.id at runtime is not worth the effort to + // support and will break a lot of things if it is allowed. + throw new lib.RequestError("Setting 'host.id' while host is running is not supported"); + } + + this.config.set(request.field as keyof lib.HostConfigFields, request.value, "control"); + } + + async handleHostConfigSetPropRequest(request: lib.HostConfigSetPropRequest) { + let { field, prop, value } = request; + this.config.setProp(field as keyof lib.HostConfigFields, prop, value, "control"); + } + async handleSyncUserListsEvent(event: lib.SyncUserListsEvent) { let updateList = (list: Set, updatedList: Set, Event: lib.EventClass) => { let added = new Set(updatedList); diff --git a/packages/lib/src/data/messages_host.ts b/packages/lib/src/data/messages_host.ts index 1ee3b4b6..4566c3f0 100644 --- a/packages/lib/src/data/messages_host.ts +++ b/packages/lib/src/data/messages_host.ts @@ -1,6 +1,62 @@ import { Type, Static } from "@sinclair/typebox"; -import { jsonArray, StringEnum } from "./composites"; +import { jsonArray, plainJson, StringEnum } from "./composites"; import { CollectorResultSerialized } from "../prometheus"; +import { HostConfig } from "../config/definitions"; + +export class HostConfigGetRequest { + declare ["constructor"]: typeof HostConfigGetRequest; + static type = "request" as const; + static src = ["control", "controller"] as const; + static dst = "host" as const; + static permission = "core.host.get_config" as const; + static Response = plainJson(HostConfig.jsonSchema); +} + +export class HostConfigSetFieldRequest { + declare ["constructor"]: typeof HostConfigSetFieldRequest; + static type = "request" as const; + static src = ["control", "controller"] as const; + static dst = "host" as const; + static permission = "core.host.update_config" as const; + + constructor( + public field: string, + public value: string, + ) { } + + static jsonSchema = Type.Object({ + "field": Type.String(), + "value": Type.String(), + }); + + static fromJSON(json: Static) { + return new this(json.field, json.value); + } +} + +export class HostConfigSetPropRequest { + declare ["constructor"]: typeof HostConfigSetPropRequest; + static type = "request" as const; + static src = ["control", "controller"] as const; + static dst = "host" as const; + static permission = "core.host.update_config" as const; + + constructor( + public field: string, + public prop: string, + public value?: unknown, + ) { } + + static jsonSchema = Type.Object({ + "field": Type.String(), + "prop": Type.String(), + "value": Type.Optional(Type.Unknown()), + }); + + static fromJSON(json: Static) { + return new this(json.field, json.prop, json.value); + } +} export class HostDetails { constructor( diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 79d5ba03..6f4494aa 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -32,6 +32,9 @@ export const dataClasses: (RequestClass | EventClass) subscriptions.SubscriptionRequest, + host.HostConfigGetRequest, + host.HostConfigSetFieldRequest, + host.HostConfigSetPropRequest, host.HostListRequest, host.HostUpdatesEvent, host.HostMetricsRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index 3f9d5f44..72c31005 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -136,6 +136,16 @@ definePermission({ "Subscribe to be notified when the system metrics detailing cpu, memory and disk usage for the " + "controller and hosts are updated.", }); +definePermission({ + name: "core.host.get_config", + title: "Get host configs", + description: "Get the config of hosts.", +}); +definePermission({ + name: "core.host.update_config", + title: "Modify host configs", + description: "Modify the host config or entries of the host config.", +}); definePermission({ name: "core.host.list", title: "List hosts", diff --git a/packages/web_ui/src/components/BaseConfigTree.tsx b/packages/web_ui/src/components/BaseConfigTree.tsx index 81feb511..c5f60849 100644 --- a/packages/web_ui/src/components/BaseConfigTree.tsx +++ b/packages/web_ui/src/components/BaseConfigTree.tsx @@ -26,12 +26,13 @@ function getInitialValues(config: lib.Config, props: BaseConfigTreeProps) { } type BaseConfigTreeProps = { - ConfigClass: typeof lib.ControllerConfig | typeof lib.InstanceConfig; + ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig; retrieveConfig: () => Promise; setField: (field:string, value:any) => Promise; setProp: (field:string, prop:string, value:any) => Promise; id?: number; onApply?: () => void; + available?: boolean; }; export default function BaseConfigTree(props: BaseConfigTreeProps) { let [config, setConfig] = useState|null>(null); @@ -39,6 +40,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { let [changedFields, setChangedFields] = useState>(new Set()); let [errorFields, setErrorFields] = useState(new Map()); let [applying, setApplying] = useState(false); + const available = props.available ?? true; async function updateConfig() { let serializedConfig = await props.retrieveConfig(); @@ -47,10 +49,12 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { return newConfig; } useEffect(() => { - updateConfig().then(updatedConfig => { - form.setFieldsValue(getInitialValues(updatedConfig, props)); - }); - }, [props.id]); + if (available) { + updateConfig().then(updatedConfig => { + form.setFieldsValue(getInitialValues(updatedConfig, props)); + }); + } + }, [props.id, available]); function renderInput(def: lib.FieldDefinition) { if (def.type === "boolean") { @@ -66,8 +70,22 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { return `Unknown type ${def.type}`; } + if (!available) { + return <> + Config + + Config is currently unavailable. + + ; + } + if (config === null) { - return ; + return <> + Config + + ; + + ; } let initialValues: any = {}; diff --git a/packages/web_ui/src/components/HostConfigTree.tsx b/packages/web_ui/src/components/HostConfigTree.tsx new file mode 100644 index 00000000..6f075ac3 --- /dev/null +++ b/packages/web_ui/src/components/HostConfigTree.tsx @@ -0,0 +1,39 @@ +import React, { useContext } from "react"; + +import * as lib from "@clusterio/lib"; + +import BaseConfigTree from "./BaseConfigTree"; +import ControlContext from "./ControlContext"; + + +export default function HostConfigTree(props: { id: number, available: boolean }) { + let control = useContext(ControlContext); + + async function retrieveConfig() { + return await control.sendTo({ hostId: props.id }, new lib.HostConfigGetRequest()); + } + + async function setField(field: string, value: any) { + await control.sendTo({ hostId: props.id }, new lib.HostConfigSetFieldRequest(field, value)); + } + + async function setProp(field: string, prop: string, value: any) { + if (value) { + try { + value = JSON.parse(value); + } catch (err) { + return; + } + } + await control.sendTo({ hostId: props.id }, new lib.HostConfigSetPropRequest(field, prop, value)); + } + + return ; +} diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index 6906bf5d..c5cfb0d3 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -5,6 +5,7 @@ import { useParams } from "react-router-dom"; import * as lib from "@clusterio/lib"; import notify, { notifyErrorHandler } from "../util/notify"; import ControlContext from "./ControlContext"; +import HostConfigTree from "./HostConfigTree"; import InstanceList from "./InstanceList"; import LogConsole from "./LogConsole"; import { useAccount } from "../model/account"; @@ -100,6 +101,7 @@ export default function HostViewPage() { Console } + {account.hasPermission("core.host.get_config") && } ; } diff --git a/packages/web_ui/src/style.css b/packages/web_ui/src/style.css index 276865a8..7fa95675 100644 --- a/packages/web_ui/src/style.css +++ b/packages/web_ui/src/style.css @@ -287,7 +287,7 @@ body { color: #000; } -.config-container { +.config-container, .config-container .ant-tree { background-color: #111111; } diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 3c653921..7025efd7 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -230,6 +230,29 @@ describe("Integration of Clusterio", function() { await execCtl("host list"); }); }); + describe("host config", function() { + it("changes host config", async function() { + await execCtl("host config set 4 host.name My-Host"); + const result = await execCtl("host config list 4"); + assert(/My-Host/.test(result.stdout), "New host name not in config output"); + }); + it("should not allow setting host.controller_token", async function() { + await assert.rejects( + execCtl("host config set 4 host.controller_token xyz"), + /Field 'host\.controller_token' is not accessible/, + ); + }); + it("should not allow setting host.id", async function() { + await assert.rejects( + execCtl("host config set 4 host.id 200"), + /Setting 'host\.id' while host is running is not supported/, + ); + }); + it("should not leak host.controller_token", async function() { + let result = await getControl().sendTo({ hostId: 4 }, new lib.HostConfigGetRequest()); + assert.equal(Object.prototype.hasOwnProperty.call(result, "host.controller_token"), false); + }); + }); describe("host generate-token", function() { it("runs", async function() { await execCtl("host generate-token --id 42");