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) }