From ff35b5c378d753433e3fd5f4c995dbc11114959a Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Tue, 29 Apr 2025 01:40:22 +0100 Subject: [PATCH 1/8] Basic functionality of remote npm --- packages/controller/src/ControlConnection.ts | 31 ++++ packages/create/create.js | 24 +++ packages/ctl/src/commands.ts | 88 ++++++++++ packages/host/src/Host.ts | 31 ++++ packages/lib/index.ts | 1 + packages/lib/src/config/definitions.ts | 48 +++++ packages/lib/src/data/messages_controller.ts | 52 ++++++ packages/lib/src/link/messages.ts | 3 + packages/lib/src/permissions.ts | 16 ++ packages/lib/src/rce_ops.ts | 26 +++ .../web_ui/src/components/ControllerPage.tsx | 5 + .../web_ui/src/components/HostViewPage.tsx | 5 + packages/web_ui/src/components/NpmButton.tsx | 166 ++++++++++++++++++ 13 files changed, 496 insertions(+) create mode 100644 packages/lib/src/rce_ops.ts create mode 100644 packages/web_ui/src/components/NpmButton.tsx diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 92db23a5..a2b81ac5 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -119,6 +119,9 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.UserDeleteRequest, this.handleUserDeleteRequest.bind(this)); this.handle(lib.UserBulkImportRequest, this.handleUserBulkImportRequest.bind(this)); this.handle(lib.UserBulkExportRequest, this.handleUserBulkExportRequest.bind(this)); + this.handle(lib.RemoteUpdateRequest, this.handleRemoteUpdateRequest.bind(this)); + this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); + this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this)); } @@ -1049,6 +1052,34 @@ export default class ControlConnection extends BaseConnection { return usersToSend; } + async handleRemoteUpdateRequest(request: lib.RemoteUpdateRequest) { + if (!this._controller.config.get("controller.allow_remote_updates")) { + throw new lib.RequestError("Remote updates are disabled on this machine"); + } + return lib.updatePackage("@clusterio/controller"); + } + + async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) { + if (!this._controller.config.get("controller.allow_plugin_updates")) { + throw new lib.RequestError("Plugin updates are disabled on this machine"); + } + if (!this._controller.pluginInfos.some(plugin => plugin.npmPackage === request.pluginPackage)) { + throw new lib.RequestError(`Plugin ${request.pluginPackage} is not installed on this machine`); + } + return lib.updatePackage(request.pluginPackage); + } + + async handlePluginInstallRequest(request: lib.PluginInstallRequest) { + if (!this._controller.config.get("controller.allow_plugin_install")) { + throw new lib.RequestError("Plugin installs are disabled on this machine"); + } + const npmRequest = await fetch(`https://www.npmjs.com/package/${encodeURI(request.pluginPackage)}`); + if (!npmRequest.ok) { + throw new lib.RequestError(`Unknown plugin: ${request.pluginPackage}`); + } + return lib.installPackage(request.pluginPackage); + } + async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) { this.ws_dumper = data => { if (this.connector.connected) { diff --git a/packages/create/create.js b/packages/create/create.js index c61cd6b1..de5413b5 100755 --- a/packages/create/create.js +++ b/packages/create/create.js @@ -688,6 +688,18 @@ async function inquirerMissingArgs(args) { } } + if (["standalone", "controller", "host"].includes(answers.mode)) { + answers = inquirer.prompt([ + { + type: "confirm", + name: "remoteNpm", + // TODO maybe link remote install to this once it isn't free text + message: "Allow remote updates via npm?", + default: true, + }, + ], answers); + } + if (answers.mode === "plugin-template") { if (args["plugin-template"] && args["plugin-template"].length > 0) { answers.pluginTemplate = args["plugin-template"]; @@ -824,6 +836,9 @@ async function main() { .option("factorio-dir", { nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string", }) + .option("remote-npm", { + nargs: 0, description: "Allow remote usage of npm [standalone/controller/host]", type: "boolean", + }) .option("plugins", { array: true, describe: "Plugins to install", type: "string", }) @@ -889,6 +904,9 @@ async function main() { logger.info("Setting up controller"); await execController(["bootstrap", "create-admin", answers.admin]); await execController(["config", "set", "controller.http_port", answers.httpPort]); + await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]); + await execController(["config", "set", "controller.allow_plugin_updates", answers.remoteNpm]); + // await execController(["config", "set", "controller.allow_plugin_install", answers.remoteNpm]); let result = await execController(["bootstrap", "generate-user-token", answers.admin]); adminToken = result.stdout.split("\n").slice(-2)[0]; } @@ -908,6 +926,9 @@ async function main() { await execHost(["config", "set", "host.controller_token", hostToken]); await execHost(["config", "set", "host.public_address", answers.publicAddress]); await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]); + await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]); + await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]); + // await execHost(["config", "set", "host.allow_plugin_install", answers.remoteNpm]); } if (answers.mode === "host") { @@ -919,6 +940,9 @@ async function main() { await execHost(["config", "set", "host.controller_token", answers.controllerToken]); await execHost(["config", "set", "host.public_address", answers.publicAddress]); await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]); + await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]); + await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]); + // await execHost(["config", "set", "host.allow_plugin_install", answers.remoteNpm]); } if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) { diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index efe86c4e..836bb2a8 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -95,6 +95,47 @@ controllerCommands.add(new lib.Command({ await control.send(new lib.ControllerRestartRequest()); }, })); +controllerCommands.add(new lib.Command({ + definition: ["update", "Update the controller", (yargs) => { + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { restart: boolean }, control: Control) { + await control.sendTo("controller", new lib.RemoteUpdateRequest()); + if (args.restart) { + await control.send(new lib.ControllerRestartRequest()); + } else { + print("Controller updated; a restart is required to apply the changes."); + } + }, +})); +controllerCommands.add(new lib.Command({ + definition: ["update-plugin", "Update a plugin on the controller", (yargs) => { + yargs.positional("plugin", { describe: "Plugin to update", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { plugin: string, restart: boolean }, control: Control) { + await control.sendTo("controller", new lib.PluginUpdateRequest(args.plugin)); + if (args.restart) { + await control.send(new lib.ControllerRestartRequest()); + } else { + print("Plugin updated; a restart is required to apply the changes."); + } + }, +})); +controllerCommands.add(new lib.Command({ + definition: ["install-plugin", "Install a plugin on the controller", (yargs) => { + yargs.positional("plugin", { describe: "Plugin to install", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { plugin: string, restart: boolean }, control: Control) { + await control.sendTo("controller", new lib.PluginInstallRequest(args.plugin)); + if (args.restart) { + await control.send(new lib.ControllerRestartRequest()); + } else { + print("Plugin installed; a restart is required to apply the changes."); + } + }, +})); const controllerConfigCommands = new lib.CommandTree({ name: "config", alias: ["c"], description: "controller config management", @@ -322,6 +363,53 @@ hostCommands.add(new lib.Command({ } }, })); +hostCommands.add(new lib.Command({ + definition: ["update", "Update the host", (yargs) => { + yargs.positional("host", { describe: "Host to update", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { host: string, restart: boolean }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + await control.sendTo({ hostId }, new lib.RemoteUpdateRequest()); + if (args.restart) { + await control.sendTo({ hostId }, new lib.HostRestartRequest()); + } else { + print("Host updated; a restart is required to apply the changes."); + } + }, +})); +hostCommands.add(new lib.Command({ + definition: ["update-plugin", "Update a plugin on a host", (yargs) => { + yargs.positional("host", { describe: "Host to update on", type: "string" }); + yargs.positional("plugin", { describe: "Plugin to update", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + await control.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin)); + if (args.restart) { + await control.sendTo({ hostId }, new lib.HostRestartRequest()); + } else { + print("Plugin updated; a restart is required to apply the changes."); + } + }, +})); +hostCommands.add(new lib.Command({ + definition: ["install-plugin", "Install a plugin on a host", (yargs) => { + yargs.positional("host", { describe: "Host to install on", type: "string" }); + yargs.positional("plugin", { describe: "Plugin to install", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + await control.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin)); + if (args.restart) { + await control.sendTo({ hostId }, new lib.HostRestartRequest()); + } else { + print("Plugin installed; a restart is required to apply the changes."); + } + }, +})); const hostConfigCommands = new lib.CommandTree({ name: "config", alias: ["c"], description: "Host config management", diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 0e60d2a1..e2450007 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -392,6 +392,9 @@ export default class Host extends lib.Link { this.handle(lib.InstancePullSaveRequest, this.handleInstancePullSaveRequest.bind(this)); this.handle(lib.InstancePushSaveRequest, this.handleInstancePushSaveRequest.bind(this)); this.handle(lib.InstanceDeleteInternalRequest, this.handleInstanceDeleteInternalRequest.bind(this)); + this.handle(lib.RemoteUpdateRequest, this.handleRemoteUpdateRequest.bind(this)); + this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); + this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); } async loadPlugins() { @@ -1043,6 +1046,34 @@ export default class Host extends lib.Link { await fs.remove(instanceInfo.path); } + async handleRemoteUpdateRequest(request: lib.RemoteUpdateRequest) { + if (!this.config.get("host.allow_remote_updates")) { + throw new lib.RequestError("Remote updates are disabled on this machine"); + } + return lib.updatePackage("@clusterio/host"); + } + + async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) { + if (!this.config.get("host.allow_plugin_updates")) { + throw new lib.RequestError("Plugin updates are disabled on this machine"); + } + if (!this.pluginInfos.some(plugin => plugin.npmPackage === request.pluginPackage)) { + throw new lib.RequestError(`Plugin ${request.pluginPackage} is not installed on this machine`); + } + return lib.updatePackage(request.pluginPackage); + } + + async handlePluginInstallRequest(request: lib.PluginInstallRequest) { + if (!this.config.get("host.allow_plugin_install")) { + throw new lib.RequestError("Plugin installs are disabled on this machine"); + } + const npmRequest = await fetch(`https://www.npmjs.com/package/${encodeURI(request.pluginPackage)}`); + if (!npmRequest.ok) { + throw new lib.RequestError(`Unknown plugin: ${request.pluginPackage}`); + } + return lib.installPackage(request.pluginPackage); + } + sendHostUpdate() { this.send( new lib.HostInfoUpdateEvent( diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 9cd87213..d5ebd73e 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -30,6 +30,7 @@ export * from "./src/zip_ops"; export * from "./src/subscriptions"; export * from "./src/datastore"; export * from "./src/load_plugin_list"; +export * from "./src/rce_ops"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as ModStore } from "./src/ModStore"; diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index b49e2a0a..5a06c228 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -28,6 +28,9 @@ export interface ControllerConfigFields { "controller.autosave_interval": number; "controller.mod_portal_cache_duration_minutes": number; "controller.mod_portal_page_size": number; + "controller.allow_remote_updates": boolean; + "controller.allow_plugin_updates": boolean; + "controller.allow_plugin_install": boolean; } /** @@ -211,6 +214,27 @@ export class ControllerConfig extends classes.Config { type: "number", initialValue: 1000, }, + "controller.allow_remote_updates": { + description: "When true, allows a remote event to trigger a clusterio update via npm", + type: "boolean", + initialValue: true, + readonly: ["controller"], + hidden: true, + }, + "controller.allow_plugin_updates": { + description: "When true, allows a remote event to trigger a plugin update via npm", + type: "boolean", + initialValue: true, + readonly: ["controller"], + hidden: true, + }, + "controller.allow_plugin_install": { + description: "When true, allows a remote event to trigger a plugin install via npm", + type: "boolean", + initialValue: false, + readonly: ["controller"], + hidden: true, + }, }; } @@ -228,6 +252,9 @@ export interface HostConfigFields { "host.factorio_username": string | null, "host.factorio_token": string | null, "host.max_reconnect_delay": number; + "host.allow_remote_updates": boolean; + "host.allow_plugin_updates": boolean; + "host.allow_plugin_install": boolean; } /** @@ -323,6 +350,27 @@ export class HostConfig extends classes.Config { type: "number", initialValue: 60, }, + "host.allow_remote_updates": { + description: "When true, allows a remote event to trigger a clusterio update via npm", + type: "boolean", + initialValue: true, + readonly: ["host"], + hidden: true, + }, + "host.allow_plugin_updates": { + description: "When true, allows a remote event to trigger a plugin update via npm", + type: "boolean", + initialValue: true, + readonly: ["host"], + hidden: true, + }, + "host.allow_plugin_install": { + description: "When true, allows a remote event to trigger a plugin install via npm", + type: "boolean", + initialValue: false, + readonly: ["host"], + hidden: true, + }, }; } diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index cd51704a..7d7f40d2 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -352,6 +352,58 @@ export class SystemInfoUpdateEvent { } } +export class RemoteUpdateRequest { + declare ["constructor"]: typeof RemoteUpdateRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission = "core.rce.remote_update"; +} + +export class PluginUpdateRequest { + declare ["constructor"]: typeof PluginUpdateRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission = "core.rce.plugin_update"; + + constructor( + public pluginPackage: string, + ) {} + + static jsonSchema = Type.String(); + + static fromJSON(json: Static) { + return new this(json); + } + + toJSON() { + return this.pluginPackage; + } +} + +export class PluginInstallRequest { + declare ["constructor"]: typeof PluginInstallRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission = "core.rce.plugin_install"; + + constructor( + public pluginPackage: string, + ) {} + + static jsonSchema = Type.String(); + + static fromJSON(json: Static) { + return new this(json); + } + + toJSON() { + return this.pluginPackage; + } +} + export class DebugDumpWsRequest { declare ["constructor"]: typeof DebugDumpWsRequest; static type = "request" as const; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 200e6c79..4e800b47 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -30,6 +30,9 @@ export const dataClasses: (RequestClass | EventClass) controller.LogMessageEvent, controller.SystemInfoRequest, controller.SystemInfoUpdateEvent, + controller.RemoteUpdateRequest, + controller.PluginUpdateRequest, + controller.PluginInstallRequest, controller.DebugDumpWsRequest, controller.DebugWsMessageEvent, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index c347e23e..27ff3263 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -525,6 +525,22 @@ definePermission({ description: "Query past entries in the cluster log. Required to see past entries in instance console.", }); +definePermission({ + name: "core.rce.remote_update", + title: "Clusterio remote update", + description: "Start a remote update of the clusterio packages", +}); +definePermission({ + name: "core.rce.plugin_update", + title: "Plugin remote update", + description: "Start a remote update of a third party plugin package", +}); +definePermission({ + name: "core.rce.plugin_install", + title: "Plugin remote install", + description: "Start a remote install of a third party plugin package", +}); + definePermission({ name: "core.debug.dump_ws", title: "Dump WebSocket", diff --git a/packages/lib/src/rce_ops.ts b/packages/lib/src/rce_ops.ts new file mode 100644 index 00000000..87ad5baa --- /dev/null +++ b/packages/lib/src/rce_ops.ts @@ -0,0 +1,26 @@ +import util from "util"; +import path from "path"; +import { exec } from "child_process"; +import { logger } from "./logging"; +const execAsync = util.promisify(exec); + +async function isDev() { + // dev: /packages/lib/dist/src/rce_ops.js + // prod: /node_modules/@clusterio/lib/dist/src/rce_ops.js + return __dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio" +} + +async function logExec(cmd: string) { + logger.audit(`RCE | ${cmd}`); + if (!isDev()) { + await execAsync(cmd); + } +} + +export async function updatePackage(name: string) { + return logExec(`npm update --save ${name}`); +} + +export async function installPackage(name: string) { + return logExec(`npm install --save ${name}`); +} diff --git a/packages/web_ui/src/components/ControllerPage.tsx b/packages/web_ui/src/components/ControllerPage.tsx index 135b9e81..6d59e1bc 100644 --- a/packages/web_ui/src/components/ControllerPage.tsx +++ b/packages/web_ui/src/components/ControllerPage.tsx @@ -17,6 +17,7 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import { notifyErrorHandler } from "../util/notify"; import webUiPackage from "../../package.json"; +import { hasNpmButtonPermission, NpmButton } from "./NpmButton"; const { Title } = Typography; @@ -65,6 +66,10 @@ export default function ControllerPage() { Restart } + { + hasNpmButtonPermission() + && + } } /> diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index 526a070e..9b1a247d 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -20,6 +20,7 @@ import { } from "./system_metrics"; import { formatTimestamp } from "../util/time_format"; import { useSystems } from "../model/system"; +import { hasNpmButtonPermission, NpmButton } from "./NpmButton"; const { Title } = Typography; @@ -101,6 +102,10 @@ export default function HostViewPage() { Restart } + { + hasNpmButtonPermission() + && + } ; return diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx new file mode 100644 index 00000000..26d4b361 --- /dev/null +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -0,0 +1,166 @@ +import { useContext, useState } from "react"; +import { Button, Checkbox, Form, FormInstance, Input, Modal, Radio, Select } from "antd"; + +import * as lib from "@clusterio/lib"; + +import { useAccount } from "../model/account"; +import { notifyErrorHandler } from "../util/notify"; +import ControlContext from "./ControlContext"; + +type NpmActionProps = { + setApplyAction(func: () => Promise): void, + form: FormInstance, + target: lib.AddressShorthand, +} + +function NpmRemoteUpdate({ setApplyAction, form, target }: NpmActionProps) { + const control = useContext(ControlContext); + + setApplyAction(async () => { + await control.sendTo(target, new lib.RemoteUpdateRequest()); + }); + + return <> + ; +} + +function NpmPluginUpdate({ setApplyAction, form, target }: NpmActionProps) { + const control = useContext(ControlContext); + + setApplyAction(async () => { + const values = form.getFieldsValue(); + if (!values.plugin || values.plugin === "") { + throw new Error("Plugin not provided"); + } + await control.sendTo(target, new lib.PluginUpdateRequest(values.plugin)); + }); + + return <> + + + + ; + + // eslint-disable-next-line multiline-comment-style + /* TODO Allow control access to plugins on hosts + return <> + + + + ; +} + +export function NpmButton(props: { target: lib.AddressShorthand, canRestart?: boolean, disabled?: boolean }) { + const control = useContext(ControlContext); + const account = useAccount(); + const [open, setOpen] = useState(false); + const [formAction, setFormAction] = useState(undefined); + const [form] = Form.useForm(); + + function onValuesChange({ action } : { action?: string }) { + if (action) { + setFormAction(action); + } + } + + let applyAction: () => Promise; + const setApplyAction = (func: () => Promise) => { applyAction = func; }; + async function onOk() { + if (!applyAction) { + form.setFields([{ name: "action", errors: ["Action is required"] }]); + return; + } + await applyAction(); + + const values = form.getFieldsValue(); + if (values.restart) { + // Ternary not used because TS disliked it + if (props.target === "controller") { + await control.sendTo(props.target, new lib.ControllerRestartRequest()); + } else { + await control.sendTo(props.target, new lib.HostRestartRequest()); + } + } + + setFormAction(undefined); + setOpen(false); + } + + // TODO retrieve config so options can be disabled rather than using the error messages + return <> + + { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} + onCancel={() => { setOpen(false); }} + destroyOnClose + > +
+ + + {account.hasPermission("core.rce.remote_update") + ? Update Clusterio : undefined} + {account.hasPermission("core.rce.plugin_update") + ? Update Plugin : undefined} + {account.hasPermission("core.rce.plugin_install") + ? Install Plugin : undefined} + + + { + formAction !== undefined + && account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.restart`) + && + + + } + {formAction === "remote_update" + ? : undefined} + {formAction === "plugin_update" + ? : undefined} + {formAction === "plugin_install" + ? : undefined} + +
+ ; +} + +export function hasNpmButtonPermission() { + const account = useAccount(); + return account.hasAnyPermission( + "core.rce.remote_update", "core.rce.plugin_update", "core.rce.plugin_install" + ); +} From 42ad7f87048019bba1595a11831be12fd6a7615f Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Wed, 30 Apr 2025 00:05:24 +0100 Subject: [PATCH 2/8] Add plugin list to update modal --- packages/controller/src/ControlConnection.ts | 20 ++- packages/ctl/src/commands.ts | 145 ++++++++++-------- packages/host/src/Host.ts | 30 ++-- packages/lib/src/data/index.ts | 1 + packages/lib/src/data/messages_controller.ts | 60 +------- packages/lib/src/data/messages_host.ts | 8 + packages/lib/src/data/messages_plugin.ts | 87 +++++++++++ packages/lib/src/link/messages.ts | 10 +- packages/lib/src/permissions.ts | 28 ++-- packages/lib/src/rce_ops.ts | 28 ++++ .../web_ui/src/components/ControllerPage.tsx | 2 +- .../web_ui/src/components/HostViewPage.tsx | 2 +- packages/web_ui/src/components/NpmButton.tsx | 44 +++--- 13 files changed, 292 insertions(+), 173 deletions(-) create mode 100644 packages/lib/src/data/messages_plugin.ts diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index a2b81ac5..410e1fd9 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -70,6 +70,7 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.ControllerStopRequest, this.handleControllerStopRequest.bind(this)); this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this)); + this.handle(lib.ControllerUpdateRequest, this.handleControllerUpdateRequest.bind(this)); this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this)); this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this)); this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this)); @@ -119,7 +120,7 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.UserDeleteRequest, this.handleUserDeleteRequest.bind(this)); this.handle(lib.UserBulkImportRequest, this.handleUserBulkImportRequest.bind(this)); this.handle(lib.UserBulkExportRequest, this.handleUserBulkExportRequest.bind(this)); - this.handle(lib.RemoteUpdateRequest, this.handleRemoteUpdateRequest.bind(this)); + this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this)); this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); @@ -1052,7 +1053,7 @@ export default class ControlConnection extends BaseConnection { return usersToSend; } - async handleRemoteUpdateRequest(request: lib.RemoteUpdateRequest) { + async handleControllerUpdateRequest(request: lib.ControllerUpdateRequest) { if (!this._controller.config.get("controller.allow_remote_updates")) { throw new lib.RequestError("Remote updates are disabled on this machine"); } @@ -1063,21 +1064,18 @@ export default class ControlConnection extends BaseConnection { if (!this._controller.config.get("controller.allow_plugin_updates")) { throw new lib.RequestError("Plugin updates are disabled on this machine"); } - if (!this._controller.pluginInfos.some(plugin => plugin.npmPackage === request.pluginPackage)) { - throw new lib.RequestError(`Plugin ${request.pluginPackage} is not installed on this machine`); - } - return lib.updatePackage(request.pluginPackage); + return await lib.handlePluginUpdate(request.pluginPackage, this._controller.pluginInfos); } async handlePluginInstallRequest(request: lib.PluginInstallRequest) { if (!this._controller.config.get("controller.allow_plugin_install")) { throw new lib.RequestError("Plugin installs are disabled on this machine"); } - const npmRequest = await fetch(`https://www.npmjs.com/package/${encodeURI(request.pluginPackage)}`); - if (!npmRequest.ok) { - throw new lib.RequestError(`Unknown plugin: ${request.pluginPackage}`); - } - return lib.installPackage(request.pluginPackage); + return await lib.handlePluginInstall(request.pluginPackage); + } + + async handlePluginListRequest(request: lib.PluginListRequest) { + return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(pluginInfo)); } async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) { diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 836bb2a8..23d60619 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -100,7 +100,7 @@ controllerCommands.add(new lib.Command({ yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); }], handler: async function(args: { restart: boolean }, control: Control) { - await control.sendTo("controller", new lib.RemoteUpdateRequest()); + await control.send(new lib.ControllerUpdateRequest()); if (args.restart) { await control.send(new lib.ControllerRestartRequest()); } else { @@ -108,34 +108,6 @@ controllerCommands.add(new lib.Command({ } }, })); -controllerCommands.add(new lib.Command({ - definition: ["update-plugin", "Update a plugin on the controller", (yargs) => { - yargs.positional("plugin", { describe: "Plugin to update", type: "string" }); - yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); - }], - handler: async function(args: { plugin: string, restart: boolean }, control: Control) { - await control.sendTo("controller", new lib.PluginUpdateRequest(args.plugin)); - if (args.restart) { - await control.send(new lib.ControllerRestartRequest()); - } else { - print("Plugin updated; a restart is required to apply the changes."); - } - }, -})); -controllerCommands.add(new lib.Command({ - definition: ["install-plugin", "Install a plugin on the controller", (yargs) => { - yargs.positional("plugin", { describe: "Plugin to install", type: "string" }); - yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); - }], - handler: async function(args: { plugin: string, restart: boolean }, control: Control) { - await control.sendTo("controller", new lib.PluginInstallRequest(args.plugin)); - if (args.restart) { - await control.send(new lib.ControllerRestartRequest()); - } else { - print("Plugin installed; a restart is required to apply the changes."); - } - }, -})); const controllerConfigCommands = new lib.CommandTree({ name: "config", alias: ["c"], description: "controller config management", @@ -275,6 +247,35 @@ controllerPluginCommands.add(new lib.Command({ print(asTable(response.body)); }, })); +controllerPluginCommands.add(new lib.Command({ + definition: ["update ", "Update a plugin on the controller", (yargs) => { + yargs.positional("plugin", { describe: "Plugin to update", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { plugin: string, restart: boolean }, control: Control) { + await control.sendTo("controller", new lib.PluginUpdateRequest(args.plugin)); + if (args.restart) { + await control.send(new lib.ControllerRestartRequest()); + } else { + print("Plugin updated; a restart is required to apply the changes."); + } + }, +})); +controllerPluginCommands.add(new lib.Command({ + definition: ["install ", "Install a plugin on the controller", (yargs) => { + yargs.positional("plugin", { describe: "Plugin to install", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { plugin: string, restart: boolean }, control: Control) { + await control.sendTo("controller", new lib.PluginInstallRequest(args.plugin)); + if (args.restart) { + await control.send(new lib.ControllerRestartRequest()); + } else { + print("Plugin installed; a restart is required to apply the changes."); + } + }, +})); + controllerCommands.add(controllerPluginCommands); @@ -370,7 +371,7 @@ hostCommands.add(new lib.Command({ }], handler: async function(args: { host: string, restart: boolean }, control: Control) { let hostId = await lib.resolveHost(control, args.host); - await control.sendTo({ hostId }, new lib.RemoteUpdateRequest()); + await control.sendTo({ hostId }, new lib.HostUpdateRequest()); if (args.restart) { await control.sendTo({ hostId }, new lib.HostRestartRequest()); } else { @@ -378,38 +379,7 @@ hostCommands.add(new lib.Command({ } }, })); -hostCommands.add(new lib.Command({ - definition: ["update-plugin", "Update a plugin on a host", (yargs) => { - yargs.positional("host", { describe: "Host to update on", type: "string" }); - yargs.positional("plugin", { describe: "Plugin to update", type: "string" }); - yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); - }], - handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) { - let hostId = await lib.resolveHost(control, args.host); - await control.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin)); - if (args.restart) { - await control.sendTo({ hostId }, new lib.HostRestartRequest()); - } else { - print("Plugin updated; a restart is required to apply the changes."); - } - }, -})); -hostCommands.add(new lib.Command({ - definition: ["install-plugin", "Install a plugin on a host", (yargs) => { - yargs.positional("host", { describe: "Host to install on", type: "string" }); - yargs.positional("plugin", { describe: "Plugin to install", type: "string" }); - yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); - }], - handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) { - let hostId = await lib.resolveHost(control, args.host); - await control.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin)); - if (args.restart) { - await control.sendTo({ hostId }, new lib.HostRestartRequest()); - } else { - print("Plugin installed; a restart is required to apply the changes."); - } - }, -})); + const hostConfigCommands = new lib.CommandTree({ name: "config", alias: ["c"], description: "Host config management", @@ -552,9 +522,60 @@ hostConfigCommands.add(new lib.Command({ } }, })); + hostCommands.add(hostConfigCommands); +const hostPluginCommands = new lib.CommandTree({ + name: "plugin", alias: ["p"], description: "host plugin inspection", +}); +hostPluginCommands.add(new lib.Command({ + definition: ["list ", "List plugins on a host", (yargs) => { + yargs.positional("host", { describe: "Host to list for", type: "string" }); + }], + handler: async function(args: { host: string }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + const plugins = await control.sendTo({ hostId }, new lib.PluginListRequest()); + print(asTable(plugins.map(p => ({ + title: p.title, version: p.version, npmPackage: p.npmPackage, + })))); + }, +})); +hostPluginCommands.add(new lib.Command({ + definition: ["update ", "Update a plugin on a host", (yargs) => { + yargs.positional("host", { describe: "Host to update on", type: "string" }); + yargs.positional("plugin", { describe: "Plugin to update", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + await control.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin)); + if (args.restart) { + await control.sendTo({ hostId }, new lib.HostRestartRequest()); + } else { + print("Plugin updated; a restart is required to apply the changes."); + } + }, +})); +hostPluginCommands.add(new lib.Command({ + definition: ["install ", "Install a plugin on a host", (yargs) => { + yargs.positional("host", { describe: "Host to install on", type: "string" }); + yargs.positional("plugin", { describe: "Plugin to install", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + await control.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin)); + if (args.restart) { + await control.sendTo({ hostId }, new lib.HostRestartRequest()); + } else { + print("Plugin installed; a restart is required to apply the changes."); + } + }, +})); +hostCommands.add(hostPluginCommands); + + 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 e2450007..4cf0df72 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -371,17 +371,23 @@ export default class Host extends lib.Link { this.handle(lib.HostStopRequest, this.handleHostStopRequest.bind(this)); this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this)); + this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this)); 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.HostMetricsRequest, this.handleHostMetricsRequest.bind(this)); this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this)); + this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this)); + this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this)); + this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); + this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); + this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.handleWhitelistUpdateEvent.bind(this)); + this.handle(lib.InstanceAssignInternalRequest, this.handleInstanceAssignInternalRequest.bind(this)); this.handle(lib.InstanceUnassignInternalRequest, this.handleInstanceUnassignInternalRequest.bind(this)); - this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this)); - this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this)); this.fallbackRequest( lib.InstanceSaveDetailsListRequest, this.fallbackInstanceSaveDetailsListRequest.bind(this), ); @@ -392,9 +398,6 @@ export default class Host extends lib.Link { this.handle(lib.InstancePullSaveRequest, this.handleInstancePullSaveRequest.bind(this)); this.handle(lib.InstancePushSaveRequest, this.handleInstancePushSaveRequest.bind(this)); this.handle(lib.InstanceDeleteInternalRequest, this.handleInstanceDeleteInternalRequest.bind(this)); - this.handle(lib.RemoteUpdateRequest, this.handleRemoteUpdateRequest.bind(this)); - this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); - this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); } async loadPlugins() { @@ -1046,7 +1049,7 @@ export default class Host extends lib.Link { await fs.remove(instanceInfo.path); } - async handleRemoteUpdateRequest(request: lib.RemoteUpdateRequest) { + async handleHostUpdateRequest(request: lib.HostUpdateRequest) { if (!this.config.get("host.allow_remote_updates")) { throw new lib.RequestError("Remote updates are disabled on this machine"); } @@ -1057,21 +1060,18 @@ export default class Host extends lib.Link { if (!this.config.get("host.allow_plugin_updates")) { throw new lib.RequestError("Plugin updates are disabled on this machine"); } - if (!this.pluginInfos.some(plugin => plugin.npmPackage === request.pluginPackage)) { - throw new lib.RequestError(`Plugin ${request.pluginPackage} is not installed on this machine`); - } - return lib.updatePackage(request.pluginPackage); + return await lib.handlePluginUpdate(request.pluginPackage, this.pluginInfos); } async handlePluginInstallRequest(request: lib.PluginInstallRequest) { if (!this.config.get("host.allow_plugin_install")) { throw new lib.RequestError("Plugin installs are disabled on this machine"); } - const npmRequest = await fetch(`https://www.npmjs.com/package/${encodeURI(request.pluginPackage)}`); - if (!npmRequest.ok) { - throw new lib.RequestError(`Unknown plugin: ${request.pluginPackage}`); - } - return lib.installPackage(request.pluginPackage); + return await lib.handlePluginInstall(request.pluginPackage); + } + + async handlePluginListRequest(request: lib.PluginListRequest) { + return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(pluginInfo)); } sendHostUpdate() { diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts index 931f75fc..d3f781cf 100644 --- a/packages/lib/src/data/index.ts +++ b/packages/lib/src/data/index.ts @@ -21,4 +21,5 @@ export * from "./messages_host"; export * from "./messages_instance"; export * from "./messages_mod"; export * from "./messages_user"; +export * from "./messages_plugin"; export * from "./version"; diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index 7d7f40d2..9be91bc6 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -19,6 +19,14 @@ export class ControllerRestartRequest { static permission = "core.controller.restart" as const; } +export class ControllerUpdateRequest { + declare ["constructor"]: typeof ControllerUpdateRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.controller.update" as const; +} + export class ControllerConfigGetRequest { declare ["constructor"]: typeof ControllerConfigGetRequest; static type = "request" as const; @@ -352,58 +360,6 @@ export class SystemInfoUpdateEvent { } } -export class RemoteUpdateRequest { - declare ["constructor"]: typeof RemoteUpdateRequest; - static type = "request" as const; - static src = "control" as const; - static dst = ["controller", "host"] as const; - static permission = "core.rce.remote_update"; -} - -export class PluginUpdateRequest { - declare ["constructor"]: typeof PluginUpdateRequest; - static type = "request" as const; - static src = "control" as const; - static dst = ["controller", "host"] as const; - static permission = "core.rce.plugin_update"; - - constructor( - public pluginPackage: string, - ) {} - - static jsonSchema = Type.String(); - - static fromJSON(json: Static) { - return new this(json); - } - - toJSON() { - return this.pluginPackage; - } -} - -export class PluginInstallRequest { - declare ["constructor"]: typeof PluginInstallRequest; - static type = "request" as const; - static src = "control" as const; - static dst = ["controller", "host"] as const; - static permission = "core.rce.plugin_install"; - - constructor( - public pluginPackage: string, - ) {} - - static jsonSchema = Type.String(); - - static fromJSON(json: Static) { - return new this(json); - } - - toJSON() { - return this.pluginPackage; - } -} - export class DebugDumpWsRequest { declare ["constructor"]: typeof DebugDumpWsRequest; static type = "request" as const; diff --git a/packages/lib/src/data/messages_host.ts b/packages/lib/src/data/messages_host.ts index 5061724d..66399f19 100644 --- a/packages/lib/src/data/messages_host.ts +++ b/packages/lib/src/data/messages_host.ts @@ -19,6 +19,14 @@ export class HostRestartRequest { static permission = "core.host.restart" as const; } +export class HostUpdateRequest { + declare ["constructor"]: typeof HostUpdateRequest; + static type = "request" as const; + static src = ["control", "controller"] as const; + static dst = "host" as const; + static permission = "core.host.update" as const; +} + export class HostConfigGetRequest { declare ["constructor"]: typeof HostConfigGetRequest; static type = "request" as const; diff --git a/packages/lib/src/data/messages_plugin.ts b/packages/lib/src/data/messages_plugin.ts new file mode 100644 index 00000000..09bf687e --- /dev/null +++ b/packages/lib/src/data/messages_plugin.ts @@ -0,0 +1,87 @@ +import { Type, Static } from "@sinclair/typebox"; +import { jsonArray } from "./composites"; +import { PluginNodeEnvInfo } from "../plugin"; + +/* This is similar to other plugin definitions but with lots removed */ +export class PluginDetails { + constructor( + public name: string, + public title: string, + public version: string, + public description?: string, + public npmPackage?: string, + ) {} + + static jsonSchema = Type.Object({ + name: Type.String(), + title: Type.String(), + version: Type.String(), + description: Type.Optional(Type.String()), + npmPackage: Type.Optional(Type.String()), + }); + + static fromJSON(json: Static) { + return new this(json.name, json.title, json.version, json.description, json.npmPackage); + } + + static fromNodeEnvInfo(pluginInfo: PluginNodeEnvInfo) { + return new this( + pluginInfo.name, pluginInfo.title, + pluginInfo.version, pluginInfo.description, + pluginInfo.npmPackage, + ); + } +} + +export class PluginListRequest { + declare ["constructor"]: typeof PluginListRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission = "core.plugin.list"; + static Response = jsonArray(PluginDetails); +} + +export class PluginUpdateRequest { + declare ["constructor"]: typeof PluginUpdateRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission = "core.plugin.update"; + + constructor( + public pluginPackage: string, + ) {} + + static jsonSchema = Type.String(); + + static fromJSON(json: Static) { + return new this(json); + } + + toJSON() { + return this.pluginPackage; + } +} + +export class PluginInstallRequest { + declare ["constructor"]: typeof PluginInstallRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission = "core.plugin.install"; + + constructor( + public pluginPackage: string, + ) {} + + static jsonSchema = Type.String(); + + static fromJSON(json: Static) { + return new this(json); + } + + toJSON() { + return this.pluginPackage; + } +} diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 4e800b47..5d4977c1 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -4,6 +4,7 @@ import * as host from "../data/messages_host"; import * as instance from "../data/messages_instance"; import * as mod from "../data/messages_mod"; import * as user from "../data/messages_user"; +import * as plugin from "../data/messages_plugin"; import * as subscriptions from "../subscriptions"; import type { RequestClass, EventClass } from "./link"; @@ -19,6 +20,7 @@ export const dataClasses: (RequestClass | EventClass) controller.ControllerStopRequest, controller.ControllerRestartRequest, + controller.ControllerUpdateRequest, controller.ControllerConfigGetRequest, controller.ControllerConfigSetFieldRequest, controller.ControllerConfigSetPropRequest, @@ -30,9 +32,6 @@ export const dataClasses: (RequestClass | EventClass) controller.LogMessageEvent, controller.SystemInfoRequest, controller.SystemInfoUpdateEvent, - controller.RemoteUpdateRequest, - controller.PluginUpdateRequest, - controller.PluginInstallRequest, controller.DebugDumpWsRequest, controller.DebugWsMessageEvent, @@ -40,6 +39,7 @@ export const dataClasses: (RequestClass | EventClass) host.HostStopRequest, host.HostRestartRequest, + host.HostUpdateRequest, host.HostConfigGetRequest, host.HostConfigSetFieldRequest, host.HostConfigSetPropRequest, @@ -125,4 +125,8 @@ export const dataClasses: (RequestClass | EventClass) user.UserUpdatesEvent, user.UserBulkImportRequest, user.UserBulkExportRequest, + + plugin.PluginListRequest, + plugin.PluginUpdateRequest, + plugin.PluginInstallRequest, ]; diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index 27ff3263..478f9039 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -131,6 +131,11 @@ definePermission({ title: "Restart controller", description: "Restart the Node.js controller process if the system is set up for restarting.", }); +definePermission({ + name: "core.controller.update", + title: "Update controller", + description: "Remotely update the controller if the controller allows for remote updates.", +}); definePermission({ name: "core.controller.get_config", title: "Get controller config", @@ -160,6 +165,11 @@ definePermission({ title: "Restart Hosts", description: "Restart Node.js host processes if the system they run on are set up for restarting.", }); +definePermission({ + name: "core.host.update", + title: "Update Hosts", + description: "Remotely update a host if the host allows for remote updates.", +}); definePermission({ name: "core.host.get_config", title: "Get host configs", @@ -526,19 +536,19 @@ definePermission({ }); definePermission({ - name: "core.rce.remote_update", - title: "Clusterio remote update", - description: "Start a remote update of the clusterio packages", + name: "core.plugin.list", + title: "Lust plugins", + description: "List all installed plugins on a machine.", }); definePermission({ - name: "core.rce.plugin_update", - title: "Plugin remote update", - description: "Start a remote update of a third party plugin package", + name: "core.plugin.update", + title: "Update plugin", + description: "Remotely update a plugin if the target allows remote updates of plugins.", }); definePermission({ - name: "core.rce.plugin_install", - title: "Plugin remote install", - description: "Start a remote install of a third party plugin package", + name: "core.plugin.install", + title: "Install plugin", + description: "Remotely install a plugin if the target allows remote installs of plugins.", }); definePermission({ diff --git a/packages/lib/src/rce_ops.ts b/packages/lib/src/rce_ops.ts index 87ad5baa..220a7b42 100644 --- a/packages/lib/src/rce_ops.ts +++ b/packages/lib/src/rce_ops.ts @@ -2,6 +2,8 @@ import util from "util"; import path from "path"; import { exec } from "child_process"; import { logger } from "./logging"; +import { RequestError } from "./errors"; +import { PluginNodeEnvInfo } from "./plugin"; const execAsync = util.promisify(exec); async function isDev() { @@ -24,3 +26,29 @@ export async function updatePackage(name: string) { export async function installPackage(name: string) { return logExec(`npm install --save ${name}`); } + +export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) { + if (!pluginInfos.some(plugin => plugin.npmPackage === pluginName)) { + throw new RequestError(`Plugin ${pluginName} is not installed on this machine`); + } + + return await updatePackage(pluginName); +} + +export async function handlePluginInstall(pluginName: string) { + if (pluginName.length > 214 || /[^a-zA-z0-9\-_.+@\/]/.test(pluginName)) { + // https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name + // https://www.npmjs.com/package/validate-npm-package-name + throw new RequestError(`Invalid plugin name: ${pluginName}`); + } + + const packageName = encodeURI(pluginName); + const npmRequest = await fetch(`https://registry.npmjs.com/${packageName}`, { + method: "HEAD", + }); + if (!npmRequest.ok) { + throw new RequestError(`Unknown plugin: ${packageName}`); + } + + return await installPackage(packageName); +} diff --git a/packages/web_ui/src/components/ControllerPage.tsx b/packages/web_ui/src/components/ControllerPage.tsx index 6d59e1bc..3133ed6f 100644 --- a/packages/web_ui/src/components/ControllerPage.tsx +++ b/packages/web_ui/src/components/ControllerPage.tsx @@ -67,7 +67,7 @@ export default function ControllerPage() { } { - hasNpmButtonPermission() + hasNpmButtonPermission(true) && } } diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index 9b1a247d..794e8e23 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -103,7 +103,7 @@ export default function HostViewPage() { } { - hasNpmButtonPermission() + hasNpmButtonPermission(false) && } ; diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx index 26d4b361..42d5be00 100644 --- a/packages/web_ui/src/components/NpmButton.tsx +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { Button, Checkbox, Form, FormInstance, Input, Modal, Radio, Select } from "antd"; import * as lib from "@clusterio/lib"; @@ -17,7 +17,10 @@ function NpmRemoteUpdate({ setApplyAction, form, target }: NpmActionProps) { const control = useContext(ControlContext); setApplyAction(async () => { - await control.sendTo(target, new lib.RemoteUpdateRequest()); + if (target === "controller") { + return await control.send(new lib.ControllerUpdateRequest()); + } + return await control.sendTo(target, new lib.HostUpdateRequest()); }); return <> @@ -26,6 +29,7 @@ function NpmRemoteUpdate({ setApplyAction, form, target }: NpmActionProps) { function NpmPluginUpdate({ setApplyAction, form, target }: NpmActionProps) { const control = useContext(ControlContext); + const [plugins, setPlugins] = useState([]); setApplyAction(async () => { const values = form.getFieldsValue(); @@ -35,27 +39,28 @@ function NpmPluginUpdate({ setApplyAction, form, target }: NpmActionProps) { await control.sendTo(target, new lib.PluginUpdateRequest(values.plugin)); }); - return <> - - - - ; + useEffect(() => { + control.sendTo(target, new lib.PluginListRequest()).then(newPlugins => { + setPlugins(newPlugins); + }).catch(notifyErrorHandler("Error fetching role list")); + }, [target]); - // eslint-disable-next-line multiline-comment-style - /* TODO Allow control access to plugins on hosts return <>