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:
@@ -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