@termix-ssh/plugin-sdk 1.0.20 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/PLUGIN-GUIDE.md +66 -4
- package/cli/commands/build.mjs +55 -36
- package/cli/commands/validate.mjs +4 -0
- package/dist/backend.d.ts +32 -7
- package/dist/frontend.d.ts +7 -0
- package/dist/host/{PluginsTab-ZSMTJ5C5.js → PluginsTab-CM2B33BD.js} +26 -16
- package/dist/host/chunk-7J4QNGFM.js +345 -0
- package/dist/host/{chunk-J3ZQPBDK.js → chunk-HQ6NNFSX.js} +115 -1575
- package/dist/host/chunk-I5F2PSMR.js +1526 -0
- package/dist/host/{chunk-2MU6WNSX.js → chunk-JRV2HIDK.js} +1 -1
- package/dist/host/{chunk-7LEJ3HYF.js → chunk-P26QKKU5.js} +41 -10
- package/dist/host/{chunk-CITUENSU.js → chunk-ROB2SH5Q.js} +77 -49
- package/dist/host/chunk-TK3Y437H.js +53 -0
- package/dist/host/{chunk-EYONBBRJ.js → chunk-VKNGNEF3.js} +34650 -34968
- package/dist/host/core-classes.json +1 -1
- package/dist/host/frame-host.css +2 -0
- package/dist/host/frame-host.js +242 -0
- package/dist/host/{linked-server-OUF3POT7.js → linked-server-J5G457OA.js} +1 -1
- package/dist/host/{rbac-api-LZVENREG.js → rbac-api-PY22W5UY.js} +5 -1
- package/dist/host/testing-host.js +9 -6
- package/dist/host/types/src/types/host-sidebar-preferences.d.ts +2 -0
- package/dist/host/types/src/types/index.d.ts +8 -0
- package/dist/host/types/src/ui/api/look-api.d.ts +2 -0
- package/dist/host/types/src/ui/api/plugins-api.d.ts +3 -2
- package/dist/host/types/src/ui/api/rbac-api.d.ts +19 -1
- package/dist/host/types/src/ui/components/data-view.d.ts +1 -1
- package/dist/host/types/src/ui/lib/app-return.d.ts +13 -0
- package/dist/host/types/src/ui/main-axios.d.ts +1 -1
- package/dist/host/types/src/ui/plugin-host/loader.d.ts +6 -0
- package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +1 -1
- package/dist/host/types/src/ui/plugins/plugin-model.d.ts +1 -1
- package/dist/host/types/src/ui/plugins/use-plugins-manager.d.ts +1 -1
- package/dist/host/types/src/ui/shell/tab-registry.d.ts +2 -0
- package/dist/host/ui.js +418 -388
- package/dist/host/{use-permissions-BYCDS5PD.js → use-permissions-EWRI3WLT.js} +1 -1
- package/dist/host-commands.d.ts +9 -6
- package/dist/host-commands.js +38 -11
- package/dist/host-scripts/docker.d.ts +137 -0
- package/dist/host-scripts/docker.js +345 -0
- package/dist/host-scripts/index.d.ts +15 -0
- package/dist/host-scripts/index.js +15 -0
- package/dist/host-scripts/journal.d.ts +45 -0
- package/dist/host-scripts/journal.js +194 -0
- package/dist/host-scripts/metrics.d.ts +134 -0
- package/dist/host-scripts/metrics.js +320 -0
- package/dist/host-scripts/openrc.d.ts +6 -0
- package/dist/host-scripts/openrc.js +90 -0
- package/dist/host-scripts/shell.d.ts +43 -0
- package/dist/host-scripts/shell.js +95 -0
- package/dist/host-scripts/systemd.d.ts +73 -0
- package/dist/host-scripts/systemd.js +320 -0
- package/dist/host-scripts/text.d.ts +23 -0
- package/dist/host-scripts/text.js +111 -0
- package/dist/manifest.d.ts +28 -1
- package/dist/manifest.js +108 -1
- package/package.json +9 -1
- package/schema/manifest.schema.json +42 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** OpenRC scripts, with states mapped onto the words systemd uses. */
|
|
2
|
+
import { type UnitAction, type UnitSummary } from "./systemd.js";
|
|
3
|
+
export declare const OPENRC_LIST_SCRIPT = "\nrc-status --servicelist 2>/dev/null\necho \"@@TERMIX_RUNLEVELS@@\"\nrc-update show -v 2>/dev/null\nexit 0\n";
|
|
4
|
+
export declare function parseOpenRcList(output: string): UnitSummary[];
|
|
5
|
+
export declare function openRcActionScript(name: string, action: UnitAction): string;
|
|
6
|
+
export declare function openRcStatusScript(name: string): string;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** OpenRC scripts, with states mapped onto the words systemd uses. */
|
|
2
|
+
import { isOpenRcName } from "./systemd.js";
|
|
3
|
+
import { HostScriptError, shellQuote as q } from "./text.js";
|
|
4
|
+
export const OPENRC_LIST_SCRIPT = `
|
|
5
|
+
rc-status --servicelist 2>/dev/null
|
|
6
|
+
echo "@@TERMIX_RUNLEVELS@@"
|
|
7
|
+
rc-update show -v 2>/dev/null
|
|
8
|
+
exit 0
|
|
9
|
+
`;
|
|
10
|
+
function activeOf(state) {
|
|
11
|
+
switch (state) {
|
|
12
|
+
case "started":
|
|
13
|
+
return { active: "active", sub: "running" };
|
|
14
|
+
case "crashed":
|
|
15
|
+
return { active: "failed", sub: "crashed" };
|
|
16
|
+
case "starting":
|
|
17
|
+
return { active: "activating", sub: "starting" };
|
|
18
|
+
case "stopping":
|
|
19
|
+
return { active: "deactivating", sub: "stopping" };
|
|
20
|
+
case "inactive":
|
|
21
|
+
case "hotplugged":
|
|
22
|
+
return { active: "inactive", sub: state };
|
|
23
|
+
default:
|
|
24
|
+
return { active: "inactive", sub: state || "stopped" };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function parseOpenRcList(output) {
|
|
28
|
+
const [list = "", levels = ""] = output.split("@@TERMIX_RUNLEVELS@@");
|
|
29
|
+
const units = new Map();
|
|
30
|
+
for (const line of list.split("\n")) {
|
|
31
|
+
const match = /^\s*(\S+)\s+\[\s*([a-z ]+?)\s*\]/.exec(line);
|
|
32
|
+
if (!match)
|
|
33
|
+
continue;
|
|
34
|
+
const { active, sub } = activeOf(match[2]);
|
|
35
|
+
units.set(match[1], {
|
|
36
|
+
name: match[1],
|
|
37
|
+
type: "service",
|
|
38
|
+
load: "loaded",
|
|
39
|
+
active,
|
|
40
|
+
sub,
|
|
41
|
+
description: "",
|
|
42
|
+
fileState: "disabled",
|
|
43
|
+
preset: "",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
for (const line of levels.split("\n")) {
|
|
47
|
+
const match = /^\s*(\S+)\s*\|\s*(.*)$/.exec(line);
|
|
48
|
+
if (!match)
|
|
49
|
+
continue;
|
|
50
|
+
const runlevels = match[2].trim();
|
|
51
|
+
const known = units.get(match[1]) ?? {
|
|
52
|
+
name: match[1],
|
|
53
|
+
type: "service",
|
|
54
|
+
load: "loaded",
|
|
55
|
+
active: "inactive",
|
|
56
|
+
sub: "stopped",
|
|
57
|
+
description: "",
|
|
58
|
+
fileState: "disabled",
|
|
59
|
+
preset: "",
|
|
60
|
+
};
|
|
61
|
+
known.fileState = runlevels ? "enabled" : "disabled";
|
|
62
|
+
known.description = runlevels ? `Runlevels: ${runlevels}` : "";
|
|
63
|
+
units.set(match[1], known);
|
|
64
|
+
}
|
|
65
|
+
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
66
|
+
}
|
|
67
|
+
function requireName(name) {
|
|
68
|
+
if (!isOpenRcName(name))
|
|
69
|
+
throw new HostScriptError("That is not a valid service name");
|
|
70
|
+
return name;
|
|
71
|
+
}
|
|
72
|
+
export function openRcActionScript(name, action) {
|
|
73
|
+
const s = q(requireName(name));
|
|
74
|
+
switch (action) {
|
|
75
|
+
case "start":
|
|
76
|
+
case "stop":
|
|
77
|
+
case "restart":
|
|
78
|
+
return `rc-service ${s} ${action} 2>&1 || { echo "SCRIPT_ERR:rc-service ${action} failed"; exit 1; }\n`;
|
|
79
|
+
case "enable":
|
|
80
|
+
return `rc-update add ${s} default 2>&1 || { echo "SCRIPT_ERR:rc-update add failed"; exit 1; }\n`;
|
|
81
|
+
case "disable":
|
|
82
|
+
return `rc-update del ${s} 2>&1 || { echo "SCRIPT_ERR:rc-update del failed"; exit 1; }\n`;
|
|
83
|
+
default:
|
|
84
|
+
throw new HostScriptError("OpenRC can't do that", 400);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function openRcStatusScript(name) {
|
|
88
|
+
const s = q(requireName(name));
|
|
89
|
+
return `rc-service ${s} status 2>&1; rc-update show -v 2>/dev/null | grep -E '^ *'${s}' *\\|'; exit 0\n`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Puts sbin on PATH and sets a plain, UTC environment. */
|
|
2
|
+
export declare const SCRIPT_PRELUDE = "export PATH=\"$PATH:/usr/sbin:/sbin:/usr/local/sbin\"; export SYSTEMD_COLORS=0 SYSTEMD_PAGER=cat LC_ALL=C TZ=UTC; ";
|
|
3
|
+
/**
|
|
4
|
+
* - `plain`: the login user.
|
|
5
|
+
* - `auto`: root when it can be had without a password, else the login user.
|
|
6
|
+
* - `root`: root or passwordless sudo, fails on anything else.
|
|
7
|
+
* - `root-only`: root logins only, for hosts where sudo is turned off.
|
|
8
|
+
*/
|
|
9
|
+
export type ScriptShellMode = "plain" | "auto" | "root" | "root-only";
|
|
10
|
+
/** The command line that runs a script sent on stdin. */
|
|
11
|
+
export declare function scriptShell(mode: ScriptShellMode): string;
|
|
12
|
+
/**
|
|
13
|
+
* Runs a script as root with a sudo password. The command is `plain`; all of
|
|
14
|
+
* the returned stdin goes to the host. The password is read by the first
|
|
15
|
+
* line into a shell variable and piped to `sudo -S`, so it is never in argv,
|
|
16
|
+
* never in the environment and never run as a command. Root logins and
|
|
17
|
+
* passwordless sudo skip the password. The script itself travels as base64.
|
|
18
|
+
*/
|
|
19
|
+
export declare function passwordSudoScript(script: string, password: string): {
|
|
20
|
+
command: string;
|
|
21
|
+
stdin: string;
|
|
22
|
+
};
|
|
23
|
+
/** sudo asked for a password or a terminal it did not get. */
|
|
24
|
+
export declare const SUDO_REFUSED_RE: RegExp;
|
|
25
|
+
/** sudo got a password and it was wrong. */
|
|
26
|
+
export declare const SUDO_WRONG_RE: RegExp;
|
|
27
|
+
/** The user may not use sudo at all. */
|
|
28
|
+
export declare const NOT_SUDOER_RE: RegExp;
|
|
29
|
+
/**
|
|
30
|
+
* Scripts print `SCRIPT_ERR:` lines (or the older `SERVICES_ERR:`) before a
|
|
31
|
+
* non zero exit to explain a failure.
|
|
32
|
+
*/
|
|
33
|
+
export declare function scriptError(stdout: string): string;
|
|
34
|
+
/** A line a script prints to explain why it stops. */
|
|
35
|
+
export declare function errLine(message: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Runs a long command in the background and stops it once stdin closes,
|
|
38
|
+
* which happens when the SSH channel ends. A pty can't be trusted for this:
|
|
39
|
+
* sudo keeps its own and the command would outlive the viewer.
|
|
40
|
+
*/
|
|
41
|
+
export declare function watchdog(command: string): string;
|
|
42
|
+
/** The wrapper a streamed command runs in, as one shell command line. */
|
|
43
|
+
export declare function streamCommand(script: string, sudo: boolean): string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a script reaches the host: the command line runs a shell that reads
|
|
3
|
+
* the script from stdin (`bash -s`), so names and values in a script are
|
|
4
|
+
* never parsed out of argv.
|
|
5
|
+
*/
|
|
6
|
+
import { shellQuote, toBase64 } from "./text.js";
|
|
7
|
+
// systemctl and friends often live in sbin, which a normal login skips.
|
|
8
|
+
// TZ=UTC so tools print every time in UTC, which parses the same anywhere.
|
|
9
|
+
const ENV = "SYSTEMD_COLORS=0 SYSTEMD_PAGER=cat LC_ALL=C TZ=UTC";
|
|
10
|
+
/** Puts sbin on PATH and sets a plain, UTC environment. */
|
|
11
|
+
export const SCRIPT_PRELUDE = `export PATH="$PATH:/usr/sbin:/sbin:/usr/local/sbin"; export ${ENV}; `;
|
|
12
|
+
const SUDO = `sudo -n env PATH="$PATH" ${ENV}`;
|
|
13
|
+
const ERR = "SCRIPT_ERR:";
|
|
14
|
+
/** The command line that runs a script sent on stdin. */
|
|
15
|
+
export function scriptShell(mode) {
|
|
16
|
+
switch (mode) {
|
|
17
|
+
case "plain":
|
|
18
|
+
return `${SCRIPT_PRELUDE}exec bash -s`;
|
|
19
|
+
case "auto":
|
|
20
|
+
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -s; else exec bash -s; fi`;
|
|
21
|
+
case "root":
|
|
22
|
+
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else exec ${SUDO} bash -s; fi`;
|
|
23
|
+
case "root-only":
|
|
24
|
+
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else echo "${ERR}This needs root, or turn on Use sudo for this host"; exit 1; fi`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Runs a script as root with a sudo password. The command is `plain`; all of
|
|
29
|
+
* the returned stdin goes to the host. The password is read by the first
|
|
30
|
+
* line into a shell variable and piped to `sudo -S`, so it is never in argv,
|
|
31
|
+
* never in the environment and never run as a command. Root logins and
|
|
32
|
+
* passwordless sudo skip the password. The script itself travels as base64.
|
|
33
|
+
*/
|
|
34
|
+
export function passwordSudoScript(script, password) {
|
|
35
|
+
if (/[\r\n\0]/.test(password)) {
|
|
36
|
+
throw new Error("A sudo password can't contain a line break");
|
|
37
|
+
}
|
|
38
|
+
const run = `bash -c "$TERMIX_S"`;
|
|
39
|
+
const stdin = `IFS= read -r TERMIX_PW
|
|
40
|
+
${password}
|
|
41
|
+
TERMIX_S=$(printf '%s' ${shellQuote(toBase64(script))} | base64 -d)
|
|
42
|
+
if [ "$(id -u)" = "0" ]; then unset TERMIX_PW; exec ${run}
|
|
43
|
+
elif sudo -n true 2>/dev/null; then unset TERMIX_PW; exec ${SUDO} ${run}
|
|
44
|
+
else P=$TERMIX_PW; unset TERMIX_PW; printf '%s\\n' "$P" | sudo -k -S -p '' env PATH="$PATH" ${ENV} ${run}; exit $?
|
|
45
|
+
fi
|
|
46
|
+
`;
|
|
47
|
+
return { command: scriptShell("plain"), stdin };
|
|
48
|
+
}
|
|
49
|
+
/** sudo asked for a password or a terminal it did not get. */
|
|
50
|
+
export const SUDO_REFUSED_RE = /password is required|a terminal is required|no tty present/i;
|
|
51
|
+
/** sudo got a password and it was wrong. */
|
|
52
|
+
export const SUDO_WRONG_RE = /incorrect password|sorry, try again|no password was provided/i;
|
|
53
|
+
/** The user may not use sudo at all. */
|
|
54
|
+
export const NOT_SUDOER_RE = /is not in the sudoers file|not allowed to run sudo/i;
|
|
55
|
+
/**
|
|
56
|
+
* Scripts print `SCRIPT_ERR:` lines (or the older `SERVICES_ERR:`) before a
|
|
57
|
+
* non zero exit to explain a failure.
|
|
58
|
+
*/
|
|
59
|
+
export function scriptError(stdout) {
|
|
60
|
+
return stdout
|
|
61
|
+
.split("\n")
|
|
62
|
+
.map((line) => {
|
|
63
|
+
if (line.startsWith(ERR))
|
|
64
|
+
return line.slice(ERR.length);
|
|
65
|
+
if (line.startsWith("SERVICES_ERR:"))
|
|
66
|
+
return line.slice(13);
|
|
67
|
+
return null;
|
|
68
|
+
})
|
|
69
|
+
.filter((line) => line !== null)
|
|
70
|
+
.join("\n")
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
|
73
|
+
/** A line a script prints to explain why it stops. */
|
|
74
|
+
export function errLine(message) {
|
|
75
|
+
return `echo ${shellQuote(ERR + message)}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Runs a long command in the background and stops it once stdin closes,
|
|
79
|
+
* which happens when the SSH channel ends. A pty can't be trusted for this:
|
|
80
|
+
* sudo keeps its own and the command would outlive the viewer.
|
|
81
|
+
*/
|
|
82
|
+
export function watchdog(command) {
|
|
83
|
+
return `${command} &
|
|
84
|
+
P=$!
|
|
85
|
+
trap 'kill $P 2>/dev/null' EXIT
|
|
86
|
+
cat >/dev/null
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
89
|
+
/** The wrapper a streamed command runs in, as one shell command line. */
|
|
90
|
+
export function streamCommand(script, sudo) {
|
|
91
|
+
const quoted = shellQuote(`${SCRIPT_PRELUDE}${script}`);
|
|
92
|
+
return sudo
|
|
93
|
+
? `if [ "$(id -u)" = "0" ]; then exec bash -c ${quoted}; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -c ${quoted}; else exec bash -c ${quoted}; fi`
|
|
94
|
+
: `exec bash -c ${quoted}`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export declare const UNIT_TYPES: readonly ["service", "socket", "timer", "target", "mount", "automount", "path", "swap", "slice", "scope", "device"];
|
|
2
|
+
export type UnitType = (typeof UNIT_TYPES)[number];
|
|
3
|
+
export declare function isUnit(name: unknown): name is string;
|
|
4
|
+
export declare function unitType(name: string): UnitType;
|
|
5
|
+
/** Units a file may be written for. Templates and device style names are out. */
|
|
6
|
+
export declare function isWritableUnitName(name: string): boolean;
|
|
7
|
+
export declare function isOpenRcName(name: unknown): name is string;
|
|
8
|
+
export declare function requireUnit(name: unknown): string;
|
|
9
|
+
export type InitSystem = "systemd" | "openrc" | "none";
|
|
10
|
+
/** A unit name or, on OpenRC, a service name. */
|
|
11
|
+
export declare function requireService(name: unknown, init: string): string;
|
|
12
|
+
export interface UnitSummary {
|
|
13
|
+
name: string;
|
|
14
|
+
type: string;
|
|
15
|
+
load: string;
|
|
16
|
+
active: string;
|
|
17
|
+
sub: string;
|
|
18
|
+
description: string;
|
|
19
|
+
/** enabled, disabled, static, masked and so on. Empty when unknown. */
|
|
20
|
+
fileState: string;
|
|
21
|
+
preset: string;
|
|
22
|
+
}
|
|
23
|
+
export interface UnitDetail {
|
|
24
|
+
name: string;
|
|
25
|
+
props: Record<string, string>;
|
|
26
|
+
status: string;
|
|
27
|
+
}
|
|
28
|
+
export interface TimerView {
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
active: string;
|
|
32
|
+
fileState: string;
|
|
33
|
+
/** Unix ms, or null when not set. */
|
|
34
|
+
next: number | null;
|
|
35
|
+
last: number | null;
|
|
36
|
+
activates: string;
|
|
37
|
+
schedule: string[];
|
|
38
|
+
}
|
|
39
|
+
export declare const UNIT_ACTIONS: readonly ["start", "stop", "restart", "reload", "enable", "disable", "enable-now", "disable-now", "mask", "unmask", "reset-failed", "revert"];
|
|
40
|
+
export type UnitAction = (typeof UNIT_ACTIONS)[number];
|
|
41
|
+
/** Actions OpenRC can do. */
|
|
42
|
+
export declare const OPENRC_ACTIONS: readonly UnitAction[];
|
|
43
|
+
export declare const DETECT_SCRIPT = "\nINIT=none\nif command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then INIT=systemd\nelif command -v rc-service >/dev/null 2>&1; then INIT=openrc; fi\necho \"init=$INIT\"\nif [ \"$INIT\" = systemd ]; then echo \"version=$(systemctl --version 2>/dev/null | head -n1 | awk '{print $2}')\"; fi\nif [ \"$INIT\" = openrc ]; then echo \"version=$(openrc --version 2>/dev/null | head -n1 | awk '{print $NF}')\"; fi\necho \"os=$( . /etc/os-release 2>/dev/null; echo \"$PRETTY_NAME\")\"\necho \"hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)\"\necho \"uid=$(id -u)\"\nif command -v journalctl >/dev/null 2>&1; then echo \"journal=1\"; fi\nif [ \"$INIT\" = systemd ]; then echo \"failed=$(systemctl list-units --state=failed --plain --no-legend --no-pager 2>/dev/null | wc -l)\"; fi\nif [ \"$INIT\" = openrc ]; then echo \"failed=$(rc-status --crashed 2>/dev/null | wc -l)\"; fi\nexit 0\n";
|
|
44
|
+
export interface Detected {
|
|
45
|
+
init: InitSystem;
|
|
46
|
+
version: string;
|
|
47
|
+
os: string;
|
|
48
|
+
hostname: string;
|
|
49
|
+
root: boolean;
|
|
50
|
+
journal: boolean;
|
|
51
|
+
failed: number;
|
|
52
|
+
}
|
|
53
|
+
export declare function parseDetect(output: string): Detected;
|
|
54
|
+
/** Types the list can show. */
|
|
55
|
+
export declare const LIST_TYPES: readonly ["service", "socket", "timer", "target", "path", "mount"];
|
|
56
|
+
export type ListType = (typeof LIST_TYPES)[number] | "all";
|
|
57
|
+
export declare function listUnitsScript(type: ListType): string;
|
|
58
|
+
/** Merges list-units and list-unit-files. Template units are left out. */
|
|
59
|
+
export declare function parseUnitList(output: string): UnitSummary[];
|
|
60
|
+
export declare const DETAIL_PROPS: readonly ["Id", "Description", "LoadState", "ActiveState", "SubState", "UnitFileState", "UnitFilePreset", "Result", "MainPID", "ExecMainStartTimestamp", "ActiveEnterTimestamp", "InactiveEnterTimestamp", "MemoryCurrent", "MemoryPeak", "CPUUsageNSec", "TasksCurrent", "NRestarts", "FragmentPath", "DropInPaths", "Requires", "Wants", "After", "WantedBy", "TriggeredBy", "Triggers", "User", "Group", "Restart", "Type", "ExecStart", "CanStart", "CanStop", "CanReload"];
|
|
61
|
+
export declare function detailScript(unit: string): string;
|
|
62
|
+
export declare function parseDetail(unit: string, output: string): UnitDetail;
|
|
63
|
+
/** The systemctl call for an action. Prints `state=` after it. */
|
|
64
|
+
export declare function actionScript(unit: string, action: UnitAction): string;
|
|
65
|
+
/** Times systemctl prints under TZ=UTC, like "Thu 2026-10-22 02:03:51 UTC". */
|
|
66
|
+
export declare function parseSystemdTime(value: string | undefined): number | null;
|
|
67
|
+
export declare const TIMERS_SCRIPT = "\nUNITS=$( { systemctl list-units --all --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; systemctl list-unit-files --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; } | grep '\\.timer$' | grep -v '@\\.timer$' | sort -u)\n[ -n \"$UNITS\" ] || exit 0\nsystemctl show --no-pager -p Id -p Description -p ActiveState -p UnitFileState -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers -p TimersCalendar -p TimersMonotonic -- $UNITS\nexit 0\n";
|
|
68
|
+
/** Reads systemctl show output for many units, split by blank lines. */
|
|
69
|
+
export declare function parseShowBlocks(output: string): Array<Record<string, string>>;
|
|
70
|
+
export declare function parseTimers(output: string): TimerView[];
|
|
71
|
+
/** One line per unit from systemctl is-active, in the order asked. */
|
|
72
|
+
export declare function isActiveScript(units: string[]): string;
|
|
73
|
+
export declare function parseIsActive(units: string[], output: string): Map<string, string>;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* systemd unit scripts and parsers, plus init system detection.
|
|
3
|
+
* Every script is meant for `scriptShell()`, sent on stdin.
|
|
4
|
+
*/
|
|
5
|
+
import { HostScriptError, parseKeys, shellQuote as q } from "./text.js";
|
|
6
|
+
export const UNIT_TYPES = [
|
|
7
|
+
"service",
|
|
8
|
+
"socket",
|
|
9
|
+
"timer",
|
|
10
|
+
"target",
|
|
11
|
+
"mount",
|
|
12
|
+
"automount",
|
|
13
|
+
"path",
|
|
14
|
+
"swap",
|
|
15
|
+
"slice",
|
|
16
|
+
"scope",
|
|
17
|
+
"device",
|
|
18
|
+
];
|
|
19
|
+
// Backslash is allowed for escaped names like dev-disk-by\x2duuid.
|
|
20
|
+
const UNIT_RE = new RegExp(`^[A-Za-z0-9_][A-Za-z0-9:_.@\\\\-]{0,250}\\.(${UNIT_TYPES.join("|")})$`);
|
|
21
|
+
const OPENRC_RE = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,127}$/;
|
|
22
|
+
export function isUnit(name) {
|
|
23
|
+
return typeof name === "string" && UNIT_RE.test(name);
|
|
24
|
+
}
|
|
25
|
+
export function unitType(name) {
|
|
26
|
+
return name.slice(name.lastIndexOf(".") + 1);
|
|
27
|
+
}
|
|
28
|
+
/** Units a file may be written for. Templates and device style names are out. */
|
|
29
|
+
export function isWritableUnitName(name) {
|
|
30
|
+
return (isUnit(name) &&
|
|
31
|
+
!name.includes("\\") &&
|
|
32
|
+
["service", "socket", "timer", "target", "path", "mount"].includes(unitType(name)));
|
|
33
|
+
}
|
|
34
|
+
export function isOpenRcName(name) {
|
|
35
|
+
return typeof name === "string" && OPENRC_RE.test(name);
|
|
36
|
+
}
|
|
37
|
+
export function requireUnit(name) {
|
|
38
|
+
if (!isUnit(name))
|
|
39
|
+
throw new HostScriptError("That is not a valid unit name");
|
|
40
|
+
return name;
|
|
41
|
+
}
|
|
42
|
+
/** A unit name or, on OpenRC, a service name. */
|
|
43
|
+
export function requireService(name, init) {
|
|
44
|
+
if (init === "openrc") {
|
|
45
|
+
if (!isOpenRcName(name))
|
|
46
|
+
throw new HostScriptError("That is not a valid service name");
|
|
47
|
+
return name;
|
|
48
|
+
}
|
|
49
|
+
return requireUnit(name);
|
|
50
|
+
}
|
|
51
|
+
export const UNIT_ACTIONS = [
|
|
52
|
+
"start",
|
|
53
|
+
"stop",
|
|
54
|
+
"restart",
|
|
55
|
+
"reload",
|
|
56
|
+
"enable",
|
|
57
|
+
"disable",
|
|
58
|
+
"enable-now",
|
|
59
|
+
"disable-now",
|
|
60
|
+
"mask",
|
|
61
|
+
"unmask",
|
|
62
|
+
"reset-failed",
|
|
63
|
+
"revert",
|
|
64
|
+
];
|
|
65
|
+
/** Actions OpenRC can do. */
|
|
66
|
+
export const OPENRC_ACTIONS = [
|
|
67
|
+
"start",
|
|
68
|
+
"stop",
|
|
69
|
+
"restart",
|
|
70
|
+
"enable",
|
|
71
|
+
"disable",
|
|
72
|
+
];
|
|
73
|
+
export const DETECT_SCRIPT = `
|
|
74
|
+
INIT=none
|
|
75
|
+
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then INIT=systemd
|
|
76
|
+
elif command -v rc-service >/dev/null 2>&1; then INIT=openrc; fi
|
|
77
|
+
echo "init=$INIT"
|
|
78
|
+
if [ "$INIT" = systemd ]; then echo "version=$(systemctl --version 2>/dev/null | head -n1 | awk '{print $2}')"; fi
|
|
79
|
+
if [ "$INIT" = openrc ]; then echo "version=$(openrc --version 2>/dev/null | head -n1 | awk '{print $NF}')"; fi
|
|
80
|
+
echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"
|
|
81
|
+
echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"
|
|
82
|
+
echo "uid=$(id -u)"
|
|
83
|
+
if command -v journalctl >/dev/null 2>&1; then echo "journal=1"; fi
|
|
84
|
+
if [ "$INIT" = systemd ]; then echo "failed=$(systemctl list-units --state=failed --plain --no-legend --no-pager 2>/dev/null | wc -l)"; fi
|
|
85
|
+
if [ "$INIT" = openrc ]; then echo "failed=$(rc-status --crashed 2>/dev/null | wc -l)"; fi
|
|
86
|
+
exit 0
|
|
87
|
+
`;
|
|
88
|
+
export function parseDetect(output) {
|
|
89
|
+
const keys = parseKeys(output);
|
|
90
|
+
const init = keys.init === "systemd" || keys.init === "openrc" ? keys.init : "none";
|
|
91
|
+
return {
|
|
92
|
+
init,
|
|
93
|
+
version: keys.version ?? "",
|
|
94
|
+
os: keys.os ?? "",
|
|
95
|
+
hostname: keys.hostname ?? "",
|
|
96
|
+
root: keys.uid === "0",
|
|
97
|
+
journal: keys.journal === "1",
|
|
98
|
+
failed: Number(keys.failed) || 0,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Types the list can show. */
|
|
102
|
+
export const LIST_TYPES = [
|
|
103
|
+
"service",
|
|
104
|
+
"socket",
|
|
105
|
+
"timer",
|
|
106
|
+
"target",
|
|
107
|
+
"path",
|
|
108
|
+
"mount",
|
|
109
|
+
];
|
|
110
|
+
export function listUnitsScript(type) {
|
|
111
|
+
const types = type === "all" ? LIST_TYPES.join(",") : type;
|
|
112
|
+
if (type !== "all" && !LIST_TYPES.includes(type))
|
|
113
|
+
throw new HostScriptError("Unknown unit type");
|
|
114
|
+
return `
|
|
115
|
+
systemctl list-units --all --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
|
|
116
|
+
echo "@@TERMIX_FILES@@"
|
|
117
|
+
systemctl list-unit-files --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
|
|
118
|
+
exit 0
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
/** Merges list-units and list-unit-files. Template units are left out. */
|
|
122
|
+
export function parseUnitList(output) {
|
|
123
|
+
const [loaded = "", files = ""] = output.split("@@TERMIX_FILES@@");
|
|
124
|
+
const units = new Map();
|
|
125
|
+
for (const raw of loaded.split("\n")) {
|
|
126
|
+
const line = raw.replace(/^[●*\s]+/, "").trimEnd();
|
|
127
|
+
if (!line)
|
|
128
|
+
continue;
|
|
129
|
+
const match = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
|
|
130
|
+
if (!match)
|
|
131
|
+
continue;
|
|
132
|
+
const [, name, load, active, sub, description] = match;
|
|
133
|
+
if (!name.includes(".") || name.includes("@."))
|
|
134
|
+
continue;
|
|
135
|
+
units.set(name, {
|
|
136
|
+
name,
|
|
137
|
+
type: unitType(name),
|
|
138
|
+
load,
|
|
139
|
+
active,
|
|
140
|
+
sub,
|
|
141
|
+
description: description.trim(),
|
|
142
|
+
fileState: "",
|
|
143
|
+
preset: "",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
for (const raw of files.split("\n")) {
|
|
147
|
+
const line = raw.trim();
|
|
148
|
+
if (!line)
|
|
149
|
+
continue;
|
|
150
|
+
const [name, state = "", preset = ""] = line.split(/\s+/);
|
|
151
|
+
if (!name || !name.includes(".") || name.includes("@."))
|
|
152
|
+
continue;
|
|
153
|
+
const known = units.get(name);
|
|
154
|
+
const cleanPreset = preset === "-" ? "" : preset;
|
|
155
|
+
if (known) {
|
|
156
|
+
known.fileState = state;
|
|
157
|
+
known.preset = cleanPreset;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
units.set(name, {
|
|
161
|
+
name,
|
|
162
|
+
type: unitType(name),
|
|
163
|
+
load: state === "masked" ? "masked" : "unloaded",
|
|
164
|
+
active: "inactive",
|
|
165
|
+
sub: "dead",
|
|
166
|
+
description: "",
|
|
167
|
+
fileState: state,
|
|
168
|
+
preset: cleanPreset,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
173
|
+
}
|
|
174
|
+
export const DETAIL_PROPS = [
|
|
175
|
+
"Id",
|
|
176
|
+
"Description",
|
|
177
|
+
"LoadState",
|
|
178
|
+
"ActiveState",
|
|
179
|
+
"SubState",
|
|
180
|
+
"UnitFileState",
|
|
181
|
+
"UnitFilePreset",
|
|
182
|
+
"Result",
|
|
183
|
+
"MainPID",
|
|
184
|
+
"ExecMainStartTimestamp",
|
|
185
|
+
"ActiveEnterTimestamp",
|
|
186
|
+
"InactiveEnterTimestamp",
|
|
187
|
+
"MemoryCurrent",
|
|
188
|
+
"MemoryPeak",
|
|
189
|
+
"CPUUsageNSec",
|
|
190
|
+
"TasksCurrent",
|
|
191
|
+
"NRestarts",
|
|
192
|
+
"FragmentPath",
|
|
193
|
+
"DropInPaths",
|
|
194
|
+
"Requires",
|
|
195
|
+
"Wants",
|
|
196
|
+
"After",
|
|
197
|
+
"WantedBy",
|
|
198
|
+
"TriggeredBy",
|
|
199
|
+
"Triggers",
|
|
200
|
+
"User",
|
|
201
|
+
"Group",
|
|
202
|
+
"Restart",
|
|
203
|
+
"Type",
|
|
204
|
+
"ExecStart",
|
|
205
|
+
"CanStart",
|
|
206
|
+
"CanStop",
|
|
207
|
+
"CanReload",
|
|
208
|
+
];
|
|
209
|
+
export function detailScript(unit) {
|
|
210
|
+
const props = DETAIL_PROPS.map((p) => `-p ${p}`).join(" ");
|
|
211
|
+
return `
|
|
212
|
+
systemctl show --no-pager ${props} -- ${q(requireUnit(unit))}
|
|
213
|
+
echo "@@TERMIX_STATUS@@"
|
|
214
|
+
systemctl status --no-pager --full -n 0 -- ${q(unit)} 2>&1
|
|
215
|
+
exit 0
|
|
216
|
+
`;
|
|
217
|
+
}
|
|
218
|
+
export function parseDetail(unit, output) {
|
|
219
|
+
const [props = "", status = ""] = output.split("@@TERMIX_STATUS@@");
|
|
220
|
+
return { name: unit, props: parseKeys(props), status: status.trim() };
|
|
221
|
+
}
|
|
222
|
+
const VERBS = {
|
|
223
|
+
start: "start",
|
|
224
|
+
stop: "stop",
|
|
225
|
+
restart: "restart",
|
|
226
|
+
reload: "reload",
|
|
227
|
+
enable: "enable",
|
|
228
|
+
disable: "disable",
|
|
229
|
+
"enable-now": "enable --now",
|
|
230
|
+
"disable-now": "disable --now",
|
|
231
|
+
mask: "mask",
|
|
232
|
+
unmask: "unmask",
|
|
233
|
+
"reset-failed": "reset-failed",
|
|
234
|
+
revert: "revert",
|
|
235
|
+
};
|
|
236
|
+
/** The systemctl call for an action. Prints `state=` after it. */
|
|
237
|
+
export function actionScript(unit, action) {
|
|
238
|
+
const verb = VERBS[action];
|
|
239
|
+
if (!verb)
|
|
240
|
+
throw new HostScriptError("Unknown action");
|
|
241
|
+
const u = q(requireUnit(unit));
|
|
242
|
+
return `
|
|
243
|
+
systemctl ${verb} -- ${u} 2>&1 || { echo "SCRIPT_ERR:systemctl ${verb} failed"; exit 1; }
|
|
244
|
+
echo "state=$(systemctl is-active -- ${u} 2>/dev/null)"
|
|
245
|
+
`;
|
|
246
|
+
}
|
|
247
|
+
/** Times systemctl prints under TZ=UTC, like "Thu 2026-10-22 02:03:51 UTC". */
|
|
248
|
+
export function parseSystemdTime(value) {
|
|
249
|
+
if (!value)
|
|
250
|
+
return null;
|
|
251
|
+
const match = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(value);
|
|
252
|
+
if (!match)
|
|
253
|
+
return null;
|
|
254
|
+
const [, y, mo, d, h, mi, s] = match.map(Number);
|
|
255
|
+
return Date.UTC(y, mo - 1, d, h, mi, s);
|
|
256
|
+
}
|
|
257
|
+
export const TIMERS_SCRIPT = `
|
|
258
|
+
UNITS=$( { systemctl list-units --all --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; systemctl list-unit-files --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; } | grep '\\.timer$' | grep -v '@\\.timer$' | sort -u)
|
|
259
|
+
[ -n "$UNITS" ] || exit 0
|
|
260
|
+
systemctl show --no-pager -p Id -p Description -p ActiveState -p UnitFileState -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers -p TimersCalendar -p TimersMonotonic -- $UNITS
|
|
261
|
+
exit 0
|
|
262
|
+
`;
|
|
263
|
+
/** Reads systemctl show output for many units, split by blank lines. */
|
|
264
|
+
export function parseShowBlocks(output) {
|
|
265
|
+
const blocks = [];
|
|
266
|
+
for (const block of output.split(/\n\s*\n/)) {
|
|
267
|
+
const lines = block.split("\n").filter((line) => line.includes("="));
|
|
268
|
+
if (!lines.length)
|
|
269
|
+
continue;
|
|
270
|
+
// A unit can have several TimersCalendar lines; keep all of them.
|
|
271
|
+
const keys = {};
|
|
272
|
+
for (const line of lines) {
|
|
273
|
+
const eq = line.indexOf("=");
|
|
274
|
+
const key = line.slice(0, eq).trim();
|
|
275
|
+
const value = line.slice(eq + 1).trim();
|
|
276
|
+
keys[key] = key in keys ? `${keys[key]}\n${value}` : value;
|
|
277
|
+
}
|
|
278
|
+
blocks.push(keys);
|
|
279
|
+
}
|
|
280
|
+
return blocks;
|
|
281
|
+
}
|
|
282
|
+
function schedules(value) {
|
|
283
|
+
if (!value)
|
|
284
|
+
return [];
|
|
285
|
+
const out = [];
|
|
286
|
+
for (const line of value.split("\n")) {
|
|
287
|
+
const match = /\{\s*(\w+)=(.+?)\s*;/.exec(line);
|
|
288
|
+
if (match)
|
|
289
|
+
out.push(`${match[1]}=${match[2]}`);
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
export function parseTimers(output) {
|
|
294
|
+
return parseShowBlocks(output)
|
|
295
|
+
.filter((keys) => keys.Id?.endsWith(".timer"))
|
|
296
|
+
.map((keys) => ({
|
|
297
|
+
name: keys.Id,
|
|
298
|
+
description: keys.Description ?? "",
|
|
299
|
+
active: keys.ActiveState ?? "",
|
|
300
|
+
fileState: keys.UnitFileState ?? "",
|
|
301
|
+
next: parseSystemdTime(keys.NextElapseUSecRealtime),
|
|
302
|
+
last: parseSystemdTime(keys.LastTriggerUSec),
|
|
303
|
+
activates: (keys.Triggers ?? "").split(/\s+/)[0] ?? "",
|
|
304
|
+
schedule: [
|
|
305
|
+
...schedules(keys.TimersCalendar),
|
|
306
|
+
...schedules(keys.TimersMonotonic),
|
|
307
|
+
],
|
|
308
|
+
}))
|
|
309
|
+
.sort((a, b) => (a.next ?? Infinity) - (b.next ?? Infinity));
|
|
310
|
+
}
|
|
311
|
+
/** One line per unit from systemctl is-active, in the order asked. */
|
|
312
|
+
export function isActiveScript(units) {
|
|
313
|
+
return `systemctl is-active -- ${units.map((u) => q(requireUnit(u))).join(" ")} 2>/dev/null; exit 0\n`;
|
|
314
|
+
}
|
|
315
|
+
export function parseIsActive(units, output) {
|
|
316
|
+
const lines = output.split("\n").map((line) => line.trim());
|
|
317
|
+
const out = new Map();
|
|
318
|
+
units.forEach((unit, i) => out.set(unit, lines[i] || "unknown"));
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quoting, encoding and the error type the host scripts share.
|
|
3
|
+
*
|
|
4
|
+
* No Node APIs here: this entry also runs in the mobile app.
|
|
5
|
+
*/
|
|
6
|
+
/** A bad input to a host script builder. `status` is the HTTP status to answer with. */
|
|
7
|
+
export declare class HostScriptError extends Error {
|
|
8
|
+
readonly status: number;
|
|
9
|
+
readonly extra: Record<string, unknown>;
|
|
10
|
+
constructor(message: string, status?: number, extra?: Record<string, unknown>);
|
|
11
|
+
}
|
|
12
|
+
/** Wraps a value in single quotes for a POSIX shell. */
|
|
13
|
+
export declare function shellQuote(value: string): string;
|
|
14
|
+
/** UTF-8 bytes of a string. */
|
|
15
|
+
export declare function utf8Encode(text: string): Uint8Array;
|
|
16
|
+
/** A string from UTF-8 bytes. Bad sequences become U+FFFD. */
|
|
17
|
+
export declare function utf8Decode(bytes: ArrayLike<number>): string;
|
|
18
|
+
/** Base64 of the UTF-8 bytes of a string, for `base64 -d` on the host. */
|
|
19
|
+
export declare function toBase64(text: string): string;
|
|
20
|
+
/** Reads key=value lines. Only the first = splits a line. */
|
|
21
|
+
export declare function parseKeys(output: string): Record<string, string>;
|
|
22
|
+
/** Trims output and keeps it short enough to show. */
|
|
23
|
+
export declare function scrub(text: string, max?: number): string;
|