dbus: hold node-message-bus, and never restart the bus live (hq ADR 0215)

A live restart of the system bus during an upgrade hung every login on a
workstation until a reboot. The module owns the bus's packages, declares
the bus running with no restart or reload trigger, publishes only curated
events (health, services, denials; never traffic) and serves tools to look
at both buses.
This commit is contained in:
jochen
2026-10-05 11:50:52 +02:00
parent 4224ac7252
commit 7b5e1d3362
19 changed files with 3106 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
package main
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
// CommandTimeout bounds every command a tool runs: a bus that hangs must cost a tool call twenty
// seconds, never the runtime's thirty.
const CommandTimeout = 20 * time.Second
// ReadCap is the most of one command's output the module reads. An introspection document of the
// service manager is about 100 KiB; nothing the module asks is near a mebibyte.
const ReadCap = 1024 * 1024
// AnswerCap is the most entries a tool answers in one list (names, messages, denials).
const AnswerCap = 500
// Runner runs one command and answers its standard output. Injected, so every tool is tested against
// recorded answers rather than this machine's bus.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// Streamer runs one command for at most the context's time and hands each line of its output to
// line, which says whether it wants more. The end of the time is the normal end, not an error.
// Injected, so dbus_monitor is tested without a bus.
type Streamer func(ctx context.Context, line func(string) bool, name string, args ...string) error
// ExecRunner runs a command, bounded by CommandTimeout. A failure carries what it said on stderr.
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "SYSTEMD_PAGER=", "SYSTEMD_COLORS=0")
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
out := capped(stdout.String(), ReadCap)
if ctx.Err() == context.DeadlineExceeded {
return out, fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
}
if err != nil {
said := strings.TrimSpace(stderr.String())
if said == "" {
said = strings.TrimSpace(stdout.String())
}
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, capped(said, 2048))
}
return out, nil
}
// ExecStreamer runs a command until the context ends, line by line. A line longer than ReadCap is
// skipped, never held: a monitored message's line carries its body, which the module never keeps.
func ExecStreamer(ctx context.Context, line func(string) bool, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "SYSTEMD_PAGER=", "SYSTEMD_COLORS=0")
// A terminate, which sudo passes on to what it runs; a kill would leave a root monitor behind.
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = 2 * time.Second
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
r := bufio.NewReaderSize(out, 64*1024)
var cur []byte
tooLong := false
for {
chunk, isPrefix, err := r.ReadLine()
if err != nil {
break
}
if !tooLong {
cur = append(cur, chunk...)
if len(cur) > ReadCap {
cur, tooLong = cur[:0], true
}
}
if isPrefix {
continue
}
if !tooLong && !line(string(cur)) {
break
}
cur, tooLong = cur[:0], false
}
_ = cmd.Process.Signal(syscall.SIGTERM)
werr := cmd.Wait()
if ctx.Err() != nil {
return nil // the time ran out: the normal end of a bounded watch
}
if werr != nil && stderr.Len() > 0 {
return fmt.Errorf("%s: %w: %s", name, werr, capped(strings.TrimSpace(stderr.String()), 2048))
}
return nil
}
func capped(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "\n… (cut)"
}
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree),
// a way to run commands, a way to stream one, and the environment the runtime gave it.
type Machine struct {
Root string
Run Runner
Stream Streamer
Env func(string) string
UID int
Now func() time.Time
}
// Here is the machine this process runs on.
func Here() *Machine {
return &Machine{Root: "/", Run: ExecRunner, Stream: ExecStreamer, Env: os.Getenv, UID: os.Getuid(), Now: time.Now}
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
func (m *Machine) read(p string) string {
b, err := os.ReadFile(m.path(p))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func (m *Machine) exists(p string) bool {
_, err := os.Stat(m.path(p))
return err == nil
}
func (m *Machine) glob(pattern string) []string {
got, _ := filepath.Glob(m.path(pattern))
out := make([]string, 0, len(got))
for _, g := range got {
rel, err := filepath.Rel(m.Root, g)
if err != nil {
continue
}
out = append(out, "/"+rel)
}
return out
}
// privileged runs a command as root without asking for a password (sudo -n), as the other modules'
// tools do: the runtime runs as the operator's account, and watching the system bus is root's.
func (m *Machine) privileged(ctx context.Context, name string, args ...string) (string, error) {
return m.Run(ctx, "sudo", append([]string{"-n", name}, args...)...)
}
// Buses are the two buses a tool can be pointed at.
var Buses = []string{"system", "session"}
// ErrNoSession is the answer on a machine where this account has no session bus: the servers.
var ErrNoSession = errors.New("no session bus for this account on this machine")
// SessionAddress is the account's session bus: the runtime's DBUS_SESSION_BUS_ADDRESS, else the
// user manager's socket under XDG_RUNTIME_DIR or /run/user/<uid>. Only a socket that exists counts.
func (m *Machine) SessionAddress() (string, error) {
if a := m.Env("DBUS_SESSION_BUS_ADDRESS"); strings.HasPrefix(a, "unix:path=") {
p := strings.TrimPrefix(a, "unix:path=")
if i := strings.IndexByte(p, ','); i >= 0 {
p = p[:i]
}
if m.exists(p) {
return a, nil
}
} else if a != "" {
return a, nil // an abstract or other address: taken as given
}
dir := m.Env("XDG_RUNTIME_DIR")
if dir == "" {
dir = "/run/user/" + strconv.Itoa(m.UID)
}
if p := dir + "/bus"; m.exists(p) {
return "unix:path=" + p, nil
}
return "", fmt.Errorf("%w (no socket at %s/bus)", ErrNoSession, dir)
}
// busArgs are busctl's words for one bus.
func (m *Machine) busArgs(bus string) ([]string, error) {
switch bus {
case "", "system":
return []string{"--system"}, nil
case "session":
a, err := m.SessionAddress()
if err != nil {
return nil, err
}
return []string{"--address=" + a}, nil
}
return nil, fmt.Errorf("bus is system or session, not %q", bus)
}
// UnitOf is the systemd unit a process runs in, from its cgroup: readable for any process.
func (m *Machine) UnitOf(pid uint32) string {
if pid == 0 {
return ""
}
return UnitFromCgroup(m.read(fmt.Sprintf("/proc/%d/cgroup", pid)))
}
// ProcessName is a process's command name.
func (m *Machine) ProcessName(pid uint32) string {
if pid == 0 {
return ""
}
return m.read(fmt.Sprintf("/proc/%d/comm", pid))
}
// UnitFromCgroup is the innermost service, socket or scope in a cgroup v2 path.
func UnitFromCgroup(cgroup string) string {
for _, line := range strings.Split(cgroup, "\n") {
if !strings.HasPrefix(line, "0::") {
continue
}
parts := strings.Split(strings.TrimPrefix(line, "0::"), "/")
for i := len(parts) - 1; i >= 0; i-- {
p := parts[i]
if strings.HasSuffix(p, ".service") || strings.HasSuffix(p, ".scope") {
return p
}
}
}
return ""
}
// BootID is this boot's id: a bus that changed across a boot did not restart, the machine did.
func (m *Machine) BootID() string { return m.read("/proc/sys/kernel/random/boot_id") }