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
|
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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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"
|
"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
@@ -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,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)
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
Reference in New Issue
Block a user