Files
mesh-catalog/modules/pacman/cmd/pacman-tools/root.go
T

507 lines
17 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"
)
// 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)
}