507 lines
17 KiB
Go
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)
|
|
}
|