Files
mesh-catalog/modules/pacman/cmd/pacman-tools/root.go
T
jschoubben 2b495c4818 pacman keeps one mirror list for the mesh, ranked in the write that opens each round and written by every node (ADR 0585, issue 1876)
Each node's reflector ranked its own list on its own timer, and on 2026-10-31 one node's
lagging first mirror left it a Node.js version behind the rest. The list is now the
module's state mirrors, key list: the node that opens a week's round ranks it with the
module's reflector options, keeps only mirrors whose last sync is no older than the
newest found, and the round's key names the list's revision. Every keeper writes
/etc/pacman.d/mirrorlist whole from the key through the root helper's new verb
mirrorlist, when the key changes and when the file differs, and never writes an empty
or unreadable list. reflector.timer is declared stopped and disabled, and reflector.conf
saves nothing. pacman_mirrors answers the shared list, its ranker and age and whether
this node's file matches; with refresh it asks this node's keeper to rank again,
refused while a round runs, naming it. The round's integration test proves the opening
write and both nodes' files.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 16:18:22 +01:00

544 lines
18 KiB
Go

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"
VerbMirrorlist = "mirrorlist"
)
// 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"`
// Servers are the mirror list's servers, in order, for the verb mirrorlist.
Servers []string `json:"servers,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
// MirrorlistPath is the mirror list file the verb mirrorlist writes: Mirrorlist, a test's own elsewhere.
MirrorlistPath 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)}
case VerbMirrorlist:
if err := checkServers(req.Servers); err != nil {
return refused("%v", err)
}
if err := writeWhole(h.MirrorlistPath, RenderMirrorlist(req.Servers), 0o644); err != nil {
return RootAnswer{Error: "writing the mirror list: " + err.Error()}
}
return RootAnswer{}
}
return refused("%q is not a verb of the root helper (%s, %s, %s, %s, %s)", req.Verb, VerbTransaction, VerbRollback, VerbMaps,
VerbJournal, VerbMirrorlist)
}
// 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) Mirrorlist(servers []string) error {
_, err := c.ask(RootRequest{Verb: VerbMirrorlist, Servers: servers})
return err
}
// writeWhole writes a file whole: into a file beside it, then renamed over it, so a reader never meets half of it.
func writeWhole(path string, content []byte, mode os.FileMode) error {
tmp := filepath.Join(filepath.Dir(path), "."+filepath.Base(path)+".mesh")
if err := os.WriteFile(tmp, content, mode); err != nil {
return err
}
if err := os.Chmod(tmp, mode); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
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,
MirrorlistPath: Mirrorlist, 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)
}