From 25024d9dda6ebbcba29336564f96e9d2ca1b9ed4 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 29 Jun 2026 18:43:49 +0200 Subject: [PATCH] capability: agent privileged-capability self-probe (manifest + build-test + runtime snapshot) v0.44.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New internal/capability: Manifest of required sudo -n grants + Prober that LISTS each via 'sudo -n -l' (never executes) + binary-exists check → ok/degraded snapshot on the hub report. Build-time test asserts manifest⊆sudoers (red-proof: dropping lxc-info FAILs the gate). Startup logs N/N ok + ERROR per degraded. Serve-degraded; no allowlist change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EPZ4GJ8L5Jqf8UiPwbn1kt --- CHANGELOG.md | 29 ++- cmd/felhom-agent/main.go | 33 +++- internal/capability/manifest.go | 92 +++++++++ internal/capability/manifest_test.go | 181 ++++++++++++++++++ internal/capability/probe.go | 99 ++++++++++ internal/capability/probe_test.go | 117 +++++++++++ internal/hub/collect.go | 28 ++- internal/hub/report.go | 11 +- internal/hub/report_test.go | 4 + internal/hub/testdata/host-report.golden.json | 1 + 10 files changed, 584 insertions(+), 11 deletions(-) create mode 100644 internal/capability/manifest.go create mode 100644 internal/capability/manifest_test.go create mode 100644 internal/capability/probe.go create mode 100644 internal/capability/probe_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e4dde6..6d23077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,34 @@ All notable changes to **felhom-agent** are recorded here. Update on every code change that gets pushed. -## (unreleased) — sudoers completeness audit: close non-root allowlist gaps (no binary change) (2026-06-29) +## v0.44.0 — privileged-capability self-probe (build-time manifest test + runtime probe + hub snapshot) (2026-06-29) + +The agent now self-checks the `sudo -n` grants it depends on, so a missing allowlist entry (the +2026-06-28 cutover class: lxc-info/make-private/…) is caught LOUD — in CI at build time and on the +host at runtime — instead of surfacing days later as user-visible breakage. **First slice of agent +self-health; the controller↔agent channel check is a separate later task.** + +- **`internal/capability` (NEW):** a `Manifest()` of the required `(binary, representative-arg)` + vectors (seeded from the 2026-06-29 audit — the OK + CLOSED rows; the SURFACED/DEFERRED rows + `pct exec *`/`pct create`/`mount UUID`/`sensors` are deliberately excluded). `Prober.Probe` lists + each against the live policy with `sudo -n -l -- ` (a policy LIST — **never + executes**, safe for mkfs/pct entries) via a DIRECT runner, plus an `os.Stat` existence check, + mapping to `ok` / `degraded` ("sudo policy denied" | "binary not found"). A total sudo failure + (drop-in missing) collapses to ONE aggregate signal. Serve-degraded: the probe never blocks + startup, panics, or errors. +- **Build-time gate (`manifest_test.go`):** parses `configs/felhom-agent.sudoers`, translates each + glob to a regex, and asserts **every manifest vector is covered by a grant** — exactly what would + have caught the dropped `lxc-info`/`make-private` lines in CI. Includes a **red-proof**: with the + `lxc-info` line removed from an in-memory copy, the check FAILS for `guest-init-pid` (and passes + on the real file) — proving the gate is not hollow. +- **Runtime wiring:** `Probe` runs once at startup (INFO `capabilities self-check N/N ok`, plus an + ERROR per degraded capability naming the gated feature) and on every hub-report cycle; the snapshot + rides the report as the new non-nil `HostReport.Capabilities []capability.Status` (golden + + contract test updated; cross-repo hub copy mirrors it). +- **No allowlist change**; the live host is post-audit complete, so the probe reports N/N ok — itself + a live proof the probe agrees with the fixed sudoers. Version `0.43.0 → 0.44.0`. + +## (sudoers completeness audit, folded into v0.44.0) — close non-root allowlist gaps (2026-06-29) A full audit of every privileged command the agent shells via `sudo -n` against `configs/felhom-agent.sudoers`, closing the read-only/fixed-vector gaps left by the 2026-06-28 diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 30804c5..d90eaac 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -26,6 +26,7 @@ import ( "gitea.dooplex.hu/admin/felhom-agent/internal/authz" "gitea.dooplex.hu/admin/felhom-agent/internal/backup" + "gitea.dooplex.hu/admin/felhom-agent/internal/capability" "gitea.dooplex.hu/admin/felhom-agent/internal/config" "gitea.dooplex.hu/admin/felhom-agent/internal/desired" "gitea.dooplex.hu/admin/felhom-agent/internal/escrow" @@ -44,7 +45,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.43.0" +var version = "0.44.0" // runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook `). On the // pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots @@ -266,6 +267,18 @@ func (r *gateRemounter) Remount(ctx context.Context, t storage.KnownTarget) { r.logger.Info("storage: re-mounted returned target", "target", t.Name, "where", t.MountPath) } +// logCapabilities logs the privileged-capability self-check at startup: one INFO summary, plus an +// ERROR per degraded capability naming the gated feature (so a missing grant is loud at cutover, +// not days later). It never exits — serve-degraded. +func logCapabilities(statuses []capability.Status, logger *slog.Logger) { + ok, total, degraded := capability.Summarize(statuses) + logger.Info("capabilities self-check", "ok", ok, "total", total, "degraded", len(degraded)) + for _, d := range degraded { + logger.Error("capability DEGRADED — privileged grant missing (feature impaired until fixed)", + "capability", d.Name, "feature", d.Feature, "reason", d.Reason, "critical", d.Critical) + } +} + // runDaemon is the default mode: collect a host-report and POST it to the hub on a // loop. Requires both proxmox (to collect) and hub config. func runDaemon(cfg config.Config, logger *slog.Logger) int { @@ -310,6 +323,14 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int { pbsTargets := pbsTargetsFromPVE(cfg, px, logger) pbsReporter := pbs.NewLiveSnapshotReporter(pbsTargets, pbsStore, pbs.DefaultLiveSnapshotTimeout, logger) collector := hub.NewCollector(px, hub.SystemctlProber{}, observer, backupStore, backupStore, pbsReporter, cfg.Hub.HostID, version, logger) + // Privileged-capability self-check (v0.44.0): probe the sudoers grants the non-root agent + // depends on. The probe runs `sudo -n -l` LITERALLY (a policy LIST, never executing the + // command), so it uses a DIRECT runner regardless of the agent's privileged mode. Probe once at + // startup (loud on any denial) and attach the snapshot to every hub report; the hub owns the + // ok→degraded alert. Serve-degraded — a missing grant never blocks startup. + capProber := capability.Prober{Runner: &proxmox.ExecRunner{Mode: proxmox.RunnerDirect}} + logCapabilities(capProber.Probe(context.Background()), logger) + collector.SetCapabilityProber(capProber.Probe) loop := hub.NewLoop(collector, client, time.Duration(hcfg.PollSeconds)*time.Second, logger) interval := time.Duration(hcfg.PollSeconds) * time.Second @@ -713,14 +734,14 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St Tokens: tokens, BackupCadence: cfg.Backup.BackupCadence(), // Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate. - Disks: hostOps, - DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID}, - Guests2: px, + Disks: hostOps, + DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID}, + Guests2: px, GuestAttach: guestBinder, // slice 10 P2: bind enrolled data drives into the guest ControllerSwap: guestBinder, // Phase 1: agentic controller update — in-guest image swap Intent: intent, // slice 10 P3: record enroll/eject intent for self-heal - GuestBinds: guestBinds, // F9: per-guest bind record for the startup re-assert - FormatJobs: formatJobs, // F20-BUG3: detached-format job record + restart recovery + GuestBinds: guestBinds, // F9: per-guest bind record for the startup re-assert + FormatJobs: formatJobs, // F20-BUG3: detached-format job record + restart recovery // Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host + // per-storage view to the customer's monitoring page (reuses the slice-4 collector). diff --git a/internal/capability/manifest.go b/internal/capability/manifest.go new file mode 100644 index 0000000..5aaafa2 --- /dev/null +++ b/internal/capability/manifest.go @@ -0,0 +1,92 @@ +// Package capability is the agent's privileged-capability self-check (slice 1 of agent +// self-health). It declares the MANIFEST — the (binary, representative-arg-vector) pairs the +// non-root agent depends on running via `sudo -n` — and a PROBE that lists each against the live +// sudoers policy (`sudo -n -l`, never executing) + checks the binary exists. The result is a +// snapshot the agent attaches to its hub report; the hub owns the ok→degraded transition + alert. +// +// Why this exists: the 2026-06-28 root→non-root cutover dropped several grants from +// configs/felhom-agent.sudoers (lxc-info, make-private, restart dnsmasq, …). Each broke a feature +// silently until a user hit it (the multi-drive flapping incident, audit 2026-06-29). A non-root +// agent that can't run a command it depends on is DEGRADED and must SAY so — at cutover, not days +// later. The companion build-time test (manifest_test.go) asserts every manifest vector is covered +// by a sudoers pattern, catching authoring gaps in CI before they ship. +package capability + +// Capability is one privileged command the agent depends on. Name is a stable id; Feature is the +// human-readable thing that breaks if the grant is missing (used in logs + the operator alert). +// Binary is the absolute path the runner invokes; ReprArgs is a CONCRETE argument vector that +// matches the corresponding sudoers glob (e.g. a vmid "9201" matches `[0-9]*`, a device "/dev/sda" +// matches `/dev/*`). Critical marks the user-facing ones — the hub alerts only when a Critical +// capability is degraded (non-critical degradations still ride the report snapshot + agent log). +type Capability struct { + Name string + Feature string + Binary string + ReprArgs []string + Critical bool +} + +// Manifest is the required set, seeded from the 2026-06-29 sudoers audit (felhom-agent/REPORT.md): +// the OK + newly-CLOSED rows. The SURFACED/DEFERRED rows are deliberately EXCLUDED — they are not +// required capabilities: the general `pct exec -- *` (controller-swap; arbitrary exec, an +// open operator decision), `pct create` (golden build, maintenance, no daemon caller), `mount +// UUID=…` (legacy/unreferenced), and the callerless `sensors -j`. Adding them here would assert +// grants the agent neither has nor should depend on. +// +// Each ReprArgs is a representative instance; the probe LISTS it (`sudo -n -l`) and never runs it, +// so even mkfs/pct-set entries are side-effect-free to probe. +func Manifest() []Capability { return manifest } + +var manifest = []Capability{ + // ---- Intermediary drive model (the multi-drive path — mostly Critical) ---- + {"guest-init-pid", "drive-gate guest-sees check (multi-drive concurrency)", "/usr/bin/lxc-info", []string{"-n", "9201", "-p", "-H"}, true}, + {"parent-self-bind", "intermediary shared-parent self-bind", "/usr/bin/mount", []string{"--bind", "/mnt/felhom-drives", "/mnt/felhom-drives"}, true}, + {"parent-make-shared", "intermediary shared-parent propagation", "/usr/bin/mount", []string{"--make-shared", "/mnt/felhom-drives"}, true}, + {"parent-make-private", "intermediary shared-parent peer-group isolation", "/usr/bin/mount", []string{"--make-private", "/mnt/felhom-drives"}, true}, + {"drive-bind", "drive attach (felhom-data bind under parent)", "/usr/bin/mount", []string{"--bind", "/mnt/felhom-usb/felhom-data", "/mnt/felhom-drives/felhom-usb"}, true}, + {"drive-umount", "drive detach (fail-closed unmount)", "/usr/bin/umount", []string{"/mnt/felhom-drives/felhom-usb"}, true}, + {"drives-mkdir-parent", "stable parent dir create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-drives"}, false}, + {"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.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.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}, + + // ---- 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}, + {"disk-lsblk", "disk topology read (format gate)", "/usr/bin/lsblk", []string{"-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", "/dev/sda"}, true}, + {"disk-mkfs-ext4", "blank-device format (ext4)", "/usr/sbin/mkfs.ext4", []string{"-F", "/dev/sda"}, true}, + {"disk-mkfs-xfs", "blank-device format (xfs)", "/usr/sbin/mkfs.xfs", []string{"-f", "/dev/sda"}, false}, + {"disk-smart", "disk SMART health read", "/usr/sbin/smartctl", []string{"-a", "-j", "/dev/sda"}, false}, + {"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-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}, + + // ---- 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-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.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-enable", "dnsmasq enable", "/usr/bin/systemctl", []string{"enable", "--now", "dnsmasq"}, false}, + {"dnsmasq-reload", "dnsmasq reload", "/usr/bin/systemctl", []string{"reload", "dnsmasq"}, false}, + {"dnsmasq-restart", "dnsmasq restart (LAN-DNS self-heal)", "/usr/bin/systemctl", []string{"restart", "dnsmasq"}, false}, + {"dnsmasq-rm", "dnsmasq drop-in remove (decommission)", "/usr/bin/rm", []string{"-f", "/etc/dnsmasq.d/felhom-x.conf"}, false}, + {"dnsmasq-guest-ip", "guest LAN IP discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "ip", "-4", "-o", "addr", "show", "dev", "eth0"}, false}, + {"dnsmasq-guest-domain", "guest domain discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "exec", "felhom-controller", "cat", "/opt/docker/felhom-controller/controller.yaml"}, false}, +} diff --git a/internal/capability/manifest_test.go b/internal/capability/manifest_test.go new file mode 100644 index 0000000..3b81b02 --- /dev/null +++ b/internal/capability/manifest_test.go @@ -0,0 +1,181 @@ +package capability + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// sudoersPath is the in-repo allowlist, relative to this test file (internal/capability/). +const sudoersPath = "../../configs/felhom-agent.sudoers" + +// parseSudoersEntries returns every command pattern from the Cmnd_Alias blocks, with the sudoers +// escapes (`\,` `\:`) unescaped. It joins continuation lines and splits the alias RHS on commas +// that are NOT backslash-escaped (escaped commas are literal arg chars, e.g. the lvs `-o` list). +func parseSudoersEntries(t *testing.T, text string) []string { + t.Helper() + // 1. Collapse line continuations, keeping only Cmnd_Alias RHS text. + var rhs strings.Builder + lines := strings.Split(text, "\n") + inAlias := false + for _, ln := range lines { + trimmed := strings.TrimSpace(ln) + if strings.HasPrefix(trimmed, "#") { + continue + } + if strings.HasPrefix(trimmed, "Cmnd_Alias ") { + inAlias = true + if eq := strings.IndexByte(trimmed, '='); eq >= 0 { + trimmed = trimmed[eq+1:] + } + } else if !inAlias { + continue + } + // The final NOPASSWD line ("felhom-agent ALL=...") ends the alias region. + if strings.Contains(trimmed, "ALL=(") { + inAlias = false + continue + } + cont := strings.HasSuffix(trimmed, "\\") + rhs.WriteString(strings.TrimSuffix(trimmed, "\\")) + rhs.WriteString(" ") + if !cont { + // A non-continued line is the last entry of this alias. Emit a comma so it does not + // merge with the next alias's first entry when all RHS text is concatenated. + rhs.WriteString(", ") + inAlias = false + } + } + // 2. Split on unescaped commas → individual command entries. + raw := rhs.String() + var entries []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 (\, → , ; \: → :) + i++ + continue + } + if c == ',' { + entries = appendTrimmed(entries, cur.String()) + cur.Reset() + continue + } + cur.WriteByte(c) + } + entries = appendTrimmed(entries, cur.String()) + return entries +} + +func appendTrimmed(entries []string, s string) []string { + if t := strings.Join(strings.Fields(s), " "); t != "" { + return append(entries, t) + } + return entries +} + +// globToRegex translates a sudoers fnmatch pattern to an anchored regex. It is NOT a perfect sudo +// emulator — it only needs to catch a removed/renamed grant (the real failure mode). `*` → `.*`, +// `[...]` char classes pass through (valid regex), regex metachars are escaped. +func globToRegex(pat string) *regexp.Regexp { + var b strings.Builder + b.WriteString("^") + for i := 0; i < len(pat); i++ { + c := pat[i] + switch { + case c == '*': + b.WriteString(".*") + case c == '[': // copy the char class verbatim (valid in regex too) + if j := strings.IndexByte(pat[i:], ']'); j > 0 { + b.WriteString(pat[i : i+j+1]) + i += j + continue + } + b.WriteString("\\[") + case strings.IndexByte(`.+()|{}^$\?`, c) >= 0: + b.WriteByte('\\') + b.WriteByte(c) + default: + b.WriteByte(c) + } + } + b.WriteString("$") + return regexp.MustCompile(b.String()) +} + +// 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) { + return true + } + } + return false +} + +// TestManifestCoveredBySudoers is the headline build-time gate: EVERY manifest capability's +// representative command line must be permitted by at least one sudoers pattern. This is exactly +// the check that would have caught the lxc-info / make-private grants being dropped at the +// 2026-06-28 cutover — in CI, before shipping. +func TestManifestCoveredBySudoers(t *testing.T) { + data, err := os.ReadFile(sudoersPath) + if err != nil { + t.Fatalf("read sudoers %s: %v", sudoersPath, err) + } + entries := parseSudoersEntries(t, string(data)) + if len(entries) < 20 { + t.Fatalf("parsed only %d sudoers entries — parser likely broke", len(entries)) + } + for _, c := range Manifest() { + cmdline := strings.TrimSpace(c.Binary + " " + strings.Join(c.ReprArgs, " ")) + if !matchesAny(cmdline, entries) { + t.Errorf("capability %q (%s) NOT covered by any sudoers grant:\n %s", + c.Name, c.Feature, cmdline) + } + } +} + +// TestRedProof_DroppedGrantFailsCheck is the companion red-proof: with the lxc-info line removed +// from an in-memory copy of the sudoers, the coverage check for guest-init-pid MUST fail. Proves +// the build gate actually catches the regression (a green test that can never go red is hollow). +func TestRedProof_DroppedGrantFailsCheck(t *testing.T) { + data, err := os.ReadFile(sudoersPath) + if err != nil { + t.Fatalf("read sudoers: %v", err) + } + // Drop the lxc-info grant line. + var kept []string + for _, ln := range strings.Split(string(data), "\n") { + if strings.Contains(ln, "lxc-info") { + continue + } + kept = append(kept, ln) + } + mutated := strings.Join(kept, "\n") + if strings.Contains(mutated, "lxc-info") { + t.Fatal("setup: lxc-info line not removed") + } + entries := parseSudoersEntries(t, mutated) + + var guestInit Capability + for _, c := range Manifest() { + if c.Name == "guest-init-pid" { + guestInit = c + } + } + if guestInit.Name == "" { + t.Fatal("manifest missing guest-init-pid") + } + cmdline := guestInit.Binary + " " + strings.Join(guestInit.ReprArgs, " ") + if matchesAny(cmdline, entries) { + t.Errorf("red-proof FAILED: guest-init-pid still matches after dropping the lxc-info grant — the build gate would NOT catch the regression") + } + + // Sanity: the UNMUTATED file MUST cover it (so the failure above is specific to the drop). + full := parseSudoersEntries(t, string(data)) + if !matchesAny(cmdline, full) { + t.Errorf("guest-init-pid should be covered by the real sudoers") + } +} diff --git a/internal/capability/probe.go b/internal/capability/probe.go new file mode 100644 index 0000000..42c341e --- /dev/null +++ b/internal/capability/probe.go @@ -0,0 +1,99 @@ +package capability + +import ( + "context" + "os" +) + +// Status is one capability's live result — the wire shape the agent attaches to its hub report +// (HostReport.Capabilities). The hub mirrors this struct field-for-field and keys its alert on +// Critical+degraded. Reason is empty when ok. +type Status struct { + Name string `json:"name"` + Feature string `json:"feature"` + Critical bool `json:"critical"` + Status string `json:"status"` // "ok" | "degraded" + Reason string `json:"reason,omitempty"` +} + +const ( + StatusOK = "ok" + StatusDegraded = "degraded" +) + +// Runner is the minimal exec seam the probe needs (satisfied by proxmox.ExecRunner). The probe +// runs `sudo -n -l -- ` LITERALLY — a sudo POLICY LIST that never executes the +// command — so the Runner MUST be a DIRECT runner (RunnerDirect), not the sudo-prepending one +// (else it would double-sudo). exit 0 ⇔ the command is permitted under the NOPASSWD allowlist. +type Runner interface { + Run(ctx context.Context, name string, args ...string) (stdout, stderr []byte, err error) +} + +// Prober checks the manifest against the live host. Exists defaults to an os.Stat check on the +// absolute binary path (what `command -v` would resolve for an absolute path) when nil. +type Prober struct { + Runner Runner + Exists func(path string) bool // nil → os.Stat +} + +// Probe lists every manifest capability against the sudo policy and checks its binary exists, +// mapping to ok/degraded (§8 of the spec). It NEVER executes a probed command and NEVER returns a +// fatal error (serve-degraded): a probe failure is reported, not raised. If sudo itself is +// unavailable for the agent (the drop-in is missing / the user has no sudo at all), it collapses +// to ONE aggregate degraded signal instead of N identical ones. +func (p Prober) Probe(ctx context.Context) []Status { + exists := p.Exists + if exists == nil { + exists = func(path string) bool { _, err := os.Stat(path); return err == nil } + } + caps := Manifest() + + // Preflight: a bare `sudo -n -l` lists the user's allowed commands. For our NOPASSWD service + // user it exits 0; if it fails, the drop-in isn't installed (or sudo is gone) and EVERY vector + // would individually fail — collapse to one aggregate signal so the operator gets one alert. + if p.Runner != nil { + if _, _, err := p.Runner.Run(ctx, "sudo", "-n", "-l"); err != nil { + return []Status{{ + Name: "sudo", + Feature: "the entire privileged surface (mount/format/pct/dnsmasq/lxc-info)", + Critical: true, + Status: StatusDegraded, + Reason: "sudoers drop-in not installed / sudo unavailable", + }} + } + } + + out := make([]Status, 0, len(caps)) + for _, c := range caps { + s := Status{Name: c.Name, Feature: c.Feature, Critical: c.Critical, Status: StatusOK} + switch { + case !exists(c.Binary): + s.Status, s.Reason = StatusDegraded, "binary not found" + case p.Runner != nil && !p.granted(ctx, c): + s.Status, s.Reason = StatusDegraded, "sudo policy denied" + } + out = append(out, s) + } + return out +} + +// granted reports whether `sudo -n -l -- ` is permitted (exit 0). List-mode is +// side-effect-free — the command is matched against the policy, never run. +func (p Prober) granted(ctx context.Context, c Capability) bool { + args := append([]string{"-n", "-l", "--", c.Binary}, c.ReprArgs...) + _, _, err := p.Runner.Run(ctx, "sudo", args...) + return err == nil +} + +// Summarize returns (okCount, total, degraded) for logging. degraded lists every non-ok status. +func Summarize(statuses []Status) (ok, total int, degraded []Status) { + total = len(statuses) + for _, s := range statuses { + if s.Status == StatusOK { + ok++ + } else { + degraded = append(degraded, s) + } + } + return ok, total, degraded +} diff --git a/internal/capability/probe_test.go b/internal/capability/probe_test.go new file mode 100644 index 0000000..6c8227f --- /dev/null +++ b/internal/capability/probe_test.go @@ -0,0 +1,117 @@ +package capability + +import ( + "context" + "errors" + "strings" + "testing" +) + +// fakeRunner returns a canned error per (command line) and records calls. deny holds binaries (or +// the bare "sudo -n -l" preflight) that should fail; everything else exits 0. +type fakeRunner struct { + preflightErr error + denyBinary map[string]bool // binary path → policy-denied + calls int + executedReal bool // set if a probed command was ever run WITHOUT -l (must never happen) +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) { + f.calls++ + // Preflight is `sudo -n -l` (exactly 2 args, no `--`). + if name == "sudo" && len(args) == 2 && args[0] == "-n" && args[1] == "-l" { + return nil, nil, f.preflightErr + } + // Every real probe must be a LIST: `sudo -n -l -- …`. + if name != "sudo" || len(args) < 4 || args[0] != "-n" || args[1] != "-l" || args[2] != "--" { + f.executedReal = true + return nil, nil, nil + } + binary := args[3] + if f.denyBinary[binary] { + return nil, nil, errors.New("sudo: a password is required") + } + return nil, nil, nil +} + +func find(statuses []Status, name string) Status { + for _, s := range statuses { + if s.Name == name { + return s + } + } + return Status{} +} + +// §7-A: all grants present + binaries exist → every capability ok. +func TestProbe_AllOK(t *testing.T) { + r := &fakeRunner{denyBinary: map[string]bool{}} + p := Prober{Runner: r, Exists: func(string) bool { return true }} + statuses := p.Probe(context.Background()) + ok, total, degraded := Summarize(statuses) + if total != len(Manifest()) { + t.Fatalf("total=%d want %d", total, len(Manifest())) + } + if ok != total || len(degraded) != 0 { + t.Fatalf("expected all ok, got %d/%d (degraded: %+v)", ok, total, degraded) + } + if r.executedReal { + t.Fatal("probe executed a command without -l (must be list-only)") + } +} + +// §7-B: one grant denied → that capability degraded "sudo policy denied", others ok. Serve-degraded. +func TestProbe_OneDenied(t *testing.T) { + r := &fakeRunner{denyBinary: map[string]bool{"/usr/bin/lxc-info": true}} + p := Prober{Runner: r, Exists: func(string) bool { return true }} + statuses := p.Probe(context.Background()) + + gi := find(statuses, "guest-init-pid") + if gi.Status != StatusDegraded || gi.Reason != "sudo policy denied" { + t.Fatalf("guest-init-pid = %+v, want degraded/sudo policy denied", gi) + } + if !gi.Critical { + t.Fatal("guest-init-pid should be Critical") + } + // A sibling stays ok. + if s := find(statuses, "drive-bind"); s.Status != StatusOK { + t.Fatalf("drive-bind = %+v, want ok", s) + } + ok, total, _ := Summarize(statuses) + if ok != total-1 { + t.Fatalf("expected exactly one degraded, got ok=%d total=%d", ok, total) + } +} + +// §7-D: binary missing but policy granted → degraded "binary not found". +func TestProbe_BinaryMissing(t *testing.T) { + r := &fakeRunner{denyBinary: map[string]bool{}} + p := Prober{Runner: r, Exists: func(path string) bool { return path != "/usr/bin/lxc-info" }} + statuses := p.Probe(context.Background()) + gi := find(statuses, "guest-init-pid") + if gi.Status != StatusDegraded || gi.Reason != "binary not found" { + t.Fatalf("guest-init-pid = %+v, want degraded/binary not found", gi) + } +} + +// §8 aggregate: sudo itself unavailable for the user → ONE aggregate degraded, not N. +func TestProbe_SudoUnavailableAggregates(t *testing.T) { + r := &fakeRunner{preflightErr: errors.New("Sorry, user felhom-agent may not run sudo"), denyBinary: map[string]bool{}} + p := Prober{Runner: r, Exists: func(string) bool { return true }} + statuses := p.Probe(context.Background()) + if len(statuses) != 1 { + t.Fatalf("expected 1 aggregate status, got %d", len(statuses)) + } + s := statuses[0] + if s.Name != "sudo" || s.Status != StatusDegraded || !s.Critical || !strings.Contains(s.Reason, "drop-in not installed") { + t.Fatalf("aggregate = %+v, want critical degraded sudo-unavailable", s) + } +} + +// Probe must never raise — even with a nil runner (e.g. a dev path) it returns statuses. +func TestProbe_NilRunnerNoPanic(t *testing.T) { + p := Prober{Runner: nil, Exists: func(string) bool { return true }} + if got := len(p.Probe(context.Background())); got != len(Manifest()) { + t.Fatalf("nil-runner probe returned %d statuses, want %d", got, len(Manifest())) + } +} diff --git a/internal/hub/collect.go b/internal/hub/collect.go index 228c4a1..398d7bc 100644 --- a/internal/hub/collect.go +++ b/internal/hub/collect.go @@ -6,6 +6,7 @@ import ( "log/slog" "time" + "gitea.dooplex.hu/admin/felhom-agent/internal/capability" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) @@ -57,7 +58,8 @@ type Collector struct { backups BackupReporter restoreTests RestoreTestReporter pbs PBSReporter - temp TempReader // slice 9: host CPU/chassis temp (nil-safe → nil temp) + temp TempReader // slice 9: host CPU/chassis temp (nil-safe → nil temp) + capProbe func(ctx context.Context) []capability.Status // v0.44.0: privileged-capability self-check (nil → empty) hostID string agentVersion string logger *slog.Logger @@ -92,6 +94,13 @@ func (c *Collector) SetTempReader(t TempReader) *Collector { return c } +// SetCapabilityProber wires the privileged-capability self-check (v0.44.0): each collect runs it +// and attaches the snapshot. nil → the report carries an empty []. Returns the collector for chaining. +func (c *Collector) SetCapabilityProber(probe func(ctx context.Context) []capability.Status) *Collector { + c.capProbe = probe + return c +} + // Collect builds the report. Best-effort liveness: a failed NodeStatus is a hard // error (no useful report — the cycle skips the POST); a failed per-guest // GuestConfig degrades that guest to status="unknown" without spec but still sends; @@ -117,8 +126,9 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) { RestoreTests: c.collectRestoreTests(ctx), PBSSnapshots: c.collectPBSSnapshots(ctx), - AuditTail: []AuditEntry{}, - Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)}, + AuditTail: []AuditEntry{}, + Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)}, + Capabilities: c.capabilities(ctx), } // DR recipe host-half — derived from the just-collected guest/storage/PBS facts (no new reads). // Secret-free by construction (identifiers/intents/sizes/coordinates only). @@ -126,6 +136,18 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) { return report, nil } +// capabilities runs the privileged-capability self-check for this report (v0.44.0), or returns an +// empty (non-nil) slice when no prober is wired (dev/test). Never fatal — serve-degraded. +func (c *Collector) capabilities(ctx context.Context) []capability.Status { + if c.capProbe == nil { + return []capability.Status{} + } + if s := c.capProbe(ctx); s != nil { + return s + } + return []capability.Status{} +} + // HostMetricsNow does a FRESH NodeStatus + CPU-temp read and returns just the host block (no // guests/storage). It is the source for the local API's GET /host/metrics (slice 9) — current // cpu%/temp, not the 15-min hub-report snapshot. Storage targets come from the observer diff --git a/internal/hub/report.go b/internal/hub/report.go index 937ecfb..06904f9 100644 --- a/internal/hub/report.go +++ b/internal/hub/report.go @@ -1,6 +1,10 @@ package hub -import "encoding/json" +import ( + "encoding/json" + + "gitea.dooplex.hu/admin/felhom-agent/internal/capability" +) // HostReport is the wire contract shared with the hub's ingest // (felhom.eu TASK-slice3-hub-ingest). Field NAMES must match the hub @@ -27,6 +31,11 @@ type HostReport struct { Cloudflared Cloudflared `json:"cloudflared"` AuditTail []AuditEntry `json:"audit_tail"` // populated by a later slice + // Capabilities is the agent's privileged-capability self-check snapshot (v0.44.0): per required + // `sudo -n` grant, whether it is permitted + the binary exists. The hub keys its operator alert + // on a Critical capability flipping to "degraded". Non-nil so it marshals as []. + Capabilities []capability.Status `json:"capabilities"` + // DR recipe — the agent (storage/guest/PBS) half of the secret-free reconstruction recipe // (SPIKE-dr-recipe-2026-06-16). Derived from the facts above; carries ONLY identifiers/intents/ // sizes/coordinates, never a secret. The hub assembles it with the controller's app half. diff --git a/internal/hub/report_test.go b/internal/hub/report_test.go index 3449798..447f1eb 100644 --- a/internal/hub/report_test.go +++ b/internal/hub/report_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "strings" "testing" + + "gitea.dooplex.hu/admin/felhom-agent/internal/capability" ) func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) { @@ -28,6 +30,7 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) { PBSSnapshots: []PBSSnapshot{}, AuditTail: []AuditEntry{}, Cloudflared: Cloudflared{Status: "active"}, + Capabilities: []capability.Status{}, } // dr_recipe is always set on the real path (Collect); set it here too so the "no null" invariant // covers it (empty pbs is omitempty → omitted, never null). @@ -46,6 +49,7 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) { `"cloudflared":{"status":"active"}`, // empty collections must be [] not null `"storage_targets":[]`, `"backups":[]`, `"restore_tests":[]`, `"pbs_snapshots":[]`, `"audit_tail":[]`, + `"capabilities":[]`, } { if !strings.Contains(got, field) { t.Errorf("report JSON missing %s\n got: %s", field, got) diff --git a/internal/hub/testdata/host-report.golden.json b/internal/hub/testdata/host-report.golden.json index 16832c1..4392845 100644 --- a/internal/hub/testdata/host-report.golden.json +++ b/internal/hub/testdata/host-report.golden.json @@ -132,6 +132,7 @@ ], "cloudflared": { "status": "active" }, "audit_tail": [], + "capabilities": [], "dr_recipe": { "recipe_version": 1, "guests": [