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

New internal/capability: Manifest of required sudo -n grants + Prober that LISTS each
via 'sudo -n -l' (never executes) + binary-exists check → ok/degraded snapshot on the hub
report. Build-time test asserts manifest⊆sudoers (red-proof: dropping lxc-info FAILs the
gate). Startup logs N/N ok + ERROR per degraded. Serve-degraded; no allowlist change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EPZ4GJ8L5Jqf8UiPwbn1kt
This commit is contained in:
2026-06-29 18:43:49 +02:00
parent 19582046ba
commit 25024d9dda
10 changed files with 584 additions and 11 deletions
+25 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -132,6 +132,7 @@
],
"cloudflared": { "status": "active" },
"audit_tail": [],
"capabilities": [],
"dr_recipe": {
"recipe_version": 1,
"guests": [