systemctl disable leaves a link whose unit file is gone, and the manager keeps asking for the unit. The verb removes only such links in the account's or the machine's own configuration, on the person's word, and records each with its reason before removing it (hq issue 332).
437 lines
16 KiB
Go
437 lines
16 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"
|
|
"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, "reset-failed": true, "daemon-reload": 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
|
|
// Home is the operator account's home, whose configuration holds its own enable links.
|
|
Home string
|
|
// ReadDir lists a directory, to find the enable links naming a unit (wanted.go).
|
|
ReadDir func(dir string) ([]Entry, error)
|
|
// Lstat reads one path again just before it is removed, and Remove removes one the account owns (unlink.go).
|
|
Lstat func(path string) (Entry, error)
|
|
Remove func(path string) error
|
|
// State is the module's state directory, where each removed link is recorded.
|
|
State string
|
|
Now func() time.Time
|
|
}
|
|
|
|
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", "--no-pager"}
|
|
for _, p := range props {
|
|
args = append(args, "--property="+p)
|
|
}
|
|
out, err := m.call(scope, "systemctl", append(args, "--", unit)...)
|
|
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, or resets its failed record, 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 node-engine 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=<v> 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: <what it was>]: 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", "--no-pager", "--property=Environment", "--value", "--", unit)
|
|
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 — and **a glob** (novox/hq issue 332): systemctl expands *, ? and [ in a unit argument to
|
|
// every loaded unit matching it, so `stop` or `reset-failed` of "*" would act on all of them. Every verb
|
|
// names one unit; `units` alone takes a pattern, as its own argument. The name is also given after "--".
|
|
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)
|
|
}
|
|
if strings.ContainsAny(unit, "*?[") {
|
|
return fmt.Errorf("%q is a pattern, not one unit's name: the service manager would act on every unit it matches", 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
|
|
}
|