R-861: narrow the agent's root grants — exact sudo patterns, felhom-priv-apply content checker, fixed hook/parent files in the bundle, signed self-update verified as root, escrow root reads pinned
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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 <name> \| dnsmasq <tmp> <name> \| 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-<run>-<layer>-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)
|
||||
|
||||
|
||||
@@ -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 <pbs-storage-id> (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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+95
-103
@@ -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 *.<customer-domain> 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 *.<customer-domain> 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 <vmid> -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 <vmid> -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/<name>), 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 <if> 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
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# felhom-agent guest pre-start self-heal hook (C1 net). PVE calls: <script> <vmid> <phase>.
|
||||
/usr/local/bin/felhom-agent guest-hook "$1" "$2" || true
|
||||
exit 0
|
||||
+57
-1
@@ -109,6 +109,10 @@ CRASH_GUARD_STATE = "/var/lib/felhom-crash-guard/state.json"
|
||||
# policy: replace → always written when it differs; if-absent → only when the box has none (a setting the operator may
|
||||
# have tuned); oob → only on a box with the OOB belt (/etc/felhom-sshd exists — the installer's --no-oob leaves none).
|
||||
# Order matters: the sudoers files come LAST, so a referenced wrapper is in place before the line that allows it.
|
||||
# R-861 (agent v0.146.0): the signed agent update, verified here (mode agent_update), then the A/B flip.
|
||||
SELFUPDATE_OP = "agent_update"
|
||||
SELFUPDATE_DIR = "/var/lib/felhom-agent/selfupdate"
|
||||
SELFUPDATE_WRAPPER = "/usr/local/sbin/felhom-selfupdate-guarded"
|
||||
BUNDLE_FORMAT = 1
|
||||
BUNDLE_OP = "agent_config_update"
|
||||
BUNDLE_RECORD = "/etc/felhom/config-bundle.json" # what the box runs (0644 root; the non-root agent reports it)
|
||||
@@ -124,6 +128,12 @@ BUNDLE_FILES = [
|
||||
("/usr/local/sbin/felhom-backup-target-apply", "felhom-backup-target-apply", 0o755, "bash", "replace"),
|
||||
("/usr/local/sbin/felhom-os-apply", "felhom-os-apply", 0o755, "python", "replace"),
|
||||
("/usr/local/sbin/felhom-crash-guard", "felhom-crash-guard", 0o755, "python", "replace"),
|
||||
# R-861 (agent v0.146.0): the content checker for every agent-staged file a root program reads, and the two FIXED
|
||||
# files the agent used to install itself from /tmp (the guest pre-start hook and the shared drive parent).
|
||||
("/usr/local/sbin/felhom-priv-apply", "felhom-priv-apply", 0o755, "python", "replace"),
|
||||
("/var/lib/vz/snippets/felhom-guest-hook.sh", "felhom-guest-hook.sh", 0o755, "sh", "replace"),
|
||||
("/usr/local/sbin/felhom-shared-parent.sh", "felhom-shared-parent.sh", 0o755, "sh", "replace"),
|
||||
("/etc/systemd/system/felhom-shared-parent.service", "felhom-shared-parent.service", 0o644, "unit", "replace"),
|
||||
("/etc/systemd/system/felhom-crash-guard.service", "felhom-crash-guard.service", 0o644, "unit", "replace"),
|
||||
("/etc/systemd/system/felhom-crash-guard-check.service", "felhom-crash-guard-check.service", 0o644, "unit", "replace"),
|
||||
("/etc/systemd/system/felhom-crash-guard-check.timer", "felhom-crash-guard-check.timer", 0o644, "unit", "replace"),
|
||||
@@ -368,8 +378,12 @@ class Apply:
|
||||
|
||||
def check_plan(self, plan):
|
||||
mode = plan.get("mode", "apply")
|
||||
if mode not in ("apply", "inventory", "health", "facts", "live-restore-on", "bundle"):
|
||||
if mode not in ("apply", "inventory", "health", "facts", "live-restore-on", "bundle", "agent_update"):
|
||||
raise Refused("R11", f"unknown mode {mode!r}")
|
||||
if mode == "agent_update":
|
||||
if plan.get("layer") != "host":
|
||||
raise Refused("R11", "agent_update is a host-layer mode")
|
||||
return mode, "host", 0, "agent_update"
|
||||
if mode == "bundle":
|
||||
if plan.get("layer") != "host":
|
||||
raise Refused("R11", "bundle is a host-layer mode")
|
||||
@@ -517,6 +531,41 @@ class Apply:
|
||||
self.burn_nonce(nonce, exp)
|
||||
return op.get("params") or {}
|
||||
|
||||
def agent_update(self, plan):
|
||||
"""R-861 (agent v0.146.0): the agent binary is replaced ONLY by an operator-signed agent_update, checked HERE as
|
||||
root — signature against the root-owned signers file, op, this host, the time window, the nonce — and only the
|
||||
staged file whose sha256 the SIGNED params pin, at the one staging path. Before v0.146.0 the agent handed the
|
||||
sha to `felhom-selfupdate-guarded apply` itself, so a compromised agent could install any binary — and the
|
||||
binary is what FELHOM_ESCROW and the guest hook run as root. The nonce is burned only after the flip."""
|
||||
trust = self.load_trust()
|
||||
params, nonce, exp = self.verify_signed(plan.get("signed"), trust, op_name=SELFUPDATE_OP, burn=False)
|
||||
ver, sha = params.get("version"), params.get("sha256")
|
||||
if not isinstance(ver, str) or not re.match(r"^[0-9]+\.[0-9]+\.[0-9]+$", ver):
|
||||
raise Refused("R19", f"the signed version {ver!r} is not bare semver")
|
||||
if not isinstance(sha, str) or not re.match(r"^[0-9a-f]{64}$", sha):
|
||||
raise Refused("R19", "the signed sha256 is not 64 lowercase hex")
|
||||
staged = os.path.join(SELFUPDATE_DIR, "felhom-agent-" + ver)
|
||||
if plan.get("staged") != staged:
|
||||
raise Refused("R19", f"the staged binary must be {staged}, got {plan.get('staged')!r}")
|
||||
try:
|
||||
st = self.r.stat(staged)
|
||||
except OSError as e:
|
||||
raise Refused("R19", f"cannot stat the staged binary: {e}")
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_uid != self.r.agent_uid():
|
||||
raise Refused("R19", "the staged binary is not a regular file owned by the agent")
|
||||
got = sha256_hex(self.r.read_bytes(staged))
|
||||
if got != sha:
|
||||
raise Refused("R19", f"the staged binary's sha256 {got[:16]}… is not the signed {sha[:16]}…")
|
||||
self.r.log(f"os-apply: AGENT-UPDATE signed by the operator: version={ver} sha={sha[:16]} — handing to the A/B wrapper")
|
||||
rc, out, err = self.r.host([SELFUPDATE_WRAPPER, "apply", staged, sha], 120)
|
||||
self.report["agent_update"] = {"version": ver, "sha256": sha, "wrapper_rc": rc,
|
||||
"wrapper": (out + err).strip()[-300:]}
|
||||
if rc != 0:
|
||||
self.report["failed"] = {"rc": rc, "step": "agent_update", "reason": (out + err).strip()[-300:]}
|
||||
return 3
|
||||
self.burn_nonce(nonce, exp)
|
||||
return 0
|
||||
|
||||
def burn_nonce(self, nonce, exp):
|
||||
seen = self.r.read_nonces()
|
||||
if nonce in seen:
|
||||
@@ -858,6 +907,8 @@ class Apply:
|
||||
return self.facts()
|
||||
if self.mode == "bundle":
|
||||
return Bundle(self).from_plan(plan)
|
||||
if self.mode == "agent_update":
|
||||
return self.agent_update(plan)
|
||||
if self.layer == "host":
|
||||
self.check_appliance()
|
||||
self.check_guest(self.vmid)
|
||||
@@ -1365,6 +1416,11 @@ class Bundle:
|
||||
if rc != 2 or "usage" not in (out + err):
|
||||
raise Refused("R18", f"the installed felhom-selfupdate-guarded does not answer with its usage (rc={rc})")
|
||||
sc["selfupdate"] = "usage ok"
|
||||
if "/usr/local/sbin/felhom-priv-apply" in {p[0] for p in plan if p[3] != "skipped"}:
|
||||
rc, out, err = self.r.host(["/usr/bin/python3", "/usr/local/sbin/felhom-priv-apply", "--self-check"], 60)
|
||||
if rc != 0 or "felhom-priv-apply ok" not in out:
|
||||
raise Refused("R18", f"the installed felhom-priv-apply does not answer its self-check (rc={rc})")
|
||||
sc["priv_apply"] = out.strip()[:80]
|
||||
dests = {p[0] for p in plan if p[3] != "skipped"}
|
||||
if "/usr/local/sbin/felhom-crash-guard" in dests:
|
||||
rc, out, err = self.r.host(["/usr/local/sbin/felhom-crash-guard", "status"], 60)
|
||||
|
||||
Executable
+414
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/python3
|
||||
"""felhom-priv-apply — the ROOT half of every file the agent writes into a root-read place (R-861, `03` §3.1).
|
||||
|
||||
Install as /usr/local/sbin/felhom-priv-apply (0755 root:root) — it rides the signed config bundle (R-840).
|
||||
|
||||
WHY IT EXISTS. Until agent v0.146.0 the agent's sudoers let it `install` a file it had written itself into a place a
|
||||
root program reads: a systemd .mount unit (a bind mount of an agent-owned directory over /etc/sudoers.d is a root
|
||||
shell), a dnsmasq drop-in (`dhcp-script=` runs as root), the WireGuard config (`PostUp=` runs as root) and the OOB
|
||||
sshd config (`AuthorizedKeysFile` + `StrictModes no`). A compromised agent PROCESS was therefore root on its host.
|
||||
Now the agent stages the file and this wrapper — root-owned, delivered only by an operator-signed bundle — checks
|
||||
the CONTENT against the exact grammar the agent's own renderers produce, and refuses anything else. The agent can no
|
||||
longer name the destination: each verb has a fixed source and a fixed (or strictly named) destination.
|
||||
|
||||
Verbs (each one sudoers line, exact-match pattern):
|
||||
unit <name> /var/lib/felhom-agent/units/<name> -> /etc/systemd/system/<name> (.mount | .automount)
|
||||
dnsmasq <tmp> <name> /tmp/felhom-resolver-<digits>.conf -> /etc/dnsmasq.d/felhom-<...>.conf
|
||||
wg /var/lib/felhom-agent/wg/wg-felhom.conf -> /etc/wireguard/wg-felhom.conf (0600)
|
||||
sshd-config /var/lib/felhom-agent/felhom-sshd/sshd_config -> /etc/felhom-sshd/sshd_config
|
||||
sshd-key /var/lib/felhom-agent/felhom-sshd/authorized_keys.felhom-op -> /etc/felhom-sshd/authorized_keys/felhom-op
|
||||
--self-check prints "felhom-priv-apply ok verbs=..." (the bundle's self-check)
|
||||
|
||||
Exit codes: 0 installed (or already identical), 2 usage, 3 refused (content or source), 4 install failed.
|
||||
Every refusal is logged to the journal (tag felhom-priv-apply) with its rule; file CONTENT is never logged.
|
||||
Pinned by configs/test_felhom_priv_apply.py (one test per rule, red-proofs in the R-861 audit).
|
||||
"""
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
AGENT_USER = "felhom-agent"
|
||||
STATE = "/var/lib/felhom-agent"
|
||||
UNITS_SRC = STATE + "/units"
|
||||
UNIT_DIR = "/etc/systemd/system"
|
||||
DNSMASQ_DIR = "/etc/dnsmasq.d"
|
||||
WG_SRC, WG_DEST = STATE + "/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"
|
||||
SSHD_SRC, SSHD_DEST = STATE + "/felhom-sshd/sshd_config", "/etc/felhom-sshd/sshd_config"
|
||||
KEY_SRC, KEY_DEST = STATE + "/felhom-sshd/authorized_keys.felhom-op", "/etc/felhom-sshd/authorized_keys/felhom-op"
|
||||
MAX_BYTES = 64 * 1024
|
||||
VERBS = ("unit", "dnsmasq", "wg", "sshd-config", "sshd-key")
|
||||
|
||||
UNIT_NAME_RE = re.compile(r"^mnt-[A-Za-z0-9_.\\-]+\.(mount|automount)$")
|
||||
DNSMASQ_TMP_RE = re.compile(r"^/tmp/felhom-resolver-[0-9]+\.conf$")
|
||||
DNSMASQ_NAME_RE = re.compile(r"^felhom-[a-z0-9][a-z0-9._-]*\.conf$")
|
||||
SEG = r"[A-Za-z0-9_-][A-Za-z0-9_.-]*"
|
||||
WHERE_RE = re.compile(r"^/mnt/(felhom-drives/)?" + SEG + r"$")
|
||||
UUID_RE = re.compile(r"^[A-Fa-f0-9]{4,}(-[A-Fa-f0-9]+){0,4}$")
|
||||
HOST_RE = re.compile(r"^[A-Za-z0-9._:-]{1,255}$")
|
||||
NET_PATH_RE = re.compile(r"^[A-Za-z0-9._/@+-]{1,512}$")
|
||||
OPT_RE = re.compile(r"^[A-Za-z0-9_.:/@+-]+(=[A-Za-z0-9_.:/@+-]+)?$")
|
||||
LOCAL_TYPES = {"ext4", "xfs", "btrfs", "exfat", "vfat", "ntfs3", "ntfs"}
|
||||
NET_TYPES = {"nfs", "nfs4", "cifs"}
|
||||
# Options that turn a device mount into something else, or let set-uid/device files act on the host.
|
||||
FORBIDDEN_OPTS = {"bind", "rbind", "move", "rmove", "remount", "suid", "dev", "user", "users", "owner", "group",
|
||||
"x-mount.mkdir", "helper"}
|
||||
UNIT_TOKEN_RE = re.compile(r"^[A-Za-z0-9@_.\\:-]+$")
|
||||
DESC_RE = re.compile(r"^[^\x00-\x1f\x7f]{0,200}$")
|
||||
WG_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$")
|
||||
KEY_LINE_RE = re.compile(r"^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com) "
|
||||
r"[A-Za-z0-9+/]+={0,3}( [ -~]{0,200})?$")
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
def __init__(self, rule, reason):
|
||||
super().__init__(reason)
|
||||
self.rule, self.reason = rule, reason
|
||||
|
||||
|
||||
class Host:
|
||||
"""Every filesystem / process effect, so the tests can play the box in memory."""
|
||||
|
||||
def agent_uid(self):
|
||||
import pwd
|
||||
return pwd.getpwnam(AGENT_USER).pw_uid
|
||||
|
||||
def read_source(self, path):
|
||||
"""The staged file: a REGULAR file owned by the agent, never a symlink, at most MAX_BYTES."""
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
except OSError as e:
|
||||
raise Refused("P1", f"cannot open the staged file {path}: {e.strerror}")
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
raise Refused("P1", f"{path} is not a regular file")
|
||||
if st.st_uid != self.agent_uid():
|
||||
raise Refused("P1", f"{path} is not owned by {AGENT_USER}")
|
||||
if st.st_size > MAX_BYTES:
|
||||
raise Refused("P1", f"{path} is larger than {MAX_BYTES} bytes")
|
||||
with os.fdopen(fd, "rb") as f:
|
||||
fd = -1
|
||||
return f.read(MAX_BYTES + 1)
|
||||
finally:
|
||||
if fd >= 0:
|
||||
os.close(fd)
|
||||
|
||||
def read_dest(self, path):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def install(self, dest, data, mode):
|
||||
"""Atomic, root-owned: a temp file beside the destination, fsync, rename."""
|
||||
d = os.path.dirname(dest)
|
||||
os.makedirs(d, mode=0o755, exist_ok=True)
|
||||
tmp = os.path.join(d, f".{os.path.basename(dest)}.felhom-new.{os.getpid()}")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fchown(f.fileno(), 0, 0)
|
||||
os.fchmod(f.fileno(), mode)
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, dest)
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def log(self, line):
|
||||
print(line, file=sys.stderr)
|
||||
try:
|
||||
subprocess.run(["logger", "-t", "felhom-priv-apply", "--", line], timeout=10, check=False)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
|
||||
def systemd_escape_path(path):
|
||||
"""`systemd-escape --path`: strip the slashes at both ends, `/` -> `-`, every byte outside [A-Za-z0-9:_.] (and a
|
||||
leading `.`) -> `\\xNN`."""
|
||||
p = path.strip("/")
|
||||
out = []
|
||||
for i, ch in enumerate(p):
|
||||
if ch == "/":
|
||||
out.append("-")
|
||||
elif (ch.isascii() and (ch.isalnum() or ch in ":_.")) and not (i == 0 and ch == "."):
|
||||
out.append(ch)
|
||||
else:
|
||||
out.extend("\\x%02x" % b for b in ch.encode())
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def text_of(data, what):
|
||||
if len(data) > MAX_BYTES:
|
||||
raise Refused("P1", f"{what} is too large")
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise Refused("P2", f"{what} is not UTF-8 text")
|
||||
if "\x00" in text or "\r" in text:
|
||||
raise Refused("P2", f"{what} carries a NUL or CR byte")
|
||||
return text
|
||||
|
||||
|
||||
def parse_ini(text, what):
|
||||
"""[Section] / Key=Value / comments / blank lines. A key outside a section or a repeated key is refused."""
|
||||
sections, cur = {}, None
|
||||
for n, raw in enumerate(text.split("\n"), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or line.startswith(";"):
|
||||
continue
|
||||
m = re.match(r"^\[([A-Za-z]+)\]$", line)
|
||||
if m:
|
||||
cur = m.group(1)
|
||||
if cur in sections:
|
||||
raise Refused("U2", f"{what}: section [{cur}] twice")
|
||||
sections[cur] = {}
|
||||
continue
|
||||
if cur is None or "=" not in line:
|
||||
raise Refused("U2", f"{what}: line {n} is not Key=Value inside a section")
|
||||
k, v = line.split("=", 1)
|
||||
k, v = k.strip(), v.strip()
|
||||
if k in sections[cur]:
|
||||
raise Refused("U2", f"{what}: {cur}.{k} given twice")
|
||||
sections[cur][k] = v
|
||||
return sections
|
||||
|
||||
|
||||
# ---------- the unit verb ----------
|
||||
def check_unit(name, text):
|
||||
if not UNIT_NAME_RE.match(name) or "/" in name:
|
||||
raise Refused("U1", f"unit name {name!r} is not mnt-<escaped path>.mount|.automount")
|
||||
kind = "automount" if name.endswith(".automount") else "mount"
|
||||
s = parse_ini(text, name)
|
||||
body = "Automount" if kind == "automount" else "Mount"
|
||||
allowed = {"Unit": {"Description", "After", "Before", "Wants", "Requires"},
|
||||
body: {"Where", "TimeoutIdleSec"} if kind == "automount" else {"What", "Where", "Type", "Options"},
|
||||
"Install": {"WantedBy"}}
|
||||
for sec, keys in s.items():
|
||||
if sec not in allowed:
|
||||
raise Refused("U2", f"{name}: section [{sec}] is not allowed")
|
||||
bad = set(keys) - allowed[sec]
|
||||
if bad:
|
||||
raise Refused("U2", f"{name}: [{sec}] key(s) {sorted(bad)} not allowed")
|
||||
u = s.get("Unit", {})
|
||||
if not DESC_RE.match(u.get("Description", "")):
|
||||
raise Refused("U2", f"{name}: Description has control characters")
|
||||
for k in ("After", "Before", "Wants", "Requires"):
|
||||
if k in u and not all(UNIT_TOKEN_RE.match(t) for t in u[k].split()):
|
||||
raise Refused("U2", f"{name}: {k}= names something that is not a unit")
|
||||
inst = s.get("Install", {})
|
||||
if inst and inst.get("WantedBy") != "multi-user.target":
|
||||
raise Refused("U2", f"{name}: WantedBy must be multi-user.target")
|
||||
m = s.get(body)
|
||||
if not m or "Where" not in m:
|
||||
raise Refused("U3", f"{name}: no [{body}] Where=")
|
||||
where = m["Where"]
|
||||
if not WHERE_RE.match(where):
|
||||
raise Refused("U3", f"{name}: Where={where} is not /mnt/<name> or /mnt/felhom-drives/<name>")
|
||||
if systemd_escape_path(where) + "." + kind != name:
|
||||
raise Refused("U3", f"{name}: the unit name does not match Where={where}")
|
||||
if kind == "automount":
|
||||
t = m.get("TimeoutIdleSec", "")
|
||||
if t and not re.match(r"^[0-9]{1,6}$", t):
|
||||
raise Refused("U2", f"{name}: TimeoutIdleSec must be seconds")
|
||||
return
|
||||
what, typ = m.get("What", ""), m.get("Type", "")
|
||||
opts = [o for o in m.get("Options", "").split(",") if o]
|
||||
net = False
|
||||
mu = re.match(r"^/dev/disk/by-uuid/(.+)$", what)
|
||||
if mu:
|
||||
if not UUID_RE.match(mu.group(1)):
|
||||
raise Refused("U4", f"{name}: What= is not a filesystem UUID")
|
||||
if typ and typ not in LOCAL_TYPES:
|
||||
raise Refused("U4", f"{name}: Type={typ} is not a local filesystem")
|
||||
else:
|
||||
net = True
|
||||
if typ not in NET_TYPES:
|
||||
raise Refused("U4", f"{name}: What= is neither /dev/disk/by-uuid/<uuid> nor a network source with Type=nfs/nfs4/cifs")
|
||||
if typ == "cifs":
|
||||
mm = re.match(r"^//([^/]+)/(.+)$", what)
|
||||
else:
|
||||
mm = re.match(r"^([^/:][^:]*):(/.*)$", what)
|
||||
if not mm or not HOST_RE.match(mm.group(1)) or not NET_PATH_RE.match(mm.group(2)) or ".." in mm.group(2).split("/"):
|
||||
raise Refused("U4", f"{name}: What= is not a clean {typ} source")
|
||||
if not where.startswith("/mnt/felhom-drives/"):
|
||||
raise Refused("U3", f"{name}: a network share mounts only under /mnt/felhom-drives/")
|
||||
for o in opts:
|
||||
if not OPT_RE.match(o):
|
||||
raise Refused("U5", f"{name}: mount option {o!r} has characters a mount option never needs")
|
||||
if o.split("=", 1)[0].lower() in FORBIDDEN_OPTS or o.lower().startswith("x-mount."):
|
||||
raise Refused("U5", f"{name}: mount option {o.split('=', 1)[0]!r} is not allowed")
|
||||
if net and not {"nosuid", "nodev"} <= set(opts):
|
||||
# A network server is outside the box: a set-uid file on it must never run as root here.
|
||||
raise Refused("U5", f"{name}: a network share must carry nosuid,nodev")
|
||||
|
||||
|
||||
# ---------- dnsmasq ----------
|
||||
def _ip(v, v6=True):
|
||||
try:
|
||||
a = ipaddress.ip_address(v)
|
||||
except ValueError:
|
||||
return False
|
||||
return v6 or a.version == 4
|
||||
|
||||
|
||||
DOMAIN_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9-]{0,62})(\.[A-Za-z0-9]([A-Za-z0-9-]{0,62}))*$")
|
||||
|
||||
|
||||
def check_dnsmasq(text):
|
||||
for n, raw in enumerate(text.split("\n"), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line in ("bind-interfaces", "no-resolv"):
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
if k == "listen-address" and _ip(v, v6=False):
|
||||
continue
|
||||
if k == "server" and (_ip(v) or (v.count("#") == 1 and _ip(v.split("#")[0]) and v.split("#")[1].isdigit())):
|
||||
continue
|
||||
m = re.match(r"^/([^/]+)/$", v)
|
||||
if k == "local" and m and DOMAIN_RE.match(m.group(1)):
|
||||
continue
|
||||
m = re.match(r"^/([^/]+)/([^/]+)$", v)
|
||||
if k == "address" and m and DOMAIN_RE.match(m.group(1)) and _ip(m.group(2), v6=False):
|
||||
continue
|
||||
raise Refused("D1", f"dnsmasq line {n} ({k or line[:20]!r}) is not one the resolver writes")
|
||||
|
||||
|
||||
# ---------- WireGuard ----------
|
||||
def check_wg(text):
|
||||
s = parse_ini(text, "wg-felhom.conf")
|
||||
if set(s) != {"Interface", "Peer"}:
|
||||
raise Refused("W1", "wg-felhom.conf must hold exactly [Interface] and [Peer]")
|
||||
i, p = s["Interface"], s["Peer"]
|
||||
if set(i) - {"PrivateKey", "Address", "MTU"} or set(p) - {"PublicKey", "Endpoint", "AllowedIPs", "PersistentKeepalive"}:
|
||||
raise Refused("W1", "wg-felhom.conf carries a key the agent never writes (PostUp/PreUp/... run as root)")
|
||||
if not WG_KEY_RE.match(i.get("PrivateKey", "")) or not WG_KEY_RE.match(p.get("PublicKey", "")):
|
||||
raise Refused("W2", "a WireGuard key is not 32 bytes of base64")
|
||||
try:
|
||||
a = ipaddress.ip_network(i.get("Address", ""), strict=False)
|
||||
if a.version != 4 or a.prefixlen != 32:
|
||||
raise ValueError
|
||||
if not (1280 <= int(i.get("MTU", "1280")) <= 1500):
|
||||
raise ValueError
|
||||
host, _, port = p.get("Endpoint", "").rpartition(":")
|
||||
if ipaddress.ip_address(host).version != 4 or not (1 <= int(port) <= 65535):
|
||||
raise ValueError
|
||||
for n in p.get("AllowedIPs", "").split(","):
|
||||
if ipaddress.ip_network(n.strip(), strict=True).prefixlen != 32:
|
||||
raise ValueError
|
||||
if not (0 <= int(p.get("PersistentKeepalive", "25")) <= 3600):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise Refused("W2", "an Address/MTU/Endpoint/AllowedIPs/PersistentKeepalive value is not what the agent renders")
|
||||
|
||||
|
||||
# ---------- OOB sshd ----------
|
||||
def render_sshd(port):
|
||||
"""Byte-identical to felhomsshd.renderConfig (internal/felhomsshd/config.go) — pinned by a Go test."""
|
||||
return ("# felhom OOB sshd — agent-managed (H1); DO NOT EDIT\n"
|
||||
f"Port {port}\n"
|
||||
"ListenAddress 0.0.0.0\n"
|
||||
"ListenAddress ::\n"
|
||||
"HostKey /etc/felhom-sshd/ssh_host_ed25519_key\n"
|
||||
"PidFile /run/felhom-sshd.pid\n"
|
||||
"AuthorizedKeysFile /etc/felhom-sshd/authorized_keys/%u\n"
|
||||
"PasswordAuthentication no\n"
|
||||
"PermitRootLogin prohibit-password\n"
|
||||
"PubkeyAuthentication yes\n"
|
||||
"KbdInteractiveAuthentication no\n"
|
||||
"UsePAM yes\n"
|
||||
"AllowUsers root felhom-op\n"
|
||||
"X11Forwarding no\n"
|
||||
"Subsystem sftp internal-sftp\n")
|
||||
|
||||
|
||||
def check_sshd(text):
|
||||
m = re.search(r"^Port ([0-9]{1,5})$", text, re.M)
|
||||
if not m or not (1 <= int(m.group(1)) <= 65535) or int(m.group(1)) == 22:
|
||||
raise Refused("S1", "sshd_config has no Port (or claims :22, the household's sshd)")
|
||||
if text != render_sshd(int(m.group(1))):
|
||||
raise Refused("S1", "sshd_config differs from the one fixed template (only the Port may vary)")
|
||||
|
||||
|
||||
def check_key(text):
|
||||
lines = [l for l in text.split("\n") if l.strip()]
|
||||
if len(lines) > 1:
|
||||
raise Refused("S2", "felhom-op's authorized_keys holds more than one key")
|
||||
if lines and not KEY_LINE_RE.match(lines[0]):
|
||||
raise Refused("S2", "the key line is not a plain public key (no options such as command= or from=)")
|
||||
|
||||
|
||||
# ---------- main ----------
|
||||
def plan(argv):
|
||||
"""(verb, source, dest, mode, checker) for an argv, or Refused("A1")."""
|
||||
if not argv or argv[0] not in VERBS:
|
||||
raise Refused("A1", "usage: felhom-priv-apply unit <name> | dnsmasq <tmp> <name> | wg | sshd-config | sshd-key")
|
||||
v, rest = argv[0], argv[1:]
|
||||
if v == "unit" and len(rest) == 1:
|
||||
if not UNIT_NAME_RE.match(rest[0]):
|
||||
raise Refused("U1", f"unit name {rest[0]!r} is not mnt-<escaped path>.mount|.automount")
|
||||
return v, os.path.join(UNITS_SRC, rest[0]), os.path.join(UNIT_DIR, rest[0]), 0o644, lambda t: check_unit(rest[0], t)
|
||||
if v == "dnsmasq" and len(rest) == 2:
|
||||
if not DNSMASQ_TMP_RE.match(rest[0]) or not DNSMASQ_NAME_RE.match(rest[1]):
|
||||
raise Refused("D2", "dnsmasq wants /tmp/felhom-resolver-<digits>.conf and felhom-<name>.conf")
|
||||
return v, rest[0], os.path.join(DNSMASQ_DIR, rest[1]), 0o644, check_dnsmasq
|
||||
if v == "wg" and not rest:
|
||||
return v, WG_SRC, WG_DEST, 0o600, check_wg
|
||||
if v == "sshd-config" and not rest:
|
||||
return v, SSHD_SRC, SSHD_DEST, 0o644, check_sshd
|
||||
if v == "sshd-key" and not rest:
|
||||
return v, KEY_SRC, KEY_DEST, 0o644, check_key
|
||||
raise Refused("A1", f"wrong arguments for {v}")
|
||||
|
||||
|
||||
def main(argv, host=None):
|
||||
host = host or Host()
|
||||
if argv == ["--self-check"]:
|
||||
print("felhom-priv-apply ok verbs=" + ",".join(VERBS))
|
||||
return 0
|
||||
if len(argv) >= 3 and argv[0] == "--check":
|
||||
# CHECK ONLY (tests and the Go contract tests): `--check <verb> [<name>] <file>` validates <file> as that
|
||||
# verb would and installs nothing. Not in sudoers. Prints OK or the rule; never the content.
|
||||
verb, file = argv[1], argv[-1]
|
||||
try:
|
||||
_, _, _, _, checker = plan([verb] + argv[2:-1] if verb != "dnsmasq" else
|
||||
[verb, "/tmp/felhom-resolver-1.conf", argv[2]])
|
||||
with open(file, "rb") as f:
|
||||
checker(text_of(f.read(), file))
|
||||
except Refused as e:
|
||||
print(f"REFUSED [{e.rule}] {e.reason}")
|
||||
return 3
|
||||
print("OK")
|
||||
return 0
|
||||
try:
|
||||
verb, src, dest, mode, checker = plan(argv)
|
||||
data = host.read_source(src)
|
||||
checker(text_of(data, src))
|
||||
except Refused as e:
|
||||
host.log(f"felhom-priv-apply: REFUSED [{e.rule}] {' '.join(argv)[:160]}: {e.reason}")
|
||||
return 2 if e.rule == "A1" else 3
|
||||
if host.read_dest(dest) == data:
|
||||
host.log(f"felhom-priv-apply: SAME {verb} {dest}")
|
||||
return 0
|
||||
try:
|
||||
host.install(dest, data, mode)
|
||||
except OSError as e:
|
||||
host.log(f"felhom-priv-apply: FAILED {verb} {dest}: {e}")
|
||||
return 4
|
||||
host.log(f"felhom-priv-apply: INSTALLED {verb} {dest} ({len(data)} bytes)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Felhom stable drive parent (shared bind for live drive hot-swap)
|
||||
After=local-fs.target
|
||||
Before=pve-guests.service
|
||||
ConditionPathExists=/usr/local/sbin/felhom-shared-parent.sh
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/sbin/felhom-shared-parent.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=pve-guests.service multi-user.target
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# felhom stable drive parent: a SHARED bind so the agent can swap backing drives underneath it and the
|
||||
# guest sees the change live (no restart). MUST run before pve-guests so the guest's parent bind inherits
|
||||
# the shared peer group (slave). Installed + enabled by felhom-agent. Idempotent.
|
||||
set -e
|
||||
mkdir -p /mnt/felhom-drives
|
||||
# Isolate + share ONLY when first creating the self-bind (a fresh boot). The self-bind inherits the root
|
||||
# mount's shared peer group, so make-private detaches it (else binds under it DOUBLE via the root peer),
|
||||
# then make-shared gives it its own group whose only slave is the guest's parent bind. Re-running this on
|
||||
# an existing parent would churn the peer-group id and orphan the guest's slave — so guard on mountpoint.
|
||||
if ! mountpoint -q /mnt/felhom-drives; then
|
||||
mount --bind /mnt/felhom-drives /mnt/felhom-drives
|
||||
mount --make-private /mnt/felhom-drives
|
||||
mount --make-shared /mnt/felhom-drives
|
||||
fi
|
||||
@@ -129,6 +129,9 @@ class Box:
|
||||
return (1, "", "parse error") if self.visudo_fail else (0, "ok", "")
|
||||
if argv[:2] == ["sudo", "-n"]:
|
||||
return 0, self.sudo_l, ""
|
||||
if argv[-2:] == ["/usr/local/sbin/felhom-priv-apply", "--self-check"]:
|
||||
body = self.files.get("/usr/local/sbin/felhom-priv-apply", b"")
|
||||
return (0, "felhom-priv-apply ok verbs=unit\n", "") if b"VERBS" in body else (1, "", "boom")
|
||||
if argv[-1] == "--self-check":
|
||||
body = self.files.get("/usr/local/sbin/felhom-os-apply", b"")
|
||||
return (0, "felhom-os-apply ok bundle-format=1 files=22\n", "") if b"BUNDLE_OP" in body else (1, "", "boom")
|
||||
@@ -541,5 +544,91 @@ class Builder(unittest.TestCase):
|
||||
self.assertIn("/etc/systemd/system/felhom-agent.service.d/felhom-agent-limits.conf", osapply.BUNDLE_DESTS)
|
||||
|
||||
|
||||
|
||||
# ---------- R-861 (agent v0.146.0): the agent binary only by an operator-signed agent_update, checked as root ----------
|
||||
STAGED = "/var/lib/felhom-agent/selfupdate/felhom-agent-0.146.0"
|
||||
NEW_BIN = b"\x7fELF the new agent"
|
||||
|
||||
|
||||
def update_job(sha, version="0.146.0", op="agent_update", nonce="u1", host=HOST):
|
||||
blob = json.dumps({"expires_at": "2026-10-04T12:30:00Z", "issued_at": "2026-10-04T11:50:00Z", "key_id": "felhom-op-1",
|
||||
"nonce": nonce, "op": op, "params": {"sha256": sha, "version": version},
|
||||
"target": {"guest_id": "", "host_id": host}}, sort_keys=True).encode()
|
||||
return {"blob_b64": base64.b64encode(blob).decode(), "sig": "-----BEGIN SSH SIGNATURE-----\nx\n-----END SSH SIGNATURE-----\n"}
|
||||
|
||||
|
||||
def update_box(job, staged=STAGED, content=NEW_BIN):
|
||||
box = Box(b"{}", None)
|
||||
box.put(PLAN, json.dumps({"release_id": "agent-0.146.0", "layer": "host", "mode": "agent_update",
|
||||
"signed": job, "staged": staged}).encode(), 0o600, uid=999)
|
||||
box.put(STAGED, content, 0o755, uid=999)
|
||||
return box
|
||||
|
||||
|
||||
def wrapper_calls(box):
|
||||
return [c for c in box.calls if c and c[0] == osapply.SELFUPDATE_WRAPPER and c[1:2] == ["apply"]]
|
||||
|
||||
|
||||
class AgentUpdate(unittest.TestCase):
|
||||
"""RED-PROOF: make agent_update skip verify_signed → test_a_bad_signature_never_reaches_the_wrapper fails."""
|
||||
|
||||
def test_signed_update_flips_and_burns_the_nonce(self):
|
||||
sha = hashlib.sha256(NEW_BIN).hexdigest()
|
||||
box = update_box(update_job(sha))
|
||||
rc, rep = run(box)
|
||||
self.assertEqual(rc, 0, rep)
|
||||
self.assertEqual(wrapper_calls(box), [[osapply.SELFUPDATE_WRAPPER, "apply", STAGED, sha]])
|
||||
self.assertIn("u1", box.nonces)
|
||||
self.assertEqual(rep["agent_update"]["version"], "0.146.0")
|
||||
|
||||
def test_a_bad_signature_never_reaches_the_wrapper(self):
|
||||
sha = hashlib.sha256(NEW_BIN).hexdigest()
|
||||
box = update_box(update_job(sha))
|
||||
box.sig_rc = 1
|
||||
rc, rep = run(box)
|
||||
self.assertTrue(rep.get("refused"), f"a job whose signature does not verify was NOT refused: {rep}")
|
||||
self.assertEqual((rc, rep["refused"]["code"]), (2, "R3"), rep)
|
||||
self.assertEqual(wrapper_calls(box), [])
|
||||
|
||||
def test_a_staged_binary_the_signature_does_not_pin_is_refused(self):
|
||||
sha = hashlib.sha256(NEW_BIN).hexdigest()
|
||||
box = update_box(update_job(sha), content=b"\x7fELF something the agent put there")
|
||||
rc, rep = run(box)
|
||||
self.assertEqual((rc, rep["refused"]["code"]), (2, "R19"), rep)
|
||||
self.assertEqual(wrapper_calls(box), [])
|
||||
self.assertNotIn("u1", box.nonces, "a refused job keeps its nonce (the operator fixes the file, not the key)")
|
||||
|
||||
def test_another_staging_path_is_refused(self):
|
||||
sha = hashlib.sha256(NEW_BIN).hexdigest()
|
||||
box = update_box(update_job(sha), staged="/tmp/felhom-agent-0.146.0")
|
||||
rc, rep = run(box)
|
||||
self.assertEqual((rc, rep["refused"]["code"]), (2, "R19"), rep)
|
||||
self.assertEqual(wrapper_calls(box), [])
|
||||
|
||||
def test_a_bundle_job_is_not_an_agent_update(self):
|
||||
sha = hashlib.sha256(NEW_BIN).hexdigest()
|
||||
box = update_box(update_job(sha, op="agent_config_update"))
|
||||
rc, rep = run(box)
|
||||
self.assertEqual((rc, rep["refused"]["code"]), (2, "R3"), rep)
|
||||
|
||||
def test_another_hosts_job_and_a_replay_are_refused(self):
|
||||
sha = hashlib.sha256(NEW_BIN).hexdigest()
|
||||
box = update_box(update_job(sha, host="tester-1-d70be4"))
|
||||
self.assertEqual(run(box)[1]["refused"]["code"], "R3")
|
||||
box2 = update_box(update_job(sha))
|
||||
box2.nonces["u1"] = 1999999999
|
||||
self.assertEqual(run(box2)[1]["refused"]["code"], "R3")
|
||||
self.assertEqual(wrapper_calls(box) + wrapper_calls(box2), [])
|
||||
|
||||
def test_a_failed_flip_keeps_the_nonce(self):
|
||||
sha = hashlib.sha256(NEW_BIN).hexdigest()
|
||||
box = update_box(update_job(sha))
|
||||
orig = box.host
|
||||
box.host = lambda argv, timeout=600, stdin=None: (1, "", "refusing apply: sha mismatch") if argv[:1] == [osapply.SELFUPDATE_WRAPPER] else orig(argv, timeout, stdin)
|
||||
rc, rep = run(box)
|
||||
self.assertEqual(rc, 3, rep)
|
||||
self.assertNotIn("u1", box.nonces)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for felhom-priv-apply (R-861, `03` §3.1). An in-memory host plays the files; nothing real is written or run.
|
||||
Each refusal rule has a test that feeds it the ATTACK it exists for; each accepted shape is a file the agent really
|
||||
renders (the Go contract tests feed the live renderers too). Red-proof: audits/hub-safety-2026-10-05/partF/.
|
||||
|
||||
Run: python3 configs/test_felhom_priv_apply.py (also run by internal/privapply's Go test)
|
||||
"""
|
||||
import sys
|
||||
sys.dont_write_bytecode = True
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
_loader = importlib.machinery.SourceFileLoader("privapply", os.environ.get("PRIVAPPLY_UNDER_TEST", str(HERE / "felhom-priv-apply")))
|
||||
_spec = importlib.util.spec_from_loader("privapply", _loader)
|
||||
pa = importlib.util.module_from_spec(_spec)
|
||||
_loader.exec_module(pa)
|
||||
|
||||
AGENT_UID = 999
|
||||
|
||||
|
||||
class FakeHost:
|
||||
def __init__(self):
|
||||
self.src, self.dest, self.logs, self.writes = {}, {}, [], []
|
||||
self.owner, self.kind = {}, {}
|
||||
|
||||
def stage(self, path, text, uid=AGENT_UID, kind="file"):
|
||||
self.src[path] = text.encode() if isinstance(text, str) else text
|
||||
self.owner[path], self.kind[path] = uid, kind
|
||||
|
||||
def agent_uid(self):
|
||||
return AGENT_UID
|
||||
|
||||
def read_source(self, path):
|
||||
# mirrors Host.read_source's refusals: absent, not a regular file (a symlink is refused by O_NOFOLLOW), owner, size
|
||||
if path not in self.src:
|
||||
raise pa.Refused("P1", f"cannot open the staged file {path}")
|
||||
if self.kind[path] != "file":
|
||||
raise pa.Refused("P1", f"{path} is not a regular file")
|
||||
if self.owner[path] != AGENT_UID:
|
||||
raise pa.Refused("P1", f"{path} is not owned by felhom-agent")
|
||||
if len(self.src[path]) > pa.MAX_BYTES:
|
||||
raise pa.Refused("P1", f"{path} is larger than {pa.MAX_BYTES} bytes")
|
||||
return self.src[path]
|
||||
|
||||
def read_dest(self, path):
|
||||
return self.dest.get(path)
|
||||
|
||||
def install(self, dest, data, mode):
|
||||
self.writes.append((dest, mode))
|
||||
self.dest[dest] = data
|
||||
|
||||
def log(self, line):
|
||||
self.logs.append(line)
|
||||
|
||||
|
||||
LOCAL_UNIT = """# Managed by felhom-agent — do not edit by hand.
|
||||
[Unit]
|
||||
Description=Felhom storage mount 91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae
|
||||
After=local-fs-pre.target
|
||||
|
||||
[Mount]
|
||||
What=/dev/disk/by-uuid/91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae
|
||||
Where=/mnt/hdd_1
|
||||
Type=ext4
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
NET_MOUNT = """# felhom network storage — do not edit by hand.
|
||||
[Unit]
|
||||
Description=Felhom network storage media (nfs)
|
||||
|
||||
[Mount]
|
||||
What=nas.lan:/volume1/media
|
||||
Where=/mnt/felhom-drives/media
|
||||
Type=nfs4
|
||||
Options=vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0,nosuid,nodev
|
||||
"""
|
||||
NET_AUTOMOUNT = """# felhom network storage — do not edit by hand.
|
||||
[Unit]
|
||||
Description=Felhom network storage automount media (nfs)
|
||||
|
||||
[Automount]
|
||||
Where=/mnt/felhom-drives/media
|
||||
TimeoutIdleSec=600
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
NET_NAME = "mnt-felhom\\x2ddrives-media.mount"
|
||||
DNS_BASE = """# felhom split-horizon resolver — host base config (agent-managed; DO NOT EDIT)
|
||||
bind-interfaces
|
||||
listen-address=192.168.0.104
|
||||
listen-address=127.0.0.1
|
||||
no-resolv
|
||||
server=1.1.1.1
|
||||
server=9.9.9.9
|
||||
"""
|
||||
DNS_GUEST = """# felhom split-horizon DNS — customer demo-hp (agent-managed; DO NOT EDIT)
|
||||
local=/enkisfelhom.hu/
|
||||
address=/enkisfelhom.hu/192.168.0.138
|
||||
"""
|
||||
K = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" # base64 of 32 bytes
|
||||
WG = f"""# felhom offsite tunnel — agent-managed (S3); DO NOT EDIT
|
||||
[Interface]
|
||||
PrivateKey = {K}
|
||||
Address = 10.77.0.3/32
|
||||
MTU = 1280
|
||||
|
||||
[Peer]
|
||||
PublicKey = {K}
|
||||
Endpoint = 49.12.1.2:51820
|
||||
AllowedIPs = 10.77.0.1/32, 10.77.0.250/32
|
||||
PersistentKeepalive = 25
|
||||
"""
|
||||
KEYLINE = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL8z0qCNgA3x2xxAB0Qj5ro8waFjGZ8Ta/sWB63tlLw+ felhom-op-1\n"
|
||||
|
||||
|
||||
def run(host, *argv):
|
||||
return pa.main(list(argv), host=host)
|
||||
|
||||
|
||||
class Accepts(unittest.TestCase):
|
||||
"""What the agent really writes is accepted and installed, root-owned, at the fixed destination."""
|
||||
|
||||
def test_local_unit_installed(self):
|
||||
h = FakeHost()
|
||||
h.stage("/var/lib/felhom-agent/units/mnt-hdd_1.mount", LOCAL_UNIT)
|
||||
self.assertEqual(run(h, "unit", "mnt-hdd_1.mount"), 0)
|
||||
self.assertEqual(h.writes, [("/etc/systemd/system/mnt-hdd_1.mount", 0o644)])
|
||||
|
||||
def test_local_unit_without_type(self): # the N100's unit has no Type= (autodetect)
|
||||
h = FakeHost()
|
||||
h.stage("/var/lib/felhom-agent/units/mnt-hdd_1.mount", LOCAL_UNIT.replace("Type=ext4\n", ""))
|
||||
self.assertEqual(run(h, "unit", "mnt-hdd_1.mount"), 0)
|
||||
|
||||
def test_network_pair(self):
|
||||
h = FakeHost()
|
||||
h.stage("/var/lib/felhom-agent/units/" + NET_NAME, NET_MOUNT)
|
||||
h.stage("/var/lib/felhom-agent/units/mnt-felhom\\x2ddrives-media.automount", NET_AUTOMOUNT)
|
||||
self.assertEqual(run(h, "unit", NET_NAME), 0)
|
||||
self.assertEqual(run(h, "unit", "mnt-felhom\\x2ddrives-media.automount"), 0)
|
||||
|
||||
def test_identical_is_not_rewritten(self):
|
||||
h = FakeHost()
|
||||
h.stage("/var/lib/felhom-agent/units/mnt-hdd_1.mount", LOCAL_UNIT)
|
||||
h.dest["/etc/systemd/system/mnt-hdd_1.mount"] = LOCAL_UNIT.encode()
|
||||
self.assertEqual(run(h, "unit", "mnt-hdd_1.mount"), 0)
|
||||
self.assertEqual(h.writes, [])
|
||||
|
||||
def test_dnsmasq_both_dropins(self):
|
||||
h = FakeHost()
|
||||
h.stage("/tmp/felhom-resolver-123.conf", DNS_BASE)
|
||||
self.assertEqual(run(h, "dnsmasq", "/tmp/felhom-resolver-123.conf", "felhom-resolver-base.conf"), 0)
|
||||
h.stage("/tmp/felhom-resolver-124.conf", DNS_GUEST)
|
||||
self.assertEqual(run(h, "dnsmasq", "/tmp/felhom-resolver-124.conf", "felhom-demo-hp.conf"), 0)
|
||||
self.assertIn(("/etc/dnsmasq.d/felhom-demo-hp.conf", 0o644), h.writes)
|
||||
|
||||
def test_wg_installed_0600(self):
|
||||
h = FakeHost()
|
||||
h.stage(pa.WG_SRC, WG)
|
||||
self.assertEqual(run(h, "wg"), 0)
|
||||
self.assertEqual(h.writes, [(pa.WG_DEST, 0o600)])
|
||||
|
||||
def test_sshd_template_and_key(self):
|
||||
h = FakeHost()
|
||||
h.stage(pa.SSHD_SRC, pa.render_sshd(8822))
|
||||
h.stage(pa.KEY_SRC, KEYLINE)
|
||||
self.assertEqual(run(h, "sshd-config"), 0)
|
||||
self.assertEqual(run(h, "sshd-key"), 0)
|
||||
h.stage(pa.KEY_SRC, "") # clearing the operator login is allowed
|
||||
self.assertEqual(run(h, "sshd-key"), 0)
|
||||
|
||||
def test_escape_matches_systemd(self):
|
||||
self.assertEqual(pa.systemd_escape_path("/mnt/felhom-drives/media"), "mnt-felhom\\x2ddrives-media")
|
||||
self.assertEqual(pa.systemd_escape_path("/mnt/hdd_1"), "mnt-hdd_1")
|
||||
self.assertEqual(pa.systemd_escape_path("/mnt/.x"), "mnt-.x") # a dot is escaped only at the very start
|
||||
|
||||
|
||||
class Refuses(unittest.TestCase):
|
||||
"""Each rule, with the attack it exists for. Nothing is written on a refusal."""
|
||||
|
||||
def refused(self, h, argv, rule):
|
||||
rc = run(h, *argv)
|
||||
self.assertIn(rc, (2, 3), f"{argv} was accepted")
|
||||
self.assertEqual(h.writes, [], f"{argv} wrote something")
|
||||
self.assertTrue(any(f"[{rule}]" in l for l in h.logs), f"{argv}: rule {rule} not logged: {h.logs}")
|
||||
|
||||
def unit(self, text, name="mnt-hdd_1.mount"):
|
||||
h = FakeHost()
|
||||
h.stage("/var/lib/felhom-agent/units/" + name, text)
|
||||
return h, ["unit", name]
|
||||
|
||||
def test_U1_name_outside_mnt(self):
|
||||
h = FakeHost()
|
||||
self.refused(h, ["unit", "etc-sudoers.d.mount"], "U1")
|
||||
self.refused(h, ["unit", "../../etc/x.mount"], "U1")
|
||||
self.refused(h, ["unit", "mnt-x.service"], "U1")
|
||||
|
||||
def test_U2_service_section(self):
|
||||
self.refused(*self.unit(LOCAL_UNIT + "\n[Service]\nExecStart=/bin/sh -c id\n"), "U2")
|
||||
|
||||
def test_U2_unknown_key(self):
|
||||
self.refused(*self.unit(LOCAL_UNIT.replace("Type=ext4", "Type=ext4\nDirectoryMode=0777")), "U2")
|
||||
|
||||
def test_U3_bind_over_sudoers_dir(self):
|
||||
# the R-861 attack: mount an agent-owned directory over /etc/sudoers.d
|
||||
t = LOCAL_UNIT.replace("Where=/mnt/hdd_1", "Where=/etc/sudoers.d")
|
||||
self.refused(*self.unit(t, "mnt-hdd_1.mount"), "U3")
|
||||
|
||||
def test_U3_name_must_match_where(self):
|
||||
self.refused(*self.unit(LOCAL_UNIT.replace("Where=/mnt/hdd_1", "Where=/mnt/other")), "U3")
|
||||
|
||||
def test_U3_traversal_in_where(self):
|
||||
# the name passes U1 and equals the escaped Where — ONLY the Where rule stops a mount at /mnt/../etc = /etc
|
||||
self.assertEqual(pa.systemd_escape_path("/mnt/../etc") + ".mount", "mnt-..-etc.mount")
|
||||
self.refused(*self.unit(LOCAL_UNIT.replace("Where=/mnt/hdd_1", "Where=/mnt/../etc"), "mnt-..-etc.mount"), "U3")
|
||||
|
||||
def test_U4_what_is_an_agent_directory(self):
|
||||
t = LOCAL_UNIT.replace("What=/dev/disk/by-uuid/91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae", "What=/var/lib/felhom-agent/evil")
|
||||
self.refused(*self.unit(t), "U4")
|
||||
|
||||
def test_U4_tmpfs(self):
|
||||
self.refused(*self.unit(LOCAL_UNIT.replace("Type=ext4", "Type=tmpfs")), "U4")
|
||||
|
||||
def test_U5_bind_option(self):
|
||||
self.refused(*self.unit(LOCAL_UNIT.replace("Type=ext4", "Type=ext4\nOptions=bind")), "U5")
|
||||
|
||||
def test_U5_network_without_nosuid(self):
|
||||
t = NET_MOUNT.replace(",nosuid,nodev", "")
|
||||
self.refused(*self.unit(t, NET_NAME), "U5")
|
||||
|
||||
def test_U5_suid_option(self):
|
||||
self.refused(*self.unit(LOCAL_UNIT.replace("Type=ext4", "Type=ext4\nOptions=suid,dev")), "U5")
|
||||
|
||||
def test_U3_network_outside_drives(self):
|
||||
t = NET_MOUNT.replace("/mnt/felhom-drives/media", "/mnt/media")
|
||||
self.refused(*self.unit(t, "mnt-media.mount"), "U3")
|
||||
|
||||
def test_D1_dhcp_script(self): # runs as root
|
||||
h = FakeHost()
|
||||
h.stage("/tmp/felhom-resolver-1.conf", DNS_BASE + "dhcp-script=/var/lib/felhom-agent/x.sh\n")
|
||||
self.refused(h, ["dnsmasq", "/tmp/felhom-resolver-1.conf", "felhom-x.conf"], "D1")
|
||||
|
||||
def test_D1_conf_dir_and_log_file(self):
|
||||
for extra in ("conf-dir=/var/lib/felhom-agent\n", "log-facility=/etc/sudoers.d/x\n", "user=root\n"):
|
||||
h = FakeHost()
|
||||
h.stage("/tmp/felhom-resolver-1.conf", DNS_GUEST + extra)
|
||||
self.refused(h, ["dnsmasq", "/tmp/felhom-resolver-1.conf", "felhom-x.conf"], "D1")
|
||||
|
||||
def test_D2_paths(self):
|
||||
h = FakeHost()
|
||||
self.refused(h, ["dnsmasq", "/etc/shadow", "felhom-x.conf"], "D2")
|
||||
self.refused(h, ["dnsmasq", "/tmp/felhom-resolver-1.conf", "../sudoers.d/x.conf"], "D2")
|
||||
|
||||
def test_W1_postup(self): # wg-quick runs PostUp as root
|
||||
h = FakeHost()
|
||||
h.stage(pa.WG_SRC, WG.replace("MTU = 1280", "MTU = 1280\nPostUp = /bin/sh -c id"))
|
||||
self.refused(h, ["wg"], "W1")
|
||||
|
||||
def test_W2_values(self):
|
||||
h = FakeHost()
|
||||
h.stage(pa.WG_SRC, WG.replace("AllowedIPs = 10.77.0.1/32, 10.77.0.250/32", "AllowedIPs = 0.0.0.0/0"))
|
||||
self.refused(h, ["wg"], "W2")
|
||||
|
||||
def test_S1_sshd_strictmodes(self): # an AuthorizedKeysFile the agent owns + StrictModes no = root login
|
||||
h = FakeHost()
|
||||
h.stage(pa.SSHD_SRC, pa.render_sshd(8822) + "StrictModes no\n")
|
||||
self.refused(h, ["sshd-config"], "S1")
|
||||
h2 = FakeHost()
|
||||
h2.stage(pa.SSHD_SRC, pa.render_sshd(8822).replace("/etc/felhom-sshd/authorized_keys/%u", "/var/lib/felhom-agent/k"))
|
||||
self.refused(h2, ["sshd-config"], "S1")
|
||||
|
||||
def test_S1_port_22(self):
|
||||
h = FakeHost()
|
||||
h.stage(pa.SSHD_SRC, pa.render_sshd(22))
|
||||
self.refused(h, ["sshd-config"], "S1")
|
||||
|
||||
def test_S2_key_options_and_two_keys(self):
|
||||
h = FakeHost()
|
||||
h.stage(pa.KEY_SRC, 'command="/bin/sh" ' + KEYLINE)
|
||||
self.refused(h, ["sshd-key"], "S2")
|
||||
h2 = FakeHost()
|
||||
h2.stage(pa.KEY_SRC, KEYLINE + KEYLINE)
|
||||
self.refused(h2, ["sshd-key"], "S2")
|
||||
|
||||
def test_P1_symlink_owner_size(self):
|
||||
h = FakeHost()
|
||||
h.stage(pa.WG_SRC, WG, kind="symlink")
|
||||
self.refused(h, ["wg"], "P1")
|
||||
h2 = FakeHost()
|
||||
h2.stage(pa.WG_SRC, WG, uid=0)
|
||||
self.refused(h2, ["wg"], "P1")
|
||||
h3 = FakeHost()
|
||||
h3.stage(pa.WG_SRC, "#" * (pa.MAX_BYTES + 1))
|
||||
self.refused(h3, ["wg"], "P1")
|
||||
|
||||
def test_A1_usage(self):
|
||||
h = FakeHost()
|
||||
self.refused(h, ["install", "/etc/shadow"], "A1")
|
||||
self.refused(h, ["wg", "/etc/shadow"], "A1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -81,10 +81,8 @@ var manifest = []Capability{
|
||||
{"drives-mkdir-sub", "per-drive stable dir create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-drives/felhom-usb"}, false, ""},
|
||||
{"drives-mkdir-data", "felhom-data namespace create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-usb/felhom-data"}, false, ""},
|
||||
{"drives-chown-data", "felhom-data guest-root chown", "/usr/bin/chown", []string{"100000:100000", "/mnt/felhom-usb/felhom-data"}, false, ""},
|
||||
{"parent-script-install", "shared-parent boot script install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-shared-parent-123456789.sh", "/usr/local/sbin/felhom-shared-parent.sh"}, false, ""},
|
||||
{"parent-unit-install", "shared-parent boot unit install", "/usr/bin/install", []string{"-m", "0644", "--", "/tmp/felhom-shared-parent-123456789.service", "/etc/systemd/system/felhom-shared-parent.service"}, false, ""},
|
||||
{"parent-unit-enable", "shared-parent boot-persistence enable", "/usr/bin/systemctl", []string{"enable", "felhom-shared-parent.service"}, false, ""},
|
||||
{"parent-bind-mp8", "parent bind into guest at provision", "/usr/sbin/pct", []string{"set", "9201", "-mp8", "/mnt/felhom-drives"}, false, ""},
|
||||
{"parent-bind-mp8", "parent bind into guest at provision", "/usr/sbin/pct", []string{"set", "9201", "-mp8", "/mnt/felhom-drives,mp=/mnt/felhom-drives"}, false, ""},
|
||||
|
||||
// ---- Disk inspect / format gate (Critical: the data-bearing classifier + format) ----
|
||||
{"disk-blkid", "disk data-bearing classify (format gate)", "/usr/sbin/blkid", []string{"-p", "-o", "export", "/dev/sda"}, true, ""},
|
||||
@@ -95,11 +93,11 @@ var manifest = []Capability{
|
||||
{"disk-lvs", "thin-pool usage read", "/usr/sbin/lvs", []string{"--reportformat", "json", "--units", "b", "-o", "lv_name,data_percent,metadata_percent", "--", "pve/data"}, false, ""},
|
||||
|
||||
// ---- Storage mount units (watchdog re-mount) ----
|
||||
{"mount-unit-install", "fs-UUID mount unit install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0644", "--", "/var/lib/felhom-agent/units/felhom-x.mount", "/etc/systemd/system/felhom-x.mount"}, false, ""},
|
||||
{"mount-unit-install", "fs-UUID mount unit install (root content check, R-861)", "/usr/local/sbin/felhom-priv-apply", []string{"unit", "mnt-felhom\\x2dx.mount"}, false, ""},
|
||||
{"mount-daemon-reload", "systemd reload after unit write", "/usr/bin/systemctl", []string{"daemon-reload"}, false, ""},
|
||||
{"mount-unit-enable", "mount unit enable", "/usr/bin/systemctl", []string{"enable", "--now", "--", "felhom-x.mount"}, false, ""},
|
||||
{"mount-unit-disable", "mount unit disable", "/usr/bin/systemctl", []string{"disable", "--", "felhom-x.mount"}, false, ""},
|
||||
{"mount-unit-stop", "mount unit stop", "/usr/bin/systemctl", []string{"stop", "--", "felhom-x.mount"}, false, ""},
|
||||
{"mount-unit-enable", "mount unit enable", "/usr/bin/systemctl", []string{"enable", "--now", "--", "mnt-felhom\\x2dx.mount"}, false, ""},
|
||||
{"mount-unit-disable", "mount unit disable", "/usr/bin/systemctl", []string{"disable", "--", "mnt-felhom\\x2dx.mount"}, false, ""},
|
||||
{"mount-unit-stop", "mount unit stop", "/usr/bin/systemctl", []string{"stop", "--", "mnt-felhom\\x2dx.mount"}, false, ""},
|
||||
|
||||
// ---- Network storage re-arm + cleanup (CAMPAIGN-3 F10/F1) ----
|
||||
{"netmount-reset-failed", "NAS automount re-arm after start-limit (F10)", "/usr/bin/systemctl", []string{"reset-failed", "--", "mnt-felhom\\x2ddrives-media.automount"}, false, ""},
|
||||
@@ -110,18 +108,17 @@ var manifest = []Capability{
|
||||
|
||||
// ---- Provisioning back-half ----
|
||||
{"provision-chown", "bootstrap mount guest-root chown", "/usr/bin/chown", []string{"-R", "100000:100000", "/var/lib/felhom-agent/guests/9201"}, false, ""},
|
||||
{"provision-config-mount", "bootstrap config bind mount", "/usr/sbin/pct", []string{"set", "9201", "-mp0", "/var/lib/felhom-agent/guests/9201"}, false, ""},
|
||||
{"provision-config-mount", "bootstrap config bind mount", "/usr/sbin/pct", []string{"set", "9201", "-mp0", "/var/lib/felhom-agent/guests/9201/bootstrap,mp=/etc/felhom-bootstrap,ro=1"}, false, ""},
|
||||
{"provision-onboot", "customer guest autostart (onboot)", "/usr/sbin/pct", []string{"set", "9201", "-onboot", "1"}, false, ""},
|
||||
|
||||
// ---- Pre-start self-heal hook + guest lifecycle ----
|
||||
{"guesthook-install", "pre-start hook snippet install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-guest-hook-123456789.sh", "/var/lib/vz/snippets/felhom-guest-hook.sh"}, false, ""},
|
||||
{"guesthook-register", "pre-start hook register", "/usr/sbin/pct", []string{"set", "9201", "--hookscript", "local:snippets/felhom-guest-hook.sh"}, false, ""},
|
||||
{"guesthook-delete-mp", "dead mountpoint slot delete (C1 net)", "/usr/sbin/pct", []string{"set", "9201", "--delete", "mp0"}, false, ""},
|
||||
{"guest-reboot", "enroll activate-binds reboot", "/usr/sbin/pct", []string{"reboot", "9201"}, false, ""},
|
||||
|
||||
// ---- LAN split-horizon resolver (dnsmasq) ----
|
||||
{"dnsmasq-install", "dnsmasq package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "dnsmasq"}, false, ""},
|
||||
{"dnsmasq-write", "dnsmasq drop-in write", "/usr/bin/install", []string{"-m", "0644", "/tmp/felhom-resolver-x.conf", "/etc/dnsmasq.d/felhom-x.conf"}, false, ""},
|
||||
{"dnsmasq-write", "dnsmasq drop-in write (root content check, R-861)", "/usr/local/sbin/felhom-priv-apply", []string{"dnsmasq", "/tmp/felhom-resolver-123456789.conf", "felhom-x.conf"}, false, ""},
|
||||
{"dnsmasq-enable", "dnsmasq enable", "/usr/bin/systemctl", []string{"enable", "--now", "dnsmasq"}, false, ""},
|
||||
|
||||
// ---- OS updates, guest fast lane (`11` §5.4.1; the wrapper holds every rule) ----
|
||||
@@ -160,7 +157,7 @@ var manifest = []Capability{
|
||||
// (one-time bootstrap) and disable (revocation, a deliberate teardown) stay non-critical. The
|
||||
// handshake read is the ONLY wg invocation (never `dump`). ----
|
||||
{"wg-tools-install", "wireguard-tools package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "wireguard-tools"}, false, ""},
|
||||
{"wg-conf-install", "wg-felhom conf install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0600", "--", "/var/lib/felhom-agent/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"}, true, ""},
|
||||
{"wg-conf-install", "wg-felhom conf install (root content check, R-861)", "/usr/local/sbin/felhom-priv-apply", []string{"wg"}, true, ""},
|
||||
{"wg-enable", "wg-quick@wg-felhom enable", "/usr/bin/systemctl", []string{"enable", "--now", "wg-quick@wg-felhom"}, true, ""},
|
||||
{"wg-restart", "wg-quick@wg-felhom restart (conf change)", "/usr/bin/systemctl", []string{"restart", "wg-quick@wg-felhom"}, true, ""},
|
||||
{"wg-disable", "wg-quick@wg-felhom disable (revocation)", "/usr/bin/systemctl", []string{"disable", "--now", "wg-quick@wg-felhom"}, false, ""},
|
||||
@@ -192,7 +189,6 @@ var manifest = []Capability{
|
||||
// operator-driven op, not a steady-state serving path — a degraded grant means "can't
|
||||
// self-update" (fall back to a manual SSH deploy), not a serving outage. The apply repr uses a
|
||||
// staging-dir path + a placeholder sha (list-mode never runs it). ----
|
||||
{"selfupdate-apply", "agent self-update apply (A/B flip)", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"apply", "/var/lib/felhom-agent/selfupdate/felhom-agent-0.0.0", "0000000000000000000000000000000000000000000000000000000000000000"}, false, ""},
|
||||
{"selfupdate-commit", "agent self-update commit", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"commit"}, false, ""},
|
||||
{"selfupdate-rollback", "agent self-update rollback", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"rollback"}, false, ""},
|
||||
}
|
||||
|
||||
@@ -56,8 +56,10 @@ func parseSudoersEntries(t *testing.T, text string) []string {
|
||||
var cur strings.Builder
|
||||
for i := 0; i < len(raw); i++ {
|
||||
c := raw[i]
|
||||
if c == '\\' && i+1 < len(raw) {
|
||||
cur.WriteByte(raw[i+1]) // unescape: keep the next char literally (\, → , ; \: → :)
|
||||
if c == '\\' && i+1 < len(raw) && strings.IndexByte(",:=", raw[i+1]) >= 0 {
|
||||
// unescape the sudoers grammar escapes only (\, → , ; \: → : ; \= → =). Every other backslash is kept: in
|
||||
// a regex entry (R-861) `\.` `\{` `\\` mean exactly what sudo's regex engine reads.
|
||||
cur.WriteByte(raw[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
@@ -108,10 +110,24 @@ func globToRegex(pat string) *regexp.Regexp {
|
||||
return regexp.MustCompile(b.String())
|
||||
}
|
||||
|
||||
// entryRegex compiles one sudoers entry. R-861 (v0.146.0): an entry whose ARGUMENTS are a sudo regular expression
|
||||
// (`^...$`, sudo >= 1.9.10) is matched as one — the binary literally, then a space, then the arguments joined by spaces
|
||||
// (sudo's own rule). Every other entry is an fnmatch glob (globToRegex). The parser has already undone the sudoers
|
||||
// escapes (`\,` `\:` `\=` `\\`), which leaves a valid RE2 pattern.
|
||||
func entryRegex(e string) *regexp.Regexp {
|
||||
if i := strings.IndexByte(e, ' '); i > 0 {
|
||||
bin, args := e[:i], e[i+1:]
|
||||
if strings.HasPrefix(args, "^") && strings.HasSuffix(args, "$") {
|
||||
return regexp.MustCompile("^" + regexp.QuoteMeta(bin) + " " + args[1:])
|
||||
}
|
||||
}
|
||||
return globToRegex(e)
|
||||
}
|
||||
|
||||
// matchesAny reports whether cmdline matches at least one sudoers entry pattern.
|
||||
func matchesAny(cmdline string, entries []string) bool {
|
||||
for _, e := range entries {
|
||||
if globToRegex(e).MatchString(cmdline) {
|
||||
if entryRegex(e).MatchString(cmdline) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-861 (agent v0.146.0): the attacks the old globs let through, each as the exact argv a compromised agent would
|
||||
// send. NONE may match any entry of the new sudoers. The same list ran against the REAL sudo 1.9.16 (a throwaway
|
||||
// container, and `sudo -l -U felhom-agent` on both demo boxes after the bundle): audits/hub-safety-2026-10-05/partF/.
|
||||
//
|
||||
// RED-PROOF: run this list against the v0.145.0 sudoers (git show v0.145.0:configs/felhom-agent.sudoers) → most of
|
||||
// these MATCH (recorded in the same evidence folder).
|
||||
var r861Injections = []string{
|
||||
// a raw host disk for a guest (pct options smuggled through a vmid glob)
|
||||
"/usr/sbin/pct set 9201 --dev0 /dev/sda -onboot 1",
|
||||
"/usr/sbin/pct set 9201 --dev0 /dev/sda -mp8 /mnt/felhom-drives",
|
||||
"/usr/sbin/pct set 9201 --delete mp0 --dev0 /dev/sda",
|
||||
"/usr/sbin/pct set 9201 -mp0 /var/lib/felhom-agent/guests/9201/bootstrap,mp=/x --dev0 /dev/sda",
|
||||
// a bind mount over /etc through traversal
|
||||
"/usr/bin/mount --bind /mnt/../var/lib/felhom-agent/x/felhom-data /mnt/felhom-drives/x",
|
||||
"/usr/bin/mount --bind /mnt/a/felhom-data /mnt/felhom-drives/../../etc/sudoers.d",
|
||||
"/usr/bin/umount /mnt/felhom-drives/x /",
|
||||
"/usr/bin/chown 100000:100000 /mnt/a/felhom-data /etc/shadow",
|
||||
"/usr/bin/mkdir -p /mnt/felhom-drives/x /etc/systemd/system/evil.mount",
|
||||
// root-read files the agent writes: gone as `install` lines
|
||||
"/usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/units/x.mount /etc/systemd/system/etc-sudoers.d.mount",
|
||||
"/usr/bin/install -m 0755 -- /tmp/felhom-guest-hook-1.sh /var/lib/vz/snippets/felhom-guest-hook.sh",
|
||||
"/usr/bin/install -m 0755 -- /tmp/felhom-shared-parent-1.sh /usr/local/sbin/felhom-shared-parent.sh",
|
||||
"/usr/bin/install -m 0644 /tmp/felhom-resolver-1.conf /etc/dnsmasq.d/felhom-x.conf",
|
||||
"/usr/bin/install -o root -g root -m 0600 -- /var/lib/felhom-agent/wg/wg-felhom.conf /etc/wireguard/wg-felhom.conf",
|
||||
"/usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/felhom-sshd/sshd_config /etc/felhom-sshd/sshd_config",
|
||||
// the unsigned binary flip
|
||||
"/usr/local/sbin/felhom-selfupdate-guarded apply /var/lib/felhom-agent/selfupdate/felhom-agent-9.9.9 0000000000000000000000000000000000000000000000000000000000000000",
|
||||
// enabling or removing anything that is not ours
|
||||
"/usr/bin/systemctl enable --now -- mnt-hdd_1.mount evil.service",
|
||||
"/usr/bin/systemctl enable --now -- etc-sudoers.d.mount",
|
||||
"/usr/bin/rm -f /etc/systemd/system/mnt-felhomx /etc/passwd",
|
||||
"/usr/bin/rm -f /etc/dnsmasq.d/felhom-x.conf /etc/shadow",
|
||||
"/usr/bin/rmdir /mnt/felhom-drives/x /etc",
|
||||
// nftables commands chained after a set element
|
||||
"/usr/sbin/nft add element inet felhom_oob operator_ips { 10.77.0.250 } ; flush ruleset",
|
||||
// extra options to read-only tools
|
||||
"/usr/sbin/smartctl -a -j /dev/sda -s off",
|
||||
"/usr/sbin/lvs --reportformat json --units b -o lv_name,data_percent,metadata_percent -- pve/data --config x",
|
||||
"/usr/sbin/pct exec 9201 --keep-env -- docker inspect -f x felhom-controller",
|
||||
"/usr/sbin/pct unlock 9201 --whatever",
|
||||
// the checker with a path it must never take
|
||||
"/usr/local/sbin/felhom-priv-apply unit ../../etc/x.mount",
|
||||
"/usr/local/sbin/felhom-priv-apply dnsmasq /etc/shadow felhom-x.conf",
|
||||
"/usr/local/sbin/felhom-priv-apply wg /etc/shadow",
|
||||
}
|
||||
|
||||
func TestSudoersRefusesTheR861Injections(t *testing.T) {
|
||||
data, err := os.ReadFile(sudoersPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := parseSudoersEntries(t, string(data))
|
||||
for _, c := range r861Injections {
|
||||
if matchesAny(c, entries) {
|
||||
t.Errorf("the sudoers still allows: %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Slice 10D.1 — IDENTITY escrow. The K-escrow (above) wraps the PBS *encryption key* via the
|
||||
@@ -71,7 +73,7 @@ func HashResticPassword(pw string) string {
|
||||
// (non-empty) but NEVER logged by callers — log the field NAME only (mirrors AttachWGKey). A missing file
|
||||
// is a clean no-attach (pre-fork-4 behavior, byte-compatible bundle).
|
||||
func AttachResticPassword(b *IdentityBundle, stagePath string) (bool, error) {
|
||||
raw, err := os.ReadFile(stagePath)
|
||||
raw, err := readStagedNoFollow(stagePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
@@ -92,7 +94,7 @@ func AttachResticPassword(b *IdentityBundle, stagePath string) (bool, error) {
|
||||
// no-attach (pre-S3 behavior, byte-compatible bundle); a corrupt one is an error (the operator
|
||||
// should know their escrow would silently lack a live identity).
|
||||
func AttachWGKey(b *IdentityBundle, keyPath string) (bool, error) {
|
||||
raw, err := os.ReadFile(keyPath)
|
||||
raw, err := readStagedNoFollow(keyPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
@@ -180,3 +182,27 @@ func UnwrapIdentityBundle(ctx context.Context, blob []byte, recoveryCode string)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// readStagedNoFollow reads a file the AGENT staged, for the escrow ceremony that runs as ROOT (FELHOM_ESCROW). R-861
|
||||
// (agent v0.146.0): both files live in the agent's own directory, so a compromised agent could put a SYMLINK there
|
||||
// (to any root-only file) and the root ceremony would seal that file into the blob and hand the agent R — a root file
|
||||
// read. So: no symlink (O_NOFOLLOW), a regular file, at most 4 KiB. A missing file keeps its os.IsNotExist meaning.
|
||||
// Pinned by TestAttach_RefusesASymlink.
|
||||
func readStagedNoFollow(path string) ([]byte, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s is not a regular file", path)
|
||||
}
|
||||
if fi.Size() > 4096 {
|
||||
return nil, fmt.Errorf("%s is larger than 4 KiB", path)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(f, 4097))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package escrow
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-861 (agent v0.146.0): the root escrow ceremony reads two files from the AGENT's directory. A symlink there to a
|
||||
// root-only file must never be read (it would be sealed under R and handed to the agent).
|
||||
// RED-PROOF (audits/hub-safety-2026-10-05/partF/red-proof.txt): use os.ReadFile in readStagedNoFollow → this fails.
|
||||
func TestAttach_RefusesASymlink(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
secret := filepath.Join(d, "root-only")
|
||||
if err := os.WriteFile(secret, []byte("ROOT-ONLY-CANARY"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(d, "restic_repo_password")
|
||||
if err := os.Symlink(secret, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b IdentityBundle
|
||||
if ok, err := AttachResticPassword(&b, link); err == nil || ok || b.ResticRepoPassword != "" {
|
||||
t.Fatalf("a symlinked staged file was read: ok=%v err=%v value-set=%v", ok, err, b.ResticRepoPassword != "")
|
||||
}
|
||||
if ok, err := AttachWGKey(&b, link); err == nil || ok {
|
||||
t.Fatalf("a symlinked wg key was read: ok=%v err=%v", ok, err)
|
||||
}
|
||||
// control: the real staged file is still read; a missing one is still a clean no-attach
|
||||
real := filepath.Join(d, "real")
|
||||
_ = os.WriteFile(real, []byte("pw\n"), 0o600)
|
||||
if ok, err := AttachResticPassword(&b, real); err != nil || !ok || b.ResticRepoPassword != "pw" {
|
||||
t.Fatalf("control: the plain staged file was not read: %v %v", ok, err)
|
||||
}
|
||||
if ok, err := AttachResticPassword(&b, filepath.Join(d, "absent")); err != nil || ok {
|
||||
t.Fatalf("control: a missing file must stay a clean no-attach: %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ const (
|
||||
PortFile = ConfDir + "/port"
|
||||
// PidFile is the instance pidfile (NOT a RuntimeDirectory — that is the G1 incident cause).
|
||||
PidFile = "/run/felhom-sshd.pid"
|
||||
// PrivApply is the root content checker that installs the config and felhom-op's key (R-861, agent v0.146.0).
|
||||
PrivApply = "/usr/local/sbin/felhom-priv-apply"
|
||||
// Unit is the systemd unit name.
|
||||
Unit = "felhom-sshd"
|
||||
// OperatorUser is the default operator login (scoped sudo; key in AuthKeysDir only).
|
||||
|
||||
@@ -143,7 +143,8 @@ func (m *Manager) Apply(ctx context.Context, block *hub.WireWireguard) (int, err
|
||||
m.logger.Error("felhomsshd: staged config failed sshd -t — NOT installing", "err", err, "stderr", strings.TrimSpace(string(errOut)))
|
||||
return port, err
|
||||
}
|
||||
if _, errOut, err := m.runner.Run(ctx, "install", "-o", "root", "-g", "root", "-m", "0644", "--", m.stagedConfPath(), ConfPath); err != nil {
|
||||
// R-861 (v0.146.0): the root checker installs it, and only if it is renderConfig's template for some port.
|
||||
if _, errOut, err := m.runner.Run(ctx, PrivApply, "sshd-config"); err != nil {
|
||||
m.logger.Error("felhomsshd: config install failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
|
||||
return port, err
|
||||
}
|
||||
@@ -181,7 +182,9 @@ func (m *Manager) applyAuthorizedKeys(ctx context.Context, sshKey string) {
|
||||
m.logger.Error("felhomsshd: staging authorized_keys", "err", err)
|
||||
return
|
||||
}
|
||||
if _, errOut, err := m.runner.Run(ctx, "install", "-o", "root", "-g", "root", "-m", "0644", "--", staged, AuthKeysUserPath); err != nil {
|
||||
// R-861: the root checker installs it — one plain public key, no options (command=, from=, …), or empty.
|
||||
_ = staged
|
||||
if _, errOut, err := m.runner.Run(ctx, PrivApply, "sshd-key"); err != nil {
|
||||
m.logger.Error("felhomsshd: authorized_keys install failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package felhomsshd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/privapplytest"
|
||||
)
|
||||
|
||||
// R-861: renderConfig is byte-identical to the checker's template (only the Port varies); a changed line is refused.
|
||||
// RED-PROOF: change one directive in renderConfig → this fails (the two templates drifted).
|
||||
func TestPrivApply_AcceptsTheRenderedConfig(t *testing.T) {
|
||||
for _, port := range []int{2222, 8822, 60022} {
|
||||
conf, err := renderConfig(port)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := privapplytest.Check(t, "sshd-config", "", conf); got != "OK" {
|
||||
t.Errorf("port %d: %s", port, got)
|
||||
}
|
||||
}
|
||||
conf, _ := renderConfig(8822)
|
||||
if got := privapplytest.Check(t, "sshd-config", "", conf+"StrictModes no\n"); !strings.HasPrefix(got, "REFUSED") {
|
||||
t.Fatalf("control: an extra directive was not refused: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -38,35 +38,26 @@ const snippetBody = `#!/bin/sh
|
||||
exit 0
|
||||
`
|
||||
|
||||
// InstallSnippet writes the pre-start hook wrapper into the PVE snippets dir (idempotent, root-owned,
|
||||
// executable). The agent runs as a non-root service user, so it writes an agent-writable temp file then
|
||||
// `install`s it host-root (same pattern as the bootstrap mount + dnsmasq drop-ins). Safe to call repeatedly.
|
||||
// The temp file is a RANDOM-named os.CreateTemp (audit B1): a fixed, predictable /tmp name could be
|
||||
// pre-created by another local user and rewritten between our write and root's install (TOCTOU into a
|
||||
// root-executed hookscript). The final mode comes from `install -m`, so the 0600 temp is fine.
|
||||
func InstallSnippet(ctx context.Context, runner proxmox.Runner) error {
|
||||
f, err := os.CreateTemp("", "felhom-guest-hook-*.sh")
|
||||
// SnippetReady (R-861, agent v0.146.0) reports whether the pre-start hook is in place: a regular file at path whose
|
||||
// content is exactly snippetBody. The hook is a FIXED, ROOT-OWNED file that arrives with the signed config bundle
|
||||
// (configs/felhom-guest-hook.sh, pinned byte-identical by TestSnippetEqualsTheBundle). The agent no longer installs it:
|
||||
// until v0.146.0 it `install`ed it from /tmp, and Proxmox runs a hookscript as root at every guest start — so the
|
||||
// install grant was a root shell for a compromised agent. A missing or different hook is an error the caller logs; it
|
||||
// then does NOT register the hook (a guest whose hookscript is missing does not start).
|
||||
func SnippetReady(path string) error {
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("guesthook: create temp snippet: %w", err)
|
||||
return fmt.Errorf("guesthook: %s is missing — it arrives with the signed config bundle (agent_config_update): %w", path, err)
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp)
|
||||
if _, err := f.WriteString(snippetBody); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("guesthook: write temp snippet: %w", err)
|
||||
if !fi.Mode().IsRegular() {
|
||||
return fmt.Errorf("guesthook: %s is not a regular file", path)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("guesthook: close temp snippet: %w", err)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("guesthook: read %s: %w", path, err)
|
||||
}
|
||||
// Ensure the snippets dir exists FIRST (B2, DRILL-day0-cleanroom-2026-07-03): a fresh PVE has
|
||||
// no /var/lib/vz/snippets, and `install` (without -D) won't create the parent — the whole
|
||||
// hook install silently failed on a freshly-bootstrapped box. Fenced root op like the install
|
||||
// itself; idempotent.
|
||||
if _, stderr, err := runner.Run(ctx, "mkdir", "-p", SnippetDir); err != nil {
|
||||
return fmt.Errorf("guesthook: ensure snippets dir %s: %w: %s", SnippetDir, err, string(stderr))
|
||||
}
|
||||
if _, stderr, err := runner.Run(ctx, "install", "-m", "0755", "--", tmp, SnippetPath); err != nil {
|
||||
return fmt.Errorf("guesthook: install snippet to %s: %w: %s", SnippetPath, err, string(stderr))
|
||||
if string(b) != snippetBody {
|
||||
return fmt.Errorf("guesthook: %s differs from this agent's hook — the next config bundle replaces it", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -29,100 +29,34 @@ func (r *recordingRunner) RunStdin(ctx context.Context, _ io.Reader, name string
|
||||
return r.Run(ctx, name, args...)
|
||||
}
|
||||
|
||||
// TestInstallSnippet_RandomTempName is the audit-B1 negative test: the staged install SOURCE must be a
|
||||
// RANDOM os.CreateTemp name (felhom-guest-hook-<random>.sh), never the fixed, pre-creatable
|
||||
// /tmp/felhom-guest-hook.sh (a local TOCTOU into a root-executed hookscript), and two consecutive
|
||||
// installs must stage through DIFFERENT paths.
|
||||
func TestInstallSnippet_RandomTempName(t *testing.T) {
|
||||
r := &recordingRunner{}
|
||||
if err := InstallSnippet(context.Background(), r); err != nil {
|
||||
t.Fatalf("InstallSnippet #1: %v", err)
|
||||
// R-861 (agent v0.146.0): the hook file comes with the signed config bundle; the agent never installs it, only checks.
|
||||
// RED-PROOF (audits/hub-safety-2026-10-05/partF/red-proof.txt): make SnippetReady accept any content → the
|
||||
// "differs" case fails.
|
||||
func TestSnippetReady(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
p := filepath.Join(d, "felhom-guest-hook.sh")
|
||||
if err := SnippetReady(p); err == nil {
|
||||
t.Fatal("a missing hook read as ready — the guest would get a hookscript that does not exist")
|
||||
}
|
||||
if err := InstallSnippet(context.Background(), r); err != nil {
|
||||
t.Fatalf("InstallSnippet #2: %v", err)
|
||||
_ = os.WriteFile(p, []byte("#!/bin/sh\nid > /tmp/x\n"), 0o755)
|
||||
if err := SnippetReady(p); err == nil {
|
||||
t.Fatal("a hook with other content read as ready")
|
||||
}
|
||||
var installs [][]string
|
||||
for _, call := range r.calls {
|
||||
if call[0] == "install" {
|
||||
installs = append(installs, call)
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(snippetBody), 0o755)
|
||||
if err := SnippetReady(p); err != nil {
|
||||
t.Fatalf("the bundle's hook was not accepted: %v", err)
|
||||
}
|
||||
if len(installs) != 2 {
|
||||
t.Fatalf("expected 2 install calls, got %d: %v", len(installs), r.calls)
|
||||
}
|
||||
|
||||
randomName := regexp.MustCompile(`felhom-guest-hook-[^/\\]+\.sh$`)
|
||||
fixedName := regexp.MustCompile(`felhom-guest-hook\.sh$`)
|
||||
var srcs []string
|
||||
for i, call := range installs {
|
||||
// install -m 0755 -- <src> <dest>
|
||||
if len(call) != 6 {
|
||||
t.Fatalf("call %d: unexpected vector %v", i, call)
|
||||
}
|
||||
src, dest := call[4], call[5]
|
||||
if dest != SnippetPath {
|
||||
t.Errorf("call %d: dest = %q, want %q", i, dest, SnippetPath)
|
||||
}
|
||||
if !randomName.MatchString(src) {
|
||||
t.Errorf("call %d: source %q does not match the random felhom-guest-hook-*.sh pattern", i, src)
|
||||
}
|
||||
if fixedName.MatchString(src) {
|
||||
t.Errorf("call %d: source %q is the FIXED predictable temp name (B1 TOCTOU)", i, src)
|
||||
}
|
||||
srcs = append(srcs, src)
|
||||
}
|
||||
if srcs[0] == srcs[1] {
|
||||
t.Errorf("two consecutive installs staged through the SAME source path %q — must be random per call", srcs[0])
|
||||
}
|
||||
// Non-hollow: the staged file must actually carry the snippet body at install time.
|
||||
for i, c := range r.srcContent {
|
||||
if c != snippetBody {
|
||||
t.Errorf("call %d: staged content is not the snippet body (got %d bytes)", i, len(c))
|
||||
}
|
||||
}
|
||||
// And the temp is cleaned up after.
|
||||
for _, src := range srcs {
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
t.Errorf("staged temp %q left behind (defer os.Remove missing)", src)
|
||||
}
|
||||
l := filepath.Join(d, "link.sh")
|
||||
_ = os.Symlink(p, l)
|
||||
if err := SnippetReady(l); err == nil {
|
||||
t.Fatal("a symlink read as the hook")
|
||||
}
|
||||
}
|
||||
|
||||
// B2 Scenario D (DRILL-day0-cleanroom-2026-07-03): on a fresh PVE, /var/lib/vz/snippets does not
|
||||
// exist and `install` (no -D) cannot create it — the drill saw
|
||||
// `install: cannot create regular file … No such file or directory` and the guest silently got no
|
||||
// pre-start self-heal hook. InstallSnippet must therefore issue a `mkdir -p <SnippetDir>` fenced op
|
||||
// BEFORE the `install` op. Pre-fix wrong outcome: no mkdir call at all — only the doomed install.
|
||||
func TestInstallSnippet_EnsuresSnippetsDirFirst(t *testing.T) {
|
||||
r := &recordingRunner{}
|
||||
if err := InstallSnippet(context.Background(), r); err != nil {
|
||||
t.Fatalf("InstallSnippet: %v", err)
|
||||
}
|
||||
|
||||
mkdirIdx, installIdx := -1, -1
|
||||
for i, call := range r.calls {
|
||||
switch call[0] {
|
||||
case "mkdir":
|
||||
if mkdirIdx == -1 {
|
||||
mkdirIdx = i
|
||||
want := []string{"mkdir", "-p", SnippetDir}
|
||||
if len(call) != 3 || call[1] != want[1] || call[2] != want[2] {
|
||||
t.Errorf("mkdir vector = %v, want %v (the sudoers fence matches exactly this argv)", call, want)
|
||||
}
|
||||
}
|
||||
case "install":
|
||||
if installIdx == -1 {
|
||||
installIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if mkdirIdx == -1 {
|
||||
t.Fatalf("no `mkdir -p %s` op issued — on a fresh box the snippet install fails ENOENT (B2); calls: %v", SnippetDir, r.calls)
|
||||
}
|
||||
if installIdx == -1 {
|
||||
t.Fatalf("no install op issued; calls: %v", r.calls)
|
||||
}
|
||||
if mkdirIdx > installIdx {
|
||||
t.Fatalf("mkdir (call %d) must PRECEDE install (call %d) — order: %v", mkdirIdx, installIdx, r.calls)
|
||||
// The bundle's copy is byte-identical to the body the agent checks against.
|
||||
func TestSnippetEqualsTheBundle(t *testing.T) {
|
||||
got, err := os.ReadFile(filepath.Join("..", "..", "configs", "felhom-guest-hook.sh"))
|
||||
if err != nil || string(got) != snippetBody {
|
||||
t.Fatalf("configs/felhom-guest-hook.sh differs from snippetBody (err %v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ const (
|
||||
DropinDir = "/etc/dnsmasq.d"
|
||||
// BaseDropin holds the host-wide listen/upstream config (one per host).
|
||||
BaseDropin = "felhom-resolver-base.conf"
|
||||
// PrivApply is the root content checker that installs a drop-in (R-861).
|
||||
PrivApply = "/usr/local/sbin/felhom-priv-apply"
|
||||
)
|
||||
|
||||
// RenderBase returns the host-wide dnsmasq drop-in: bind to the host LAN IP (+ loopback), no-resolv,
|
||||
@@ -263,7 +265,13 @@ func (m *Manager) writeFileIfChanged(ctx context.Context, path, content, mode st
|
||||
return false, fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
tmp.Close()
|
||||
if _, errOut, err := m.runner.Run(ctx, "install", "-m", mode, tmpName, path); err != nil {
|
||||
// R-861 (v0.146.0): a dnsmasq drop-in reaches /etc/dnsmasq.d only through the root checker, which allows exactly
|
||||
// the lines RenderBase/RenderGuestDropin write (a `dhcp-script=` would run as root). Mode is fixed (0644) there.
|
||||
_ = mode
|
||||
if filepath.Dir(path) != DropinDir {
|
||||
return false, fmt.Errorf("drop-in %s is not in %s", path, DropinDir)
|
||||
}
|
||||
if _, errOut, err := m.runner.Run(ctx, PrivApply, "dnsmasq", tmpName, filepath.Base(path)); err != nil {
|
||||
return false, fmt.Errorf("install %s: %s: %w", path, strings.TrimSpace(string(errOut)), err)
|
||||
}
|
||||
return true, nil
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package lanresolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/privapplytest"
|
||||
)
|
||||
|
||||
// R-861: both drop-ins the resolver renders are accepted by the root checker; a dhcp-script line is not.
|
||||
func TestPrivApply_AcceptsTheRenderedDropins(t *testing.T) {
|
||||
if got := privapplytest.Check(t, "dnsmasq", BaseDropin, RenderBase("192.168.0.104", []string{"1.1.1.1", "8.8.8.8"})); got != "OK" {
|
||||
t.Errorf("base drop-in: %s", got)
|
||||
}
|
||||
if got := privapplytest.Check(t, "dnsmasq", DropinName("demo-hp"), RenderGuestDropin("demo-hp", "enkisfelhom.hu", "192.168.0.138")); got != "OK" {
|
||||
t.Errorf("guest drop-in: %s", got)
|
||||
}
|
||||
evil := RenderGuestDropin("demo-hp", "enkisfelhom.hu", "192.168.0.138") + "dhcp-script=/var/lib/felhom-agent/x\n"
|
||||
if got := privapplytest.Check(t, "dnsmasq", "felhom-x.conf", evil); !strings.HasPrefix(got, "REFUSED") {
|
||||
t.Fatalf("control: dhcp-script was not refused: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -121,32 +121,33 @@ func (b *GuestBinder) EnsureSharedParent(ctx context.Context) error {
|
||||
// only the script (the unit was unchanged), so the earlier unit-only gate never redeployed it —
|
||||
// leaving hosts running the pre-fix script (no make-private), whose self-bind stays in root's shared
|
||||
// peer group and DOUBLES every drive bind. Comparing both files closes that deploy gap.
|
||||
if sharedParentInstallStale(sharedParentUnitPath, sharedParentScriptPath) {
|
||||
if ierr := b.installSharedParentUnit(ctx); ierr != nil {
|
||||
b.logger.Warn("shared-parent: boot-persistence (re)install failed (live setup OK; survives until host reboot)", "err", ierr)
|
||||
}
|
||||
if err := b.ensureSharedParentBoot(ctx, sharedParentUnitPath, sharedParentScriptPath, sharedParentWantsLink); err != nil {
|
||||
b.logger.Warn("shared-parent: boot persistence not enabled (live setup OK; survives until host reboot)", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stageTemp writes content to a fresh random-named temp file (os.CreateTemp pattern — `*` is replaced
|
||||
// by a random string) and returns its path. Caller removes it after the privileged `install`.
|
||||
func stageTemp(pattern, content string) (string, error) {
|
||||
f, err := os.CreateTemp("", pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
// sharedParentWantsLink exists once `systemctl enable felhom-shared-parent.service` ran (WantedBy=pve-guests.service).
|
||||
const sharedParentWantsLink = "/etc/systemd/system/pve-guests.service.wants/felhom-shared-parent.service"
|
||||
|
||||
// ensureSharedParentBoot (R-861, agent v0.146.0) — the boot script and its unit are ROOT-OWNED FIXED files that arrive
|
||||
// with the signed config bundle (configs/felhom-shared-parent.{sh,service}, byte-identical to the constants above,
|
||||
// pinned by TestSharedParentFilesEqualTheBundle). The agent NEVER installs them any more: until v0.146.0 it installed
|
||||
// them from /tmp, and a script a root unit runs at boot was a root shell for a compromised agent. Here it only checks
|
||||
// them, and enables the unit (a fixed sudoers line) when the bundle has put it in place but nothing has enabled it — a
|
||||
// fresh install. Missing or different files: one warning, nothing run (the next bundle brings them).
|
||||
func (b *GuestBinder) ensureSharedParentBoot(ctx context.Context, unitPath, scriptPath, wantsLink string) error {
|
||||
if sharedParentInstallStale(unitPath, scriptPath) {
|
||||
return fmt.Errorf("%s or %s is missing or differs from this agent's — it arrives with the signed config bundle (agent_config_update); the agent does not install it (R-861)", scriptPath, unitPath)
|
||||
}
|
||||
name := f.Name()
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
os.Remove(name)
|
||||
return "", err
|
||||
if _, err := os.Lstat(wantsLink); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(name)
|
||||
return "", err
|
||||
if err := b.run(ctx, "systemctl", "enable", "felhom-shared-parent.service"); err != nil {
|
||||
return fmt.Errorf("enable unit: %w", err)
|
||||
}
|
||||
return name, nil
|
||||
b.logger.Info("shared-parent: boot-persistence unit enabled (files from the config bundle)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// sharedParentInstallStale reports whether the on-disk boot script OR unit is missing or differs from
|
||||
@@ -162,37 +163,6 @@ func sharedParentInstallStale(unitPath, scriptPath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// installSharedParentUnit writes the script + unit (from agent-written temps) and enables the unit so the
|
||||
// shared parent is re-established on every host boot before pve-guests. Idempotent. The temps are
|
||||
// RANDOM-named os.CreateTemp files (audit B1): a fixed, predictable /tmp name could be pre-created by
|
||||
// another local user and rewritten between our write and root's install (TOCTOU into a root-executed
|
||||
// boot script). The final modes come from `install -m`, so the 0600 temps are fine.
|
||||
func (b *GuestBinder) installSharedParentUnit(ctx context.Context) error {
|
||||
tmpScript, err := stageTemp("felhom-shared-parent-*.sh", sharedParentScript)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write temp script: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpScript)
|
||||
if err := b.run(ctx, "install", "-m", "0755", "--", tmpScript, sharedParentScriptPath); err != nil {
|
||||
return fmt.Errorf("install script: %w", err)
|
||||
}
|
||||
tmpUnit, err := stageTemp("felhom-shared-parent-*.service", sharedParentUnit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write temp unit: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpUnit)
|
||||
if err := b.run(ctx, "install", "-m", "0644", "--", tmpUnit, sharedParentUnitPath); err != nil {
|
||||
return fmt.Errorf("install unit: %w", err)
|
||||
}
|
||||
if err := b.run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||
return fmt.Errorf("daemon-reload: %w", err)
|
||||
}
|
||||
if err := b.run(ctx, "systemctl", "enable", "felhom-shared-parent.service"); err != nil {
|
||||
return fmt.Errorf("enable unit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachDrive binds a drive's felhom-data namespace under the stable parent so it appears live in the
|
||||
// guest at the returned stable path (via propagation — no pct, no reboot). `where` is the drive's RAW
|
||||
// host PVE mount (/mnt/<name>); only `<where>/felhom-data` crosses into the guest (confinement). The
|
||||
|
||||
@@ -4,94 +4,82 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stagingRecorderRunner is a fake proxmox.Runner recording every call vector and snapshotting each
|
||||
// install SOURCE file's content at call time (the deferred os.Remove erases it afterwards).
|
||||
type stagingRecorderRunner struct {
|
||||
calls [][]string
|
||||
srcContent map[string]string // install dest → staged source content
|
||||
}
|
||||
// R-861 (agent v0.146.0): the shared-parent boot script and unit arrive with the signed config bundle; the agent never
|
||||
// installs them. It checks them and enables the unit when nothing has.
|
||||
//
|
||||
// RED-PROOF (audits/hub-safety-2026-10-05/partF/red-proof.txt): put the old installSharedParentUnit call back (an
|
||||
// `install` of a /tmp file) → TestSharedParentBoot_NeverInstalls fails.
|
||||
|
||||
func (r *stagingRecorderRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
type callRecorder struct{ calls [][]string }
|
||||
|
||||
func (r *callRecorder) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
r.calls = append(r.calls, append([]string{name}, args...))
|
||||
if name == "install" && len(args) >= 2 {
|
||||
src, dest := args[len(args)-2], args[len(args)-1]
|
||||
if r.srcContent == nil {
|
||||
r.srcContent = map[string]string{}
|
||||
}
|
||||
b, _ := os.ReadFile(src)
|
||||
r.srcContent[dest] = string(b)
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r *stagingRecorderRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||
func (r *callRecorder) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||
return r.Run(ctx, name, args...)
|
||||
}
|
||||
|
||||
// installSources returns the install-call source paths keyed by destination.
|
||||
func (r *stagingRecorderRunner) installSources() map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, c := range r.calls {
|
||||
if c[0] == "install" && len(c) >= 3 {
|
||||
src, dest := c[len(c)-2], c[len(c)-1]
|
||||
out[dest] = append(out[dest], src)
|
||||
}
|
||||
func bootFiles(t *testing.T, script, unit string) (string, string, string) {
|
||||
t.Helper()
|
||||
d := t.TempDir()
|
||||
sp, up := filepath.Join(d, "felhom-shared-parent.sh"), filepath.Join(d, "felhom-shared-parent.service")
|
||||
if script != "" {
|
||||
_ = os.WriteFile(sp, []byte(script), 0o755)
|
||||
}
|
||||
return out
|
||||
if unit != "" {
|
||||
_ = os.WriteFile(up, []byte(unit), 0o644)
|
||||
}
|
||||
return sp, up, filepath.Join(d, "wants-link")
|
||||
}
|
||||
|
||||
// TestInstallSharedParent_RandomTempName is the audit-B1 negative test for the shared-parent boot
|
||||
// persistence install: both staged install SOURCES (script + unit) must be RANDOM os.CreateTemp names
|
||||
// (felhom-shared-parent-<random>.sh / .service), never the fixed, pre-creatable /tmp names (a local
|
||||
// TOCTOU into a root-executed boot script), and two consecutive installs must use DIFFERENT paths.
|
||||
func TestInstallSharedParent_RandomTempName(t *testing.T) {
|
||||
r := &stagingRecorderRunner{}
|
||||
func TestSharedParentBoot_NeverInstalls(t *testing.T) {
|
||||
for _, c := range []struct{ name, script, unit string }{
|
||||
{"both missing", "", ""},
|
||||
{"script differs", "#!/bin/sh\necho old\n", sharedParentUnit},
|
||||
{"unit missing", sharedParentScript, ""},
|
||||
} {
|
||||
r := &callRecorder{}
|
||||
b := NewGuestBinder(r, nil)
|
||||
sp, up, link := bootFiles(t, c.script, c.unit)
|
||||
if err := b.ensureSharedParentBoot(context.Background(), up, sp, link); err == nil {
|
||||
t.Errorf("%s: no error — the operator would not learn the bundle is missing", c.name)
|
||||
}
|
||||
if len(r.calls) != 0 {
|
||||
t.Errorf("%s: the agent ran %v — it must install nothing (R-861)", c.name, r.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedParentBoot_EnablesOnceTheBundleBroughtTheFiles(t *testing.T) {
|
||||
r := &callRecorder{}
|
||||
b := NewGuestBinder(r, nil)
|
||||
if err := b.installSharedParentUnit(context.Background()); err != nil {
|
||||
t.Fatalf("installSharedParentUnit #1: %v", err)
|
||||
sp, up, link := bootFiles(t, sharedParentScript, sharedParentUnit)
|
||||
if err := b.ensureSharedParentBoot(context.Background(), up, sp, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.installSharedParentUnit(context.Background()); err != nil {
|
||||
t.Fatalf("installSharedParentUnit #2: %v", err)
|
||||
if len(r.calls) != 1 || len(r.calls[0]) != 3 || r.calls[0][0] != "systemctl" || r.calls[0][1] != "enable" ||
|
||||
r.calls[0][2] != "felhom-shared-parent.service" {
|
||||
t.Fatalf("want exactly `systemctl enable felhom-shared-parent.service`, got %v", r.calls)
|
||||
}
|
||||
_ = os.Symlink(up, link)
|
||||
r.calls = nil
|
||||
if err := b.ensureSharedParentBoot(context.Background(), up, sp, link); err != nil || len(r.calls) != 0 {
|
||||
t.Fatalf("already enabled: want no calls, got %v (%v)", r.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
srcs := r.installSources()
|
||||
cases := []struct {
|
||||
dest string
|
||||
random *regexp.Regexp
|
||||
fixed *regexp.Regexp
|
||||
content string
|
||||
}{
|
||||
{sharedParentScriptPath, regexp.MustCompile(`felhom-shared-parent-[^/\\]+\.sh$`),
|
||||
regexp.MustCompile(`felhom-shared-parent\.sh$`), sharedParentScript},
|
||||
{sharedParentUnitPath, regexp.MustCompile(`felhom-shared-parent-[^/\\]+\.service$`),
|
||||
regexp.MustCompile(`felhom-shared-parent\.service$`), sharedParentUnit},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := srcs[tc.dest]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("dest %s: expected 2 install calls, got %d (%v)", tc.dest, len(got), got)
|
||||
}
|
||||
for i, src := range got {
|
||||
if !tc.random.MatchString(src) {
|
||||
t.Errorf("dest %s call %d: source %q does not match the random temp pattern", tc.dest, i, src)
|
||||
}
|
||||
if tc.fixed.MatchString(src) {
|
||||
t.Errorf("dest %s call %d: source %q is the FIXED predictable temp name (B1 TOCTOU)", tc.dest, i, src)
|
||||
}
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
t.Errorf("dest %s: staged temp %q left behind (defer os.Remove missing)", tc.dest, src)
|
||||
}
|
||||
}
|
||||
if got[0] == got[1] {
|
||||
t.Errorf("dest %s: two consecutive installs staged through the SAME source %q — must be random per call", tc.dest, got[0])
|
||||
}
|
||||
// Non-hollow: the staged file carried the real content at install time.
|
||||
if r.srcContent[tc.dest] != tc.content {
|
||||
t.Errorf("dest %s: staged content mismatch (got %d bytes, want %d)", tc.dest, len(r.srcContent[tc.dest]), len(tc.content))
|
||||
// The bundle's copies are byte-identical to the constants the agent compares against.
|
||||
func TestSharedParentFilesEqualTheBundle(t *testing.T) {
|
||||
for file, want := range map[string]string{"felhom-shared-parent.sh": sharedParentScript, "felhom-shared-parent.service": sharedParentUnit} {
|
||||
got, err := os.ReadFile(filepath.Join("..", "..", "configs", file))
|
||||
if err != nil || string(got) != want {
|
||||
t.Errorf("configs/%s differs from the agent's constant (err %v)", file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ func TestWrapperSuite(t *testing.T) {
|
||||
t.Skip("python3 not available")
|
||||
}
|
||||
// the OS wrapper and (agent v0.142.0) the crash guard — both root programs in configs/ with their own suites
|
||||
for _, suite := range []string{"../../configs/test_felhom_os_apply.py", "../../configs/test_felhom_crash_guard.py", "../../configs/test_felhom_config_bundle.py"} {
|
||||
for _, suite := range []string{"../../configs/test_felhom_os_apply.py", "../../configs/test_felhom_crash_guard.py", "../../configs/test_felhom_config_bundle.py", "../../configs/test_felhom_priv_apply.py"} {
|
||||
cmd := exec.Command(py, "-B", suite)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package privapplytest runs configs/felhom-priv-apply in CHECK-ONLY mode from Go tests (R-861): each renderer's
|
||||
// real output must be accepted by the root checker, so the two can never drift apart unnoticed. Test-only helper.
|
||||
package privapplytest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Check writes content to a temp file and runs `felhom-priv-apply --check <verb> [name] <file>`. It returns the
|
||||
// checker's verdict line ("OK" or "REFUSED [rule] …"). Skips when python3 is absent.
|
||||
func Check(t *testing.T, verb, name, content string) string {
|
||||
t.Helper()
|
||||
py, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
t.Skip("python3 not available")
|
||||
}
|
||||
_, here, _, _ := runtime.Caller(0)
|
||||
wrapper := filepath.Join(filepath.Dir(here), "..", "..", "configs", "felhom-priv-apply")
|
||||
f := filepath.Join(t.TempDir(), "staged")
|
||||
if err := os.WriteFile(f, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := []string{"-B", wrapper, "--check", verb}
|
||||
if name != "" {
|
||||
args = append(args, name)
|
||||
}
|
||||
out, _ := exec.Command(py, append(args, f)...).CombinedOutput()
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
@@ -186,8 +186,9 @@ func (b *BackHalf) Provision(ctx context.Context, in Input) (Result, error) {
|
||||
// 6. Install + register the pre-start self-heal hook (C1 net): if a data drive is absent at a future
|
||||
// boot, the hook creates a placeholder for its missing bind source so the guest still starts.
|
||||
// Best-effort + non-fatal — it's defense-in-depth; a provision must not fail over the hook.
|
||||
if err := guesthook.InstallSnippet(ctx, b.runner); err != nil {
|
||||
b.logger.Warn("provision: pre-start hook snippet install failed (non-fatal)", "vmid", in.VMID, "err", err)
|
||||
// R-861 (v0.146.0): the hook FILE comes with the signed config bundle; the agent only checks it and registers it.
|
||||
if err := guesthook.SnippetReady(guesthook.SnippetPath); err != nil {
|
||||
b.logger.Warn("provision: pre-start hook not in place — not registering it (non-fatal)", "vmid", in.VMID, "err", err)
|
||||
} else if err := guesthook.Register(ctx, b.runner, in.VMID); err != nil {
|
||||
b.logger.Warn("provision: pre-start hook registration failed (non-fatal)", "vmid", in.VMID, "err", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package selfupdate
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
||||
@@ -47,6 +49,7 @@ type Executor struct {
|
||||
username string
|
||||
token string
|
||||
stateDir string
|
||||
planDir string // felhom-os-apply's plan dir; "" = defaultPlanDir (tests set a temp dir)
|
||||
runner WrapperRunner
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
@@ -126,18 +129,70 @@ func (e *Executor) Execute(ctx context.Context, op string, params json.RawMessag
|
||||
return fmt.Errorf("agent_update: chmod staged: %w", err)
|
||||
}
|
||||
|
||||
// Hand off to the root wrapper. It re-verifies the sha, flips A/B, writes the pending marker,
|
||||
// and schedules the detached restart. After this the new binary starts; the commit is the
|
||||
// Manager's job once it has dwelled cleanly.
|
||||
e.logger.Warn("agent_update: handing staged binary to the guarded wrapper", "staged", staged, "version", p.Version)
|
||||
stdout, stderr, err := e.runner.Run(ctx, wrapperPath, "apply", staged, p.SHA256)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent_update: wrapper apply failed: %w (stderr: %s)", err, string(stderr))
|
||||
// R-861 (v0.146.0): hand the SIGNED job to the root wrapper felhom-os-apply (mode agent_update). It verifies the
|
||||
// operator's signature itself (root-owned signers file, this host, the window, the nonce), re-hashes the staged
|
||||
// file against the SIGNED sha, and only then runs the A/B flip (felhom-selfupdate-guarded apply, no longer in the
|
||||
// agent's sudoers). Until v0.146.0 the agent passed the sha to the flip itself, so a compromised agent could
|
||||
// install any binary — and the binary is what the escrow ceremony and the guest hook run as root.
|
||||
so, ok := signedjobs.SignedOpFrom(ctx)
|
||||
if !ok {
|
||||
return fmt.Errorf("agent_update: no signed envelope in the context — the root wrapper could not verify it")
|
||||
}
|
||||
e.logger.Warn("agent_update: apply handed off; restart scheduled", "version", p.Version, "wrapper", trim(stdout))
|
||||
planDir := e.planDir
|
||||
if planDir == "" {
|
||||
planDir = defaultPlanDir
|
||||
}
|
||||
if err := os.MkdirAll(planDir, 0o700); err != nil {
|
||||
return fmt.Errorf("agent_update: plan dir: %w", err)
|
||||
}
|
||||
plan, _ := json.Marshal(map[string]any{"release_id": "agent-" + p.Version, "layer": "host", "mode": "agent_update",
|
||||
"staged": staged, "signed": map[string]string{"blob_b64": base64.StdEncoding.EncodeToString(so.Blob), "sig": string(so.Sig)}})
|
||||
planPath := filepath.Join(planDir, "plan-agentupdate-"+p.Version+".json")
|
||||
if err := os.WriteFile(planPath, plan, 0o600); err != nil {
|
||||
return fmt.Errorf("agent_update: write plan: %w", err)
|
||||
}
|
||||
defer os.Remove(planPath)
|
||||
e.logger.Warn("agent_update: handing the signed job to the root wrapper (felhom-os-apply agent_update)", "staged", staged, "version", p.Version)
|
||||
stdout, stderr, err := e.runner.Run(ctx, osApplyPath, "--plan", planPath)
|
||||
rep := parseOSApplyReport(stdout)
|
||||
var r struct {
|
||||
Refused json.RawMessage `json:"refused"`
|
||||
Failed json.RawMessage `json:"failed"`
|
||||
AgentUpdate json.RawMessage `json:"agent_update"`
|
||||
}
|
||||
jerr := json.Unmarshal([]byte(rep), &r)
|
||||
refused := len(r.Refused) > 0 && string(r.Refused) != "null"
|
||||
if err != nil || jerr != nil || refused || len(r.Failed) > 0 || len(r.AgentUpdate) == 0 {
|
||||
return fmt.Errorf("agent_update: the root wrapper did not apply it: %v (report: %s; stderr: %s)", err, trimStr(rep), trim(stderr))
|
||||
}
|
||||
e.logger.Warn("agent_update: signed update verified as root and handed off; restart scheduled", "version", p.Version, "report", trimStr(rep))
|
||||
return nil
|
||||
}
|
||||
|
||||
// osApplyPath is the root wrapper that verifies the signed agent_update (R-861). Fixed, never config-overridable.
|
||||
const osApplyPath = "/usr/local/sbin/felhom-os-apply"
|
||||
|
||||
// defaultPlanDir is felhom-os-apply's ONLY plan directory (PLAN_DIR there; osupdate.DefaultPlanDir here).
|
||||
const defaultPlanDir = "/var/lib/felhom-agent/os"
|
||||
|
||||
// parseOSApplyReport returns the JSON after the wrapper's last "OSAPPLY-REPORT " line ("" when there is none).
|
||||
func parseOSApplyReport(stdout []byte) string {
|
||||
rep := ""
|
||||
for _, line := range strings.Split(string(stdout), "\n") {
|
||||
if strings.HasPrefix(line, "OSAPPLY-REPORT ") {
|
||||
rep = strings.TrimPrefix(line, "OSAPPLY-REPORT ")
|
||||
}
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
func trimStr(s string) string {
|
||||
if len(s) > 400 {
|
||||
return s[:400] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// download streams url → dest (0644, fsync'd) and returns the lowercase-hex sha256 of the bytes.
|
||||
func (e *Executor) download(ctx context.Context, url, dest string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
||||
@@ -15,34 +15,56 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
||||
)
|
||||
|
||||
// fakeWrapper records the verbs the executor/manager shell out, and returns a configurable error.
|
||||
// fakeWrapper records the verbs the executor/manager shell out, and returns a configurable error. For the os-apply
|
||||
// call it snapshots the plan file at call time (the executor removes it afterwards) and answers with report.
|
||||
type fakeWrapper struct {
|
||||
mu sync.Mutex
|
||||
calls [][]string
|
||||
err error
|
||||
mu sync.Mutex
|
||||
calls [][]string
|
||||
err error
|
||||
plans []map[string]any
|
||||
report string // the OSAPPLY-REPORT JSON; "" = a successful agent_update
|
||||
}
|
||||
|
||||
func (f *fakeWrapper) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, append([]string{name}, args...))
|
||||
if name == osApplyPath && len(args) == 2 && args[0] == "--plan" {
|
||||
var p map[string]any
|
||||
b, _ := os.ReadFile(args[1])
|
||||
_ = json.Unmarshal(b, &p)
|
||||
f.plans = append(f.plans, p)
|
||||
rep := f.report
|
||||
if rep == "" {
|
||||
rep = `{"agent_update": {"version": "x", "wrapper_rc": 0}, "mode": "agent_update", "refused": null}`
|
||||
}
|
||||
return []byte("OSAPPLY-REPORT " + rep + "\n"), nil, f.err
|
||||
}
|
||||
return []byte("ok"), nil, f.err
|
||||
}
|
||||
|
||||
// applyCalls are the hand-offs to the root wrapper (felhom-os-apply --plan …). Since v0.146.0 the agent never calls
|
||||
// `felhom-selfupdate-guarded apply` itself.
|
||||
func (f *fakeWrapper) applyCalls() [][]string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out [][]string
|
||||
for _, c := range f.calls {
|
||||
if len(c) >= 2 && c[1] == "apply" {
|
||||
if c[0] == osApplyPath || (len(c) >= 2 && c[1] == "apply") {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func signedCtx() context.Context {
|
||||
return signedjobs.WithSignedOp(context.Background(), &reconcile.SignedOp{Blob: []byte(`{"op":"agent_update"}`), Sig: []byte("SIG")})
|
||||
}
|
||||
|
||||
func sha256Of(b []byte) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
@@ -65,6 +87,7 @@ func newExec(t *testing.T, srv *httptest.Server, wrap WrapperRunner) (*Executor,
|
||||
Runner: wrap,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
e.planDir = t.TempDir()
|
||||
return e, stateDir
|
||||
}
|
||||
|
||||
@@ -84,7 +107,7 @@ func TestExecutor_HappyPath(t *testing.T) {
|
||||
e, stateDir := newExec(t, srv, wrap)
|
||||
|
||||
sha := sha256Of(body)
|
||||
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha)); err != nil {
|
||||
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha)); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
staged := filepath.Join(stateDir, "selfupdate", "felhom-agent-0.70.1")
|
||||
@@ -93,11 +116,46 @@ func TestExecutor_HappyPath(t *testing.T) {
|
||||
t.Fatalf("staged binary missing/mismatch: %v", err)
|
||||
}
|
||||
calls := wrap.applyCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("apply invoked %d times, want 1 (%v)", len(calls), wrap.calls)
|
||||
if len(calls) != 1 || calls[0][0] != osApplyPath || calls[0][1] != "--plan" {
|
||||
t.Fatalf("want exactly one hand-off to felhom-os-apply --plan, got %v", wrap.calls)
|
||||
}
|
||||
if calls[0][2] != staged || calls[0][3] != sha {
|
||||
t.Errorf("apply args = %v, want [.. apply %s %s]", calls[0], staged, sha)
|
||||
// R-861: the plan carries the SIGNED envelope and the staged path; the wrapper, not the agent, decides.
|
||||
p := wrap.plans[0]
|
||||
sg, _ := p["signed"].(map[string]any)
|
||||
if p["mode"] != "agent_update" || p["layer"] != "host" || p["staged"] != staged || sg["sig"] != "SIG" || sg["blob_b64"] == "" {
|
||||
t.Errorf("plan = %v, want mode agent_update + the staged path + the signed envelope", p)
|
||||
}
|
||||
if _, err := os.Stat(calls[0][2]); !os.IsNotExist(err) {
|
||||
t.Error("the plan file was left behind")
|
||||
}
|
||||
}
|
||||
|
||||
// R-861: without the signed envelope nothing reaches the root wrapper.
|
||||
// RED-PROOF (audits/hub-safety-2026-10-05/partF/red-proof.txt): call `felhom-selfupdate-guarded apply` directly again
|
||||
// → the happy path's "exactly one hand-off to felhom-os-apply" fails.
|
||||
func TestExecutor_NoEnvelopeNoHandOff(t *testing.T) {
|
||||
body := []byte("good bytes")
|
||||
srv := artifactServer(t, body)
|
||||
defer srv.Close()
|
||||
wrap := &fakeWrapper{}
|
||||
e, _ := newExec(t, srv, wrap)
|
||||
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
|
||||
t.Fatal("an update with no signed envelope was handed on")
|
||||
}
|
||||
if len(wrap.applyCalls()) != 0 {
|
||||
t.Fatalf("the root wrapper was called without an envelope: %v", wrap.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal in the wrapper's report is a failure, even when its exit code reads 0.
|
||||
func TestExecutor_WrapperRefusalSurfaces(t *testing.T) {
|
||||
body := []byte("good bytes")
|
||||
srv := artifactServer(t, body)
|
||||
defer srv.Close()
|
||||
wrap := &fakeWrapper{report: `{"mode": "agent_update", "refused": {"code": "R3", "reason": "the operator signature does not verify"}}`}
|
||||
e, _ := newExec(t, srv, wrap)
|
||||
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
|
||||
t.Fatal("a refused signed update read as applied")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +216,7 @@ func TestExecutor_WrapperFailureSurfaces(t *testing.T) {
|
||||
defer srv.Close()
|
||||
wrap := &fakeWrapper{err: errors.New("wrapper refused: sha mismatch")}
|
||||
e, _ := newExec(t, srv, wrap)
|
||||
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
|
||||
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
|
||||
t.Fatal("wrapper failure must surface")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ type MountSpec struct {
|
||||
type Binaries struct {
|
||||
Systemctl string
|
||||
Install string
|
||||
// PrivApply is the root content checker (R-861, agent v0.146.0): a unit file reaches /etc/systemd/system only
|
||||
// through `felhom-priv-apply unit <name>`, which reads the staged copy from /var/lib/felhom-agent/units/ and
|
||||
// refuses anything the renderers here never produce (a bind over /etc, a Where outside /mnt, …).
|
||||
PrivApply string
|
||||
Smartctl string
|
||||
Lvs string
|
||||
Blkid string // device signature probe (8C data-bearing detection)
|
||||
@@ -119,6 +123,9 @@ func (b Binaries) withDefaults() Binaries {
|
||||
if b.Install == "" {
|
||||
b.Install = "/usr/bin/install"
|
||||
}
|
||||
if b.PrivApply == "" {
|
||||
b.PrivApply = "/usr/local/sbin/felhom-priv-apply"
|
||||
}
|
||||
if b.Smartctl == "" {
|
||||
b.Smartctl = "/usr/sbin/smartctl"
|
||||
}
|
||||
@@ -246,9 +253,9 @@ func (h *SudoHostOps) EnsureMount(ctx context.Context, spec MountSpec) error {
|
||||
return fmt.Errorf("storage: staging unit: %w", err)
|
||||
}
|
||||
|
||||
dest := filepath.Join(h.unitDir, unitName)
|
||||
// install as root (atomic copy with fixed mode/owner) — fixed arg vector.
|
||||
if err := h.run(ctx, h.bins.Install, "-o", "root", "-g", "root", "-m", "0644", "--", stagePath, dest); err != nil {
|
||||
// R-861: the root checker installs it (fixed source dir, fixed destination dir, content checked) — never a
|
||||
// plain `install` of a file the agent wrote.
|
||||
if err := h.run(ctx, h.bins.PrivApply, "unit", unitName); err != nil {
|
||||
return fmt.Errorf("storage: installing unit %s: %w", unitName, err)
|
||||
}
|
||||
if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil {
|
||||
|
||||
@@ -55,8 +55,9 @@ func TestHostOps_MountLifecycle(t *testing.T) {
|
||||
if len(rr.calls) != 3 {
|
||||
t.Fatalf("expected 3 commands, got %d: %v", len(rr.calls), rr.calls)
|
||||
}
|
||||
if rr.calls[0][0] != "/usr/bin/install" || !contains(rr.calls[0], "0644") {
|
||||
t.Errorf("call[0] not the install: %v", rr.calls[0])
|
||||
// R-861: the unit is installed by the root content checker, named — never a plain `install` of a staged path.
|
||||
if rr.calls[0][0] != "/usr/local/sbin/felhom-priv-apply" || len(rr.calls[0]) != 3 || rr.calls[0][1] != "unit" {
|
||||
t.Errorf("call[0] not the checker's unit install: %v", rr.calls[0])
|
||||
}
|
||||
if !contains(rr.calls[1], "daemon-reload") {
|
||||
t.Errorf("call[1] not daemon-reload: %v", rr.calls[1])
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestMigrateNetworkUnits_RewritesDriftedOnceIdempotent(t *testing.T) {
|
||||
if strings.Contains(joined, "daemon-reload") {
|
||||
reloads++
|
||||
}
|
||||
if strings.Contains(joined, "install") {
|
||||
if strings.Contains(joined, "felhom-priv-apply unit") {
|
||||
installs++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,9 +236,13 @@ func (s NetworkMountSpec) mountOptions() string {
|
||||
"file_mode=0664",
|
||||
"dir_mode=0775",
|
||||
"_netdev",
|
||||
"nosuid",
|
||||
"nodev",
|
||||
}, ",")
|
||||
}
|
||||
return "vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0"
|
||||
// R-861 (v0.146.0): nosuid,nodev — a set-uid file or a device node on a server outside the box must never act on
|
||||
// the host. felhom-priv-apply refuses a network unit without them.
|
||||
return "vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0,nosuid,nodev"
|
||||
}
|
||||
|
||||
// renderNetworkMountUnit builds the .mount unit (triggered by the .automount; deliberately NO [Install]
|
||||
@@ -409,8 +413,8 @@ func (h *SudoHostOps) installUnit(ctx context.Context, unitName, content string)
|
||||
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("netmount: staging unit %s: %w", unitName, err)
|
||||
}
|
||||
dest := filepath.Join(h.unitDir, unitName)
|
||||
if err := h.run(ctx, h.bins.Install, "-o", "root", "-g", "root", "-m", "0644", "--", stagePath, dest); err != nil {
|
||||
// R-861: through the root checker (felhom-priv-apply unit), never a plain install of an agent-written file.
|
||||
if err := h.run(ctx, h.bins.PrivApply, "unit", unitName); err != nil {
|
||||
return fmt.Errorf("netmount: installing unit %s: %w", unitName, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -264,7 +264,7 @@ func TestEnsureNetworkMount_Commands(t *testing.T) {
|
||||
switch {
|
||||
case strings.Contains(joined, "mkdir") && strings.Contains(joined, "/mnt/felhom-drives/media"):
|
||||
sawMkdir = true
|
||||
case strings.Contains(joined, "install"):
|
||||
case strings.Contains(joined, "felhom-priv-apply unit"):
|
||||
installs++
|
||||
case strings.Contains(joined, "daemon-reload"):
|
||||
sawReload = true
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/privapplytest"
|
||||
)
|
||||
|
||||
// R-861: every unit the agent renders is one the root checker installs — the name it computes, the Where, the
|
||||
// options. RED-PROOF: drop "nosuid","nodev" from mountOptions → the network cases are REFUSED [U5].
|
||||
func TestPrivApply_AcceptsTheRenderedUnits(t *testing.T) {
|
||||
local := MountSpec{Name: "x", UUID: "91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae", Where: "/mnt/hdd_1", FSType: "ext4"}
|
||||
name, _ := UnitNameForMount(local.Where)
|
||||
if got := privapplytest.Check(t, "unit", name, renderMountUnit(local)); got != "OK" {
|
||||
t.Errorf("local unit %s: %s", name, got)
|
||||
}
|
||||
local.FSType = ""
|
||||
if got := privapplytest.Check(t, "unit", name, renderMountUnit(local)); got != "OK" {
|
||||
t.Errorf("local unit without Type: %s", got)
|
||||
}
|
||||
for _, spec := range []NetworkMountSpec{
|
||||
{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000},
|
||||
{Name: "photos", Protocol: ProtocolSMB, Server: "nas.lan", Export: "photos", CredsRef: "/etc/felhom/netmount/photos.cred", MappedUID: 1000, MappedGID: 1000},
|
||||
} {
|
||||
mu, err := UnitNameForMount(spec.Where())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := privapplytest.Check(t, "unit", mu, renderNetworkMountUnit(spec)); got != "OK" {
|
||||
t.Errorf("%s .mount: %s", spec.Name, got)
|
||||
}
|
||||
au := strings.TrimSuffix(mu, ".mount") + ".automount"
|
||||
if got := privapplytest.Check(t, "unit", au, renderNetworkAutomountUnit(spec)); got != "OK" {
|
||||
t.Errorf("%s .automount: %s", spec.Name, got)
|
||||
}
|
||||
}
|
||||
// control: the checker is really looking — a unit over /etc is refused
|
||||
evil := strings.Replace(renderMountUnit(MountSpec{UUID: local.UUID, Where: "/mnt/hdd_1"}), "Where=/mnt/hdd_1", "Where=/etc/sudoers.d", 1)
|
||||
if got := privapplytest.Check(t, "unit", name, evil); !strings.HasPrefix(got, "REFUSED") {
|
||||
t.Fatalf("control: a unit over /etc/sudoers.d was not refused: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ const (
|
||||
Iface = "wg-felhom"
|
||||
// confDest is the installed conf path — must match the FELHOM_WG sudoers entry EXACTLY.
|
||||
confDest = "/etc/wireguard/wg-felhom.conf"
|
||||
// privApply is the root content checker that installs confDest (R-861).
|
||||
privApply = "/usr/local/sbin/felhom-priv-apply"
|
||||
// unit is the systemd unit the sudoers allowlists.
|
||||
unit = "wg-quick@wg-felhom"
|
||||
|
||||
@@ -507,8 +509,9 @@ func (m *Manager) ensureTunnelLocked(ctx context.Context, block *hub.WireWiregua
|
||||
m.logger.Error("wgtunnel: staging conf", "err", err)
|
||||
return
|
||||
}
|
||||
// argv matches the FELHOM_WG sudoers entry exactly (fixed source + dest).
|
||||
if _, errOut, err := m.runner.Run(ctx, "install", "-o", "root", "-g", "root", "-m", "0600", "--", m.stagedConfPath(), confDest); err != nil {
|
||||
// R-861 (v0.146.0): the root checker installs the staged conf (fixed source /var/lib/felhom-agent/wg/, fixed
|
||||
// destination, 0600) and refuses any key renderConf never writes — PostUp/PreUp run as root under wg-quick.
|
||||
if _, errOut, err := m.runner.Run(ctx, privApply, "wg"); err != nil {
|
||||
m.logger.Error("wgtunnel: conf install failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ func TestScenarioA_RegisterThenApplyOrdering(t *testing.T) {
|
||||
// Block arrives → conf staged + installed + enabled.
|
||||
pub := localPub(t, m)
|
||||
m.Apply(ctx, true, testBlock(pub))
|
||||
if rr.count("install") != 1 || rr.count("systemctl") != 1 {
|
||||
if rr.count(privApply) != 1 || rr.count("systemctl") != 1 {
|
||||
t.Fatalf("apply execs = %v", rr.calls)
|
||||
}
|
||||
if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl enable --now wg-quick@wg-felhom" {
|
||||
@@ -419,7 +419,7 @@ func TestAdoptLostMarkerWithKnownKey(t *testing.T) {
|
||||
if mk == nil || mk.Pubkey != pub || mk.AssignedIP != "10.77.0.2/32" {
|
||||
t.Fatalf("adoption marker = %+v", mk)
|
||||
}
|
||||
if rr.count("install") != 1 {
|
||||
if rr.count(privApply) != 1 {
|
||||
t.Errorf("adopt did not proceed to conf apply: %v", rr.calls)
|
||||
}
|
||||
}
|
||||
@@ -686,13 +686,13 @@ func TestInitialResolveFailureNoTeardown(t *testing.T) {
|
||||
m.Apply(ctx, false, nil) // register (no DNS)
|
||||
pub := localPub(t, m)
|
||||
m.Apply(ctx, true, testBlock(pub)) // resolve fails → cannot apply
|
||||
if rr.count("install") != 0 || rr.count("systemctl") != 0 {
|
||||
if rr.count(privApply) != 0 || rr.count("systemctl") != 0 {
|
||||
t.Errorf("applied/tore-down despite a resolve failure: %v", rr.calls)
|
||||
}
|
||||
// DNS returns → the tunnel comes up on the next tick.
|
||||
fr.set(netip.MustParseAddr("167.233.158.164"))
|
||||
m.Apply(ctx, true, testBlock(pub))
|
||||
if rr.count("install") != 1 {
|
||||
if rr.count(privApply) != 1 {
|
||||
t.Errorf("did not recover after DNS returned: %v", rr.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package wgtunnel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/privapplytest"
|
||||
)
|
||||
|
||||
// R-861: the conf renderConf writes (with and without the operator OOB peer) is accepted; a PostUp line is not.
|
||||
func TestPrivApply_AcceptsTheRenderedConf(t *testing.T) {
|
||||
priv := "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
b := testBlock("CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=")
|
||||
for _, oob := range []string{"", "10.77.0.250"} {
|
||||
b.OOBPeerIP = oob
|
||||
conf, err := renderConf(b, priv, "49.12.1.2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := privapplytest.Check(t, "wg", "", conf); got != "OK" {
|
||||
t.Errorf("rendered conf (oob=%q): %s", oob, got)
|
||||
}
|
||||
if got := privapplytest.Check(t, "wg", "", strings.Replace(conf, "MTU = 1280", "MTU = 1280\nPostUp = id", 1)); !strings.HasPrefix(got, "REFUSED") {
|
||||
t.Fatalf("control: PostUp was not refused: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user