diff --git a/CHANGELOG.md b/CHANGELOG.md index eef77ca..2217eb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## v0.48.0 — report the served local-API leaf fingerprint (hub-side re-key detection, Part A) (2026-06-29) + +The agent now rides its **served leaf fingerprint** on every host report so the hub can detect an +agent re-key fleet-wide (the last self-health leg — `host_leaf_changed`, hub v0.22.0). + +- **`internal/hub/report.go`:** new `HostReport.LeafFingerprint string` (`leaf_fingerprint`) — the + SHA-256 of the leaf the agent currently serves. Empty when the local API is disabled (no leaf) → the + hub treats "" as unknown, never an alert. Not a secret. +- **`internal/hub/collect.go` + `cmd/felhom-agent/main.go`:** `Collector.SetLeafFingerprint(fp)` threads + the `fp` from `EnsureLeaf` (the SAME value the loud LOADED/REGENERATED log reports) into every report, + next to `Capabilities`. +- Tests: the report includes the fp when set, `""` when unset (local API disabled); golden + contract + + field-names tests updated (cross-repo golden mirrors `leaf_fingerprint`). Version `0.47.0 → 0.48.0`. + ## v0.47.0 — controller-swap verify hardening: reject a crash-looping no-healthcheck image (F1) (2026-06-29) Closes F1 from the no-mercy testrun: a controller image with **no HEALTHCHECK that crash-loops** could diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index fb02b9d..cb3f67b 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -45,7 +45,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.47.0" +var version = "0.48.0" // runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook `). On the // pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots @@ -723,6 +723,8 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St } else { logger.Info("local-api leaf LOADED", "fingerprint_sha256", fp, "cert", cfg.LocalAPI.CertPath()) } + // v0.48.0: ride the served leaf fp on every host report so the hub can detect a re-key fleet-wide. + collector.SetLeafFingerprint(fp) runner := backup.NewBackupRunner(px, cfg.Backup.BackupTarget(), "", "felhom local-api", logger) // Guest data-drive passthrough (slice 10 P2): a root-CLI runner for the `pct set` bind + chown // (same fenced ExecRunner the host-storage + provision back-half use). diff --git a/internal/hub/collect.go b/internal/hub/collect.go index 398d7bc..d307102 100644 --- a/internal/hub/collect.go +++ b/internal/hub/collect.go @@ -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). diff --git a/internal/hub/collect_test.go b/internal/hub/collect_test.go index 9677dc5..6d1398f 100644 --- a/internal/hub/collect_test.go +++ b/internal/hub/collect_test.go @@ -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) + } +} diff --git a/internal/hub/dr_recipe.go b/internal/hub/dr_recipe.go index 1820ec5..ede08e6 100644 --- a/internal/hub/dr_recipe.go +++ b/internal/hub/dr_recipe.go @@ -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. // diff --git a/internal/hub/dr_recipe_test.go b/internal/hub/dr_recipe_test.go index 2be9870..33176c8 100644 --- a/internal/hub/dr_recipe_test.go +++ b/internal/hub/dr_recipe_test.go @@ -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, diff --git a/internal/hub/loop_test.go b/internal/hub/loop_test.go index 9e2a9c8..4862719 100644 --- a/internal/hub/loop_test.go +++ b/internal/hub/loop_test.go @@ -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. diff --git a/internal/hub/report.go b/internal/hub/report.go index 06904f9..ceed357 100644 --- a/internal/hub/report.go +++ b/internal/hub/report.go @@ -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. diff --git a/internal/hub/report_test.go b/internal/hub/report_test.go index 447f1eb..5702e43 100644 --- a/internal/hub/report_test.go +++ b/internal/hub/report_test.go @@ -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) diff --git a/internal/hub/testdata/host-report.golden.json b/internal/hub/testdata/host-report.golden.json index 4392845..5943e36 100644 --- a/internal/hub/testdata/host-report.golden.json +++ b/internal/hub/testdata/host-report.golden.json @@ -133,6 +133,7 @@ "cloudflared": { "status": "active" }, "audit_tail": [], "capabilities": [], + "leaf_fingerprint": "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245", "dr_recipe": { "recipe_version": 1, "guests": [