// systemctl and journalctl, asked in one scope or the other (novox/hq ADR 0177). // // Who asks. The node tools runtime runs as the operator account, not root (novox/hq ADR 0175 §4), // and launches this bundle as a process of its own (ADR 0188, ADR 0193) with the runtime's words: // HOME, a PATH, MESH_OPERATOR_ACCOUNT and MESH_OPERATOR_HOME — and no session words. // // The system manager is the machine's. Reading it needs nothing; acting on it (start, stop, // restart, enable, disable) is refused by polkit to an account that is not root, so those acts go // through `sudo -n`, as the packet filter's and the intrusion prevention's do, and a refusal is // named by how it failed. // // The user manager is the operator account's own, and this process IS that account. systemctl and // journalctl find it by the account's runtime directory, /run/user/, which the runtime's // environment does not name; so a user-scope call is given XDG_RUNTIME_DIR and the session bus // there. It answers only while the account's manager runs — a login, or lingering enabled — and // when it does not, that is said, never read as "no units". import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import { userInfo } from "node:os"; export type Scope = "system" | "user"; export type Act = "start" | "stop" | "restart" | "enable" | "disable"; export interface Unit { unit: string; load: string; active: string; sub: string; description: string; } /** What a command did: its output, its exit status, and the spawn error when it never ran. */ export interface Ran { stdout: string; stderr: string; status: number; /** Why it did not run to an answer: the spawn failure's code ("ENOENT" when the program is not * there), or that it was ended for taking too long. */ error?: string; } /** A command runner, so the verbs can be tested without a service manager. */ export type Runner = (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => Promise; /** How long one systemctl or journalctl may take: below the runtime's thirty-second call limit, so * a manager that hangs is answered as such rather than as a call the runtime gave up on. */ export const CALL_TIMEOUT_MS = 20_000; export const execRunner: Runner = (cmd, args, env) => new Promise((resolve) => { execFile(cmd, args, { maxBuffer: 16 * 1024 * 1024, env: env ?? process.env, timeout: CALL_TIMEOUT_MS }, (err, stdout, stderr) => { const e = err as (Error & { code?: unknown; killed?: boolean }) | null; if (e?.killed) { resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: 124, error: `no answer within ${CALL_TIMEOUT_MS / 1000} s` }); return; } if (e && typeof e.code === "string") { resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: 127, error: e.code }); return; } resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: e ? (typeof e.code === "number" ? e.code : 1) : 0 }); }); }); /** The first line of a unit file the host writes for a module's own process (mesh-host * internal/apply/process.go, unitFor). A unit loaded from a file that begins so is one the mesh * declares, and the host writes it back at its next apply. */ export const MESH_UNIT_HEADER = "# Generated by the mesh."; /** The acts that change the system manager's state, which polkit keeps from a non-root account. */ const ACTS: ReadonlySet = new Set(["start", "stop", "restart", "enable", "disable"]); /** The command as it is run: as given when this process is root or the call only reads, else an * act on the system manager through sudo without a prompt. */ export function escalated(cmd: string, args: string[], scope: Scope, uid: number | undefined = process.getuid?.()): [string, string[]] { if (uid === 0 || scope === "user" || cmd !== "systemctl" || !ACTS.has(args[0] ?? "")) return [cmd, args]; return ["sudo", ["-n", cmd, ...args]]; } /** The words that let systemctl and journalctl reach the account's own manager. */ export function sessionEnv(uid: number, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { const runtime = `/run/user/${uid}`; return { ...base, XDG_RUNTIME_DIR: runtime, DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtime}/bus` }; } export interface Options { /** The operator account, as the mesh told the runtime. */ account: string; /** This process's user id and name. */ uid: number; user: string; run?: Runner; /** Reads a unit file, to tell whether the mesh wrote it. */ read?: (path: string) => Promise; } export class ServiceManager { private readonly o: Options; private readonly run: Runner; private readonly read: (path: string) => Promise; constructor(o: Options) { this.o = o; this.run = o.run ?? execRunner; this.read = o.read ?? ((p) => readFile(p, "utf8")); } static fromEnv(env: NodeJS.ProcessEnv): ServiceManager { const me = userInfo(); return new ServiceManager({ account: env.MESH_OPERATOR_ACCOUNT?.trim() || me.username, uid: me.uid, user: me.username }); } /** One call to systemctl or journalctl in a scope, failing with what went wrong named. */ async call(scope: Scope, cmd: "systemctl" | "journalctl", ...args: string[]): Promise { let env: NodeJS.ProcessEnv | undefined; if (scope === "user") { // The user manager is the account's, and only the account's own process reaches it with // plain --user. The runtime is that account; anything else is a runtime this was not // written for, and is said rather than answered from the wrong manager. if (this.o.user !== this.o.account) { throw new Error(`the user scope is ${this.o.account}'s service manager, and this runs as ${this.o.user}`); } env = sessionEnv(this.o.uid); args = ["--user", ...args]; } const [program, argv] = escalated(cmd, args, scope, this.o.uid); const r = await this.run(program, argv, env); if (r.status === 0 && !r.error) { // systemctl answers a user manager it cannot reach on stderr and still exits 0 for some // verbs (list-units among them): that is a failure, not an empty answer. if (scope === "user" && /Failed to connect to (user scope )?bus/i.test(r.stderr)) throw this.unreachable(r.stderr); return r.stdout; } throw this.failure(cmd, program, scope, r); } private unreachable(said: string): Error { return new Error( `${this.o.account}'s own service manager does not answer at /run/user/${this.o.uid} — the account has no ` + `session and does not linger (loginctl enable-linger ${this.o.account}): ${firstLine(said)}`, ); } /** What failed, named by how it failed: sudo missing is a spawn error, sudo refusing speaks on its * own stderr line, polkit refusing says so, an unreachable user manager says so, and the rest is * the tool's own last line. */ private failure(cmd: string, program: string, scope: Scope, r: Ran): Error { const said = `${r.stderr}\n${r.stdout}`.trim(); if (r.error === "ENOENT") { return program === "sudo" ? new Error(`${cmd} needs root for this, and sudo is not installed here for the runtime's account to escalate with`) : new Error(`${cmd} is not installed on this machine`); } if (r.error) return new Error(`${cmd} did not answer: ${r.error}`); if (program === "sudo" && /^sudo:/m.test(said)) { return new Error(`${cmd} needs root for this and the runtime's account may not run it without a prompt: ${firstLine(said)}`); } if (/interactive authentication/i.test(said)) { return new Error(`the service manager refused the runtime's account: ${firstLine(said)}`); } if (scope === "user" && /Failed to connect to (user scope )?bus/i.test(said)) return this.unreachable(said); const lines = said.split("\n").map((l) => l.trim()).filter(Boolean); return new Error(lines.length ? `${cmd} failed (${r.status}): ${lines[0]}` : `${cmd} failed with status ${r.status}`); } async units(scope: Scope, pattern?: string): Promise { const args = ["list-units", "--all", "--no-legend", "--plain", "--no-pager"]; if (pattern) args.push("--", pattern); const stdout = await this.call(scope, "systemctl", ...args); return stdout .split("\n") .map((l) => l.trim()) .filter(Boolean) .map((l) => { const [unit, load, active, sub, ...rest] = l.split(/\s+/); return { unit, load, active, sub, description: rest.join(" ") }; }); } /** One unit's state, and whether the mesh declares it. * * **Declared** is read from the unit file systemd loaded (FragmentPath): the host writes every * unit of a module's own process whole, under its own header, and writes it back at its next * apply. That is the case a person's act is undone in, so it is the one the answer must name. * A unit the mesh only puts into a state through the `service` shape — a package's own unit — * carries no mark, and the host's record of it is root's; such a unit answers false here. */ async status(scope: Scope, unit: string): Promise> { const props = ["LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID", "ExecMainStatus", "Description", "FragmentPath"]; const stdout = await this.call(scope, "systemctl", "show", unitArg(unit), "--no-pager", ...props.map((p) => `--property=${p}`)); const out: Record = { unit, scope }; for (const line of stdout.split("\n")) { const i = line.indexOf("="); if (i > 0) out[line.slice(0, i)] = line.slice(i + 1); } out.mesh_declared = await this.writtenByMesh(String(out.FragmentPath ?? "")); return out; } private async writtenByMesh(path: string): Promise { if (!path) return false; const text = await this.read(path).catch(() => ""); return text.startsWith(MESH_UNIT_HEADER); } async act(scope: Scope, verb: Act, unit: string): Promise> { await this.call(scope, "systemctl", verb, unitArg(unit)); const after = await this.status(scope, unit); const answer: Record = { unit, scope, verb, ok: true, active: after.ActiveState, boot: after.UnitFileState, mesh_declared: after.mesh_declared }; if (after.mesh_declared) answer.note = "the mesh declares this unit: the host restores its declared state at its next apply"; return answer; } async journal(scope: Scope, unit: string, lines: number): Promise<{ unit: string; scope: Scope; lines: string[] }> { const stdout = await this.call(scope, "journalctl", "--no-pager", "-n", String(lines), "-u", unitArg(unit), "-o", "short-iso"); return { unit, scope, lines: stdout.split("\n").filter(Boolean) }; } /** Every failed unit in both managers. A manager that does not answer is reported as such, * beside the other's answer — never as "nothing failed". */ async failed(): Promise<{ system: Unit[] | { error: string }; user: Unit[] | { error: string } }> { const failedIn = async (scope: Scope) => { try { return (await this.units(scope)).filter((u) => u.active === "failed"); } catch (err) { return { error: (err as Error).message }; } }; return { system: await failedIn("system"), user: await failedIn("user") }; } } /** A unit's name as an argument: never something systemctl or journalctl would read as an option, * which under sudo would be root's option. */ export function unitArg(unit: string): string { if (!unit || unit.startsWith("-") || /[\s\0]/.test(unit)) throw new Error(`${JSON.stringify(unit)} is not a unit's name`); return unit; } function firstLine(text: string): string { return text.split("\n").map((l) => l.trim()).find(Boolean) ?? ""; }