capability: agent privileged-capability self-probe (manifest + build-test + runtime snapshot) v0.44.0

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EPZ4GJ8L5Jqf8UiPwbn1kt
This commit is contained in:
2026-06-29 18:43:49 +02:00
parent 19582046ba
commit 25024d9dda
10 changed files with 584 additions and 11 deletions
+28 -1
View File
@@ -3,7 +3,34 @@
All notable changes to **felhom-agent** are recorded here. Update on every code All notable changes to **felhom-agent** are recorded here. Update on every code
change that gets pushed. 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 -- <binary> <args>` (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 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 `configs/felhom-agent.sudoers`, closing the read-only/fixed-vector gaps left by the 2026-06-28
+27 -6
View File
@@ -26,6 +26,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/authz" "gitea.dooplex.hu/admin/felhom-agent/internal/authz"
"gitea.dooplex.hu/admin/felhom-agent/internal/backup" "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/config"
"gitea.dooplex.hu/admin/felhom-agent/internal/desired" "gitea.dooplex.hu/admin/felhom-agent/internal/desired"
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow" "gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
@@ -44,7 +45,7 @@ import (
// version is the agent version. Overridable at build time with // version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version. // -ldflags "-X main.version=<v>"; 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 <vmid> <phase>`). On the // runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook <vmid> <phase>`). On the
// pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots // 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) 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 // 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. // loop. Requires both proxmox (to collect) and hub config.
func runDaemon(cfg config.Config, logger *slog.Logger) int { 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) pbsTargets := pbsTargetsFromPVE(cfg, px, logger)
pbsReporter := pbs.NewLiveSnapshotReporter(pbsTargets, pbsStore, pbs.DefaultLiveSnapshotTimeout, logger) pbsReporter := pbs.NewLiveSnapshotReporter(pbsTargets, pbsStore, pbs.DefaultLiveSnapshotTimeout, logger)
collector := hub.NewCollector(px, hub.SystemctlProber{}, observer, backupStore, backupStore, pbsReporter, cfg.Hub.HostID, version, 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) loop := hub.NewLoop(collector, client, time.Duration(hcfg.PollSeconds)*time.Second, logger)
interval := time.Duration(hcfg.PollSeconds) * time.Second interval := time.Duration(hcfg.PollSeconds) * time.Second
@@ -713,14 +734,14 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
Tokens: tokens, Tokens: tokens,
BackupCadence: cfg.Backup.BackupCadence(), BackupCadence: cfg.Backup.BackupCadence(),
// Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate. // Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate.
Disks: hostOps, Disks: hostOps,
DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID}, DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID},
Guests2: px, Guests2: px,
GuestAttach: guestBinder, // slice 10 P2: bind enrolled data drives into the guest GuestAttach: guestBinder, // slice 10 P2: bind enrolled data drives into the guest
ControllerSwap: guestBinder, // Phase 1: agentic controller update — in-guest image swap ControllerSwap: guestBinder, // Phase 1: agentic controller update — in-guest image swap
Intent: intent, // slice 10 P3: record enroll/eject intent for self-heal Intent: intent, // slice 10 P3: record enroll/eject intent for self-heal
GuestBinds: guestBinds, // F9: per-guest bind record for the startup re-assert GuestBinds: guestBinds, // F9: per-guest bind record for the startup re-assert
FormatJobs: formatJobs, // F20-BUG3: detached-format job record + restart recovery FormatJobs: formatJobs, // F20-BUG3: detached-format job record + restart recovery
// Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host + // 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). // per-storage view to the customer's monitoring page (reuses the slice-4 collector).
+92
View File
@@ -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 <vmid> -- *` (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},
}
+181
View File
@@ -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")
}
}
+99
View File
@@ -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 -- <binary> <args…>` 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 -- <binary> <reprArgs…>` 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
}
+117
View File
@@ -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 -- <binary> …`.
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()))
}
}
+25 -3
View File
@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"time" "time"
"gitea.dooplex.hu/admin/felhom-agent/internal/capability"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
) )
@@ -57,7 +58,8 @@ type Collector struct {
backups BackupReporter backups BackupReporter
restoreTests RestoreTestReporter restoreTests RestoreTestReporter
pbs PBSReporter 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 hostID string
agentVersion string agentVersion string
logger *slog.Logger logger *slog.Logger
@@ -92,6 +94,13 @@ func (c *Collector) SetTempReader(t TempReader) *Collector {
return c 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 // 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 // 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; // 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), RestoreTests: c.collectRestoreTests(ctx),
PBSSnapshots: c.collectPBSSnapshots(ctx), PBSSnapshots: c.collectPBSSnapshots(ctx),
AuditTail: []AuditEntry{}, AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)}, 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). // 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). // 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 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 // 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 // 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 // cpu%/temp, not the 15-min hub-report snapshot. Storage targets come from the observer
+10 -1
View File
@@ -1,6 +1,10 @@
package hub 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 // HostReport is the wire contract shared with the hub's ingest
// (felhom.eu TASK-slice3-hub-ingest). Field NAMES must match the hub // (felhom.eu TASK-slice3-hub-ingest). Field NAMES must match the hub
@@ -27,6 +31,11 @@ type HostReport struct {
Cloudflared Cloudflared `json:"cloudflared"` Cloudflared Cloudflared `json:"cloudflared"`
AuditTail []AuditEntry `json:"audit_tail"` // populated by a later slice 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 // 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/ // (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. // sizes/coordinates, never a secret. The hub assembles it with the controller's app half.
+4
View File
@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"strings" "strings"
"testing" "testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/capability"
) )
func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) { func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) {
@@ -28,6 +30,7 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) {
PBSSnapshots: []PBSSnapshot{}, PBSSnapshots: []PBSSnapshot{},
AuditTail: []AuditEntry{}, AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: "active"}, 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 // 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). // covers it (empty pbs is omitempty → omitted, never null).
@@ -46,6 +49,7 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) {
`"cloudflared":{"status":"active"}`, `"cloudflared":{"status":"active"}`,
// empty collections must be [] not null // empty collections must be [] not null
`"storage_targets":[]`, `"backups":[]`, `"restore_tests":[]`, `"pbs_snapshots":[]`, `"audit_tail":[]`, `"storage_targets":[]`, `"backups":[]`, `"restore_tests":[]`, `"pbs_snapshots":[]`, `"audit_tail":[]`,
`"capabilities":[]`,
} { } {
if !strings.Contains(got, field) { if !strings.Contains(got, field) {
t.Errorf("report JSON missing %s\n got: %s", field, got) t.Errorf("report JSON missing %s\n got: %s", field, got)
+1
View File
@@ -132,6 +132,7 @@
], ],
"cloudflared": { "status": "active" }, "cloudflared": { "status": "active" },
"audit_tail": [], "audit_tail": [],
"capabilities": [],
"dr_recipe": { "dr_recipe": {
"recipe_version": 1, "recipe_version": 1,
"guests": [ "guests": [