diff --git a/modules/systemd/client.ts b/modules/systemd/client.ts deleted file mode 100644 index affad91..0000000 --- a/modules/systemd/client.ts +++ /dev/null @@ -1,242 +0,0 @@ -// 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) ?? ""; -} diff --git a/modules/systemd/cmd/systemd-tools/client.go b/modules/systemd/cmd/systemd-tools/client.go new file mode 100644 index 0000000..2145ee5 --- /dev/null +++ b/modules/systemd/cmd/systemd-tools/client.go @@ -0,0 +1,349 @@ +package main + +// 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 its units 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. +// +// **So does reading a system unit's journal** (novox/hq issue 255). journalctl shows an account that is +// neither root nor in the journal's group only that account's own entries, and answers "-- No entries --" — +// which read as a quiet service, not as a refusal. Every system service's journal was empty through this +// verb, and a person reached for a shell to read it. +// +// 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 ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// Scope is which service manager: the machine's, or the operator account's own. +type Scope string + +const ( + System Scope = "system" + User Scope = "user" +) + +// Unit is one unit as list-units answers it. +type Unit struct { + Unit string `json:"unit"` + Load string `json:"load"` + Active string `json:"active"` + Sub string `json:"sub"` + Description string `json:"description"` +} + +// Ran is what a command did: its output, its exit status, and why it did not run to an answer. +type Ran struct { + Stdout, Stderr string + Status int + // Error is "ENOENT" when the program is not there, or that it took too long. + Error string +} + +// Runner runs a command, so the verbs can be tested without a service manager. +type Runner func(cmd string, args []string, env []string) Ran + +// CallTimeout is 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. +const CallTimeout = 20 * time.Second + +func execRunner(cmd string, args []string, env []string) Ran { + ctx, cancel := context.WithTimeout(context.Background(), CallTimeout) + defer cancel() + c := exec.CommandContext(ctx, cmd, args...) + if env != nil { + c.Env = env + } + var out, errb bytes.Buffer + c.Stdout, c.Stderr = &out, &errb + err := c.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + switch { + case ctx.Err() == context.DeadlineExceeded: + r.Status, r.Error = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + case errors.Is(err, exec.ErrNotFound): + r.Status, r.Error = 127, "ENOENT" + case err != nil: + var exit *exec.ExitError + if errors.As(err, &exit) { + r.Status = exit.ExitCode() + } else { + r.Status, r.Error = 127, err.Error() + } + } + return r +} + +// MeshUnitHeader is 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. +const MeshUnitHeader = "# Generated by the mesh." + +// acts are the verbs that change the system manager's state, which polkit keeps from a non-root account. +var acts = map[string]bool{"start": true, "stop": true, "restart": true, "enable": true, "disable": true} + +// escalated is the command as it is run: as given when this process is root, or in the user scope, or +// when the call is a systemctl read; else through sudo without a prompt — an act on the system manager, +// or a read of the system journal (issue 255). +func escalated(cmd string, args []string, scope Scope, uid int) (string, []string) { + if uid == 0 || scope == User { + return cmd, args + } + if cmd == "journalctl" || (cmd == "systemctl" && len(args) > 0 && acts[args[0]]) { + return "sudo", append([]string{"-n", cmd}, args...) + } + return cmd, args +} + +// sessionEnv is the words that let systemctl and journalctl reach the account's own manager. +func sessionEnv(uid int, base []string) []string { + runtime := fmt.Sprintf("/run/user/%d", uid) + out := []string{} + for _, kv := range base { + if !strings.HasPrefix(kv, "XDG_RUNTIME_DIR=") && !strings.HasPrefix(kv, "DBUS_SESSION_BUS_ADDRESS=") { + out = append(out, kv) + } + } + return append(out, "XDG_RUNTIME_DIR="+runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path="+runtime+"/bus") +} + +// Manager asks the service managers. +type Manager struct { + // Account is the operator account, as the mesh told the runtime. + Account string + // UID and User are this process's. + UID int + User string + Run Runner + // Read reads a unit file, to tell whether the mesh wrote it. + Read func(path string) (string, error) + // Env is this process's environment, the base of a user-scope call's. + Env []string +} + +var userBus = regexp.MustCompile(`(?i)Failed to connect to (user scope )?bus`) + +// call is one call to systemctl or journalctl in a scope, failing with what went wrong named. +func (m *Manager) call(scope Scope, cmd string, args ...string) (string, error) { + var env []string + 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 m.User != m.Account { + return "", fmt.Errorf("the user scope is %s's service manager, and this runs as %s", m.Account, m.User) + } + env = sessionEnv(m.UID, m.Env) + args = append([]string{"--user"}, args...) + } + program, argv := escalated(cmd, args, scope, m.UID) + r := m.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 && userBus.MatchString(r.Stderr) { + return "", m.unreachable(r.Stderr) + } + return r.Stdout, nil + } + return "", m.failure(cmd, program, scope, r) +} + +func (m *Manager) unreachable(said string) error { + return fmt.Errorf("%s's own service manager does not answer at /run/user/%d — the account has no session "+ + "and does not linger (loginctl enable-linger %s): %s", m.Account, m.UID, m.Account, firstLine(said)) +} + +var ( + sudoSaid = regexp.MustCompile(`(?m)^sudo:`) + polkit = regexp.MustCompile(`(?i)interactive authentication`) +) + +// failure names what failed 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 first line. +func (m *Manager) failure(cmd, program string, scope Scope, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Error == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Error != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Error) + } + if program == "sudo" && sudoSaid.MatchString(said) { + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if polkit.MatchString(said) { + return fmt.Errorf("the service manager refused the runtime's account: %s", firstLine(said)) + } + if scope == User && userBus.MatchString(said) { + return m.unreachable(said) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +var spaces = regexp.MustCompile(`\s+`) + +// Units is the units a manager knows in a scope, narrowed to a pattern when one is given. +func (m *Manager) Units(scope Scope, pattern string) ([]Unit, error) { + args := []string{"list-units", "--all", "--no-legend", "--plain", "--no-pager"} + if pattern != "" { + args = append(args, "--", pattern) + } + out, err := m.call(scope, "systemctl", args...) + if err != nil { + return nil, err + } + units := []Unit{} + for _, line := range strings.Split(out, "\n") { + f := spaces.Split(strings.TrimSpace(line), -1) + if len(f) < 4 || f[0] == "" { + continue + } + units = append(units, Unit{Unit: f[0], Load: f[1], Active: f[2], Sub: f[3], Description: strings.Join(f[4:], " ")}) + } + return units, nil +} + +// Status is 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; such a unit answers false. +func (m *Manager) Status(scope Scope, unit string) (map[string]any, error) { + if err := unitArg(unit); err != nil { + return nil, err + } + props := []string{"LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID", "ExecMainStatus", "Description", "FragmentPath"} + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.call(scope, "systemctl", args...) + if err != nil { + return nil, err + } + answer := map[string]any{"unit": unit, "scope": string(scope)} + for _, line := range strings.Split(out, "\n") { + if k, v, ok := strings.Cut(line, "="); ok && k != "" { + answer[k] = v + } + } + fragment, _ := answer["FragmentPath"].(string) + answer["mesh_declared"] = m.writtenByMesh(fragment) + return answer, nil +} + +func (m *Manager) writtenByMesh(path string) bool { + if path == "" { + return false + } + text, err := m.Read(path) + return err == nil && strings.HasPrefix(text, MeshUnitHeader) +} + +// Act starts, stops, restarts, enables or disables one unit, and answers with the state after. +func (m *Manager) Act(scope Scope, verb, unit string) (map[string]any, error) { + if err := unitArg(unit); err != nil { + return nil, err + } + if _, err := m.call(scope, "systemctl", verb, unit); err != nil { + return nil, err + } + after, err := m.Status(scope, unit) + if err != nil { + return nil, err + } + answer := map[string]any{"unit": unit, "scope": string(scope), "verb": verb, "ok": true, + "active": after["ActiveState"], "boot": after["UnitFileState"], "mesh_declared": after["mesh_declared"]} + if after["mesh_declared"] == true { + answer["note"] = "the mesh declares this unit: the host restores its declared state at its next apply" + } + return answer, nil +} + +// Journal is the last lines of one unit's journal. +func (m *Manager) Journal(scope Scope, unit string, lines int) (map[string]any, error) { + if err := unitArg(unit); err != nil { + return nil, err + } + out, err := m.call(scope, "journalctl", "--no-pager", "-n", strconv.Itoa(lines), "-u", unit, "-o", "short-iso") + if err != nil { + return nil, err + } + kept := []string{} + for _, l := range strings.Split(out, "\n") { + if l != "" { + kept = append(kept, l) + } + } + return map[string]any{"unit": unit, "scope": string(scope), "lines": kept}, nil +} + +// Failed is 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". +func (m *Manager) Failed() map[string]any { + in := func(scope Scope) any { + units, err := m.Units(scope, "") + if err != nil { + return map[string]string{"error": err.Error()} + } + failed := []Unit{} + for _, u := range units { + if u.Active == "failed" { + failed = append(failed, u) + } + } + return failed + } + return map[string]any{"system": in(System), "user": in(User)} +} + +// unitArg refuses a unit name systemctl or journalctl would read as an option — which under sudo would be +// root's option. +func unitArg(unit string) error { + if unit == "" || strings.HasPrefix(unit, "-") || strings.ContainsAny(unit, " \t\n\r\x00") { + return fmt.Errorf("%q is not a unit's name", unit) + } + return nil +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func readFile(path string) (string, error) { + raw, err := os.ReadFile(path) + return string(raw), err +} diff --git a/modules/systemd/cmd/systemd-tools/client_test.go b/modules/systemd/cmd/systemd-tools/client_test.go new file mode 100644 index 0000000..6e5007f --- /dev/null +++ b/modules/systemd/cmd/systemd-tools/client_test.go @@ -0,0 +1,294 @@ +package main + +// The service manager's verbs over a fake runner (novox/hq ADR 0177, to-be 41 WP4), ported with the +// TypeScript module's tests: which manager a call reaches and how, acts on the system manager escalated, +// failures named rather than read as empty answers, whether the mesh declares a unit — and the system +// journal read escalated (novox/hq issue 255). + +import ( + "encoding/json" + "errors" + "os" + "reflect" + "strings" + "testing" +) + +type call struct { + cmd string + args []string + env []string +} + +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(cmd string, args []string, env []string) Ran { + c := call{cmd, args, env} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +const ( + list = "sshd.service loaded active running OpenSSH Daemon\nbroken.service loaded failed failed A broken thing\n" + showMesh = "LoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\nMainPID=42\nExecMainStatus=0\nDescription=showcase, a mesh daemon\nFragmentPath=/etc/systemd/system/showcase.service\n" + showPackage = "LoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\nMainPID=7\nExecMainStatus=0\nDescription=OpenSSH Daemon\nFragmentPath=/usr/lib/systemd/system/sshd.service\n" +) + +var files = map[string]string{ + "/etc/systemd/system/showcase.service": MeshUnitHeader + " Do not edit — this file is replaced whenever the\n[Unit]\n", + "/usr/lib/systemd/system/sshd.service": "[Unit]\nDescription=OpenSSH Daemon\n", +} + +func read(p string) (string, error) { + if s, ok := files[p]; ok { + return s, nil + } + return "", errors.New("ENOENT") +} + +func manager(run Runner, uid int, name string) *Manager { + return &Manager{Account: "operator", UID: uid, User: name, Run: run, Read: read, Env: []string{"HOME=/h"}} +} + +func operator(run Runner) *Manager { return manager(run, 1000, "operator") } + +func TestAnActAndASystemJournalReadGoThroughSudoUnlessRoot(t *testing.T) { + for _, verb := range []string{"start", "stop", "restart", "enable", "disable"} { + if p, _ := escalated("systemctl", []string{verb, "x.service"}, System, 1000); p != "sudo" { + t.Errorf("%s was not escalated", verb) + } + } + if p, a := escalated("systemctl", []string{"restart", "sshd.service"}, System, 1000); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "systemctl", "restart", "sshd.service"}) { + t.Errorf("%s %v", p, a) + } + if p, _ := escalated("systemctl", []string{"restart", "sshd.service"}, System, 0); p != "systemctl" { + t.Error("root escalated") + } + for _, args := range [][]string{{"show", "sshd.service"}, {"list-units", "restart"}} { + if p, _ := escalated("systemctl", args, System, 1000); p != "systemctl" { + t.Errorf("a read was escalated: %v", args) + } + } + if p, _ := escalated("systemctl", []string{"--user", "restart", "x.service"}, User, 1000); p != "systemctl" { + t.Error("the user scope was escalated") + } + // The system journal is read as root: unescalated, an account outside the journal's group sees only + // its own entries and every service's journal reads empty (issue 255). + if p, a := escalated("journalctl", []string{"-u", "x"}, System, 1000); p != "sudo" || a[1] != "journalctl" { + t.Errorf("the system journal read was not escalated: %s %v", p, a) + } + if p, _ := escalated("journalctl", []string{"--user", "-u", "x"}, User, 1000); p != "journalctl" { + t.Error("the account's own journal was escalated") + } +} + +func TestTheUserScopeIsPlainUserWithTheAccountsRuntimeDirectoryAndBus(t *testing.T) { + var calls []call + m := manager(fake(func(call) Ran { return Ran{Stdout: list} }, &calls), 1234, "operator") + if _, err := m.Units(User, ""); err != nil { + t.Fatal(err) + } + if calls[0].cmd != "systemctl" || calls[0].args[0] != "--user" { + t.Fatalf("%+v", calls[0]) + } + env := strings.Join(calls[0].env, "\n") + if !strings.Contains(env, "XDG_RUNTIME_DIR=/run/user/1234") || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1234/bus") || !strings.Contains(env, "HOME=/h") { + t.Fatalf("%v", calls[0].env) + } + if _, err := m.Journal(User, "watcher.service", 10); err != nil { + t.Fatal(err) + } + if calls[1].cmd != "journalctl" || calls[1].args[0] != "--user" { + t.Fatalf("%+v", calls[1]) + } +} + +func TestTheSystemScopeIsGivenNoSessionWords(t *testing.T) { + var calls []call + if _, err := operator(fake(func(call) Ran { return Ran{Stdout: list} }, &calls)).Units(System, ""); err != nil { + t.Fatal(err) + } + if calls[0].env != nil { + t.Fatalf("%v", calls[0].env) + } + for _, a := range calls[0].args { + if a == "--user" { + t.Fatal("--user in a system call") + } + } +} + +func TestTheUserScopeFromAnotherAccountIsRefused(t *testing.T) { + m := manager(fake(func(call) Ran { return Ran{Stdout: list} }, nil), 0, "root") + if _, err := m.Units(User, ""); err == nil || !strings.Contains(err.Error(), "operator's service manager, and this runs as root") { + t.Fatalf("%v", err) + } +} + +func TestASystemActEscalatesAndAnswersTheStateAfter(t *testing.T) { + var calls []call + m := operator(fake(func(c call) Ran { + if contains(c.args, "show") { + return Ran{Stdout: showPackage} + } + return Ran{} + }, &calls)) + r, err := m.Act(System, "restart", "sshd.service") + if err != nil { + t.Fatal(err) + } + if got := append([]string{calls[0].cmd}, calls[0].args...); !reflect.DeepEqual(got, []string{"sudo", "-n", "systemctl", "restart", "sshd.service"}) { + t.Fatalf("%v", got) + } + if r["ok"] != true || r["active"] != "active" || r["mesh_declared"] != false || r["note"] != nil { + t.Fatalf("%v", r) + } +} + +func TestTheRestoreNoteIsOnlyOnAUnitTheMeshDeclares(t *testing.T) { + m := operator(fake(func(c call) Ran { + if contains(c.args, "show") { + return Ran{Stdout: showMesh} + } + return Ran{} + }, nil)) + r, _ := m.Act(System, "stop", "showcase.service") + if r["mesh_declared"] != true || !strings.Contains(r["note"].(string), "host restores its declared state") { + t.Fatalf("%v", r) + } +} + +func TestStatusSaysWhetherTheMeshDeclaresTheUnit(t *testing.T) { + answer := func(s string) Runner { return fake(func(call) Ran { return Ran{Stdout: s} }, nil) } + mesh, _ := operator(answer(showMesh)).Status(System, "showcase.service") + pkg, _ := operator(answer(showPackage)).Status(System, "sshd.service") + none, _ := operator(answer("LoadState=not-found\nFragmentPath=\n")).Status(System, "nope.service") + if mesh["mesh_declared"] != true || mesh["MainPID"] != "42" || pkg["mesh_declared"] != false || none["mesh_declared"] != false { + t.Fatalf("%v %v %v", mesh, pkg, none) + } + if MeshUnitHeader != "# Generated by the mesh." { + t.Fatal("the header is not the one the host writes") + } +} + +func TestRefusalsAreNamed(t *testing.T) { + refused := operator(fake(func(call) Ran { return Ran{Status: 1, Stderr: "sudo: a password is required\n"} }, nil)) + if _, err := refused.Act(System, "start", "x.service"); err == nil || !strings.Contains(err.Error(), "may not run it without a prompt: sudo: a password is required") { + t.Fatalf("%v", err) + } + missing := operator(fake(func(call) Ran { return Ran{Status: 127, Error: "ENOENT"} }, nil)) + if _, err := missing.Act(System, "start", "x.service"); err == nil || !strings.Contains(err.Error(), "sudo is not installed here") { + t.Fatalf("%v", err) + } + polkitRefused := manager(fake(func(call) Ran { + return Ran{Status: 1, Stderr: "Failed to stop x.service: Access denied as the requested operation requires interactive authentication.\n"} + }, nil), 0, "root") + if _, err := polkitRefused.Act(System, "stop", "x.service"); err == nil || !strings.Contains(err.Error(), "the service manager refused the runtime's account") { + t.Fatalf("%v", err) + } + failing := operator(fake(func(call) Ran { return Ran{Status: 1, Stderr: "Failed to list units: Connection timed out\n"} }, nil)) + if _, err := failing.Units(System, ""); err == nil || !strings.Contains(err.Error(), "systemctl failed (1): Failed to list units: Connection timed out") { + t.Fatalf("%v", err) + } +} + +func TestAnUnreachableUserManagerIsSaidEvenWhenSystemctlExitsZero(t *testing.T) { + said := "Failed to connect to user scope bus via local transport: No such file or directory\n" + m := operator(fake(func(call) Ran { return Ran{Stderr: said} }, nil)) + if _, err := m.Units(User, ""); err == nil || !strings.Contains(err.Error(), "operator's own service manager does not answer at /run/user/1000") { + t.Fatalf("%v", err) + } + nonzero := operator(fake(func(call) Ran { return Ran{Status: 1, Stderr: said} }, nil)) + if _, err := nonzero.Status(User, "x.service"); err == nil || !strings.Contains(err.Error(), "does not answer") { + t.Fatalf("%v", err) + } +} + +func TestFailedReportsEachManagerAndOneThatDoesNotAnswerByItsError(t *testing.T) { + m := operator(fake(func(c call) Ran { + if c.args[0] == "--user" { + return Ran{Status: 1, Stderr: "Failed to connect to user scope bus via local transport: No such file or directory\n"} + } + return Ran{Stdout: list} + }, nil)) + r := m.Failed() + system, _ := json.Marshal(r["system"]) + if string(system) != `[{"unit":"broken.service","load":"loaded","active":"failed","sub":"failed","description":"A broken thing"}]` { + t.Fatalf("%s", system) + } + if e, ok := r["user"].(map[string]string); !ok || !strings.Contains(e["error"], "does not answer") { + t.Fatalf("%v", r["user"]) + } +} + +func TestAUnitsNameIsNeverAnOption(t *testing.T) { + for _, bad := range []string{"--host=elsewhere", "a b", ""} { + if unitArg(bad) == nil { + t.Errorf("%q accepted", bad) + } + } + var calls []call + m := operator(fake(func(call) Ran { return Ran{Stdout: list} }, &calls)) + if _, err := m.Act(System, "stop", "-H"); err == nil || len(calls) != 0 { + t.Fatalf("an option ran as a unit: %v %d", err, len(calls)) + } + if _, err := m.Units(System, "-x*"); err != nil { + t.Fatal(err) + } + if a := calls[0].args; a[len(a)-2] != "--" || a[len(a)-1] != "-x*" { + t.Fatalf("%v", a) + } +} + +// The manifest owns the systemd package, claims the seat's eight verbs, and lists exactly the tools served. +func TestTheManifestOwnsThePackageAndListsWhatIsServed(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Capabilities []string `json:"capabilities"` + Tools []string `json:"tools"` + Claims []struct { + Name string `json:"name"` + Serves []string `json:"serves"` + } `json:"claims"` + Resources []struct { + Type string `json:"type"` + Package string `json:"package"` + } `json:"resources"` + } + _ = json.Unmarshal(raw, &m) + owns := false + for _, r := range m.Resources { + owns = owns || (r.Type == "package" && r.Package == "systemd") + } + if !owns || !contains(m.Capabilities, "package-manager") { + t.Fatal("the manifest does not own the systemd package") + } + served := map[string]bool{} + for _, tool := range tools(operator(nil)) { + served[tool.Name] = true + } + want := map[string]bool{} + for _, v := range m.Claims[0].Serves { + want[seat+"."+v] = true + } + for _, n := range m.Tools { + want[n] = true + } + if !reflect.DeepEqual(served, want) { + t.Fatalf("served %v, the manifest says %v", served, want) + } +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} diff --git a/modules/systemd/cmd/systemd-tools/main.go b/modules/systemd/cmd/systemd-tools/main.go new file mode 100644 index 0000000..237df05 --- /dev/null +++ b/modules/systemd/cmd/systemd-tools/main.go @@ -0,0 +1,150 @@ +// systemd's tools: the node-service-manager seat's eight verbs — the units on this machine in both scopes, +// read and acted on by name — and the module's own reading of what has failed (novox/hq ADR 0177). The node +// tools runtime launches this bundle as a process of its own and serves what it serves (ADR 0188, ADR 0193); +// it runs as the operator account, so acts on the system manager, and reads of its journal, escalate with +// sudo -n, and the user scope is the account's own manager (client.go). The host applies units; this answers +// about them. stdout is the MCP channel; this says nothing else. +package main + +import ( + "fmt" + "os" + "os/user" + "strconv" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +const seat = "node-service-manager" + +func str(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} + +var ( + scopeArg = str(`"system" (the default) or "user": the operator account's own manager`) + unitArgS = str("the unit's name, as the service manager knows it") +) + +func scopeOf(a map[string]any) (Scope, error) { + s, _ := a["scope"].(string) + switch s { + case "", "system": + return System, nil + case "user": + return User, nil + } + return "", fmt.Errorf("scope %q: \"system\" or \"user\"", s) +} + +func unitOf(a map[string]any) (string, error) { + u, _ := a["unit"].(string) + if u = strings.TrimSpace(u); u == "" { + return "", fmt.Errorf("a unit is required") + } + return u, nil +} + +func tools(m *Manager) []stdio.Tool { + act := func(verb, description string) stdio.Tool { + return stdio.Tool{Name: seat + "." + verb, Description: description, + Input: map[string]any{"scope": scopeArg, "unit": unitArgS}, + Run: func(a map[string]any) (any, error) { + scope, err := scopeOf(a) + if err != nil { + return nil, err + } + unit, err := unitOf(a) + if err != nil { + return nil, err + } + return m.Act(scope, verb, unit) + }} + } + return []stdio.Tool{ + {Name: seat + ".units", + Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", + Input: map[string]any{"scope": scopeArg, "pattern": str("a glob the unit's name must match (optional)")}, + Run: func(a map[string]any) (any, error) { + scope, err := scopeOf(a) + if err != nil { + return nil, err + } + pattern, _ := a["pattern"].(string) + units, err := m.Units(scope, pattern) + if err != nil { + return nil, err + } + return map[string]any{"scope": string(scope), "units": units}, nil + }}, + {Name: seat + ".status", + Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and mesh_declared — true when its unit file is one the mesh wrote (a unit the mesh only puts into a state is not recognised from here).", + Input: map[string]any{"scope": scopeArg, "unit": unitArgS}, + Run: func(a map[string]any) (any, error) { + scope, err := scopeOf(a) + if err != nil { + return nil, err + } + unit, err := unitOf(a) + if err != nil { + return nil, err + } + return m.Status(scope, unit) + }}, + act("start", "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at its next apply."), + act("stop", "Stop one unit; for a unit the mesh declares, the answer says the host will restore its declared state."), + act("restart", "Restart one unit."), + act("enable", "Make one unit start at boot (or at the account's login, in user scope)."), + act("disable", "Stop one unit starting at boot (or at login, in user scope)."), + {Name: seat + ".journal", + Description: "The last lines of one unit's journal (at most 2000) — a system service's included: the read is escalated, so it is the service's own lines and not only the operator account's.", + Input: map[string]any{"scope": scopeArg, "unit": unitArgS, + "lines": map[string]any{"type": "number", "description": "how many lines from the end (default 100, at most 2000)"}}, + Run: func(a map[string]any) (any, error) { + scope, err := scopeOf(a) + if err != nil { + return nil, err + } + unit, err := unitOf(a) + if err != nil { + return nil, err + } + // Bounded so the answer stays well below what the runtime carries back in one reply. + n := 100 + if v, ok := a["lines"].(float64); ok && v >= 1 { + n = min(int(v), 2000) + } + return m.Journal(scope, unit, n) + }}, + {Name: "systemd_failed", + Description: "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, not as nothing failed.", + Run: func(map[string]any) (any, error) { return m.Failed(), nil }}, + } +} + +func fromEnv() *Manager { + me, _ := user.Current() + name := "" + if me != nil { + name = me.Username + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = name + } + uid := os.Getuid() + if me != nil { + if n, err := strconv.Atoi(me.Uid); err == nil { + uid = n + } + } + return &Manager{Account: account, UID: uid, User: name, Run: execRunner, Read: readFile, Env: os.Environ()} +} + +func main() { + if err := stdio.Serve("", tools(fromEnv())); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/modules/systemd/go.mod b/modules/systemd/go.mod new file mode 100644 index 0000000..3b60d17 --- /dev/null +++ b/modules/systemd/go.mod @@ -0,0 +1,5 @@ +module systemd-tools + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/systemd/go.sum b/modules/systemd/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/systemd/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/systemd/module.json b/modules/systemd/module.json index f2715aa..3e9a0e9 100644 --- a/modules/systemd/module.json +++ b/modules/systemd/module.json @@ -29,9 +29,12 @@ { "name": "tools", "kind": "bundle", - "language": "typescript", - "entrypoints": [ - "tools/index.js" + "language": "go", + "system": "arch", + "from": "cmd/systemd-tools", + "binary": "systemd-tools", + "loads": [ + "systemd-tools" ] } ] diff --git a/modules/systemd/package.json b/modules/systemd/package.json deleted file mode 100644 index 489a361..0000000 --- a/modules/systemd/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@novox/module-systemd", - "version": "0.1.0", - "description": "systemd \u2014 the machine's service manager as a module: holds node-service-manager and answers for the units in both scopes (novox/hq ADR 0177). The host applies units; this answers about them.", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.1" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - }, - "scripts": { - "build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist", - "test": "node --test --experimental-strip-types 'test/*.test.ts'" - } -} diff --git a/modules/systemd/test/client.test.ts b/modules/systemd/test/client.test.ts deleted file mode 100644 index 19317e1..0000000 --- a/modules/systemd/test/client.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -// The service manager's verbs over a fake runner (novox/hq ADR 0177, to-be 41 WP4): which manager -// a call reaches and how, acts on the system manager escalated, failures named rather than read as -// empty answers, and whether the mesh declares a unit. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { MESH_UNIT_HEADER, ServiceManager, escalated, sessionEnv, unitArg, type Ran, type Runner } from "../client.ts"; - -interface Call { - cmd: string; - args: string[]; - env?: NodeJS.ProcessEnv; -} - -function fake(answer: (c: Call) => Partial, calls: Call[] = []): Runner { - return async (cmd, args, env) => { - const c = { cmd, args, env }; - calls.push(c); - return { stdout: "", stderr: "", status: 0, ...answer(c) }; - }; -} - -const LIST = "sshd.service loaded active running OpenSSH Daemon\nbroken.service loaded failed failed A broken thing\n"; -const SHOW_MESH = "LoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\nMainPID=42\nExecMainStatus=0\nDescription=showcase, a mesh daemon\nFragmentPath=/etc/systemd/system/showcase.service\n"; -const SHOW_PACKAGE = "LoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\nMainPID=7\nExecMainStatus=0\nDescription=OpenSSH Daemon\nFragmentPath=/usr/lib/systemd/system/sshd.service\n"; - -const files: Record = { - "/etc/systemd/system/showcase.service": `${MESH_UNIT_HEADER} Do not edit — this file is replaced whenever the\n[Unit]\n`, - "/usr/lib/systemd/system/sshd.service": "[Unit]\nDescription=OpenSSH Daemon\n", -}; -const read = async (p: string) => { - if (p in files) return files[p]; - throw new Error("ENOENT"); -}; - -function manager(run: Runner, o: { uid?: number; user?: string } = {}): ServiceManager { - return new ServiceManager({ account: "operator", uid: o.uid ?? 1000, user: o.user ?? "operator", run, read }); -} - -test("an act on the system manager goes through sudo without a prompt unless this is root; reads never do", () => { - assert.deepEqual(escalated("systemctl", ["restart", "sshd.service"], "system", 1000), ["sudo", ["-n", "systemctl", "restart", "sshd.service"]]); - for (const verb of ["start", "stop", "enable", "disable"]) { - assert.equal(escalated("systemctl", [verb, "x.service"], "system", 1000)[0], "sudo"); - } - assert.deepEqual(escalated("systemctl", ["restart", "sshd.service"], "system", 0), ["systemctl", ["restart", "sshd.service"]]); - assert.deepEqual(escalated("systemctl", ["show", "sshd.service"], "system", 1000), ["systemctl", ["show", "sshd.service"]]); - assert.deepEqual(escalated("systemctl", ["list-units", "restart"], "system", 1000)[0], "systemctl"); - assert.deepEqual(escalated("systemctl", ["--user", "restart", "x.service"], "user", 1000)[0], "systemctl"); - assert.deepEqual(escalated("journalctl", ["-u", "x"], "system", 1000)[0], "journalctl"); -}); - -test("the user scope is plain --user, with the account's runtime directory and bus named", async () => { - const calls: Call[] = []; - const m = manager(fake(() => ({ stdout: LIST }), calls), { uid: 1234 }); - await m.units("user"); - assert.equal(calls[0].cmd, "systemctl"); - assert.equal(calls[0].args[0], "--user"); - assert.ok(!calls[0].args.some((a) => a.startsWith("--machine"))); - assert.equal(calls[0].env?.XDG_RUNTIME_DIR, "/run/user/1234"); - assert.equal(calls[0].env?.DBUS_SESSION_BUS_ADDRESS, "unix:path=/run/user/1234/bus"); - await m.journal("user", "watcher.service", 10); - assert.deepEqual(calls[1].args.slice(0, 1), ["--user"]); - assert.equal(calls[1].cmd, "journalctl"); - assert.equal(calls[1].env?.XDG_RUNTIME_DIR, "/run/user/1234"); - assert.deepEqual(sessionEnv(5, { HOME: "/h" }), { HOME: "/h", XDG_RUNTIME_DIR: "/run/user/5", DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/5/bus" }); -}); - -test("the system scope is given no session words", async () => { - const calls: Call[] = []; - await manager(fake(() => ({ stdout: LIST }), calls)).units("system"); - assert.equal(calls[0].env, undefined); - assert.ok(!calls[0].args.includes("--user")); -}); - -test("the user scope from a process that is not the account is refused, not answered from the wrong manager", async () => { - const m = manager(fake(() => ({ stdout: LIST })), { user: "root", uid: 0 }); - await assert.rejects(() => m.units("user"), /operator's service manager, and this runs as root/); -}); - -test("a system act escalates, and answers with the state after", async () => { - const calls: Call[] = []; - const m = manager(fake((c) => (c.args.includes("show") ? { stdout: SHOW_PACKAGE } : {}), calls)); - const r = await m.act("system", "restart", "sshd.service"); - assert.deepEqual([calls[0].cmd, ...calls[0].args], ["sudo", "-n", "systemctl", "restart", "sshd.service"]); - assert.equal(r.ok, true); - assert.equal(r.active, "active"); - assert.equal(r.mesh_declared, false); - assert.equal(r.note, undefined, "no restore note on a unit the mesh did not write"); -}); - -test("the restore note is attached only to a unit the mesh declares", async () => { - const m = manager(fake((c) => (c.args.includes("show") ? { stdout: SHOW_MESH } : {}))); - const r = await m.act("system", "stop", "showcase.service"); - assert.equal(r.mesh_declared, true); - assert.match(String(r.note), /host restores its declared state/); -}); - -test("status says whether the mesh declares the unit, from the unit file systemd loaded", async () => { - const mesh = await manager(fake(() => ({ stdout: SHOW_MESH }))).status("system", "showcase.service"); - assert.equal(mesh.mesh_declared, true); - assert.equal(mesh.MainPID, "42"); - const pkg = await manager(fake(() => ({ stdout: SHOW_PACKAGE }))).status("system", "sshd.service"); - assert.equal(pkg.mesh_declared, false); - const none = await manager(fake(() => ({ stdout: "LoadState=not-found\nFragmentPath=\n" }))).status("system", "nope.service"); - assert.equal(none.mesh_declared, false); -}); - -test("the header recognised is the one the host writes", () => { - // mesh-host internal/apply/process.go, unitFor: the first line of every unit the host writes. - assert.equal(MESH_UNIT_HEADER, "# Generated by the mesh."); -}); - -test("sudo refusing is named as a refusal; sudo missing is named as missing", async () => { - const refused = manager(fake(() => ({ status: 1, stderr: "sudo: a password is required\n" }))); - await assert.rejects(() => refused.act("system", "start", "x.service"), /needs root for this and the runtime's account may not run it without a prompt: sudo: a password is required/); - const missing = manager(fake(() => ({ status: 127, error: "ENOENT" }))); - await assert.rejects(() => missing.act("system", "start", "x.service"), /sudo is not installed here/); -}); - -test("polkit refusing is named", async () => { - const m = manager(fake(() => ({ status: 1, stderr: "Failed to stop x.service: Access denied as the requested operation requires interactive authentication.\n" })), { uid: 0, user: "root" }); - await assert.rejects(() => m.act("system", "stop", "x.service"), /the service manager refused the runtime's account/); -}); - -test("a failed systemctl is an error, not an empty list", async () => { - const m = manager(fake(() => ({ status: 1, stderr: "Failed to list units: Connection timed out\n" }))); - await assert.rejects(() => m.units("system"), /systemctl failed \(1\): Failed to list units: Connection timed out/); -}); - -test("an unreachable user manager is said, even when systemctl exits 0", async () => { - const said = "Failed to connect to user scope bus via local transport: No such file or directory\n"; - const m = manager(fake(() => ({ status: 0, stderr: said })), { uid: 1000 }); - await assert.rejects(() => m.units("user"), /operator's own service manager does not answer at \/run\/user\/1000/); - const nonzero = manager(fake(() => ({ status: 1, stderr: said }))); - await assert.rejects(() => nonzero.status("user", "x.service"), /does not answer/); -}); - -test("failed reports each manager's failed units, and a manager that does not answer by its error", async () => { - const m = manager(fake((c) => - c.args[0] === "--user" ? { status: 1, stderr: "Failed to connect to user scope bus via local transport: No such file or directory\n" } : { stdout: LIST })); - const r = await m.failed(); - assert.deepEqual(r.system, [{ unit: "broken.service", load: "loaded", active: "failed", sub: "failed", description: "A broken thing" }]); - assert.ok(!Array.isArray(r.user)); - assert.match((r.user as { error: string }).error, /does not answer/); -}); - -test("a unit's name is never an option", async () => { - assert.throws(() => unitArg("--host=elsewhere"), /is not a unit's name/); - assert.throws(() => unitArg("a b"), /is not a unit's name/); - assert.equal(unitArg("sshd.service"), "sshd.service"); - const calls: Call[] = []; - const m = manager(fake(() => ({ stdout: LIST }), calls)); - await assert.rejects(() => m.act("system", "stop", "-H"), /is not a unit's name/); - assert.equal(calls.length, 0, "nothing ran"); - await m.units("system", "-x*"); - assert.deepEqual(calls[0].args.slice(-2), ["--", "-x*"]); -}); - -test("the manifest owns the systemd package — the service manager's own, never a component module's", () => { - const m = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")); - assert.ok((m.resources ?? []).some((r: { type: string; package?: string }) => r.type === "package" && r.package === "systemd")); - assert.ok(m.capabilities.includes("package-manager")); - // networkd is a component of systemd and configures it; it never claims the package. - const networkd = JSON.parse(readFileSync(new URL("../../systemd-networkd/module.json", import.meta.url), "utf8")); - assert.ok(!(networkd.resources ?? []).some((r: { type: string; package?: string }) => r.type === "package" && r.package === "systemd")); - assert.deepEqual(m.claims[0].serves, ["units", "status", "start", "stop", "restart", "enable", "disable", "journal"]); -}); diff --git a/modules/systemd/tools/index.ts b/modules/systemd/tools/index.ts deleted file mode 100644 index c9e8e6b..0000000 --- a/modules/systemd/tools/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -// systemd's tools: the node-service-manager seat's eight verbs — the units on this machine in -// both scopes, read and acted on by name — and the module's own reading of what has failed -// (novox/hq ADR 0177). The node tools runtime launches this bundle as a process of its own and -// serves what it registers (ADR 0188, ADR 0193); it runs as the operator account, so acts on the -// system manager escalate with sudo -n and the user scope is the account's own manager (client.ts). -// The host applies units; this answers about them. -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { ServiceManager, type Scope } from "../client.js"; - -const scope = { type: "string", description: "\"system\" (the default) or \"user\": the operator account's own manager" }; -const unit = { type: "string", description: "the unit's name, as the service manager knows it" }; - -function scopeOf(args: Readonly>): Scope { - const s = String(args.scope ?? "system"); - if (s !== "system" && s !== "user") throw new Error(`scope ${JSON.stringify(s)}: "system" or "user"`); - return s; -} -function unitOf(args: Readonly>): string { - const u = String(args.unit ?? "").trim(); - if (!u) throw new Error("a unit is required"); - return u; -} - -export function getSeatVerbs(manager: ServiceManager): ToolDefinition[] { - const act = (verb: "start" | "stop" | "restart" | "enable" | "disable", description: string): ToolDefinition => ({ - name: verb, - description, - input: { type: "object", properties: { scope, unit }, required: ["unit"] }, - run: async (args) => manager.act(scopeOf(args), verb, unitOf(args)), - }); - return [ - { - name: "units", - description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", - input: { type: "object", properties: { scope, pattern: { type: "string", description: "a glob the unit's name must match (optional)" } } }, - run: async (args) => ({ scope: scopeOf(args), units: await manager.units(scopeOf(args), args.pattern ? String(args.pattern) : undefined) }), - }, - { - name: "status", - description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and mesh_declared — true when its unit file is one the mesh wrote (a unit the mesh only puts into a state is not recognised from here).", - input: { type: "object", properties: { scope, unit }, required: ["unit"] }, - run: async (args) => manager.status(scopeOf(args), unitOf(args)), - }, - act("start", "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at its next apply."), - act("stop", "Stop one unit; for a unit the mesh declares, the answer says the host will restore its declared state."), - act("restart", "Restart one unit."), - act("enable", "Make one unit start at boot (or at the account's login, in user scope)."), - act("disable", "Stop one unit starting at boot (or at login, in user scope)."), - { - name: "journal", - description: "The last lines of one unit's journal (at most 2000).", - input: { type: "object", properties: { scope, unit, lines: { type: "number", description: "how many lines from the end (default 100, at most 2000)" } }, required: ["unit"] }, - run: async (args) => { - // Bounded so the answer stays well below what the runtime carries back in one reply. - const n = Math.floor(Number(args.lines ?? 100)); - return manager.journal(scopeOf(args), unitOf(args), Number.isFinite(n) && n > 0 ? Math.min(n, 2000) : 100); - }, - }, - ]; -} - -export function getOwnTools(manager: ServiceManager): ToolDefinition[] { - return [ - { - name: "systemd_failed", - description: "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, not as nothing failed.", - input: { type: "object", properties: {} }, - run: async () => manager.failed(), - }, - ]; -} - -registerModuleTools("node-service-manager", (env) => getSeatVerbs(ServiceManager.fromEnv(env))); -registerModuleTools("systemd", (env) => getOwnTools(ServiceManager.fromEnv(env))); diff --git a/modules/systemd/tsconfig.json b/modules/systemd/tsconfig.json deleted file mode 100644 index 862dc1f..0000000 --- a/modules/systemd/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": [ - "tools/index.ts", - "client.ts" - ] -}