Files
mesh-catalog/modules/systemd/cmd/systemd-tools/client.go
T
jochen c42f1ce45b systemd: port to Go, and read a system unit's journal as root
The journal verb ran journalctl as the operator account, which outside the
journal's group sees only its own entries: every system service read
'-- No entries --', and a person reached for a shell. The read now
escalates with sudo -n like the acts; ported to Go with every test. hq
issue 255.
2026-10-05 18:09:42 +02:00

350 lines
13 KiB
Go

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/<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 (
"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
}