diff --git a/modules/pacman/cmd/pacman-tools/keeper.go b/modules/pacman/cmd/pacman-tools/keeper.go index 7385e88a..85e21c47 100644 --- a/modules/pacman/cmd/pacman-tools/keeper.go +++ b/modules/pacman/cmd/pacman-tools/keeper.go @@ -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. diff --git a/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go b/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go index ae7cdfec..e0d9db80 100644 --- a/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go +++ b/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go @@ -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) diff --git a/modules/pacman/cmd/pacman-tools/main.go b/modules/pacman/cmd/pacman-tools/main.go index a4f6a20d..65099734 100644 --- a/modules/pacman/cmd/pacman-tools/main.go +++ b/modules/pacman/cmd/pacman-tools/main.go @@ -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 { diff --git a/modules/pacman/cmd/pacman-tools/root.go b/modules/pacman/cmd/pacman-tools/root.go new file mode 100644 index 00000000..df21a062 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/root.go @@ -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 `) 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 `, 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) +} diff --git a/modules/pacman/cmd/pacman-tools/root_test.go b/modules/pacman/cmd/pacman-tools/root_test.go new file mode 100644 index 00000000..8d9d2962 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/root_test.go @@ -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) + } +} diff --git a/modules/pacman/module.json b/modules/pacman/module.json index 49ced9c5..69d08ff7 100644 --- a/modules/pacman/module.json +++ b/modules/pacman/module.json @@ -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"