agent: report served local-API leaf fingerprint (hub re-key detection, Part A) v0.48.0

HostReport.LeafFingerprint rides the served fp (from EnsureLeaf) on every report; empty when local
API disabled. Collector.SetLeafFingerprint threads it like Capabilities. Hub watches it for a re-key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
This commit is contained in:
2026-06-29 23:14:52 +02:00
parent 61f9b4dcc3
commit bf8e3be3f4
10 changed files with 76 additions and 13 deletions
+13 -3
View File
@@ -60,6 +60,7 @@ type Collector struct {
pbs PBSReporter
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)
leafFP string // v0.48.0: served local-API leaf fp (static per process; "" when local API disabled)
hostID string
agentVersion string
logger *slog.Logger
@@ -101,6 +102,14 @@ func (c *Collector) SetCapabilityProber(probe func(ctx context.Context) []capabi
return c
}
// SetLeafFingerprint records the served local-API leaf fp (v0.48.0) to ride every host report (the hub
// watches it for a re-key). Static per process — set once at startup. "" when the local API is
// disabled. Returns the collector for chaining.
func (c *Collector) SetLeafFingerprint(fp string) *Collector {
c.leafFP = fp
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;
@@ -126,9 +135,10 @@ 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)},
Capabilities: c.capabilities(ctx),
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)},
Capabilities: c.capabilities(ctx),
LeafFingerprint: c.leafFP,
}
// 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).
+24
View File
@@ -146,3 +146,27 @@ func TestCollect_CloudflaredProbeErrorIsUnknown(t *testing.T) {
t.Error("empty collections must be non-nil")
}
}
// Part A: the served leaf fp rides the report when set (v0.48.0); empty when the local API is disabled
// (no SetLeafFingerprint). Companion: the unset case proves the threading is what populates it.
func TestCollect_LeafFingerprint(t *testing.T) {
px := &fakePx{node: "n", ns: newTestNodeStatus()}
const fp = "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245"
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.48.0", quietLogger())
c.SetLeafFingerprint(fp)
r, err := c.Collect(context.Background())
if err != nil {
t.Fatalf("Collect: %v", err)
}
if r.LeafFingerprint != fp {
t.Fatalf("leaf_fingerprint = %q, want %q", r.LeafFingerprint, fp)
}
// Companion: no SetLeafFingerprint (local API disabled) → empty, never a fabricated value.
c2 := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.48.0", quietLogger())
r2, _ := c2.Collect(context.Background())
if r2.LeafFingerprint != "" {
t.Fatalf("unset leaf_fingerprint = %q, want empty", r2.LeafFingerprint)
}
}
+1
View File
@@ -21,6 +21,7 @@ import "sort"
// - restic_repo_coord — RESERVED for a future offsite bulk-volume backup tier. None exists today:
// external-drive data has no offsite/second-failure-domain copy (cross-drive backup is rsync to
// the SAME internal SSD), so the field named nothing real. Re-add when that tier ships.
//
// The pbs coord, by contrast, is resolved LIVE each collect (LiveSnapshotReporter) so the restore
// SOURCE is present whenever PBS is reachable — not gated on the 6 h verify cadence.
//
+1 -1
View File
@@ -80,7 +80,7 @@ func TestBuildDRRecipeHostHalf_NoPBS(t *testing.T) {
// TestDRRecipeHostHalf_V1DriveShape pins the v1 host-half drive shape: a drive object carries ONLY
// {durable_id, mount_path, intent, total_bytes} (fs_type is omitempty) — and specifically NEITHER the
// dropped "role" NOR "restic_repo_coord" keys. Re-adding either field to DRDrive makes this fail
// (the companion: `Role string \`json:"role"\`` reintroduces the "role" key → caught here).
// (the companion: `Role string \`json:"role"\ reintroduces the "role" key → caught here).
func TestDRRecipeHostHalf_V1DriveShape(t *testing.T) {
h := BuildDRRecipeHostHalf(
nil,
+3 -1
View File
@@ -38,7 +38,9 @@ func (r *fakeReporter) Report(ctx context.Context, _ *HostReport) (*ControlEnvel
// recordingObserver records the envelopes the loop hands it (slice 10A EnvelopeObserver seam).
type recordingObserver struct{ envs []*ControlEnvelope }
func (o *recordingObserver) OnEnvelope(_ context.Context, e *ControlEnvelope) { o.envs = append(o.envs, e) }
func (o *recordingObserver) OnEnvelope(_ context.Context, e *ControlEnvelope) {
o.envs = append(o.envs, e)
}
// The loop notifies the EnvelopeObserver once per successful cycle (with the envelope) AND still
// adopts PollIntervalSeconds — the two are independent.
+7
View File
@@ -36,6 +36,13 @@ type HostReport struct {
// on a Critical capability flipping to "degraded". Non-nil so it marshals as [].
Capabilities []capability.Status `json:"capabilities"`
// LeafFingerprint is the SHA-256 of the local-API leaf the agent CURRENTLY serves (v0.48.0). The
// hub records the first value per host as the baseline and raises `host_leaf_changed` if it ever
// changes — a proactive, fleet-wide agent-re-key alert independent of any controller's channel
// check. Empty when the local API is disabled (no leaf) → the hub treats "" as unknown, never an
// alert. Not a secret (the fp is public; the token is never reported).
LeafFingerprint string `json:"leaf_fingerprint"`
// 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.
+9 -7
View File
@@ -24,13 +24,14 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) {
VMID: 100, Name: "felhom-cust-acme", Status: "running", ControllerVersion: "",
Spec: &GuestSpec{Cores: 2, MemoryBytes: 2147483648, DiskBytes: 21474836480},
}},
StorageTargets: []StorageTarget{},
Backups: []Backup{},
RestoreTests: []RestoreTest{},
PBSSnapshots: []PBSSnapshot{},
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: "active"},
Capabilities: []capability.Status{},
StorageTargets: []StorageTarget{},
Backups: []Backup{},
RestoreTests: []RestoreTest{},
PBSSnapshots: []PBSSnapshot{},
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: "active"},
Capabilities: []capability.Status{},
LeafFingerprint: "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245",
}
// 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).
@@ -50,6 +51,7 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) {
// empty collections must be [] not null
`"storage_targets":[]`, `"backups":[]`, `"restore_tests":[]`, `"pbs_snapshots":[]`, `"audit_tail":[]`,
`"capabilities":[]`,
`"leaf_fingerprint":"60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245"`,
} {
if !strings.Contains(got, field) {
t.Errorf("report JSON missing %s\n got: %s", field, got)
+1
View File
@@ -133,6 +133,7 @@
"cloudflared": { "status": "active" },
"audit_tail": [],
"capabilities": [],
"leaf_fingerprint": "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245",
"dr_recipe": {
"recipe_version": 1,
"guests": [