Basic functionality of remote npm

This commit is contained in:
Cooldude2606 committed 2025-04-29 01:40:22 +01:00
1 parent 47c5e0fabd
commit ff35b5c378
13 files changed
+496

No files matched your search

@@ -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) {
+24
View File
@@ -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)) {
+88
View File
@@ -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",
+31
View File
@@ -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(
+1
View File
@@ -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";
+48
View File
@@ -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<ControllerConfigFields> {
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<HostConfigFields> {
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,
},
};
}
@@ -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<typeof this.jsonSchema>) {
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<typeof this.jsonSchema>) {
return new this(json);
}
toJSON() {
return this.pluginPackage;
}
}
export class DebugDumpWsRequest {
declare ["constructor"]: typeof DebugDumpWsRequest;
static type = "request" as const;
+3
View File
@@ -30,6 +30,9 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.LogMessageEvent,
controller.SystemInfoRequest,
controller.SystemInfoUpdateEvent,
controller.RemoteUpdateRequest,
controller.PluginUpdateRequest,
controller.PluginInstallRequest,
controller.DebugDumpWsRequest,
controller.DebugWsMessageEvent,
+16
View File
@@ -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",
+26
View File
@@ -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: <devRoot>/packages/lib/dist/src/rce_ops.js
// prod: <prodRoot>/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}`);
}
@@ -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
</Button>
}
{
hasNpmButtonPermission()
&& <NpmButton target="controller" canRestart={system?.canRestart}/>
}
</Space>}
/>
@@ -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
</Button>
}
{
hasNpmButtonPermission()
&& <NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
}
</Space>;
return <PageLayout nav={nav}>
@@ -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>): 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 <>
<Form.Item label="Plugin" name="plugin">
<Input />
</Form.Item>
</>;
// eslint-disable-next-line multiline-comment-style
/* TODO Allow control access to plugins on hosts
return <>
<Form.Item label="Plugin" name="plugin">
<Select
showSearch
placeholder="Select a plugin"
optionLabelProp="label"
options={plugins.map(plugin => ({
value: plugin.npmPackage,
label: `${plugin.name} (v${plugin.version})`,
}))}
/>
</Form.Item>
</>; */
}
function NpmPluginInstall({ setApplyAction, form, target }: NpmActionProps) {
const control = useContext(ControlContext);
setApplyAction(async () => {
const values = form.getFieldsValue();
if (!values.newPlugin || values.newPlugin === "") {
throw new Error("Plugin not provided");
}
await control.sendTo(target, new lib.PluginInstallRequest(values.newPlugin));
});
// TODO custom npm search based on tags
return <>
<Form.Item label="Plugin" name="newPlugin">
<Input />
</Form.Item>
</>;
}
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<string | undefined>(undefined);
const [form] = Form.useForm();
function onValuesChange({ action } : { action?: string }) {
if (action) {
setFormAction(action);
}
}
let applyAction: () => Promise<void>;
const setApplyAction = (func: () => Promise<void>) => { 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 <>
<Button
type="default"
disabled={props.disabled}
onClick={() => { setOpen(true); }}
>Updates</Button>
<Modal
title="Updates"
okText="Apply"
open={open}
okButtonProps={{disabled: formAction === undefined}}
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
onCancel={() => { setOpen(false); }}
destroyOnClose
>
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
<Form.Item label="Action" name="action">
<Radio.Group value={formAction}>
{account.hasPermission("core.rce.remote_update")
? <Radio.Button value="remote_update">Update Clusterio</Radio.Button> : undefined}
{account.hasPermission("core.rce.plugin_update")
? <Radio.Button value="plugin_update">Update Plugin</Radio.Button> : undefined}
{account.hasPermission("core.rce.plugin_install")
? <Radio.Button value="plugin_install">Install Plugin</Radio.Button> : undefined}
</Radio.Group>
</Form.Item>
{
formAction !== undefined
&& account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.restart`)
&& <Form.Item label="Restart on completion" name="restart">
<Checkbox disabled={props.canRestart === false}/>
</Form.Item>
}
{formAction === "remote_update"
? <NpmRemoteUpdate {...{setApplyAction, form, target: props.target}}/> : undefined}
{formAction === "plugin_update"
? <NpmPluginUpdate {...{setApplyAction, form, target: props.target}}/> : undefined}
{formAction === "plugin_install"
? <NpmPluginInstall {...{setApplyAction, form, target: props.target}}/> : undefined}
</Form>
</Modal>
</>;
}
export function hasNpmButtonPermission() {
const account = useAccount();
return account.hasAnyPermission(
"core.rce.remote_update", "core.rce.plugin_update", "core.rce.plugin_install"
);
}