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 }