issue 1841/S9: carry out the upgrade's root acts through a root helper on a socket only the keeper opens, so no sudo rule lets the keeper's account run anything as root (ADR 0510)

This commit is contained in:
2026-10-22 17:34:20 +02:00
parent f339d0aab7
commit 28ef6e9e27
6 changed files with 866 additions and 13 deletions
+14 -7
View File
@@ -181,26 +181,33 @@ func keeperHooks(k *Keeper) Hooks {
}
}
// newRootActs is the client of the module's root helper (ADR 0510), which carries out the acts that need root;
// the helper's socket client replaces it. Until then every act is refused, named.
var newRootActs = func() RootActs { return noRoot{} }
// newRootActs is the client of the module's root helper (ADR 0510), which carries out the acts that need root,
// at the socket the manifest names to the keeper; with none named, every act is refused, named.
var newRootActs = func() RootActs {
if sock := os.Getenv(EnvRootSocket); sock != "" {
return rootClient{Socket: sock}
}
return noRoot{}
}
type noRoot struct{}
const noRootSaid = "the keeper was started without " + EnvRootSocket + ", the root helper's socket"
func (noRoot) Transaction(kind, run string, ignore []string) error {
return errors.New("the root helper's client is not built in yet: no transaction can start")
return errors.New(noRootSaid + ": no transaction can start")
}
func (noRoot) Journal(unit string, n int) ([]string, error) {
return nil, errors.New("the root helper's client is not built in yet: no journal can be read")
return nil, errors.New(noRootSaid + ": no journal can be read")
}
func (noRoot) Rollback(run string, plan []RollbackItem) error {
return errors.New("the root helper's client is not built in yet: no rollback can start")
return errors.New(noRootSaid + ": no rollback can start")
}
func (noRoot) Maps() ([]Mapped, error) {
return nil, errors.New("the root helper's client is not built in yet: no process's maps can be read")
return nil, errors.New(noRootSaid + ": no process's maps can be read")
}
// Keeper is the module's process on one node.
@@ -73,8 +73,9 @@ func TestTheKeeperIsAProcessUnderItsOwnAccountWithABusAccountOfItsOwn(t *testing
t.Errorf("the keeper runs as its own account, never root (ADR 0508 §8): user %v", k["user"])
}
env, _ := k["env"].(map[string]any)
if len(env) != 2 || env[EnvBroker] != "${dir:state}/broker-keeper.secret" || env[EnvConfig] != "${dir:state}/keeper.json" {
t.Errorf("the keeper's environment names its account file and its config file: %v", env)
if len(env) != 3 || env[EnvBroker] != "${dir:state}/broker-keeper.secret" || env[EnvConfig] != "${dir:state}/keeper.json" ||
env[EnvRootSocket] != RootSocketPath {
t.Errorf("the keeper's environment names its account file, its config file and the root helper's socket: %v", env)
}
secrets, _ := rawManifest(t)["own-secrets"].(map[string]any)
broker, _ := secrets["broker-keeper"].(map[string]any)
+12 -3
View File
@@ -21,9 +21,18 @@ const binaryName = "pacman-tools"
func bg() context.Context { return context.Background() }
func main() {
// `pacman-tools keeper` is the module's process (keeper.go); without a word it serves the tools.
if len(os.Args) > 1 && os.Args[1] == "keeper" {
os.Exit(keeper())
// `pacman-tools keeper` is the module's process (keeper.go), `root` its root helper (root.go); without a
// word it serves the tools.
if len(os.Args) > 1 {
switch os.Args[1] {
case "keeper":
os.Exit(keeper())
case "root":
// the root helper (root.go, ADR 0510), and the command of a rollback's unit it starts
os.Exit(rootHelper())
case "root-rollback":
os.Exit(rootRollback(os.Args[2:]))
}
}
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): pacman.
if err := stdio.Serve("", tools(ThisMachine())); err != nil {
+506
View File
@@ -0,0 +1,506 @@
package main
// The root helper (novox/hq ADR 0510): the module's acts that need root go through a second process of the same
// build, `pacman-root`, running as root with no bus account, never through a sudo rule. It serves a closed list
// of verbs on a unix socket only the keeper's account opens, the peer's account checked on every connection:
//
// - transaction: start a run's full upgrade as a unit of the service manager, named by the run, leaving out
// the packages the run holds back;
// - rollback: start a rollback's transaction as such a unit, carrying out the plan sent with the verb;
// - maps: the processes still mapping a file an upgrade replaced, by unit or container and executable;
// - journal: the last lines a transaction unit of the module logged.
//
// Every other verb is refused, and every argument that is not a unit name of the module's own transactions, a
// package name, a version, a run's id, or a plan item's location in the package cache or the Arch Linux
// Archive. The helper's program is root's (ADR 0510 §3): the node-engine places a process's program in a
// directory its own account owns, and that account is root.
//
// A rollback's unit runs this same program (`pacman-tools root-rollback <plan>`) on a plan file the helper
// wrote into a directory only root reads: it finds each package of the dated repository by its file's name,
// installs the plan's packages in one pacman transaction, then removes the ones the rollback was asked to
// remove. Nothing is changed when a package cannot be found.
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"os/user"
"path"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"golang.org/x/sys/unix"
)
// The helper's verbs.
const (
VerbTransaction = "transaction"
VerbRollback = "rollback"
VerbMaps = "maps"
VerbJournal = "journal"
)
// The helper's environment, and the keeper's word for where the helper listens.
const (
EnvRootSocket = "PACMAN_ROOT_SOCKET" // the helper's socket, for the helper and the keeper
EnvRootPeer = "PACMAN_ROOT_PEER" // the only account the helper answers: the keeper's
// RootSocketPath is the socket as the manifest names it: in a directory of the module's state the helper
// makes, root's.
RootSocketPath = "${dir:state}/root/helper.sock"
)
// rootTimeout bounds one exchange with the helper: every verb answers at once (a transaction is started, not
// waited for).
const rootTimeout = 30 * time.Second
// mostJournalLines is the most lines the journal verb reads.
const mostJournalLines = 400
// RootRequest is one verb sent to the helper, one per connection.
type RootRequest struct {
Verb string `json:"verb"`
Kind string `json:"kind,omitempty"`
Run string `json:"run,omitempty"`
Ignore []string `json:"ignore,omitempty"`
Plan []RollbackItem `json:"plan,omitempty"`
Unit string `json:"unit,omitempty"`
Lines int `json:"lines,omitempty"`
}
// RootAnswer is the helper's answer: an error, or what the verb read.
type RootAnswer struct {
Error string `json:"error,omitempty"`
Lines []string `json:"lines,omitempty"`
Mapped []Mapped `json:"mapped,omitempty"`
}
// RootHelper is the helper: what it runs, the process table it reads, where it keeps plans, its own program,
// and the one account it answers.
type RootHelper struct {
Run Runner
Proc ProcFS
ProcRoot string
PlanDir string
Self string
Peer int
// PeerOf is the account at the other end of a connection; nil reads it from the socket (SO_PEERCRED).
PeerOf func(net.Conn) (int, error)
Logf func(format string, a ...any)
}
var (
// runIDShape is a run's id (RunID): its start in UTC to the second.
runIDShape = regexp.MustCompile(`^[0-9]{8}T[0-9]{6}Z$`)
// ownUnit is a transaction unit of the module's own runs (TransactionUnit).
ownUnit = regexp.MustCompile(`^` + unitPrefix + `(` + RunUpgrade + `|` + RunRollback + `)-[0-9]{8}t[0-9]{6}z$`)
// archArg is a package's architecture in a file's name.
archArg = regexp.MustCompile(`^[a-z0-9_]+$`)
// datedRepository is the archive's repository as it stood on one day, its architecture left to pacman.
datedRepository = regexp.MustCompile(`^` + regexp.QuoteMeta(ArchiveURL) + `/repos/[0-9]{4}/[0-9]{2}/[0-9]{2}/[A-Za-z0-9_-]+/os/\$arch$`)
)
func refused(format string, a ...any) RootAnswer {
return RootAnswer{Error: "refused: " + fmt.Sprintf(format, a...)}
}
func checkRun(run string) error {
if !runIDShape.MatchString(run) {
return fmt.Errorf("%q is not a run's id", run)
}
return nil
}
// checkItem refuses a plan item that is not a package brought back from the cache or the archive, or removed.
func checkItem(i RollbackItem) error {
if !packageName.MatchString(i.Name) {
return fmt.Errorf("%q is not a package's name", i.Name)
}
if i.Source == SourceRemoved {
if i.To != "" || i.Location != "" {
return fmt.Errorf("%s is removed, and names a version or a place to take it from", i.Name)
}
return nil
}
if !packageVersion.MatchString(i.To) {
return fmt.Errorf("%q is not a version of %s", i.To, i.Name)
}
switch i.Source {
case SourceCache:
f := cacheFile.FindStringSubmatch(path.Base(i.Location))
if path.Clean(i.Location) != i.Location || path.Dir(i.Location) != CacheDir || f == nil || f[1] != i.Name || f[2] != i.To {
return fmt.Errorf("%q is not %s %s's file in the package cache", i.Location, i.Name, i.To)
}
case SourceArchive:
prefix := ArchiveURL + "/packages/" + i.Name[:1] + "/" + i.Name + "/" + i.Name + "-" + i.To + "-"
arch, ok := strings.CutPrefix(i.Location, prefix)
if !ok || !strings.HasSuffix(arch, ".pkg.tar.zst") || !archArg.MatchString(strings.TrimSuffix(arch, ".pkg.tar.zst")) {
return fmt.Errorf("%q is not %s %s's file in the archive", i.Location, i.Name, i.To)
}
case SourceRepository:
if !datedRepository.MatchString(i.Location) {
return fmt.Errorf("%q is not a dated repository of the archive", i.Location)
}
default:
return fmt.Errorf("%q is not a source a package is brought back from", i.Source)
}
return nil
}
// Handle carries out one verb, or refuses it naming why; nothing runs for a refused verb.
func (h *RootHelper) Handle(req RootRequest) RootAnswer {
switch req.Verb {
case VerbTransaction:
if req.Kind != RunUpgrade {
return refused("a transaction is a full upgrade, not %q", req.Kind)
}
if err := checkRun(req.Run); err != nil {
return refused("%v", err)
}
for _, n := range req.Ignore {
if !packageName.MatchString(n) {
return refused("%q is not a package's name to leave out", n)
}
}
args := append([]string{"pacman"}, UpgradeArgs...)
if len(req.Ignore) > 0 {
args = append(args, "--ignore", strings.Join(req.Ignore, ","))
}
return h.startUnit(TransactionUnit(RunUpgrade, req.Run), "pacman full upgrade of run "+req.Run, args)
case VerbRollback:
if err := checkRun(req.Run); err != nil {
return refused("%v", err)
}
if len(req.Plan) == 0 {
return refused("a rollback with no plan")
}
for _, i := range req.Plan {
if err := checkItem(i); err != nil {
return refused("%v", err)
}
}
raw, err := json.Marshal(req.Plan)
if err != nil {
return RootAnswer{Error: err.Error()}
}
if err := os.MkdirAll(h.PlanDir, 0o700); err != nil {
return RootAnswer{Error: "the plan's directory: " + err.Error()}
}
file := filepath.Join(h.PlanDir, req.Run+".json")
if err := os.WriteFile(file, raw, 0o600); err != nil {
return RootAnswer{Error: "writing the plan: " + err.Error()}
}
return h.startUnit(TransactionUnit(RunRollback, req.Run), "pacman rollback of run "+req.Run, []string{h.Self, "root-rollback", file})
case VerbMaps:
mapped, err := MappedDeleted(h.Proc, h.ProcRoot)
if err != nil {
return RootAnswer{Error: "reading the process table: " + err.Error()}
}
return RootAnswer{Mapped: mapped}
case VerbJournal:
if !ownUnit.MatchString(req.Unit) {
return refused("%q is not a transaction unit of the module", req.Unit)
}
if req.Lines < 1 || req.Lines > mostJournalLines {
return refused("a journal is read 1 to %d lines at a time, not %d", mostJournalLines, req.Lines)
}
r := h.Run(context.Background(), "journalctl", "--no-pager", "-o", "cat", "-n", strconv.Itoa(req.Lines), "-u", req.Unit)
if r.Status != 0 || r.Err != "" {
return RootAnswer{Error: failure("journalctl", "journalctl", r).Error()}
}
return RootAnswer{Lines: lines(r.Stdout)}
}
return refused("%q is not a verb of the root helper (%s, %s, %s, %s)", req.Verb, VerbTransaction, VerbRollback, VerbMaps, VerbJournal)
}
// startUnit starts command as a transient unit of the service manager and answers once it started.
func (h *RootHelper) startUnit(unit, description string, command []string) RootAnswer {
args := append([]string{"--unit=" + unit, "--description=" + description + ", started by the mesh's pacman keeper", "--quiet"}, command...)
r := h.Run(context.Background(), "systemd-run", args...)
if r.Status != 0 || r.Err != "" {
return RootAnswer{Error: failure("systemd-run", "systemd-run", r).Error()}
}
return RootAnswer{}
}
// peerCred is the account at the other end of a unix socket.
func peerCred(c net.Conn) (int, error) {
uc, ok := c.(*net.UnixConn)
if !ok {
return -1, errors.New("not a unix socket")
}
raw, err := uc.SyscallConn()
if err != nil {
return -1, err
}
uid := -1
var gerr error
if err := raw.Control(func(fd uintptr) {
cred, err := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
if err != nil {
gerr = err
return
}
uid = int(cred.Uid)
}); err != nil {
return -1, err
}
return uid, gerr
}
// Serve answers each connection on l, one request each, until ctx ends: a peer that is not the keeper's
// account is told so and nothing it sent is read.
func (h *RootHelper) Serve(ctx context.Context, l net.Listener) error {
go func() { <-ctx.Done(); l.Close() }()
peerOf := h.PeerOf
if peerOf == nil {
peerOf = peerCred
}
for {
c, err := l.Accept()
if err != nil {
if ctx.Err() != nil {
return nil
}
return err
}
go h.serveOne(c, peerOf)
}
}
func (h *RootHelper) serveOne(c net.Conn, peerOf func(net.Conn) (int, error)) {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(rootTimeout))
answer := func(a RootAnswer) { _ = json.NewEncoder(c).Encode(a) }
uid, err := peerOf(c)
if err != nil || uid != h.Peer {
if h.Logf != nil {
h.Logf("[pacman] root helper: a connection from account %d refused: only the keeper's (%d) is answered", uid, h.Peer)
}
answer(refused("only the keeper's account is answered"))
return
}
var req RootRequest
if err := json.NewDecoder(io.LimitReader(c, 4<<20)).Decode(&req); err != nil {
answer(refused("no request: %v", err))
return
}
a := h.Handle(req)
if h.Logf != nil {
said := "carried out"
if a.Error != "" {
said = a.Error
}
h.Logf("[pacman] root helper: %s %s %s: %s", req.Verb, req.Kind, req.Run+req.Unit, said)
}
answer(a)
}
// rootClient is the keeper's side: the RootActs over the helper's socket.
type rootClient struct{ Socket string }
func (c rootClient) ask(req RootRequest) (RootAnswer, error) {
conn, err := net.DialTimeout("unix", c.Socket, rootTimeout)
if err != nil {
return RootAnswer{}, fmt.Errorf("the root helper cannot be reached at %s: %w", c.Socket, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(rootTimeout))
if err := json.NewEncoder(conn).Encode(req); err != nil {
return RootAnswer{}, fmt.Errorf("asking the root helper: %w", err)
}
var a RootAnswer
if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&a); err != nil {
return RootAnswer{}, fmt.Errorf("the root helper's answer: %w", err)
}
if a.Error != "" {
return a, fmt.Errorf("the root helper: %s", a.Error)
}
return a, nil
}
func (c rootClient) Transaction(kind, run string, ignore []string) error {
_, err := c.ask(RootRequest{Verb: VerbTransaction, Kind: kind, Run: run, Ignore: ignore})
return err
}
func (c rootClient) Rollback(run string, plan []RollbackItem) error {
_, err := c.ask(RootRequest{Verb: VerbRollback, Run: run, Plan: plan})
return err
}
func (c rootClient) Journal(unit string, n int) ([]string, error) {
a, err := c.ask(RootRequest{Verb: VerbJournal, Unit: unit, Lines: n})
return a.Lines, err
}
func (c rootClient) Maps() ([]Mapped, error) {
a, err := c.ask(RootRequest{Verb: VerbMaps})
if a.Mapped == nil && err == nil {
a.Mapped = []Mapped{}
}
return a.Mapped, err
}
// rootHelper is `pacman-tools root`: it listens on its socket, made the keeper's alone, until stopped.
func rootHelper() int {
logf := func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
if os.Getuid() != 0 {
logf("[pacman] root helper: it runs as root, and runs as account %d", os.Getuid())
return 2
}
sock, peerName := os.Getenv(EnvRootSocket), os.Getenv(EnvRootPeer)
if sock == "" || peerName == "" {
logf("[pacman] root helper was started without %s or %s", EnvRootSocket, EnvRootPeer)
return 2
}
peer, err := user.Lookup(peerName)
if err != nil {
logf("[pacman] root helper: the keeper's account %s: %v", peerName, err)
return 1
}
uid, err := strconv.Atoi(peer.Uid)
if err != nil {
logf("[pacman] root helper: account %s has no number: %v", peerName, err)
return 1
}
self, err := os.Executable()
if err != nil {
logf("[pacman] root helper: its own program: %v", err)
return 1
}
dir := filepath.Dir(sock)
if err := os.MkdirAll(dir, 0o711); err != nil || os.Chown(dir, 0, 0) != nil || os.Chmod(dir, 0o711) != nil {
logf("[pacman] root helper: its directory %s cannot be made root's: %v", dir, err)
return 1
}
_ = os.Remove(sock)
l, err := net.Listen("unix", sock)
if err != nil {
logf("[pacman] root helper: listening on %s: %v", sock, err)
return 1
}
if err := os.Chown(sock, uid, 0); err != nil || os.Chmod(sock, 0o600) != nil {
logf("[pacman] root helper: the socket cannot be made the keeper's alone: %v", err)
return 1
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
h := &RootHelper{Run: ExecRunner, Proc: osProc{}, ProcRoot: "/proc", PlanDir: filepath.Join(dir, "plans"), Self: self, Peer: uid, Logf: logf}
logf("[pacman] root helper listening on %s for account %s (%d)", sock, peerName, uid)
if err := h.Serve(ctx, l); err != nil {
logf("[pacman] root helper stopped: %v", err)
return 1
}
return 0
}
// machineArch is this machine's architecture as Arch Linux names it.
func machineArch() string {
switch runtime.GOARCH {
case "amd64":
return "x86_64"
case "arm64":
return "aarch64"
}
return runtime.GOARCH
}
// archiveHas says whether the archive holds a file, by asking for its head.
func archiveHas(url string) bool {
c := &http.Client{Timeout: 30 * time.Second}
resp, err := c.Head(url)
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// rollbackUnit carries out a rollback plan inside its unit: every package of a dated repository found by its
// file's name (this machine's architecture, then any), the plan's packages installed in one transaction, then
// the packages to remove removed. Nothing is changed when a package is not found.
func rollbackUnit(file, arch string, run func(string, ...string) int, probe func(string) bool, log io.Writer) int {
raw, err := os.ReadFile(file)
if err != nil {
fmt.Fprintf(log, "error: reading the plan: %v\n", err)
return 1
}
var plan []RollbackItem
if err := json.Unmarshal(raw, &plan); err != nil {
fmt.Fprintf(log, "error: the plan is not one: %v\n", err)
return 1
}
var targets, remove []string
for _, i := range plan {
if err := checkItem(i); err != nil {
fmt.Fprintf(log, "error: %v\n", err)
return 1
}
switch i.Source {
case SourceRemoved:
remove = append(remove, i.Name)
case SourceRepository:
base := strings.Replace(i.Location, "$arch", arch, 1)
found := ""
for _, a := range []string{arch, "any"} {
if u := base + "/" + i.Name + "-" + i.To + "-" + a + ".pkg.tar.zst"; probe(u) {
found = u
break
}
}
if found == "" {
fmt.Fprintf(log, "error: %s %s is not in %s: nothing was changed\n", i.Name, i.To, base)
return 1
}
targets = append(targets, found)
default:
targets = append(targets, i.Location)
}
}
if len(targets) > 0 {
if code := run("pacman", append([]string{"-U", "--noconfirm", "--"}, targets...)...); code != 0 {
return code
}
}
if len(remove) > 0 {
return run("pacman", append([]string{"-R", "--noconfirm", "--"}, remove...)...)
}
return 0
}
// rootRollback is `pacman-tools root-rollback <plan>`, the rollback unit's command: pacman's output goes to the
// unit's journal.
func rootRollback(args []string) int {
if len(args) != 1 || os.Getuid() != 0 {
fmt.Fprintln(os.Stderr, "error: root-rollback takes one plan file, and runs as root in its unit")
return 2
}
run := func(name string, a ...string) int {
cmd := exec.Command(name, a...)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
cmd.Env = append(os.Environ(), "LC_ALL=C")
if err := cmd.Run(); err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
return exit.ExitCode()
}
fmt.Fprintf(os.Stderr, "error: %s: %v\n", name, err)
return 1
}
return 0
}
return rollbackUnit(args[0], machineArch(), run, archiveHas, os.Stderr)
}
@@ -0,0 +1,312 @@
package main
// The root helper (novox/hq ADR 0510; acceptance 1841/AC10 as reworded on the issue): each listed verb with valid
// arguments is carried out; every other verb, a unit name outside the module's own, a package name with other
// characters and a run of no run's shape are refused; a connection from an account other than the keeper's is
// refused; the manifest declares `pacman-root` as root with no bus and places nothing under the sudo
// configuration.
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// rootBed is a helper over a recording runner, a stand-in process table and a plan directory of its own.
type rootBed struct {
h *RootHelper
ran [][]string
}
func newRootBed(t *testing.T) *rootBed {
b := &rootBed{}
b.h = &RootHelper{
Run: func(_ context.Context, name string, args ...string) Ran {
b.ran = append(b.ran, append([]string{name}, args...))
if name == "journalctl" {
return Ran{Stdout: "error: failed to commit transaction\n"}
}
return Ran{}
},
Proc: stubProc{"/proc": {"1"}, "/proc/1/maps": {"7f00 r-xp 0 0:1 1 /usr/lib/libc.so.6 (deleted)"}, "/proc/1/cgroup": {"0::/system.slice/sshd.service"}, "/proc/1/exe": {"/usr/bin/sshd"}},
ProcRoot: "/proc",
PlanDir: t.TempDir(),
Self: "/var/lib/mesh/daemons/pacman-root/pacman-tools",
}
return b
}
// stubProc is a process table in memory: a directory lists its names, a file holds its one line, a link its target.
type stubProc map[string][]string
func (s stubProc) ReadDir(dir string) ([]string, error) { return s[dir], nil }
func (s stubProc) ReadFile(name string) ([]byte, error) {
v, ok := s[name]
if !ok {
return nil, os.ErrNotExist
}
return []byte(strings.Join(v, "\n")), nil
}
func (s stubProc) Readlink(name string) (string, error) {
v, ok := s[name]
if !ok {
return "", os.ErrNotExist
}
return v[0], nil
}
const aRun = "20261024T040000Z"
// testSocket is a socket in the abstract namespace, named for the test: a path under a test's temporary
// directory can pass the 108 bytes a socket's path may hold.
func testSocket(t *testing.T) string {
return fmt.Sprintf("@pacman-root-test-%d-%s", os.Getpid(), t.Name())
}
func TestTheRootHelperStartsAnUpgradeAsTheRunsUnit(t *testing.T) {
b := newRootBed(t)
a := b.h.Handle(RootRequest{Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"linux", "linux-headers"}})
if a.Error != "" {
t.Fatalf("refused: %s", a.Error)
}
want := []string{"systemd-run", "--unit=" + TransactionUnit(RunUpgrade, aRun), "--description=pacman full upgrade of run " + aRun + ", started by the mesh's pacman keeper",
"--quiet", "pacman", "-Syu", "--noconfirm", "--ignore", "linux,linux-headers"}
if len(b.ran) != 1 || !reflect.DeepEqual(b.ran[0], want) {
t.Fatalf("ran %q, want %q", b.ran, want)
}
}
func TestTheRootHelperStartsARollbackWithThePlanInTheRequest(t *testing.T) {
b := newRootBed(t)
plan := []RollbackItem{
{Name: "bash", From: "5.3.20-1", To: "5.3.15-1", Source: SourceCache, Location: CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst"},
{Name: "glibc", From: "2.43-2", To: "2.43-1", Source: SourceArchive, Location: ArchiveURL + "/packages/g/glibc/glibc-2.43-1-x86_64.pkg.tar.zst"},
{Name: "zlib", From: "1.3.2-1", To: "1.3.1-2", Source: SourceRepository, Location: ArchiveURL + "/repos/2026/10/04/core/os/$arch"},
{Name: "made-up-library", From: "1.0-1", Source: SourceRemoved},
}
a := b.h.Handle(RootRequest{Verb: VerbRollback, Run: aRun, Plan: plan})
if a.Error != "" {
t.Fatalf("refused: %s", a.Error)
}
file := filepath.Join(b.h.PlanDir, aRun+".json")
want := []string{"systemd-run", "--unit=" + TransactionUnit(RunRollback, aRun), "--description=pacman rollback of run " + aRun + ", started by the mesh's pacman keeper",
"--quiet", b.h.Self, "root-rollback", file}
if len(b.ran) != 1 || !reflect.DeepEqual(b.ran[0], want) {
t.Fatalf("ran %q, want %q", b.ran, want)
}
raw, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
var got []RollbackItem
if err := json.Unmarshal(raw, &got); err != nil || !reflect.DeepEqual(got, plan) {
t.Fatalf("the plan file holds %s (%v)", raw, err)
}
if fi, _ := os.Stat(file); fi.Mode().Perm() != 0o600 {
t.Errorf("the plan file is mode %v, want 0600: only root reads what root carries out", fi.Mode().Perm())
}
}
func TestTheRootHelperReadsTheReplacedFilesMappings(t *testing.T) {
b := newRootBed(t)
a := b.h.Handle(RootRequest{Verb: VerbMaps})
want := []Mapped{{Unit: "sshd.service", Executable: "/usr/bin/sshd"}}
if a.Error != "" || !reflect.DeepEqual(a.Mapped, want) {
t.Fatalf("maps answered %+v, want %+v", a, want)
}
}
func TestTheRootHelperReadsATransactionUnitsJournal(t *testing.T) {
b := newRootBed(t)
unit := TransactionUnit(RunUpgrade, aRun)
a := b.h.Handle(RootRequest{Verb: VerbJournal, Unit: unit, Lines: 40})
if a.Error != "" || !reflect.DeepEqual(a.Lines, []string{"error: failed to commit transaction"}) {
t.Fatalf("journal answered %+v", a)
}
want := []string{"journalctl", "--no-pager", "-o", "cat", "-n", "40", "-u", unit}
if !reflect.DeepEqual(b.ran[0], want) {
t.Fatalf("ran %q, want %q", b.ran[0], want)
}
}
func TestTheRootHelperRefusesEverythingOffItsList(t *testing.T) {
good := RollbackItem{Name: "bash", From: "5.3.20-1", To: "5.3.15-1", Source: SourceCache, Location: CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst"}
with := func(f func(*RollbackItem)) []RollbackItem { i := good; f(&i); return []RollbackItem{i} }
cases := map[string]RootRequest{
"another verb": {Verb: "shell", Run: aRun},
"an empty verb": {},
"another kind of transaction": {Verb: VerbTransaction, Kind: "remove", Run: aRun},
"a rollback as a transaction": {Verb: VerbTransaction, Kind: RunRollback, Run: aRun},
"a run of no run's shape": {Verb: VerbTransaction, Kind: RunUpgrade, Run: "../../etc"},
"a run with an option in it": {Verb: VerbTransaction, Kind: RunUpgrade, Run: "--now"},
"an ignored name with a space": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"linux headers"}},
"an ignored name as an option": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"--root=/tmp"}},
"an ignored name with a comma": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"linux,glibc"}},
"an ignored name in capitals": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"Linux"}},
"a journal of another unit": {Verb: VerbJournal, Unit: "sshd.service", Lines: 10},
"a journal of a look-alike unit": {Verb: VerbJournal, Unit: "mesh-pacman-upgrade-x;reboot", Lines: 10},
"a journal of too many lines": {Verb: VerbJournal, Unit: TransactionUnit(RunUpgrade, aRun), Lines: 100000},
"a rollback of no plan": {Verb: VerbRollback, Run: aRun},
"a rollback of a bad run": {Verb: VerbRollback, Run: "x", Plan: []RollbackItem{good}},
"a plan item with a bad name": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Name = "bash;rm" })},
"a plan item with a bad version": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.To = "1 2" })},
"a cache file outside the cache": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Location = "/tmp/bash-5.3.15-1-x86_64.pkg.tar.zst" })},
"a cache file climbing out": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Location = CacheDir + "/../../../tmp/bash-5.3.15-1-x86_64.pkg.tar.zst" })},
"a cache file of another package": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Location = CacheDir + "/evil-1-1-x86_64.pkg.tar.zst" })},
"an archive URL elsewhere": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) {
i.Source, i.Location = SourceArchive, "https://example.org/packages/b/bash/bash-5.3.15-1-x86_64.pkg.tar.zst"
})},
"a repository URL elsewhere": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) {
i.Source, i.Location = SourceRepository, ArchiveURL+"/repos/2026/10/04/../../../../x"
})},
"another source": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Source = "file" })},
"a removal with a location": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) {
i.Source, i.To = SourceRemoved, ""
})},
}
for name, req := range cases {
b := newRootBed(t)
if a := b.h.Handle(req); a.Error == "" {
t.Errorf("%s: carried out (%+v)", name, a)
}
if len(b.ran) != 0 {
t.Errorf("%s: ran %q although refused", name, b.ran)
}
}
}
// serveOnce starts the helper on a socket of a test directory, allowing peer, and answers one request.
func serveOnce(t *testing.T, peer int, req RootRequest) (RootAnswer, error) {
t.Helper()
b := newRootBed(t)
b.h.Peer = peer
sock := testSocket(t)
l, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go func() { _ = b.h.Serve(ctx, l) }()
return rootClient{Socket: sock}.ask(req)
}
func TestTheRootHelperAnswersTheKeepersAccountOverItsSocket(t *testing.T) {
a, err := serveOnce(t, os.Getuid(), RootRequest{Verb: VerbMaps})
if err != nil || a.Error != "" || len(a.Mapped) != 1 {
t.Fatalf("the keeper's own account was answered %+v, %v", a, err)
}
}
func TestTheRootHelperRefusesAnyOtherAccount(t *testing.T) {
a, err := serveOnce(t, os.Getuid()+1, RootRequest{Verb: VerbMaps})
if err == nil && a.Error == "" {
t.Fatalf("another account was answered: %+v", a)
}
if a.Mapped != nil {
t.Fatalf("another account was handed the mappings: %+v", a)
}
}
func TestTheClientCarriesTheRootActsAndSaysARefusal(t *testing.T) {
b := newRootBed(t)
b.h.Peer = os.Getuid()
sock := testSocket(t)
l, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() { _ = b.h.Serve(ctx, l) }()
var acts RootActs = rootClient{Socket: sock}
if err := acts.Transaction(RunUpgrade, aRun, nil); err != nil {
t.Fatalf("transaction: %v", err)
}
if err := acts.Transaction(RunUpgrade, "bad", nil); err == nil || !strings.Contains(err.Error(), "run") {
t.Fatalf("a bad run was not refused by name: %v", err)
}
if lines, err := acts.Journal(TransactionUnit(RunUpgrade, aRun), 5); err != nil || len(lines) != 1 {
t.Fatalf("journal: %q, %v", lines, err)
}
if _, err := (rootClient{Socket: testSocket(t) + "-none"}).Maps(); err == nil || !strings.Contains(err.Error(), "root helper") {
t.Fatalf("a helper that is not there is not said: %v", err)
}
}
func TestTheRootHelperIsRootWithNoBusAndNoSudoRule(t *testing.T) {
m := manifest(t)
r := m.resource(t, "root-helper")
if r["type"] != "process" || r["name"] != "pacman-root" || r["artifact"] != "tools" ||
strings.Join(strs(r["run"]), " ") != "./"+binaryName+" root" {
t.Fatalf("the root helper: %v", r)
}
if _, ok := r["user"]; ok {
t.Errorf("the root helper runs as root (ADR 0510 §1, §3), and names a user: %v", r["user"])
}
if _, ok := r["bus"]; ok {
t.Errorf("the root helper has no bus account (ADR 0510 §1): %v", r["bus"])
}
env, _ := r["env"].(map[string]any)
if env[EnvRootSocket] != RootSocketPath || env[EnvRootPeer] != "pacman-keeper" {
t.Errorf("the root helper's environment names its socket and the keeper's account: %v", env)
}
k := m.resource(t, "keeper")
kenv, _ := k["env"].(map[string]any)
if kenv[EnvRootSocket] != RootSocketPath {
t.Errorf("the keeper is told the helper's socket: %v", kenv)
}
for _, res := range m.Resources {
if p, _ := res["path"].(string); strings.Contains(p, "sudoers") {
t.Errorf("a file under the sudo configuration is placed: %s (ADR 0510 §1)", p)
}
}
}
func TestTheRollbackUnitInstallsThePlanThenRemovesWhatWasAskedInOrder(t *testing.T) {
dir := t.TempDir()
plan := []RollbackItem{
{Name: "bash", To: "5.3.15-1", Source: SourceCache, Location: CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst"},
{Name: "zlib", To: "1.3.1-2", Source: SourceRepository, Location: ArchiveURL + "/repos/2026/10/04/core/os/$arch"},
{Name: "made-up-library", From: "1.0-1", Source: SourceRemoved},
}
raw, _ := json.Marshal(plan)
file := filepath.Join(dir, aRun+".json")
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
var ran [][]string
run := func(name string, args ...string) int { ran = append(ran, append([]string{name}, args...)); return 0 }
probed := []string{}
probe := func(url string) bool { probed = append(probed, url); return strings.HasSuffix(url, "-any.pkg.tar.zst") }
if code := rollbackUnit(file, "x86_64", run, probe, os.Stderr); code != 0 {
t.Fatalf("exit %d", code)
}
want := [][]string{
{"pacman", "-U", "--noconfirm", "--", CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst", ArchiveURL + "/repos/2026/10/04/core/os/x86_64/zlib-1.3.1-2-any.pkg.tar.zst"},
{"pacman", "-R", "--noconfirm", "--", "made-up-library"},
}
if !reflect.DeepEqual(ran, want) {
t.Fatalf("ran %q, want %q", ran, want)
}
if len(probed) != 2 || !strings.HasSuffix(probed[0], "-x86_64.pkg.tar.zst") {
t.Errorf("the machine's architecture is tried first, then any: %q", probed)
}
}
func TestTheRollbackUnitChangesNothingWhenAPackageIsNotFound(t *testing.T) {
file := filepath.Join(t.TempDir(), aRun+".json")
raw, _ := json.Marshal([]RollbackItem{{Name: "zlib", To: "1.3.1-2", Source: SourceRepository, Location: ArchiveURL + "/repos/2026/10/04/core/os/$arch"}})
_ = os.WriteFile(file, raw, 0o600)
var ran [][]string
run := func(name string, args ...string) int { ran = append(ran, append([]string{name}, args...)); return 0 }
if code := rollbackUnit(file, "x86_64", run, func(string) bool { return false }, &strings.Builder{}); code == 0 || len(ran) != 0 {
t.Fatalf("exit %d, ran %q: a package the archive does not hold fails the unit before any change", code, ran)
}
}
+19 -1
View File
@@ -164,6 +164,23 @@
"template": "files/keeper.json",
"trusted": true
},
{
"id": "root-helper",
"type": "process",
"name": "pacman-root",
"artifact": "tools",
"run": [
"./pacman-tools",
"root"
],
"env": {
"PACMAN_ROOT_SOCKET": "${dir:state}/root/helper.sock",
"PACMAN_ROOT_PEER": "pacman-keeper"
},
"health": {
"kind": "unit"
}
},
{
"id": "keeper",
"type": "process",
@@ -176,7 +193,8 @@
"user": "pacman-keeper",
"env": {
"PACMAN_KEEPER_BROKER": "${dir:state}/broker-keeper.secret",
"PACMAN_KEEPER_CONFIG": "${dir:state}/keeper.json"
"PACMAN_KEEPER_CONFIG": "${dir:state}/keeper.json",
"PACMAN_ROOT_SOCKET": "${dir:state}/root/helper.sock"
},
"restart-on": [
"keeper-config"