Files
Termix/packages/plugin-sdk/src/host-scripts/systemd.ts
T

390 lines
11 KiB
TypeScript

/**
* systemd unit scripts and parsers, plus init system detection.
* Every script is meant for `scriptShell()`, sent on stdin.
*/
import { HostScriptError, parseKeys, shellQuote as q } from "./text.js";
export const UNIT_TYPES = [
"service",
"socket",
"timer",
"target",
"mount",
"automount",
"path",
"swap",
"slice",
"scope",
"device",
] as const;
export type UnitType = (typeof UNIT_TYPES)[number];
// Backslash is allowed for escaped names like dev-disk-by\x2duuid.
const UNIT_RE = new RegExp(
`^[A-Za-z0-9_][A-Za-z0-9:_.@\\\\-]{0,250}\\.(${UNIT_TYPES.join("|")})$`,
);
const OPENRC_RE = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,127}$/;
export function isUnit(name: unknown): name is string {
return typeof name === "string" && UNIT_RE.test(name);
}
export function unitType(name: string): UnitType {
return name.slice(name.lastIndexOf(".") + 1) as UnitType;
}
/** Units a file may be written for. Templates and device style names are out. */
export function isWritableUnitName(name: string): boolean {
return (
isUnit(name) &&
!name.includes("\\") &&
["service", "socket", "timer", "target", "path", "mount"].includes(
unitType(name),
)
);
}
export function isOpenRcName(name: unknown): name is string {
return typeof name === "string" && OPENRC_RE.test(name);
}
export function requireUnit(name: unknown): string {
if (!isUnit(name)) throw new HostScriptError("That is not a valid unit name");
return name;
}
export type InitSystem = "systemd" | "openrc" | "none";
/** A unit name or, on OpenRC, a service name. */
export function requireService(name: unknown, init: string): string {
if (init === "openrc") {
if (!isOpenRcName(name))
throw new HostScriptError("That is not a valid service name");
return name;
}
return requireUnit(name);
}
export interface UnitSummary {
name: string;
type: string;
load: string;
active: string;
sub: string;
description: string;
/** enabled, disabled, static, masked and so on. Empty when unknown. */
fileState: string;
preset: string;
}
export interface UnitDetail {
name: string;
props: Record<string, string>;
status: string;
}
export interface TimerView {
name: string;
description: string;
active: string;
fileState: string;
/** Unix ms, or null when not set. */
next: number | null;
last: number | null;
activates: string;
schedule: string[];
}
export const UNIT_ACTIONS = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
"enable-now",
"disable-now",
"mask",
"unmask",
"reset-failed",
"revert",
] as const;
export type UnitAction = (typeof UNIT_ACTIONS)[number];
/** Actions OpenRC can do. */
export const OPENRC_ACTIONS: readonly UnitAction[] = [
"start",
"stop",
"restart",
"enable",
"disable",
];
export const DETECT_SCRIPT = `
INIT=none
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then INIT=systemd
elif command -v rc-service >/dev/null 2>&1; then INIT=openrc; fi
echo "init=$INIT"
if [ "$INIT" = systemd ]; then echo "version=$(systemctl --version 2>/dev/null | head -n1 | awk '{print $2}')"; fi
if [ "$INIT" = openrc ]; then echo "version=$(openrc --version 2>/dev/null | head -n1 | awk '{print $NF}')"; fi
echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"
echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"
echo "uid=$(id -u)"
if command -v journalctl >/dev/null 2>&1; then echo "journal=1"; fi
if [ "$INIT" = systemd ]; then echo "failed=$(systemctl list-units --state=failed --plain --no-legend --no-pager 2>/dev/null | wc -l)"; fi
if [ "$INIT" = openrc ]; then echo "failed=$(rc-status --crashed 2>/dev/null | wc -l)"; fi
exit 0
`;
export interface Detected {
init: InitSystem;
version: string;
os: string;
hostname: string;
root: boolean;
journal: boolean;
failed: number;
}
export function parseDetect(output: string): Detected {
const keys = parseKeys(output);
const init =
keys.init === "systemd" || keys.init === "openrc" ? keys.init : "none";
return {
init,
version: keys.version ?? "",
os: keys.os ?? "",
hostname: keys.hostname ?? "",
root: keys.uid === "0",
journal: keys.journal === "1",
failed: Number(keys.failed) || 0,
};
}
/** Types the list can show. */
export const LIST_TYPES = [
"service",
"socket",
"timer",
"target",
"path",
"mount",
] as const;
export type ListType = (typeof LIST_TYPES)[number] | "all";
export function listUnitsScript(type: ListType): string {
const types = type === "all" ? LIST_TYPES.join(",") : type;
if (type !== "all" && !LIST_TYPES.includes(type))
throw new HostScriptError("Unknown unit type");
return `
systemctl list-units --all --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
echo "@@TERMIX_FILES@@"
systemctl list-unit-files --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
exit 0
`;
}
/** Merges list-units and list-unit-files. Template units are left out. */
export function parseUnitList(output: string): UnitSummary[] {
const [loaded = "", files = ""] = output.split("@@TERMIX_FILES@@");
const units = new Map<string, UnitSummary>();
for (const raw of loaded.split("\n")) {
const line = raw.replace(/^[●*\s]+/, "").trimEnd();
if (!line) continue;
const match = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
if (!match) continue;
const [, name, load, active, sub, description] = match;
if (!name.includes(".") || name.includes("@.")) continue;
units.set(name, {
name,
type: unitType(name),
load,
active,
sub,
description: description.trim(),
fileState: "",
preset: "",
});
}
for (const raw of files.split("\n")) {
const line = raw.trim();
if (!line) continue;
const [name, state = "", preset = ""] = line.split(/\s+/);
if (!name || !name.includes(".") || name.includes("@.")) continue;
const known = units.get(name);
const cleanPreset = preset === "-" ? "" : preset;
if (known) {
known.fileState = state;
known.preset = cleanPreset;
} else {
units.set(name, {
name,
type: unitType(name),
load: state === "masked" ? "masked" : "unloaded",
active: "inactive",
sub: "dead",
description: "",
fileState: state,
preset: cleanPreset,
});
}
}
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export const DETAIL_PROPS = [
"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",
] as const;
export function detailScript(unit: string): string {
const props = DETAIL_PROPS.map((p) => `-p ${p}`).join(" ");
return `
systemctl show --no-pager ${props} -- ${q(requireUnit(unit))}
echo "@@TERMIX_STATUS@@"
systemctl status --no-pager --full -n 0 -- ${q(unit)} 2>&1
exit 0
`;
}
export function parseDetail(unit: string, output: string): UnitDetail {
const [props = "", status = ""] = output.split("@@TERMIX_STATUS@@");
return { name: unit, props: parseKeys(props), status: status.trim() };
}
const VERBS: Record<UnitAction, string> = {
start: "start",
stop: "stop",
restart: "restart",
reload: "reload",
enable: "enable",
disable: "disable",
"enable-now": "enable --now",
"disable-now": "disable --now",
mask: "mask",
unmask: "unmask",
"reset-failed": "reset-failed",
revert: "revert",
};
/** The systemctl call for an action. Prints `state=` after it. */
export function actionScript(unit: string, action: UnitAction): string {
const verb = VERBS[action];
if (!verb) throw new HostScriptError("Unknown action");
const u = q(requireUnit(unit));
return `
systemctl ${verb} -- ${u} 2>&1 || { echo "SCRIPT_ERR:systemctl ${verb} failed"; exit 1; }
echo "state=$(systemctl is-active -- ${u} 2>/dev/null)"
`;
}
/** Times systemctl prints under TZ=UTC, like "Thu 2026-10-22 02:03:51 UTC". */
export function parseSystemdTime(value: string | undefined): number | null {
if (!value) return null;
const match = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(value);
if (!match) return null;
const [, y, mo, d, h, mi, s] = match.map(Number);
return Date.UTC(y, mo - 1, d, h, mi, s);
}
export const TIMERS_SCRIPT = `
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)
[ -n "$UNITS" ] || exit 0
systemctl show --no-pager -p Id -p Description -p ActiveState -p UnitFileState -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers -p TimersCalendar -p TimersMonotonic -- $UNITS
exit 0
`;
/** Reads systemctl show output for many units, split by blank lines. */
export function parseShowBlocks(output: string): Array<Record<string, string>> {
const blocks: Array<Record<string, string>> = [];
for (const block of output.split(/\n\s*\n/)) {
const lines = block.split("\n").filter((line) => line.includes("="));
if (!lines.length) continue;
// A unit can have several TimersCalendar lines; keep all of them.
const keys: Record<string, string> = {};
for (const line of lines) {
const eq = line.indexOf("=");
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
keys[key] = key in keys ? `${keys[key]}\n${value}` : value;
}
blocks.push(keys);
}
return blocks;
}
function schedules(value: string | undefined): string[] {
if (!value) return [];
const out: string[] = [];
for (const line of value.split("\n")) {
const match = /\{\s*(\w+)=(.+?)\s*;/.exec(line);
if (match) out.push(`${match[1]}=${match[2]}`);
}
return out;
}
export function parseTimers(output: string): TimerView[] {
return parseShowBlocks(output)
.filter((keys) => keys.Id?.endsWith(".timer"))
.map((keys) => ({
name: keys.Id,
description: keys.Description ?? "",
active: keys.ActiveState ?? "",
fileState: keys.UnitFileState ?? "",
next: parseSystemdTime(keys.NextElapseUSecRealtime),
last: parseSystemdTime(keys.LastTriggerUSec),
activates: (keys.Triggers ?? "").split(/\s+/)[0] ?? "",
schedule: [
...schedules(keys.TimersCalendar),
...schedules(keys.TimersMonotonic),
],
}))
.sort((a, b) => (a.next ?? Infinity) - (b.next ?? Infinity));
}
/** One line per unit from systemctl is-active, in the order asked. */
export function isActiveScript(units: string[]): string {
return `systemctl is-active -- ${units.map((u) => q(requireUnit(u))).join(" ")} 2>/dev/null; exit 0\n`;
}
export function parseIsActive(
units: string[],
output: string,
): Map<string, string> {
const lines = output.split("\n").map((line) => line.trim());
const out = new Map<string, string>();
units.forEach((unit, i) => out.set(unit, lines[i] || "unknown"));
return out;
}