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:
+13
-3
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"cloudflared": { "status": "active" },
|
||||
"audit_tail": [],
|
||||
"capabilities": [],
|
||||
"leaf_fingerprint": "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245",
|
||||
"dr_recipe": {
|
||||
"recipe_version": 1,
|
||||
"guests": [
|
||||
|
||||
Reference in New Issue
Block a user