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" "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 that a query keeps, its secrets redacted. // // **Every argument is one word of journalctl's argv, never a shell's** (journal.go): the unit is refused // when it would read as an option, a window bound is given as --since= so a relative "-30min" is its // value and never a flag, and the rest is validated to the forms journalctl reads before anything runs — // under sudo, a word read as an option would be root's option. // // **A match is a fixed string, applied here to the redacted lines**, not journalctl's --grep, which is a // pattern and depends on how journalctl was built; and applied after redaction, so a caller cannot find a // secret by asking which lines hold it. journalctl is then asked for a bounded scan of the window's last // lines, and the answer says how many were read, so a match that found fewer than asked is not read as // all there is when the scan was full. func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]any, error) { if err := unitArg(unit); err != nil { return nil, err } q, err := q.valid() if err != nil { return nil, err } read := q.Lines if q.Match != "" { read = MatchScan } out, err := m.call(scope, "journalctl", q.argv(unit, read)...) if err != nil { return nil, err } known, envErr := m.unitSecrets(scope, unit) all := []string{} for _, l := range strings.Split(out, "\n") { if l != "" { all = append(all, l) } } scanned := len(all) kept, redacted := []string{}, 0 for _, l := range all { l, n := redact(l, known) redacted += n if q.Match != "" && !strings.Contains(l, q.Match) { continue } if len(l) > LongestLine { l = l[:LongestLine] + "…" } kept = append(kept, l) } if len(kept) > q.Lines { kept = kept[len(kept)-q.Lines:] } answer := map[string]any{"unit": unit, "scope": string(scope), "lines": kept, "count": len(kept)} for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} { if v != "" { answer[k] = v } } if q.Match != "" { answer["scanned"] = scanned if scanned >= MatchScan { answer["note"] = fmt.Sprintf("the match was looked for in the window's last %d lines only: narrow the window to reach earlier ones", MatchScan) } } if envErr != nil { answer["redaction"] = "only what a line's shape says is a secret: the unit's environment could not be read (" + envErr.Error() + ")" } if redacted > 0 { answer["redacted"] = redacted answer["leak"] = "this unit's journal holds secrets, shown as [redacted: ]: rotate each one after the program stops printing it" } return answer, nil } // unitSecrets are the values of the unit's own Environment= that must not be answered. Read with // systemctl show, which needs no escalation; a unit that does not exist has none. func (m *Manager) unitSecrets(scope Scope, unit string) ([]knownSecret, error) { out, err := m.call(scope, "systemctl", "show", unit, "--no-pager", "--property=Environment", "--value") if err != nil { return nil, err } return secretsIn(environment(strings.TrimSpace(out))), nil } // Failed is every failed unit in the managers asked — both when none is named. A manager that does not // answer is reported as such, beside the other's answer — never as "nothing failed". func (m *Manager) Failed(scopes ...Scope) 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 } if len(scopes) == 0 { scopes = []Scope{System, User} } answer := map[string]any{} for _, s := range scopes { answer[string(s)] = in(s) } return answer } // 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 }