diff --git a/REUSE.md b/REUSE.md index b5dedf8..0dac0a7 100644 --- a/REUSE.md +++ b/REUSE.md @@ -14,13 +14,15 @@ | `Privileged` (CreateGoldenLXC/MountUSBByUUID/SMART/Sensors) | internal/proxmox/privileged.go | methods on `*Privileged` | the 3 fenced root-CLI exceptions ONLY | Do NOT add methods — fence is structural (`routing_test.go` asserts it) | | `SudoHostOps.run` | internal/storage/hostops.go | `run(ctx, name, args...) error` | allowlisted exec with stderr-wrapped error | Every arg pre-validated via validate.go before this is called | | `Prober.Probe` | internal/capability/probe.go | `Probe(ctx) []Status` | live sudo-policy capability check (`sudo -n -l --`) | Needs a DIRECT runner (never the sudo-prefixing one — double-sudo); never executes probed cmds. v0.86.0: config-gated caps (`Capability.GatedBy` + `Prober.GateActive`) report `inactive`/"disabled by configuration" ONLY when healthy — broken plumbing stays degraded; the pbsdr-* gate answers from `pbsdr.Manager.DRConfigured` (marker-backed across restarts) | -| `stageTemp` | internal/localapi/intermediary.go | `stageTemp(pattern, content) (path, err)` | random-named temp before a root `install` (audit B1) | Fixed /tmp names are a TOCTOU — sudoers globs expect `/tmp/felhom-*-*.ext` | -| `BUNDLE_FILES` + `Bundle` (mode `bundle`, `--install-bundle`) | configs/felhom-os-apply | the ONE table of root-owned paths + the installer of them | ANY new root-owned file the installer writes (sudoers line, wrapper, unit) — add it to the table, never a new installer fetch (R-840) | The builder (`scripts/build-config-bundle.py`) and the installer read the same table; `test_every_root_file_the_installer_writes_is_in_the_bundle` fails on a path the bundle lacks. Trust files (`/etc/felhom/os-trust.json`, `operator-signers`) are NEVER bundle paths (R17) | +| ~~`stageTemp`~~ (REMOVED v0.146.0, R-861) | — | — | — | Nothing the agent writes is `install`ed where root reads it any more: use `felhom-priv-apply` (below) or ship a fixed file in the bundle | +| `felhom-priv-apply` (v0.146.0, R-861) | configs/felhom-priv-apply | `felhom-priv-apply unit \| dnsmasq \| wg \| sshd-config \| sshd-key` | ANY agent-rendered file a root program reads (systemd unit, dnsmasq drop-in, wg-quick conf, OOB sshd) — fixed source + destination, CONTENT checked against the agent's own renderers | A new renderer needs a verb + a contract test (`internal/privapplytest.Check`) feeding its REAL output; never a new `install` sudoers line | +| `privapplytest.Check` | internal/privapplytest/check.go | `Check(t, verb, name, content) string` | the Go↔root-checker contract: a renderer's real output must read `OK` | Skips without python3; one call per rendered shape + one refused control | +| `BUNDLE_FILES` + `Bundle` (mode `bundle`, `--install-bundle`; mode `agent_update` v0.146.0, R-861) | configs/felhom-os-apply | the ONE table of root-owned paths + the installer of them | ANY new root-owned file the installer writes (sudoers line, wrapper, unit) — add it to the table, never a new installer fetch (R-840) | The builder (`scripts/build-config-bundle.py`) and the installer read the same table; `test_every_root_file_the_installer_writes_is_in_the_bundle` fails on a path the bundle lacks. Trust files (`/etc/felhom/os-trust.json`, `operator-signers`) are NEVER bundle paths (R17) | | `osupdate.ConfigUpdateExecutor` | internal/osupdate/bundle.go | signed op `agent_config_update` {agent_version, bundle_sha256} | delivering the bundle to an installed box | a courier only: the root wrapper re-verifies signature, host, nonce and sha itself | | `osupdate.Leg.SendUnsent` / `lockPass` (v0.144.0, R-868) | internal/osupdate/unsent.go | `(ctx) int` | an OS-pass report the agent never sent (killed mid-pass): the wrapper keeps `report---apply.json` beside the plan; the agent deletes it once the hub has it | any new caller that runs an apply pass must hold `lockPass` (flock, across processes) — the sender must never take a running pass's copy | | `osupdate.LoadSavedBlock` (v0.144.0, R-866) | internal/osupdate/leg.go | `(planDir) (block, savedAt, ok)` | the hub's newest os_update block as the daemon last received it (`os-update-block.json`) | the debug pass uses it ONLY when the hub cannot be reached, and says so in its header; no saved block → no pass | | `dpkg_state()` / `DPKG_STATE_SCRIPT` (v0.145.0, R-876) | configs/felhom-os-apply | `audit, journal = self.dpkg_state()` | dpkg's state in ONE call: `--audit` AND the update journal | never gate a repair on `--audit` alone — a crash leaves only the journal (measured); keep it one call (R-845) | -| `guesthook.InstallSnippet` / `Register` | internal/guesthook/install.go | `InstallSnippet(ctx, runner) error` | pre-start self-heal hook install (C1 net) | Same random-temp+install pattern; snippet delegates to the agent binary (no shell logic). Issues `mkdir -p /var/lib/vz/snippets` FIRST (v0.63.0, B2 — fresh boxes lack the dir; sudoers grants exactly that argv) | +| `guesthook.SnippetReady` / `Register` (v0.146.0, R-861) | internal/guesthook/install.go | `SnippetReady(path) error` | is the pre-start hook (a FIXED file from the bundle) in place — register only then | The agent never installs the hook (Proxmox runs it as root); a missing hookscript stops a guest start, so never `Register` without `SnippetReady` | ### Disk / format safety (role gates, durable IDs, format guards) diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index f4daa04..b399741 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -22,6 +22,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "regexp" "strconv" "strings" "sync" @@ -2697,6 +2698,9 @@ func (e *escrowCeremonyErr) Error() string { return e.err.Error() } // restic password auto-attach), Create (R + self-verified blob), wipe the staged secret, upload // when asked. It PRINTS NOTHING — the output-mode shells own every byte of stdout/stderr. R is // returned for the caller to surface exactly once; escrow.Create never logs it and neither do we. +// pbsStorageIDRe is a PVE storage id (letters, digits, '-', '_', '.'; starts with a letter) — never a path (R-861). +var pbsStorageIDRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{0,63}$`) + func escrowCeremony(ctx context.Context, cfg config.Config, logger *slog.Logger, opts escrowCeremonyOpts) (escrowCeremonyOutcome, *escrowCeremonyErr) { var out escrowCeremonyOutcome storage := opts.storage @@ -2706,6 +2710,16 @@ func escrowCeremony(ctx context.Context, cfg config.Config, logger *slog.Logger, if storage == "" { return out, &escrowCeremonyErr{kind: "usage", err: fmt.Errorf("selftest=escrow-create requires -storage (or escrow.pbs_storage_id)")} } + // R-861 (v0.146.0): this runs as ROOT through FELHOM_ESCROW, but agent.json is owned by the agent user. So the + // paths a root run reads never come from it: the PVE secret dir and the WireGuard state dir are the fixed defaults, + // and the storage id is a plain PVE id (no slash, no dot-dot) — a crafted id or dir would read another root file. + if os.Geteuid() == 0 { + cfg.Backup.PBSSecretDir = "" + cfg.WGTunnel.StateDir = "" + } + if !pbsStorageIDRe.MatchString(storage) { + return out, &escrowCeremonyErr{kind: "usage", err: fmt.Errorf("selftest=escrow-create: storage id %q is not a plain PVE storage id", storage)} + } out.Storage = storage keyPath := cfg.Backup.PBSEncKeyPath(storage) if _, err := os.Stat(keyPath); err != nil { diff --git a/cmd/felhom-agent/r861_escrow_storage_id_test.go b/cmd/felhom-agent/r861_escrow_storage_id_test.go new file mode 100644 index 0000000..302ae0d --- /dev/null +++ b/cmd/felhom-agent/r861_escrow_storage_id_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "io" + "log/slog" + "testing" + + "gitea.dooplex.hu/admin/felhom-agent/internal/config" +) + +// R-861 (agent v0.146.0): the root escrow ceremony builds a file path from the storage id; an id that is a path is +// refused before anything is read. RED-PROOF: drop the pbsStorageIDRe check → the "../" ids reach the key stat and +// come back as a "setup" error instead of "usage". +func TestEscrowCeremony_StorageIDIsNeverAPath(t *testing.T) { + lg := slog.New(slog.NewTextHandler(io.Discard, nil)) + for _, id := range []string{"../../../etc/shadow", "a/b", "/etc/pve/priv/x", ".hidden", ""} { + cfg := config.Default() + cfg.Escrow.PBSStorageID = "" // the flag decides here + _, e := escrowCeremony(context.Background(), cfg, lg, escrowCeremonyOpts{storage: id}) + if e == nil || e.kind != "usage" { + t.Errorf("storage id %q was not refused as usage (got %+v)", id, e) + } + } + cfg := config.Default() + cfg.Backup.PBSSecretDir = t.TempDir() + _, e := escrowCeremony(context.Background(), cfg, lg, escrowCeremonyOpts{storage: "felhom-pbs"}) + if e == nil || e.kind != "setup" { + t.Fatalf("control: a plain id must pass the check and fail later on the missing key (setup), got %+v", e) + } +} diff --git a/configs/felhom-agent.sudoers b/configs/felhom-agent.sudoers index 4d82a1a..e3e1ff1 100644 --- a/configs/felhom-agent.sudoers +++ b/configs/felhom-agent.sudoers @@ -1,30 +1,38 @@ -# felhom-agent sudoers allowlist — the NARROW host-root surface (slice 5 Phase B, doc 03 §3/§7). +# felhom-agent sudoers allowlist — the NARROW host-root surface (slice 5 Phase B, doc 03 §3/§7; narrowed R-861). # -# Install as a drop-in: /etc/sudoers.d/felhom-agent (mode 0440, root:root), validated with -# `visudo -cf`. The agent runs as the non-root `felhom-agent` service user and shells out via -# `sudo -n` with FIXED argument vectors (no shell). The fine-grained validation is done IN -# the agent BEFORE exec (internal/storage/validate.go): UUIDs against a strict hex regex, -# mount paths confined+traversal-checked, SMART devices whitelisted to raw disks, LVM names -# charset-checked. These sudoers wildcards are the COARSE allowlist; the agent is the fine -# gate, so a wildcard can never be abused by a value the agent didn't already validate. +# Install as a drop-in: /etc/sudoers.d/felhom-agent (mode 0440, root:root), validated with `visudo -cf`. It rides the +# signed config bundle (R-840). The agent runs as the non-root `felhom-agent` user and shells out via `sudo -n` with +# FIXED argument vectors (no shell). # -# Binary paths MUST match the agent config (privileged.systemctl/install/smartctl/lvs). Adjust -# for your distro (Debian/PVE shown). A missing/declined entry degrades the agent with a -# warning (SMART→UNKNOWN, mount→logged error), it does not crash. +# R-861 (agent v0.146.0) — EXACT PATTERNS, NOT GLOBS. A sudoers `*` in the ARGUMENTS also matches spaces, so +# `pct set [0-9]* -onboot 1` matched `pct set 100 --dev0 /dev/sda -onboot 1` (a raw host disk for the guest), and +# `mount --bind /mnt/*/felhom-data /mnt/felhom-drives/*` matched a `..` path onto /etc. Every argument list that varies +# is now a sudo regular expression (`^...$`, sudo >= 1.9.10; Debian 13 / PVE 9 ship 1.9.16): one value per slot, a +# fixed character set, no `..`, no extra argument. Lines with no variable part stay literal. The patterns are pinned +# by the capability manifest (every real call must match: TestManifestCoveredBySudoers) and by injection cases that +# must NOT match (configs/test_sudoers_patterns.py, and live with `sudo -l -U felhom-agent` on the demo boxes). +# +# R-861 — NO FILE THE AGENT WROTE IS INSTALLED WHERE ROOT READS IT. The `install` lines are gone: a mount unit, a +# dnsmasq drop-in, the WireGuard config and the OOB sshd config + key go through `felhom-priv-apply`, a root wrapper +# from the bundle that checks the CONTENT against the agent's own renderers; the guest pre-start hook and the shared +# drive parent are FIXED files that come with the bundle itself; the agent binary is replaced only by an +# operator-signed agent_update that `felhom-os-apply` verifies as root (`felhom-selfupdate-guarded apply` is no longer +# here). The two remaining root runs of agent code (FELHOM_ESCROW, the guest hook) therefore run only a signed binary. +# +# Binary paths MUST match the agent config (privileged.systemctl/install/smartctl/lvs). A missing/declined entry +# degrades the agent with a warning (SMART→UNKNOWN, mount→logged error), it does not crash; the capability probe +# reports it to the hub. Cmnd_Alias FELHOM_MOUNT = \ - /usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/units/* /etc/systemd/system/*.mount, \ + /usr/local/sbin/felhom-priv-apply ^unit mnt-[A-Za-z0-9_.\\-]+\.(mount|automount)$, \ /usr/bin/systemctl daemon-reload, \ - /usr/bin/systemctl enable --now -- *.mount, \ - /usr/bin/systemctl disable -- *.mount, \ - /usr/bin/systemctl stop -- *.mount + /usr/bin/systemctl ^enable --now -- mnt-[A-Za-z0-9_.\\-]+\.mount$, \ + /usr/bin/systemctl ^disable -- mnt-[A-Za-z0-9_.\\-]+\.mount$, \ + /usr/bin/systemctl ^stop -- mnt-[A-Za-z0-9_.\\-]+\.mount$ Cmnd_Alias FELHOM_DISK = \ - /usr/sbin/smartctl -a -j /dev/sd[a-z]*, \ - /usr/sbin/smartctl -a -j /dev/nvme[0-9]*n[0-9]*, \ - /usr/sbin/smartctl -a -j /dev/vd[a-z]*, \ - /usr/sbin/smartctl -a -j /dev/hd[a-z]*, \ - /usr/sbin/lvs --reportformat json --units b -o lv_name\,data_percent\,metadata_percent -- *, \ + /usr/sbin/smartctl ^-a -j /dev/(sd[a-z]+|nvme[0-9]+n[0-9]+|vd[a-z]+|hd[a-z]+)$, \ + /usr/sbin/lvs ^--reportformat json --units b -o lv_name\,data_percent\,metadata_percent -- [A-Za-z0-9_.+-]+(/[A-Za-z0-9_.+-]+)?$, \ /usr/sbin/pvs --reportformat json --noheadings -o pv_name, \ /usr/sbin/zpool status -P @@ -35,9 +43,9 @@ Cmnd_Alias FELHOM_DISK = \ # (the wildcard only ever names a path the agent itself created), and the bootstrap file the agent # writes there is the only thing these touch. ':' is escaped per sudoers grammar. Cmnd_Alias FELHOM_PROVISION = \ - /usr/bin/chown -R 100000\:100000 /var/lib/felhom-agent/guests/*, \ - /usr/sbin/pct set [0-9]* -mp[0-9]* /var/lib/felhom-agent/guests/*, \ - /usr/sbin/pct set [0-9]* -onboot 1 + /usr/bin/chown ^-R 100000\:100000 /var/lib/felhom-agent/guests/[0-9]+(/bootstrap)?$, \ + /usr/sbin/pct ^set [0-9]+ -mp[0-9]+ /var/lib/felhom-agent/guests/[0-9]+/bootstrap\,mp\=/[A-Za-z0-9/_.-]+(\,ro\=1)?$, \ + /usr/sbin/pct ^set [0-9]+ -onboot 1$ # Disk inspection + format (slice 8C + Impl-1). blkid/lsblk read the device's data-bearing evidence # (the agent decides data-bearing-ness from THIS, never the caller's claim). Format goes ONLY through @@ -45,66 +53,54 @@ Cmnd_Alias FELHOM_PROVISION = \ # mkfs the OS disk — the wrapper re-checks the catastrophic cases (system disk / LVM PV / foreign mount) # as root and refuses, and the agent's unclaimed-disk filter (claim.go) is the primary guard above it. Cmnd_Alias FELHOM_FORMAT = \ - /usr/sbin/blkid -p -o export /dev/*, \ - /usr/bin/lsblk -J -o NAME\,FSTYPE\,PTTYPE\,MOUNTPOINT /dev/*, \ - /usr/local/sbin/felhom-mkfs-guarded /dev/* * + /usr/sbin/blkid ^-p -o export /dev/[^ ]+$, \ + /usr/bin/lsblk ^-J -o NAME\,FSTYPE\,PTTYPE\,MOUNTPOINT /dev/[^ ]+$, \ + /usr/local/sbin/felhom-mkfs-guarded ^/dev/[^ ]+ (ext4|xfs)$ # LAN split-horizon resolver (internal/lanresolver): the agent manages a host-side dnsmasq that -# answers *. with each guest's live LAN IP. install only ever writes felhom-*.conf -# drop-ins (from agent-written /tmp temp files); the two `pct exec` reads are FIXED command vectors +# answers *. with each guest's live LAN IP. A felhom-*.conf drop-in reaches /etc/dnsmasq.d +# only through felhom-priv-apply, which allows exactly the lines the resolver renders (R-861: a `dhcp-script=` would +# run as root); the two `pct exec` reads are FIXED command vectors # (the guest's eth0 IPv4 + the controller's pulled controller.yaml for the domain) — NOT a general # `pct exec`. systemctl is scoped to the dnsmasq unit only. The agent never edits /etc/resolv.conf. Cmnd_Alias FELHOM_DNSMASQ = \ /usr/bin/apt-get install -y -q dnsmasq, \ - /usr/bin/install -m 0644 /tmp/felhom-resolver-*.conf /etc/dnsmasq.d/felhom-*.conf, \ + /usr/local/sbin/felhom-priv-apply ^dnsmasq /tmp/felhom-resolver-[0-9]+\.conf felhom-[a-z0-9][a-z0-9._-]*\.conf$, \ /usr/bin/systemctl enable --now dnsmasq, \ /usr/bin/systemctl reload dnsmasq, \ /usr/bin/systemctl restart dnsmasq, \ - /usr/bin/rm -f /etc/dnsmasq.d/felhom-*.conf, \ - /usr/sbin/pct exec [0-9]* -- ip -4 -o addr show dev eth0, \ - /usr/sbin/pct exec [0-9]* -- docker exec felhom-controller cat /opt/docker/felhom-controller/controller.yaml + /usr/bin/rm ^-f /etc/dnsmasq\.d/felhom-[a-z0-9][a-z0-9._-]*\.conf$, \ + /usr/sbin/pct ^exec [0-9]+ -- ip -4 -o addr show dev eth0$, \ + /usr/sbin/pct ^exec [0-9]+ -- docker exec felhom-controller cat /opt/docker/felhom-controller/controller\.yaml$ -# Guest mountpoint lifecycle (intermediary-mount re-architecture + C1 net). The pre-start self-heal hook -# wrapper is installed once into the PVE snippets dir (from an agent-written /tmp file) and registered -# per-guest; decommission/eject DELETE the dead mountpoint slot so a missing bind source can't brick the -# guest at next boot (the B3 C1 fix). The agent fine-validates the vmid (numeric) + slot (mp[0-9]+) and -# the snippet path is fixed — the wildcards are the coarse allowlist. The install SOURCE is a -# random-named agent temp (os.CreateTemp, audit B1 — a fixed /tmp name was a local TOCTOU), hence the -# glob; the DESTINATION stays pinned. The `mkdir -p` creates the snippets dir on a FRESH box — -# `install` won't create parents, so without it the hook install failed silently on Day-0 boxes -# (B2, DRILL-day0-cleanroom-2026-07-03; fixed agent v0.63.0). +# Guest mountpoint lifecycle (intermediary-mount re-architecture + C1 net). The pre-start self-heal hook is a FIXED file +# from the config bundle (/var/lib/vz/snippets/felhom-guest-hook.sh — R-861: the agent no longer installs it from /tmp; +# Proxmox runs it as root at every guest start). The agent only registers it per guest, deletes a dead mountpoint slot +# (the B3 C1 fix) and reboots a guest to activate binds — each with an exact vmid / slot. Cmnd_Alias FELHOM_GUESTHOOK = \ - /usr/bin/mkdir -p /var/lib/vz/snippets, \ - /usr/bin/install -m 0755 -- /tmp/felhom-guest-hook-*.sh /var/lib/vz/snippets/felhom-guest-hook.sh, \ - /usr/sbin/pct set [0-9]* --hookscript local\:snippets/felhom-guest-hook.sh, \ - /usr/sbin/pct set [0-9]* --delete mp[0-9]*, \ - /usr/sbin/pct reboot [0-9]* + /usr/sbin/pct ^set [0-9]+ --hookscript local\:snippets/felhom-guest-hook\.sh$, \ + /usr/sbin/pct ^set [0-9]+ --delete mp[0-9]+$, \ + /usr/sbin/pct ^reboot [0-9]+$ # Intermediary mount model (the drive hot-swap re-architecture). The agent keeps a SHARED host parent -# /mnt/felhom-drives (self-bind + make-shared + a boot-persistence systemd unit) and binds/unbinds each -# drive's felhom-data namespace UNDERNEATH it so the change propagates into the running guest live (no -# pct, no reboot). The agent fine-validates the drive name + confines paths before any exec; the trailing -# `*` (matching the comma-laden mp spec) mirrors the existing FELHOM_PROVISION pattern. -# `lxc-info -n -p -H` resolves the guest init PID for the GuestSeesMount / bound_under_parent check -# (a READ — the drive-gate's "is the drive live in the guest?" signal); WITHOUT it the non-root agent gets -# an empty PID and reports every drive absent (multi-drive flapping, audit 2026-06-29). `make-private` -# isolates the parent's peer group on FIRST setup only (EnsureSharedParent guards on mountpoint, so it -# never re-churns a live parent); without it the parent stays in root's group and submounts double. +# /mnt/felhom-drives (self-bind + make-shared) and binds/unbinds each drive's felhom-data namespace UNDERNEATH it so the +# change propagates into the running guest live. The boot-persistence script + unit are FIXED files from the config +# bundle (R-861: the agent no longer installs them from /tmp); the agent only enables the unit. A drive name is one +# path segment that cannot start with a dot (no `..`); `lxc-info -n -p -H` resolves the guest init PID for the +# GuestSeesMount check; `make-private` isolates the parent's peer group on FIRST setup only. Cmnd_Alias FELHOM_INTERMEDIARY = \ /usr/bin/mkdir -p /mnt/felhom-drives, \ - /usr/bin/mkdir -p /mnt/felhom-drives/*, \ - /usr/bin/mkdir -p /mnt/*/felhom-data, \ - /usr/bin/chown 100000\:100000 /mnt/*/felhom-data, \ + /usr/bin/mkdir ^-p /mnt/felhom-drives/[A-Za-z0-9_-][A-Za-z0-9_.-]*$, \ + /usr/bin/mkdir ^-p /mnt/[A-Za-z0-9_-][A-Za-z0-9_.-]*/felhom-data$, \ + /usr/bin/chown ^100000\:100000 /mnt/[A-Za-z0-9_-][A-Za-z0-9_.-]*/felhom-data$, \ /usr/bin/mount --bind /mnt/felhom-drives /mnt/felhom-drives, \ /usr/bin/mount --make-shared /mnt/felhom-drives, \ /usr/bin/mount --make-private /mnt/felhom-drives, \ - /usr/bin/mount --bind /mnt/*/felhom-data /mnt/felhom-drives/*, \ - /usr/bin/umount /mnt/felhom-drives/*, \ - /usr/bin/install -m 0755 -- /tmp/felhom-shared-parent-*.sh /usr/local/sbin/felhom-shared-parent.sh, \ - /usr/bin/install -m 0644 -- /tmp/felhom-shared-parent-*.service /etc/systemd/system/felhom-shared-parent.service, \ + /usr/bin/mount ^--bind /mnt/[A-Za-z0-9_-][A-Za-z0-9_.-]*/felhom-data /mnt/felhom-drives/[A-Za-z0-9_-][A-Za-z0-9_.-]*$, \ + /usr/bin/umount ^/mnt/felhom-drives/[A-Za-z0-9_-][A-Za-z0-9_.-]*$, \ /usr/bin/systemctl enable felhom-shared-parent.service, \ - /usr/bin/lxc-info -n [0-9]* -p -H, \ - /usr/sbin/pct set [0-9]* -mp8 /mnt/felhom-drives* + /usr/bin/lxc-info ^-n [0-9]+ -p -H$, \ + /usr/sbin/pct ^set [0-9]+ -mp8 /mnt/felhom-drives\,mp\=/mnt/felhom-drives$ # Controller-swap / managed auto-update (Option A, non-root). The agent owns the in-guest controller # image SWAP (it survives the controller being killed mid-swap): read the baked image ref, check the @@ -119,11 +115,11 @@ Cmnd_Alias FELHOM_INTERMEDIARY = \ # the agent strict-validates the ref (controllerImageRe) before the write. # Validated GO: felhom.eu/documentation/audits/SPIKE-controllerswap-narrow-grants-2026-06-29.md. Cmnd_Alias FELHOM_CONTROLLERSWAP = \ - /usr/sbin/pct exec [0-9]* -- cat /etc/felhom-controller-image, \ - /usr/sbin/pct exec [0-9]* -- docker image inspect *, \ - /usr/sbin/pct exec [0-9]* -- docker inspect -f *, \ - /usr/sbin/pct exec [0-9]* -- systemctl restart felhom-controller-bootstrap.service, \ - /usr/sbin/pct exec [0-9]* -- tee /etc/felhom-controller-image + /usr/sbin/pct ^exec [0-9]+ -- cat /etc/felhom-controller-image$, \ + /usr/sbin/pct ^exec [0-9]+ -- docker image inspect gitea\.dooplex\.hu/admin/felhom-controller\:[0-9]+\.[0-9]+\.[0-9]+$, \ + /usr/sbin/pct ^exec [0-9]+ -- docker inspect -f .+ (felhom-controller|cloudflared)$, \ + /usr/sbin/pct ^exec [0-9]+ -- systemctl restart felhom-controller-bootstrap\.service$, \ + /usr/sbin/pct ^exec [0-9]+ -- tee /etc/felhom-controller-image$ # Stale-lock recovery (F2-b, v0.49.0). A host reboot DURING a vzdump backup leaves the guest with a # `snapshot-delete`/`backup` lock + `onboot:1` then can't start it → the customer box stays DOWN. The @@ -131,7 +127,7 @@ Cmnd_Alias FELHOM_CONTROLLERSWAP = \ # with no API equivalent (snapshot-delete + start go through the API token); the agent fine-validates the # vmid (numeric) before exec — the `[0-9]*` is the coarse allowlist. Cmnd_Alias FELHOM_STALELOCK = \ - /usr/sbin/pct unlock [0-9]* + /usr/sbin/pct ^unlock [0-9]+$ # Restore-test scratch teardown (F-LEAK, Campaign 8, v0.110.0). A restore-test whose restore FAILS # leaves a scratch guest the API token CANNOT destroy: `FelhomAgentGuest` is granted at /pool/felhom and @@ -160,8 +156,9 @@ Cmnd_Alias FELHOM_SCRATCH_TEARDOWN = \ # into the guest through the existing shared bind (an unprivileged LXC cannot mount NFS/CIFS itself). # A NAS is NOT a drive — no durable-id, no SMART, no wipe; these grants only install/enable/remove the # unit pair. The agent fine-validates every value (share name, server, export, uid/gid, creds path) before -# any unit is rendered (internal/storage/netmount.go ValidateNetworkMountSpec); the trailing globs are the -# COARSE allowlist. The `.mount` install/enable/disable/stop reuse FELHOM_MOUNT; this alias adds the +# any unit is rendered (internal/storage/netmount.go ValidateNetworkMountSpec); the unit FILE reaches +# /etc/systemd/system only through `felhom-priv-apply unit` (FELHOM_MOUNT), which requires nosuid,nodev on a network +# share (R-861). The `.mount` enable/disable/stop reuse FELHOM_MOUNT; this alias adds the # `.automount` variants + the unit-file removal. The unit FILE name is the systemd-escaped mountpoint, # which always begins `mnt-felhom` (the mountpoint is /mnt/felhom-drives/), so the rm glob is scoped # to felhom mount units only. mkdir of the mountpoint reuses FELHOM_INTERMEDIARY's /mnt/felhom-drives/*. @@ -176,51 +173,46 @@ Cmnd_Alias FELHOM_SCRATCH_TEARDOWN = \ # behind (the campaign accumulated 10 stub-shaped leftovers). rmdir ONLY (never rm -rf): it refuses # a non-empty dir, so unexpected data is preserved, not destroyed — a fail-safe grant. Cmnd_Alias FELHOM_NETMOUNT = \ - /usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/units/* /etc/systemd/system/*.automount, \ - /usr/bin/systemctl enable --now -- *.automount, \ - /usr/bin/systemctl disable -- *.automount, \ - /usr/bin/systemctl stop -- *.automount, \ - /usr/bin/systemctl reset-failed -- mnt-felhom*, \ - /usr/bin/rmdir /mnt/felhom-drives/*, \ - /usr/bin/rm -f /etc/systemd/system/mnt-felhom* + /usr/bin/systemctl ^enable --now -- mnt-[A-Za-z0-9_.\\-]+\.automount$, \ + /usr/bin/systemctl ^disable -- mnt-[A-Za-z0-9_.\\-]+\.automount$, \ + /usr/bin/systemctl ^stop -- mnt-[A-Za-z0-9_.\\-]+\.automount$, \ + /usr/bin/systemctl ^reset-failed -- mnt-felhom[A-Za-z0-9_.\\-]*\.(mount|automount)$, \ + /usr/bin/rmdir ^/mnt/felhom-drives/[A-Za-z0-9_-][A-Za-z0-9_.-]*$, \ + /usr/bin/rm ^-f /etc/systemd/system/mnt-felhom[A-Za-z0-9_.\\-]*\.(mount|automount)$ # Offsite WG tunnel (S3, doc 06 §3.3). The agent manages wg-quick@wg-felhom as an agent-managed # host service (the dnsmasq/lanresolver shape): conf staged in the agent-owned StateDir (never -# /tmp), installed 0600 to the FIXED destination, unit enable/restart/disable. The ONLY wg read +# /tmp), installed 0600 to the FIXED destination by `felhom-priv-apply wg`, which refuses any key renderConf never +# writes (R-861: PostUp/PreUp run as root under wg-quick), unit enable/restart/disable. The ONLY wg read # is `latest-handshakes` — `wg show dump` is FORBIDDEN everywhere (its interface line -# carries the PRIVATE KEY; the S1 session-log incident). Both install paths are FIXED (no glob): -# the agent has exactly one tunnel conf to manage. +# carries the PRIVATE KEY; the S1 session-log incident). Source and destination are fixed in the wrapper. Cmnd_Alias FELHOM_WG = \ /usr/bin/apt-get install -y -q wireguard-tools, \ - /usr/bin/install -o root -g root -m 0600 -- /var/lib/felhom-agent/wg/wg-felhom.conf /etc/wireguard/wg-felhom.conf, \ + /usr/local/sbin/felhom-priv-apply wg, \ /usr/bin/systemctl enable --now wg-quick@wg-felhom, \ /usr/bin/systemctl restart wg-quick@wg-felhom, \ /usr/bin/systemctl disable --now wg-quick@wg-felhom, \ /usr/bin/wg show wg-felhom latest-handshakes -# Agent self-update (TASK D1, SPIKE-agent-selfupdate-2026-07-05). The agent downloads the -# operator-SIGNED binary (sha256 pinned in the signed op — neither hub nor Gitea compromise can -# substitute it), verifies the sha in-process, then hands off to the guarded wrapper, which -# RE-verifies the sha as root, confines the staged path to /var/lib/felhom-agent/selfupdate/, -# performs the A/B flip (atomic same-fs rename, .prev retained) and schedules a detached restart. -# The apply args are a COARSE glob (spike S4b: sudoers fnmatch makes a [a-f0-9]* sha pattern -# first-char-only anyway) — the wrapper's own sha re-verify + path confinement is the real gate. -# `rollback` is normally run by felhom-agent-rollback.service (root, OnFailure=), not via sudo; -# granting it here keeps the verb probe-able (capability self-check) and operator-invokable. +# Agent self-update (TASK D1; R-861). The A/B flip (`felhom-selfupdate-guarded apply`) is NO LONGER the agent's: the +# agent hands the operator-SIGNED agent_update to felhom-os-apply (FELHOM_OSAPPLY, mode agent_update), which verifies +# the signature as root and only then runs the flip. Until v0.146.0 the agent passed the sha itself, so a compromised +# agent could install any binary — the binary FELHOM_ESCROW and the guest hook run as root. `commit` (clear the pending +# marker) and `rollback` (pending-guarded revert, normally run by felhom-agent-rollback.service) stay. Cmnd_Alias FELHOM_SELFUPDATE = \ - /usr/local/sbin/felhom-selfupdate-guarded apply /var/lib/felhom-agent/selfupdate/* *, \ /usr/local/sbin/felhom-selfupdate-guarded commit, \ /usr/local/sbin/felhom-selfupdate-guarded rollback # Dedicated OOB sshd (TASK H1). The agent manages felhom-sshd like wg-felhom/dnsmasq: it RENDERS the # config (Port from its claim) + the operator's authorized_keys, validates with `sshd -t`, and reloads -# (never restart-on-change [SF-2]). Both install SOURCES are the agent-owned staged files under -# StateDir; both DESTINATIONS are FIXED. `sshd -t/-T` are the validate/discover reads. The +# (never restart-on-change [SF-2]). Both files reach /etc/felhom-sshd only through felhom-priv-apply (R-861): the config +# must be the ONE template with only the Port varying (an AuthorizedKeysFile the agent owns + `StrictModes no` would be +# a root login), the key file one plain public key without options. `sshd -t/-T` are the validate/discover reads. The # systemctl verbs are SCOPED to felhom-sshd only. reset-failed precedes a deliberate restart [SF-5]. # NOTHING here can touch the stock sshd, :22, or /etc/ssh. Cmnd_Alias FELHOM_SSHD = \ - /usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/felhom-sshd/sshd_config /etc/felhom-sshd/sshd_config, \ - /usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/felhom-sshd/authorized_keys.felhom-op /etc/felhom-sshd/authorized_keys/felhom-op, \ + /usr/local/sbin/felhom-priv-apply sshd-config, \ + /usr/local/sbin/felhom-priv-apply sshd-key, \ /usr/sbin/sshd -t -f /var/lib/felhom-agent/felhom-sshd/sshd_config, \ /usr/sbin/sshd -t -f /etc/felhom-sshd/sshd_config, \ /usr/sbin/sshd -T -f /etc/felhom-sshd/sshd_config, \ @@ -270,8 +262,8 @@ Cmnd_Alias FELHOM_OOB = \ /usr/sbin/nft list set inet felhom_oob ssh_port, \ /usr/sbin/nft flush set inet felhom_oob operator_ips, \ /usr/sbin/nft flush set inet felhom_oob ssh_port, \ - /usr/sbin/nft add element inet felhom_oob operator_ips *, \ - /usr/sbin/nft add element inet felhom_oob ssh_port * + /usr/sbin/nft ^add element inet felhom_oob operator_ips \{ [0-9.]+(/[0-9]+)? \}$, \ + /usr/sbin/nft ^add element inet felhom_oob ssh_port \{ [0-9]+ \}$ # Escrow ceremony (controller-driven, TASK 2026-07-13; mechanics validated by # SPIKE-controller-escrow-2026-07-13). ONE fixed argv — sudoers matches the argument vector @@ -307,9 +299,9 @@ Cmnd_Alias FELHOM_OSAPPLY = \ /usr/local/sbin/felhom-os-apply --plan /var/lib/felhom-agent/os/plan-*.json Cmnd_Alias FELHOM_GUESTNET = \ - /usr/sbin/pct exec [0-9]* -- ip route show default, \ - /usr/sbin/pct exec [0-9]* -- cat /etc/network/interfaces, \ - /usr/sbin/pct exec [0-9]* -- pgrep -x dhclient, \ - /usr/sbin/pct exec [0-9]* -- dhclient -pf /run/dhclient.eth0.pid -lf /var/lib/dhcp/dhclient.eth0.leases eth0 + /usr/sbin/pct ^exec [0-9]+ -- ip route show default$, \ + /usr/sbin/pct ^exec [0-9]+ -- cat /etc/network/interfaces$, \ + /usr/sbin/pct ^exec [0-9]+ -- pgrep -x dhclient$, \ + /usr/sbin/pct ^exec [0-9]+ -- dhclient -pf /run/dhclient\.eth0\.pid -lf /var/lib/dhcp/dhclient\.eth0\.leases eth0$ felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_NETMOUNT, FELHOM_WG, FELHOM_SELFUPDATE, FELHOM_SSHD, FELHOM_OOB, FELHOM_PBSDR, FELHOM_BACKUPTARGET, FELHOM_SELFHEAL, FELHOM_ESCROW, FELHOM_GUESTNET, FELHOM_SCRATCH_TEARDOWN, FELHOM_OSAPPLY diff --git a/configs/felhom-guest-hook.sh b/configs/felhom-guest-hook.sh new file mode 100755 index 0000000..8923f37 --- /dev/null +++ b/configs/felhom-guest-hook.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# felhom-agent guest pre-start self-heal hook (C1 net). PVE calls: