diff --git a/docs/devs/protocol.md b/docs/devs/protocol.md index 230ad045..ea28005c 100644 --- a/docs/devs/protocol.md +++ b/docs/devs/protocol.md @@ -107,9 +107,7 @@ Sent by the controller when the WebSocket connection has been opened. Host handshake for establishing a new connection session. - `token: string` - Authentication token. -- `agent: string` - Human readable string of the software connecting. - `version: string` - The protocol version of the host, e.g. "2.0.0". -- `name: string` - Name of the host. - `id: integer` - ID of the host. - `plugins: Object` - Object mapping plugin names to plugin version for plugins that are available on the host. @@ -119,7 +117,6 @@ Host handshake for establishing a new connection session. Control handshake for enstablishing a new connection session. - `token: string` - Authentication token. -- `agent: string` - Human readable string of the software connecting. - `version: string` - The protocol version of the control, e.g. "2.0.0". #### `resume` @@ -243,9 +240,7 @@ Host registering a new connection session. "type": "register_host", "data": { "token": "xyzabc123", - "agent": "Clusterio Host", "version": "2.0.0", - "name": "Foo", "id": 123 } } diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 6cf9deca..9284b97c 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -24,7 +24,6 @@ const queryLogTime = new lib.Summary( * @alias module:controller/src/ControlConnection */ export default class ControlConnection extends BaseConnection { - private _agent: string; private _version: string; logTransport: lib.LinkTransport | null = null; logSubscriptions = { @@ -38,7 +37,7 @@ export default class ControlConnection extends BaseConnection { declare connector: WsServerConnector; constructor( - registerData: { agent:string, version:string }, + registerData: { version: string }, connector: WsServerConnector, controller: Controller, public user: ControllerUser, // The user making this connection. @@ -46,7 +45,6 @@ export default class ControlConnection extends BaseConnection { ) { super(connector, controller); - this._agent = registerData.agent; this._version = registerData.version; this.connector.on("connect", () => { diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 037dabc8..7c2bdc4c 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -16,10 +16,7 @@ import HostInfo from "./HostInfo"; * @alias module:controller/src/HostConnection */ export default class HostConnection extends BaseConnection { - private _agent: string; - private _id: number; - private _name: string; - private _version: string; + private info: HostInfo; plugins: Map; constructor( @@ -29,28 +26,24 @@ export default class HostConnection extends BaseConnection { ) { super(connector, controller); - this._agent = registerData.agent; - this._id = registerData.id; - this._name = registerData.name; - this._version = registerData.version; this.plugins = new Map(Object.entries(registerData.plugins)); - this._checkPluginVersions(); - const previousHostInfo = this._controller.hosts.get(this._id); - const currentHostInfo = new HostInfo( - this._agent, - this._id, - this._name, - this._version, + const previousHostInfo = this._controller.hosts.get(registerData.id); + this.info = new HostInfo( + registerData.id, + previousHostInfo?.name ?? "", + registerData.version, this.plugins, true, - registerData.publicAddress ?? "", + previousHostInfo?.publicAddress, previousHostInfo?.tokenValidAfter, - Date.now(), + 0, false, ); - this._controller.hosts.set(this._id, currentHostInfo); - this._controller.hostsUpdated([currentHostInfo]); + this._controller.hosts.set(this.id, this.info); + this._controller.hostsUpdated([this.info]); + + this._checkPluginVersions(); for (let event of ["connect", "drop", "resume", "close"] as const) { // eslint-disable-next-line no-loop-func @@ -65,7 +58,7 @@ export default class HostConnection extends BaseConnection { // Update status to unknown for instances on this host. const instances: InstanceInfo[] = []; for (let instance of this._controller.instances.values()) { - if (instance.config.get("instance.assigned_host") !== this._id) { + if (instance.config.get("instance.assigned_host") !== this.id) { continue; } @@ -76,10 +69,11 @@ export default class HostConnection extends BaseConnection { } this._controller.instanceDetailsUpdated(instances); - currentHostInfo.connected = false; - this._controller.hostsUpdated([currentHostInfo]); + this.info.connected = false; + this._controller.hostsUpdated([this.info]); }); + this.handle(lib.HostInfoUpdateEvent, this.handleHostInfoUpdateEvent.bind(this)); this.handle(lib.InstanceStatusChangedEvent, this.handleInstanceStatusChangedEvent.bind(this)); this.handle(lib.InstancesUpdateRequest, this.handleInstancesUpdateRequest.bind(this)); this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this)); @@ -104,7 +98,7 @@ export default class HostConnection extends BaseConnection { case lib.Address.instance: let instance = this._controller.instances.get(message.src.id); - if (!instance || instance.config.get("instance.assigned_host") !== this._id) { + if (!instance || instance.config.get("instance.assigned_host") !== this.id) { throw new lib.InvalidMessage( `Received message with invalid src ${message.src} from ${origin}` ); @@ -121,21 +115,24 @@ export default class HostConnection extends BaseConnection { for (let [name, version] of this.plugins) { let info = pluginInfos.get(name); if (!info) { - logger.warn(`Host ${this._name} has plugin ${name} ${version} which the controller does not have`); + logger.warn( + `Host ${this.info.name} (${this.id}) has plugin ${name} ${version} which the ` + + "controller does not have", + ); continue; } if (info.version !== version) { logger.warn( - `Host ${this._name} has plugin ${name} ${version} which does not match the version of this ` + - `plugin on the controller (${info.version})` + `Host ${this.info.name} (${this.id}) has plugin ${name} ${version} which does not match ` + + `the version of this plugin on the controller (${info.version})` ); } } for (let [name, info] of pluginInfos) { if (!this.plugins.has(name)) { - logger.warn(`Host ${this._name} is missing plugin ${name} ${info.version}`); + logger.warn(`Host ${this.info.name} (${this.id}) is missing plugin ${name} ${info.version}`); } } } @@ -151,7 +148,13 @@ export default class HostConnection extends BaseConnection { * @returns {number} host ID. */ get id() { - return this._id; + return this.info.id; + } + + async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) { + this.info.name = event.update.name; + this.info.publicAddress = event.update.publicAddress; + this._controller.hostsUpdated([this.info]); } async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) { @@ -161,7 +164,7 @@ export default class HostConnection extends BaseConnection { // or is not assigned to the host it originated from if it was // reassigned or deleted while the connection to the host it was // originally on was down at the time. - if (!instance || instance.config.get("instance.assigned_host") !== this._id) { + if (!instance || instance.config.get("instance.assigned_host") !== this.id) { logger.warn(`Got bogus update for instance id ${request.instanceId}`); return; } @@ -187,7 +190,7 @@ export default class HostConnection extends BaseConnection { async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) { // Push updated instance configs for (let instance of this._controller.instances.values()) { - if (instance.config.get("instance.assigned_host") === this._id) { + if (instance.config.get("instance.assigned_host") === this.id) { await this.send( new lib.InstanceAssignInternalRequest(instance.id, instance.config.toRemote("host")) ); @@ -203,7 +206,7 @@ export default class HostConnection extends BaseConnection { let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id")); if (controllerInstance) { // Check if this instance is assigned somewhere else. - if (controllerInstance.config.get("instance.assigned_host") !== this._id) { + if (controllerInstance.config.get("instance.assigned_host") !== this.id) { await this.send( new lib.InstanceUnassignInternalRequest(instanceConfig.get("instance.id")) ); @@ -225,7 +228,7 @@ export default class HostConnection extends BaseConnection { continue; } - instanceConfig.set("instance.assigned_host", this._id); + instanceConfig.set("instance.assigned_host", this.id); let newInstance = new InstanceInfo( instanceConfig, instanceData.status, @@ -301,8 +304,8 @@ export default class HostConnection extends BaseConnection { async handleLogMessageEvent(event: lib.LogMessageEvent) { this._controller.clusterLogger.log({ ...event.info, - host_id: this._id, - host_name: this._name, + host_id: this.id, + host_name: this.info.name, }); } diff --git a/packages/controller/src/HostInfo.ts b/packages/controller/src/HostInfo.ts index 7a817c53..6b523c9c 100644 --- a/packages/controller/src/HostInfo.ts +++ b/packages/controller/src/HostInfo.ts @@ -6,8 +6,6 @@ import { HostDetails } from "@clusterio/lib"; */ export default class HostInfo { constructor( - /** Agent this host last connected with */ - public agent: string, /** Id of this host */ public id: number, /** Version this host last connected with */ @@ -29,7 +27,6 @@ export default class HostInfo { ) { } static jsonSchema = Type.Object({ - "agent": Type.String(), "id": Type.Number(), "name": Type.String(), "version": Type.String(), @@ -43,7 +40,6 @@ export default class HostInfo { static fromJSON(json: Static) { return new this( - json.agent, json.id, json.name, json.version, @@ -58,7 +54,6 @@ export default class HostInfo { toJSON(): Static { return { - agent: this.agent, id: this.id, name: this.name, version: this.version, @@ -73,7 +68,6 @@ export default class HostInfo { toHostDetails() { return new HostDetails( - this.agent, this.version, this.name, this.id, diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts index e4b86251..3fc0d988 100644 --- a/packages/controller/src/WsServer.ts +++ b/packages/controller/src/WsServer.ts @@ -261,11 +261,11 @@ ${err.stack}` } logger.info( - `WsServer | registered host ${data.name} (${data.id}) using agent ${data.agent} ${data.version}` + `WsServer | registered host ${data.id} using version ${data.version}` ); - if (data.agent === "Clusterio Host" && data.version !== packageVersion) { + if (data.version !== packageVersion) { logger.warn( - `Host ${data.name} (${data.id}) connected using version ${data.version} which does not match the ` + + `Host ${data.id} connected using version ${data.version} which does not match the ` + `version of the controller is currently running (${packageVersion}). It may not work as expected.` ); } diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index 46863461..b5edb31c 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -38,7 +38,6 @@ class ControlConnector extends lib.WebSocketClientConnector { new lib.MessageRegisterControl( new lib.RegisterControlData( this._token, - "clusterioctl", version, ) ) 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/host.ts b/packages/host/host.ts index 56a8c346..2026ebe4 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -51,11 +51,8 @@ export class HostConnector extends lib.WebSocketClientConnector { new lib.MessageRegisterHost( new lib.RegisterHostData( this.hostConfig.get("host.controller_token"), - "Clusterio Host", version, - this.hostConfig.get("host.name"), this.hostConfig.get("host.id"), - this.hostConfig.get("host.public_address"), plugins, ) ) diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 5e9297f9..a274f6a6 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -288,6 +288,9 @@ export default class Host extends lib.Link { this.config = hostConfig; this.config.on("fieldChanged", (name, curr, prev) => { + if (name === "host.name" || name === "host.public_address") { + this.sendHostUpdate(); + } lib.invokeHook(this.plugins, "onHostConfigFieldChanged", name, curr, prev); }); @@ -301,6 +304,7 @@ export default class Host extends lib.Link { return; } + this.sendHostUpdate(); this.updateInstances().catch((err) => { if (err instanceof lib.SessionLost) { return undefined; @@ -341,6 +345,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 +430,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); @@ -970,6 +996,17 @@ export default class Host extends lib.Link { await fs.remove(instanceInfo.path); } + sendHostUpdate() { + this.send( + new lib.HostInfoUpdateEvent( + new lib.HostInfoUpdate( + this.config.get("host.name"), + this.config.get("host.public_address"), + ), + ), + ); + } + /** * Discover available instances * diff --git a/packages/lib/src/data/messages_core.ts b/packages/lib/src/data/messages_core.ts index a5c2d54b..4a84e9c4 100644 --- a/packages/lib/src/data/messages_core.ts +++ b/packages/lib/src/data/messages_core.ts @@ -211,26 +211,20 @@ export class MessageHello extends Message { export class RegisterHostData { constructor( public token: string, - public agent: string, public version: string, - public name: string, public id: number, - public publicAddress: string | undefined, public plugins: Record, ) { } static jsonSchema = Type.Object({ "token": Type.String(), - "agent": Type.String(), "version": Type.String(), - "name": Type.String(), "id": Type.Integer(), - "publicAddress": Type.Optional(Type.String()), "plugins": Type.Record(Type.String(), Type.String()), }); static fromJSON(json: Static) { - return new this(json.token, json.agent, json.version, json.name, json.id, json.publicAddress, json.plugins); + return new this(json.token, json.version, json.id, json.plugins); } } @@ -254,18 +248,16 @@ export class MessageRegisterHost extends Message { export class RegisterControlData { constructor( public token: string, - public agent: string, public version: string, ) { } static jsonSchema = Type.Object({ "token": Type.String(), - "agent": Type.String(), "version": Type.String(), }); static fromJSON(json: Static) { - return new this(json.token, json.agent, json.version); + return new this(json.token, json.version); } } diff --git a/packages/lib/src/data/messages_host.ts b/packages/lib/src/data/messages_host.ts index 674ff34e..5f2cf88a 100644 --- a/packages/lib/src/data/messages_host.ts +++ b/packages/lib/src/data/messages_host.ts @@ -1,10 +1,65 @@ 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( - public agent: string, public version: string, public name: string, public id: number, @@ -17,7 +72,6 @@ export class HostDetails { ) { } static jsonSchema = Type.Object({ - "agent": Type.String(), "version": Type.String(), "name": Type.String(), "id": Type.Integer(), @@ -30,7 +84,6 @@ export class HostDetails { static fromJSON(json: Static) { return new this( - json.agent, json.version, json.name, json.id, @@ -52,6 +105,44 @@ export class HostListRequest { static Response = jsonArray(HostDetails); } +export class HostInfoUpdate { + constructor( + public name: string, + public publicAddress: string, + ) { } + + static jsonSchema = Type.Object({ + "name": Type.String(), + "publicAddress": Type.String(), + }); + + static fromJSON(json: Static) { + return new this( + json.name, + json.publicAddress, + ); + } +} + +export class HostInfoUpdateEvent { + declare ["constructor"]: typeof HostInfoUpdateEvent; + static type = "event" as const; + static src = "host" as const; + static dst = "controller" as const; + + constructor( + public update: HostInfoUpdate, + ) { } + + static jsonSchema = Type.Object({ + "update": HostInfoUpdate.jsonSchema, + }); + + static fromJSON(json: Static) { + return new this(HostInfoUpdate.fromJSON(json.update)); + } +} + export class HostUpdatesEvent { declare ["constructor"]: typeof HostUpdatesEvent; static type = "event" as const; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 79d5ba03..560a5da6 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -32,7 +32,11 @@ export const dataClasses: (RequestClass | EventClass) subscriptions.SubscriptionRequest, + host.HostConfigGetRequest, + host.HostConfigSetFieldRequest, + host.HostConfigSetPropRequest, host.HostListRequest, + host.HostInfoUpdateEvent, host.HostUpdatesEvent, host.HostMetricsRequest, host.ControllerConnectionEvent, 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..be22aeb2 100644 --- a/packages/web_ui/src/components/BaseConfigTree.tsx +++ b/packages/web_ui/src/components/BaseConfigTree.tsx @@ -1,7 +1,9 @@ import * as lib from "@clusterio/lib"; import React, { useEffect, useState } from "react"; -import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tooltip, Tree, Typography } from "antd"; +import { + Button, Card, Checkbox, Form, FormInstance, Input, InputNumber, Space, Spin, Tooltip, Tree, Typography, +} from "antd"; import ReloadOutlined from "@ant-design/icons/ReloadOutlined"; const { Title } = Typography; @@ -25,20 +27,36 @@ function getInitialValues(config: lib.Config, props: BaseConfigTreeProps) { return initialValues; } +function renderInput(def: lib.FieldDefinition) { + if (def.type === "boolean") { + return ; + } + if (def.type === "string") { + return ; + } + if (def.type === "number") { + return ; + } + + return `Unknown type ${def.type}`; +} + 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); let [form] = Form.useForm(); let [changedFields, setChangedFields] = useState>(new Set()); - let [errorFields, setErrorFields] = useState(new Map()); + 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,27 +65,29 @@ 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") { - return ; - } - if (def.type === "string") { - return ; - } - if (def.type === "number") { - return ; - } - - return `Unknown type ${def.type}`; + if (!available) { + return <> + Config + + Config is currently unavailable. + + ; } if (config === null) { - return ; + return <> + Config + + ; + + ; } let initialValues: any = {}; @@ -91,12 +111,109 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { } } + const [propsMap, treeData] = computeTreeData( + form, + initialValues, + errorFields, + config, + setConfig, + props.ConfigClass, + onValuesChange, + ); + + return <> + + Config + <Space style={{ float: "right" }}> + <Button + type="primary" + size="small" + loading={applying} + onClick={async () => { + setApplying(true); + for (let field of changedFields) { + let value = form.getFieldValue(field); + let request; + if (propsMap.has(field)) { + let [fieldName, prop] = propsMap.get(field); + request = props.setProp(fieldName, prop, value); + + } else { + request = props.setField(field, value === null ? "" : String(value)); + } + + try { + await request; + setChangedFields(changed => { + let newChanged = new Set(changed); + newChanged.delete(field); + return newChanged; + }); + + } catch (err: any) { + setErrorFields(errors => { + let newErrors = new Map(errors); + newErrors.set(field, err.message); + return newErrors; + }); + } + } + + await updateConfig(); + setApplying(false); + + if (props.onApply) { + props.onApply(); + } + }} + disabled={changedFields.size === 0} + > + Apply + </Button> + <Button + size="small" + onClick={() => { + form.resetFields(); + setChangedFields(new Set()); + setErrorFields(new Map()); + }} + > + Revert + </Button> + </Space> + + +
+ changedFields.has(node.key)} + /> + +
+ ; +} + +function computeTreeData( + form: FormInstance, + initialValues: any, + errorFields: Map, + config: lib.Config, + setConfig: (newConfig: lib.Config) => void, + ConfigClass: BaseConfigTreeProps["ConfigClass"], + onValuesChange: (changedValues: object) => void, +) { let restartTip = ; - type ChildNode = { key: string; children: any[]; @@ -111,7 +228,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { let treeData = []; let propsMap = new Map(); const groups = new Map(); - for (let [fieldName, def] of Object.entries(props.ConfigClass.fieldDefinitions)) { + for (let [fieldName, def] of Object.entries(ConfigClass.fieldDefinitions)) { const [groupName, field] = lib.splitOn(".", fieldName); let group = groups.get(groupName); if (!group) { @@ -203,7 +320,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { let propName = form.getFieldValue(`${newPropPath}.name`); let propValue = form.getFieldValue(`${newPropPath}.value`); if (!Object.prototype.hasOwnProperty.call(value, propName)) { - let newConfig = props.ConfigClass.fromJSON(config!.toJSON(), "control"); + let newConfig = ConfigClass.fromJSON(config!.toJSON(), "control"); (newConfig as lib.Config).setProp(fieldName, propName, null); setConfig(newConfig); } @@ -246,81 +363,5 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { treeData.push(treeNode); } - return <> - - Config - <Space style={{ float: "right" }}> - <Button - type="primary" - size="small" - loading={applying} - onClick={async () => { - setApplying(true); - for (let field of changedFields) { - let value = form.getFieldValue(field); - let request; - if (propsMap.has(field)) { - let [fieldName, prop] = propsMap.get(field); - request = props.setProp(fieldName, prop, value); - - } else { - request = props.setField(field, value === null ? "" : String(value)); - } - - try { - await request; - setChangedFields(changed => { - let newChanged = new Set(changed); - newChanged.delete(field); - return newChanged; - }); - - } catch (err: any) { - setErrorFields(errors => { - let newErrors = new Map(errors); - newErrors.set(field, err.message); - return newErrors; - }); - } - } - - await updateConfig(); - setApplying(false); - - if (props.onApply) { - props.onApply(); - } - }} - disabled={changedFields.size === 0} - > - Apply - </Button> - <Button - size="small" - onClick={() => { - form.resetFields(); - setChangedFields(new Set()); - setErrorFields(new Map()); - }} - > - Revert - </Button> - </Space> - - -
- changedFields.has(node.key)} - /> - -
- ; + return [propsMap, treeData] as [typeof propsMap, typeof treeData]; } 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 f9c9f74e..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"; @@ -83,7 +84,6 @@ export default function HostViewPage() { - {host["agent"]} {host["version"]} { host.tokenValidAfter @@ -101,6 +101,7 @@ export default function HostViewPage() { Console } + {account.hasPermission("core.host.get_config") && } ; } diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index 6a6586fa..510f6e2b 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -251,12 +251,6 @@ export default function HostsPage() { render: (_, host) => , hidden: !showNumbers, }, - { - title: "Agent", - dataIndex: "agent", - sorter: (a, b) => strcmp(a.agent, b.agent), - responsive: ["lg"], - }, { title: "Version", dataIndex: "version", 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/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index 6596725b..f987ce15 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -30,7 +30,6 @@ export class ControlConnector extends lib.WebSocketClientConnector { new lib.MessageRegisterControl( new lib.RegisterControlData( this.token, - "Clusterio Web UI", packageJson.version, ) ) 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"); diff --git a/test/lib/command.js b/test/lib/command.js index 82decfea..86e299c6 100644 --- a/test/lib/command.js +++ b/test/lib/command.js @@ -16,7 +16,7 @@ describe("lib/command", function() { let testControl = new mock.MockControl(controlConnector); let testController = new lib.Link(controllerConnector); testController.handle( - lib.HostListRequest, () => [new lib.HostDetails("test", "0.1", "Test Host", 11, false)] + lib.HostListRequest, () => [new lib.HostDetails("0.1", "Test Host", 11, false)] ); testController.handle( lib.InstanceDetailsListRequest, () => [new lib.InstanceDetails("Test Instance", 57, 4, undefined, "stopped")]