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.
243 lines
12 KiB
TypeScript
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) ?? "";
|
|
}
|