diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md index 261d4241..5dac03b2 100644 --- a/docs/managing-a-cluster.md +++ b/docs/managing-a-cluster.md @@ -28,6 +28,21 @@ Generate a token for a host. `--id` is the host id. Create a host config. `--id` id the host id. `--name` is the host name. `--generate-token` decides whether to also generate a token, defaulting to `0`. `--output` is the filename for the output of the config; Default is "config-host.json", "-" for stdout. +### Update Clusterio + + ctl> host update [--id ] [--name ] [--restart] + +If remote updates are enabled on the host, this will attempt to update `@clusterio/host` on the machine. Changes will not be applied until the host is restarted. If the `--restart` flag is provided, the host will automatically restart once the update is complete. + +Remote updates are enabled by default but can be disabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the following command locally while the host is offline: + + host> config set host.allow_remote_updates true/false + +The controller also supports remote updates, following the same logic. The corresponding commands are: + + ctl> controller update [--restart] + controller> config set controller.allow_remote_updates true/false + ## Instances ### List Instances @@ -199,3 +214,50 @@ Note: If the player joins the cluster again a new account will be made for them ## Roles To be written. + + +## Plugins (Local) + +To be written. + + +## Plugins (Remote) + +By default, remote updates are enabled unless you selected "no" during installation. +Remote updates allow ctl and the web UI to update plugins on machines within the cluster. +However, remote installations are disabled by default to prevent the risk of arbitrary code execution through the installation of malicious npm packages onto the machine. See below if you wish to enable this feature. + +### List + + ctl> controller plugin list + ctl> host plugin list + +This command lists all plugins currently known to the remote. Newly installed plugins will not be displayed until the remote is restarted. Plugins that are distributed as an npm package can be updated remotely. + +### Update + + ctl> controller plugin update [--restart] + ctl> host plugin update [--restart] + +If remote plugin updates are enabled on the remote, this will execute `npm update --save `. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete. + +### Install + + ctl> controller plugin install [--restart] + ctl> host plugin install [--restart] + +If remote plugin installs are enabled on the remote, this will execute `npm install --save `. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete. + +### Enable/Disable Updates + + host> config set host.allow_plugin_updates true/false + controller> config set controller.allow_plugin_updates true/false + +Remote updates are enabled by default but can be disabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the above commands locally while the remote is offline. + +### Enable/Disable Installs + + host> config set host.allow_plugin_install true/false + controller> config set controller.allow_plugin_install true/false + +Remote installs are disabled by default but can be enabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the above commands locally while the remote is offline. diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 92db23a5..a2f7be4c 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,6 +120,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.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)); this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this)); } @@ -1049,6 +1053,37 @@ export default class ControlConnection extends BaseConnection { return usersToSend; } + 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"); + } + 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"); + } + 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"); + } + return await lib.handlePluginInstall(request.pluginPackage); + } + + async handlePluginListRequest(request: lib.PluginListRequest) { + return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( + pluginInfo, + this._controller.plugins.has(pluginInfo.name), + this._controller.config.get( + `${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields + ) as boolean, + )); + } + 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..68d4923f 100755 --- a/packages/create/create.js +++ b/packages/create/create.js @@ -688,6 +688,17 @@ async function inquirerMissingArgs(args) { } } + if (["standalone", "controller", "host"].includes(answers.mode)) { + answers = inquirer.prompt([ + { + type: "confirm", + name: "remoteNpm", + 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 +835,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 updates via npm [standalone/controller/host]", type: "boolean", + }) .option("plugins", { array: true, describe: "Plugins to install", type: "string", }) @@ -889,6 +903,8 @@ 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]); let result = await execController(["bootstrap", "generate-user-token", answers.admin]); adminToken = result.stdout.split("\n").slice(-2)[0]; } @@ -908,6 +924,8 @@ 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]); } if (answers.mode === "host") { @@ -919,6 +937,8 @@ 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]); } if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) { diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index efe86c4e..7d67dbd3 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -95,6 +95,19 @@ 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.send(new lib.ControllerUpdateRequest()); + if (args.restart) { + await control.send(new lib.ControllerRestartRequest()); + } else { + print("Controller updated; a restart is required to apply the changes."); + } + }, +})); const controllerConfigCommands = new lib.CommandTree({ name: "config", alias: ["c"], description: "controller config management", @@ -234,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); @@ -322,6 +364,22 @@ 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.HostUpdateRequest()); + if (args.restart) { + await control.sendTo({ hostId }, new lib.HostRestartRequest()); + } else { + print("Host updated; a restart is required to apply the changes."); + } + }, +})); + const hostConfigCommands = new lib.CommandTree({ name: "config", alias: ["c"], description: "Host config management", @@ -464,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, loaded: p.loaded, enabled: p.enabled, 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 8dfddad9..e265c9a8 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -376,17 +376,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), ); @@ -1065,6 +1071,35 @@ export default class Host extends lib.Link { await fs.remove(instanceInfo.path); } + async handleHostUpdateRequest(request: lib.HostUpdateRequest) { + 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"); + } + 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"); + } + return await lib.handlePluginInstall(request.pluginPackage); + } + + async handlePluginListRequest(request: lib.PluginListRequest) { + return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( + pluginInfo, + this.plugins.has(pluginInfo.name), + this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields) as boolean, + )); + } + 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 b05123f1..81919c3c 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -29,6 +29,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; } /** @@ -218,6 +221,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, + }, }; } @@ -236,6 +260,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; } /** @@ -337,6 +364,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/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 dd2c858f..dfcc368c 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; 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..edbe7664 --- /dev/null +++ b/packages/lib/src/data/messages_plugin.ts @@ -0,0 +1,93 @@ +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 loaded: boolean, + public enabled: boolean, + public description?: string, + public npmPackage?: string, + ) {} + + static jsonSchema = Type.Object({ + name: Type.String(), + title: Type.String(), + version: Type.String(), + loaded: Type.Boolean(), + enabled: Type.Boolean(), + description: Type.Optional(Type.String()), + npmPackage: Type.Optional(Type.String()), + }); + + static fromJSON(json: Static) { + return new this( + json.name, json.title, json.version, json.loaded, json.enabled, + json.description, json.npmPackage + ); + } + + static fromNodeEnvInfo(pluginInfo: PluginNodeEnvInfo, loaded: boolean, enabled: boolean) { + return new this( + pluginInfo.name, pluginInfo.title, pluginInfo.version, loaded, enabled, + 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 200e6c79..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, @@ -37,6 +39,7 @@ export const dataClasses: (RequestClass | EventClass) host.HostStopRequest, host.HostRestartRequest, + host.HostUpdateRequest, host.HostConfigGetRequest, host.HostConfigSetFieldRequest, host.HostConfigSetPropRequest, @@ -122,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 c347e23e..ba50389b 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", @@ -525,6 +535,22 @@ definePermission({ description: "Query past entries in the cluster log. Required to see past entries in instance console.", }); +definePermission({ + name: "core.plugin.list", + title: "List plugins", + description: "List all installed plugins on a machine.", +}); +definePermission({ + name: "core.plugin.update", + title: "Update plugin", + description: "Remotely update a plugin if the target allows remote updates of plugins.", +}); +definePermission({ + name: "core.plugin.install", + title: "Install plugin", + description: "Remotely install a plugin if the target allows remote installs of plugins.", +}); + 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..f9195ae4 --- /dev/null +++ b/packages/lib/src/rce_ops.ts @@ -0,0 +1,54 @@ +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() { + // 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}`); +} + +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 05f9152e..97a2aa43 100644 --- a/packages/web_ui/src/components/ControllerPage.tsx +++ b/packages/web_ui/src/components/ControllerPage.tsx @@ -18,6 +18,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; @@ -70,6 +71,10 @@ export default function ControllerPage() { } + { + hasNpmButtonPermission(true) + && + } } /> diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index 8739fa67..bd689ee4 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -21,6 +21,7 @@ import { } from "./system_metrics"; import { formatTimestamp } from "../util/time_format"; import { useSystems } from "../model/system"; +import { hasNpmButtonPermission, NpmButton } from "./NpmButton"; const { Title } = Typography; @@ -106,6 +107,10 @@ export default function HostViewPage() { } + { + hasNpmButtonPermission(false) + && + } ; 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..019bb884 --- /dev/null +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -0,0 +1,211 @@ +import { useContext, useEffect, 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 () => { + if (target === "controller") { + return await control.send(new lib.ControllerUpdateRequest()); + } + return await control.sendTo(target, new lib.HostUpdateRequest()); + }); + + return <> + ; +} + +function NpmPluginUpdate({ setApplyAction, form, target }: NpmActionProps) { + const control = useContext(ControlContext); + const [plugins, setPlugins] = useState([]); + + 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)); + }); + + useEffect(() => { + control.sendTo(target, new lib.PluginListRequest()).then(newPlugins => { + setPlugins(newPlugins); + }).catch(notifyErrorHandler("Error fetching role list")); + }, [target]); + + 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(); + + const [allows, setAllows] = useState({ + remoteUpdates: true, pluginUpdates: true, pluginInstall: true, + }); + + useEffect(() => { + if (props.target === "controller") { + if (!account.hasPermission("core.controller.get_config")) { + setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true }); + return; // If a user doesn't have config perms, then assume true to allow the sending the request + } + control.send(new lib.ControllerConfigGetRequest()).then(async (serializedConfig) => { + const config = lib.ControllerConfig.fromJSON(serializedConfig, "control"); + setAllows({ + remoteUpdates: config.get("controller.allow_remote_updates"), + pluginUpdates: config.get("controller.allow_plugin_updates"), + pluginInstall: config.get("controller.allow_plugin_install"), + }); + }).catch(notifyErrorHandler("Failed to fetch controller config for remote updates")); + } else { + if (!account.hasPermission("core.host.get_config")) { + setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true }); + return; // If a user doesn't have config perms, then assume true to allow the sending the request + } + control.sendTo(props.target, new lib.HostConfigGetRequest()).then(async (serializedConfig) => { + const config = lib.HostConfig.fromJSON(serializedConfig, "control"); + setAllows({ + remoteUpdates: config.get("host.allow_remote_updates"), + pluginUpdates: config.get("host.allow_plugin_updates"), + pluginInstall: config.get("host.allow_plugin_install"), + }); + }).catch(notifyErrorHandler("Failed to fetch host config for remote updates")); + } + }, [props.target]); + + 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); + } + + return <> + + { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} + onCancel={() => { setOpen(false); }} + destroyOnClose + > +
+ + + {account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.update`) + ? + Update Clusterio + : undefined} + {account.hasPermission("core.plugin.update") + ? + Update Plugin + : undefined} + {account.hasPermission("core.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(controller: boolean) { + const account = useAccount(); + return account.hasAnyPermission( + controller ? "core.controller.update" : "core.host.update", + "core.plugin.update", "core.plugin.install" + ); +} diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index e8e715b7..ef1ecebe 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -256,6 +256,7 @@ describe("Integration of Clusterio", function() { assert.equal(Object.prototype.hasOwnProperty.call(result, "controller.auth_secret"), false); }); }); + describe("controller config set", function() { it("sets given config option", async function() { await execCtl('controller config set controller.name "Test Cluster"'); @@ -273,11 +274,58 @@ describe("Integration of Clusterio", function() { }); }); + describe("controller plugin update", function() { + it("runs", async function() { + // In dev plugins have no npm package, so best we can do is get an error from the controller + assert.rejects( + execCtl("controller plugin update foo"), + "Plugin foo is not installed on this machine" + ); + }); + // Update always fails, we can not test restart option + }); + + describe("controller plugin install", function() { + it("runs", async function() { + // Default is to disallow updates, changing this value would require a restart + // Additionally, it can not be changed via ctl, so best we can do is get an error from the controller + assert.rejects( + execCtl("controller plugin install foo"), + "Plugin installs are disabled on this machine" + ); + }); + // Install always fails, we can not test restart option + }); + + describe("controller update", function() { + it("runs", async function() { + await execCtl("controller update"); + }); + it("accepts --restart", async function() { + // We cannot restart the controller, so we check for controller error instead + assert.rejects( + execCtl("controller update --restart"), + "Cannot restart, controller does not have a process monitor to restart it." + ); + }); + }); + + describe("controller restart", function() { + it("runs", async function() { + // We cannot restart the controller, so we check for controller error instead + assert.rejects( + execCtl("controller restart"), + "Cannot restart, controller does not have a process monitor to restart it." + ); + }); + }); + describe("host list", function() { it("runs", async 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"); @@ -301,6 +349,59 @@ describe("Integration of Clusterio", function() { assert.equal(Object.prototype.hasOwnProperty.call(result, "host.controller_token"), false); }); }); + + describe("host plugin list", function() { + it("runs", async function() { + await execCtl("host plugin list 4"); + }); + }); + + describe("host plugin update", function() { + it("runs", async function() { + // In dev plugins have no npm package, so best we can do is get an error from the host + assert.rejects( + execCtl("host plugin update 4 foo"), + "Plugin foo is not installed on this machine" + ); + }); + // Update always fails, we can not test restart option + }); + + describe("host plugin install", function() { + it("runs", async function() { + // Default is to disallow updates, changing this value would require a restart + // Additionally, it can not be changed via ctl, so best we can do is get an error from the host + assert.rejects( + execCtl("host plugin install 4 foo"), + "Plugin installs are disabled on this machine" + ); + }); + // Install always fails, we can not test restart option + }); + + describe("host update", function() { + it("runs", async function() { + await execCtl("host update 4"); + }); + it("accepts --restart", async function() { + // We cannot restart the host, so we check for host error instead + assert.rejects( + execCtl("host update 4 --restart"), + "Cannot restart, host does not have a process monitor to restart it." + ); + }); + }); + + describe("host restart", function() { + it("runs", async function() { + // We cannot restart the host, so we check for host error instead + assert.rejects( + execCtl("host restart 4"), + "Cannot restart, host does not have a process monitor to restart it." + ); + }); + }); + describe("host generate-token", function() { it("runs", async function() { await execCtl("host generate-token --id 42"); @@ -309,6 +410,7 @@ describe("Integration of Clusterio", function() { await execCtl("host generate-token"); }); }); + describe("host revoke-token", async function() { it("should disconnect existing host", async function() { slowTest(this); @@ -330,6 +432,7 @@ describe("Integration of Clusterio", function() { assert(sawDisconnected, "No host update with status disconnected was sent after revoking token"); }); }); + describe("instance list", function() { it("runs", async function() { await execCtl("instance list"); diff --git a/test/lib/rce_ops.js b/test/lib/rce_ops.js new file mode 100644 index 00000000..ec692462 --- /dev/null +++ b/test/lib/rce_ops.js @@ -0,0 +1,76 @@ +"use strict"; +const assert = require("assert").strict; +const lib = require("@clusterio/lib"); + +describe("rce_ops", function() { + describe("updatePackage", function() { + it("runs", async function() { + // In dev env no side effects occur, so best we can do is check it doesn't error + await lib.updatePackage("foo"); + }); + }); + + describe("installPackage", function() { + it("runs", async function() { + // In dev env no side effects occur, so best we can do is check it doesn't error + await lib.installPackage("foo"); + }); + }); + + describe("handlePluginUpdate", function() { + it("runs", async function() { + await lib.handlePluginUpdate("foo", [{ npmPackage: "foo" }]); + }); + it("rejects when plugin not installed", async function() { + assert.rejects( + lib.handlePluginUpdate("foo", []), + "Plugin foo is not installed on this machine" + ); + }); + }); + + describe("handlePluginInstall", function() { + const _fetch = global.fetch; + before(function() { + global.fetch = function() { return { ok: false }; }; + }); + after(function() { + global.fetch = _fetch; + }); + + it("runs", async function() { + let calledWith = ""; + global.fetch = function(url) { + calledWith = url; + return { ok: true }; + }; + + await lib.handlePluginInstall("foo"); + assert.equal(calledWith, "https://registry.npmjs.com/foo"); + }); + it("rejects when name too long", async function() { + const pluginName = "a".repeat(215); + assert.rejects( + lib.handlePluginInstall(pluginName), + `Invalid plugin name: ${pluginName}` + ); + }); + it("rejects when invalid symbol present", async function() { + const pluginName = "?"; + assert.rejects( + lib.handlePluginInstall(pluginName), + `Invalid plugin name: ${pluginName}` + ); + }); + it("rejects unregistered packages", async function() { + let calledWith = ""; + global.fetch = function(url) { + calledWith = url; + return { ok: false }; + }; + + assert.rejects(lib.handlePluginInstall("foo"), "Unknown plugin: foo"); + assert.equal(calledWith, "https://registry.npmjs.com/foo"); + }); + }); +}); diff --git a/test/messages/controller.test.js b/test/messages/controller.test.js new file mode 100644 index 00000000..89f43ff4 --- /dev/null +++ b/test/messages/controller.test.js @@ -0,0 +1,37 @@ +"use strict"; +const assert = require("assert").strict; +const lib = require("@clusterio/lib"); + +const { Controller, ControlConnection, ControllerUser } = require("@clusterio/controller"); + +describe("messages/controller", function() { + /** @type {Controller} */ + let controller; + /** @type {ControlConnection} */ + let controlConnection; + + beforeEach(function() { + const controllerConfig = new lib.ControllerConfig("controller"); + const connection = new lib.VirtualConnector( + lib.Address.fromShorthand("controller"), + lib.Address.fromShorthand({ controlId: 1 }), + ); + controller = new Controller(lib.logger, [], "", controllerConfig); + const user = new ControllerUser(controller.userManager, undefined, "test"); + controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1); + }); + + describe("ControllerUpdateRequest", function() { + it("runs", async function() { + controller.config.set("controller.allow_remote_updates", true); + await controlConnection.handleControllerUpdateRequest(new lib.ControllerUpdateRequest()); + }); + it("rejects if updates are disabled", async function() { + controller.config.set("controller.allow_remote_updates", false); + assert.rejects( + controlConnection.handleControllerUpdateRequest(new lib.ControllerUpdateRequest()), + "Remote updates are disabled on this machine" + ); + }); + }); +}); diff --git a/test/messages/host.test.js b/test/messages/host.test.js new file mode 100644 index 00000000..47a250ac --- /dev/null +++ b/test/messages/host.test.js @@ -0,0 +1,33 @@ +"use strict"; +const assert = require("assert").strict; +const lib = require("@clusterio/lib"); + +const { Host } = require("@clusterio/host"); + +describe("messages/controller", function() { + /** @type {Host} */ + let host; + + beforeEach(function() { + const hostConfig = new lib.HostConfig("host"); + const hostConnector = new lib.VirtualConnector( + lib.Address.fromShorthand({ hostId: 1 }), + lib.Address.fromShorthand("controller"), + ); + host = new Host(hostConnector, "", hostConfig, undefined, []); + }); + + describe("HostUpdateRequest", function() { + it("runs", async function() { + host.config.set("host.allow_remote_updates", true); + await host.handleHostUpdateRequest(new lib.HostUpdateRequest()); + }); + it("rejects if updates are disabled", async function() { + host.config.set("host.allow_remote_updates", false); + assert.rejects( + host.handleHostUpdateRequest(new lib.HostUpdateRequest()), + "Remote updates are disabled on this machine" + ); + }); + }); +}); diff --git a/test/messages/plugin.test.js b/test/messages/plugin.test.js new file mode 100644 index 00000000..c4a2f466 --- /dev/null +++ b/test/messages/plugin.test.js @@ -0,0 +1,176 @@ +"use strict"; +const assert = require("assert").strict; +const lib = require("@clusterio/lib"); + +const { Controller, ControlConnection, ControllerUser, BaseControllerPlugin } = require("@clusterio/controller"); +const { Host } = require("@clusterio/host"); + +describe("messages/plugin", function() { + /** @type {Host} */ + let host; + /** @type {Controller} */ + let controller; + /** @type {ControlConnection} */ + let controlConnection; + + beforeEach(function() { + const controllerConfig = new lib.ControllerConfig("controller"); + const connection = new lib.VirtualConnector( + lib.Address.fromShorthand("controller"), + lib.Address.fromShorthand({ controlId: 1 }), + ); + controller = new Controller(lib.logger, [], "", controllerConfig); + const user = new ControllerUser(controller.userManager, undefined, "test"); + controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1); + + const hostConfig = new lib.HostConfig("host"); + const hostConnector = new lib.VirtualConnector( + lib.Address.fromShorthand({ hostId: 1 }), + lib.Address.fromShorthand("controller"), + ); + host = new Host(hostConnector, "", hostConfig, undefined, []); + }); + + describe("PluginDetails", function() { + it("has round trip json serialisation", function() { + const pluginDetails = new lib.PluginDetails( + "Name", "Title", "Version", true, true, + "Description", "Npm Package", + ); + + const json = JSON.stringify(pluginDetails); + const reconstructed = lib.PluginDetails.fromJSON(JSON.parse(json)); + assert.deepEqual(reconstructed, pluginDetails); + }); + it("can be created from NodeEnvInfo interface", function() { + const pluginDetails = lib.PluginDetails.fromNodeEnvInfo({ + name: "Name", title: "Title", version: "Version", + description: "Description", npmPackage: "Npm Package", + }, true, true); + assert.deepEqual(pluginDetails, new lib.PluginDetails( + "Name", "Title", "Version", true, true, + "Description", "Npm Package", + )); + }); + }); + + describe("PluginListRequest", function() { + before(function() { + lib.ControllerConfig.fieldDefinitions["Name.load_plugin"] = { + type: "boolean", + initialValue: true, + }; + lib.HostConfig.fieldDefinitions["Name.load_plugin"] = { + type: "boolean", + initialValue: true, + }; + }); + after(function() { + delete lib.ControllerConfig.fieldDefinitions["Name.load_plugin"]; + delete lib.HostConfig.fieldDefinitions["Name.load_plugin"]; + }); + it("returns correct info from the controller", async function() { + controller.pluginInfos = [{ + name: "Name", title: "Title", version: "Version", + description: "Description", npmPackage: "Npm Package", + }]; + + controller.plugins.set("Name", {}); + controller.config.set("Name.load_plugin", true); + + const response = await controlConnection.handlePluginListRequest(new lib.PluginListRequest()); + assert.deepEqual(response, [new lib.PluginDetails( + "Name", "Title", "Version", true, true, + "Description", "Npm Package", + )]); + }); + it("returns correct info from a host", async function() { + host.pluginInfos = [{ + name: "Name", title: "Title", version: "Version", + description: "Description", npmPackage: "Npm Package", + }]; + + host.plugins.set("Name", {}); + host.config.set("Name.load_plugin", true); + + const response = await host.handlePluginListRequest(new lib.PluginListRequest()); + assert.deepEqual(response, [new lib.PluginDetails( + "Name", "Title", "Version", true, true, + "Description", "Npm Package", + )]); + }); + }); + + describe("PluginUpdateRequest", function() { + it("has round trip json serialisation", function() { + const request = new lib.PluginUpdateRequest("foo"); + const json = JSON.stringify(request); + const reconstructed = lib.PluginUpdateRequest.fromJSON(JSON.parse(json)); + assert.deepEqual(reconstructed, request); + }); + it("runs on the controller", async function() { + controller.pluginInfos = [{ npmPackage: "foo" }]; + controller.config.set("controller.allow_plugin_updates", true); + await controlConnection.handlePluginUpdateRequest(new lib.PluginUpdateRequest("foo")); + }); + it("rejects if updates are disabled on the controller", async function() { + controller.config.set("controller.allow_plugin_updates", false); + assert.rejects( + controlConnection.handlePluginUpdateRequest(new lib.PluginUpdateRequest("foo")), + "Plugin updates are disabled on this machine" + ); + }); + it("runs on a host", async function() { + host.pluginInfos = [{ npmPackage: "foo" }]; + host.config.set("host.allow_plugin_updates", true); + await host.handlePluginUpdateRequest(new lib.PluginUpdateRequest("foo")); + }); + it("rejects if updates are disabled on the host", async function() { + host.config.set("host.allow_plugin_updates", false); + assert.rejects( + host.handlePluginUpdateRequest(new lib.PluginUpdateRequest("foo")), + "Plugin updates are disabled on this machine" + ); + }); + }); + + describe("PluginInstallRequest", function() { + const _fetch = global.fetch; + before(function() { + // This is needed to bypass the npm registry check when installing a plugin + global.fetch = function() { return { ok: true }; }; + }); + after(function() { + global.fetch = _fetch; + }); + + it("has round trip json serialisation", function() { + const request = new lib.PluginInstallRequest("foo"); + const json = JSON.stringify(request); + const reconstructed = lib.PluginInstallRequest.fromJSON(JSON.parse(json)); + assert.deepEqual(reconstructed, request); + }); + it("runs on the controller", async function() { + controller.config.set("controller.allow_plugin_install", true); + await controlConnection.handlePluginInstallRequest(new lib.PluginInstallRequest("foo")); + }); + it("rejects if updates are disabled on the controller", async function() { + controller.config.set("controller.allow_plugin_install", false); + assert.rejects( + controlConnection.handlePluginInstallRequest(new lib.PluginInstallRequest("foo")), + "Plugin updates are disabled on this machine" + ); + }); + it("runs on a host", async function() { + host.config.set("host.allow_plugin_install", true); + await host.handlePluginInstallRequest(new lib.PluginInstallRequest("foo")); + }); + it("rejects if updates are disabled on the host", async function() { + host.config.set("host.allow_plugin_install", false); + assert.rejects( + host.handlePluginInstallRequest(new lib.PluginInstallRequest("foo")), + "Plugin updates are disabled on this machine" + ); + }); + }); +});