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:
+28
-1
@@ -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 -- <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
|
||||
`configs/felhom-agent.sudoers`, closing the read-only/fixed-vector gaps left by the 2026-06-28
|
||||
|
||||
@@ -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=<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
|
||||
// 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).
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
+10
-1
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
],
|
||||
"cloudflared": { "status": "active" },
|
||||
"audit_tail": [],
|
||||
"capabilities": [],
|
||||
"dr_recipe": {
|
||||
"recipe_version": 1,
|
||||
"guests": [
|
||||
|
||||
Reference in New Issue
Block a user