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.
350 lines
13 KiB
Go
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
|
|
}
|