Files
mesh-catalog/modules/systemd/client.ts
T
jochen 0596503db5 systemd: act as the runtime's account can, and never read a failure as an answer
The node tools runtime runs as the operator account, not root, and gives its bundles no
session words (novox/hq ADR 0175, 0188, 0193). So, per hq to-be 41 WP4:

- system-scope start/stop/restart/enable/disable go through sudo -n when not root, as the
  packet filter and intrusion prevention do, and a refusal is named by how it failed;
- user scope is plain --user with XDG_RUNTIME_DIR and the session bus of /run/user/<uid>;
  the dead --machine branches are gone;
- a failed systemctl or journalctl is an error, and an unreachable user manager is said
  even when systemctl exits 0; systemd_failed reports it beside the other manager's answer
  instead of claiming nothing failed;
- status says whether the mesh declares the unit: its loaded unit file begins with the
  header the host writes for a module's process. Only such a unit carries the restore note;
- the package resource goes: the service manager is always present, and it collided with
  systemd-networkd's identical declaration;
- calls are bounded below the runtime's call limit, a unit name is never an option, and
  the runner is injected so the tests use a fake one.
2026-10-04 03:58:19 +02:00

243 lines
12 KiB
TypeScript

// 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/<uid>, 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<Ran>;
/** 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<string> = new Set<Act>(["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<string>;
}
export class ServiceManager {
private readonly o: Options;
private readonly run: Runner;
private readonly read: (path: string) => Promise<string>;
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<string> {
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<Unit[]> {
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<Record<string, string | boolean>> {
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<string, string | boolean> = { 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<boolean> {
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<Record<string, unknown>> {
await this.call(scope, "systemctl", verb, unitArg(unit));
const after = await this.status(scope, unit);
const answer: Record<string, unknown> = { 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) ?? "";
}