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:
@@ -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)
|
## 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
|
Closes F1 from the no-mercy testrun: a controller image with **no HEALTHCHECK that crash-loops** could
|
||||||
|
|||||||
@@ -45,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.47.0"
|
var version = "0.48.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
|
||||||
@@ -723,6 +723,8 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
|
|||||||
} else {
|
} else {
|
||||||
logger.Info("local-api leaf LOADED", "fingerprint_sha256", fp, "cert", cfg.LocalAPI.CertPath())
|
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)
|
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
|
// 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).
|
// (same fenced ExecRunner the host-storage + provision back-half use).
|
||||||
|
|||||||
+13
-3
@@ -60,6 +60,7 @@ type Collector struct {
|
|||||||
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)
|
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
|
hostID string
|
||||||
agentVersion string
|
agentVersion string
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
@@ -101,6 +102,14 @@ func (c *Collector) SetCapabilityProber(probe func(ctx context.Context) []capabi
|
|||||||
return c
|
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
|
// 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;
|
||||||
@@ -126,9 +135,10 @@ 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),
|
Capabilities: c.capabilities(ctx),
|
||||||
|
LeafFingerprint: c.leafFP,
|
||||||
}
|
}
|
||||||
// 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).
|
||||||
|
|||||||
@@ -146,3 +146,27 @@ func TestCollect_CloudflaredProbeErrorIsUnknown(t *testing.T) {
|
|||||||
t.Error("empty collections must be non-nil")
|
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:
|
// - 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
|
// 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 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
|
// 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.
|
// 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
|
// 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
|
// {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
|
// 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) {
|
func TestDRRecipeHostHalf_V1DriveShape(t *testing.T) {
|
||||||
h := BuildDRRecipeHostHalf(
|
h := BuildDRRecipeHostHalf(
|
||||||
nil,
|
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).
|
// recordingObserver records the envelopes the loop hands it (slice 10A EnvelopeObserver seam).
|
||||||
type recordingObserver struct{ envs []*ControlEnvelope }
|
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
|
// The loop notifies the EnvelopeObserver once per successful cycle (with the envelope) AND still
|
||||||
// adopts PollIntervalSeconds — the two are independent.
|
// 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 [].
|
// on a Critical capability flipping to "degraded". Non-nil so it marshals as [].
|
||||||
Capabilities []capability.Status `json:"capabilities"`
|
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
|
// 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.
|
||||||
|
|||||||
@@ -24,13 +24,14 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) {
|
|||||||
VMID: 100, Name: "felhom-cust-acme", Status: "running", ControllerVersion: "",
|
VMID: 100, Name: "felhom-cust-acme", Status: "running", ControllerVersion: "",
|
||||||
Spec: &GuestSpec{Cores: 2, MemoryBytes: 2147483648, DiskBytes: 21474836480},
|
Spec: &GuestSpec{Cores: 2, MemoryBytes: 2147483648, DiskBytes: 21474836480},
|
||||||
}},
|
}},
|
||||||
StorageTargets: []StorageTarget{},
|
StorageTargets: []StorageTarget{},
|
||||||
Backups: []Backup{},
|
Backups: []Backup{},
|
||||||
RestoreTests: []RestoreTest{},
|
RestoreTests: []RestoreTest{},
|
||||||
PBSSnapshots: []PBSSnapshot{},
|
PBSSnapshots: []PBSSnapshot{},
|
||||||
AuditTail: []AuditEntry{},
|
AuditTail: []AuditEntry{},
|
||||||
Cloudflared: Cloudflared{Status: "active"},
|
Cloudflared: Cloudflared{Status: "active"},
|
||||||
Capabilities: []capability.Status{},
|
Capabilities: []capability.Status{},
|
||||||
|
LeafFingerprint: "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245",
|
||||||
}
|
}
|
||||||
// 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).
|
||||||
@@ -50,6 +51,7 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) {
|
|||||||
// 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":[]`,
|
`"capabilities":[]`,
|
||||||
|
`"leaf_fingerprint":"60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245"`,
|
||||||
} {
|
} {
|
||||||
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)
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
"cloudflared": { "status": "active" },
|
"cloudflared": { "status": "active" },
|
||||||
"audit_tail": [],
|
"audit_tail": [],
|
||||||
"capabilities": [],
|
"capabilities": [],
|
||||||
|
"leaf_fingerprint": "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245",
|
||||||
"dr_recipe": {
|
"dr_recipe": {
|
||||||
"recipe_version": 1,
|
"recipe_version": 1,
|
||||||
"guests": [
|
"guests": [
|
||||||
|
|||||||
Reference in New Issue
Block a user