From 9b0d6c2c82b19e2b2a1272dc04f25c08d6dc8b05 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 29 Jun 2026 21:45:25 +0200 Subject: [PATCH] agent: EnsureLeaf signals + loud-WARNs a regenerated leaf (prevention B.1) v0.46.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnsureLeaf returns generated bool; call-site logs INFO 'leaf LOADED' vs WARN 'leaf REGENERATED — previously issued bootstrap pins now INVALID'. Catches the 2026-06-28 silent-regen incident class. Test: first=generated, second=loaded+same fp. No new sudo surface. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ --- CHANGELOG.md | 16 ++++++++++++++++ cmd/felhom-agent/main.go | 15 +++++++++++---- internal/localapi/cert.go | 19 ++++++++++++------- internal/localapi/cert_test.go | 15 ++++++++++++--- 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01618c7..1a25994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## v0.46.0 — leaf lifecycle: signal + loud-log a regenerated leaf (prevention, Part B.1) (2026-06-29) + +Makes an accidental local-API leaf **regeneration** (the 2026-06-28 root→non-root migration class — +moving `/var/lib/felhom-agent` aside silently minted a new leaf → every controller's pin invalidated +for days) **visible immediately** instead of silent. + +- **`EnsureLeaf` now returns `generated bool`** (`internal/localapi/cert.go`): false = an existing + pair was LOADED (stable fingerprint), true = a fresh leaf was GENERATED. +- **Loud call-site (`cmd/felhom-agent/main.go`):** a load logs `INFO local-api leaf LOADED`; a + regeneration logs **`WARN local-api leaf REGENERATED — any previously issued bootstrap pins are now + INVALID; controllers will fail the pin check until re-bootstrapped`** (with the new fingerprint). +- No new sudo/capability surface — pure return + log change. The companion install-script preservation + (`--preserve-state-from` + the populated-host guard) lives in `felhom.eu/scripts/felhom-host-install.sh`. +- Tests: `EnsureLeaf` first call `generated==true`, second `generated==false` AND **same fingerprint** + (persistence keeps the pin stable). Version `0.45.0 → 0.46.0`. + # Changelog All notable changes to **felhom-agent** are recorded here. Update on every code diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 640811b..c752fb8 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.45.0" +var version = "0.46.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 @@ -710,12 +710,19 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St } *outTokens = tokens host, _, _ := net.SplitHostPort(cfg.LocalAPI.ListenAddr) - cert, fp, err := localapi.EnsureLeaf(cfg.LocalAPI.CertPath(), cfg.LocalAPI.KeyPath(), host) + cert, fp, generated, err := localapi.EnsureLeaf(cfg.LocalAPI.CertPath(), cfg.LocalAPI.KeyPath(), host) if err != nil { logger.Warn("daemon: local-api disabled (leaf cert)", "err", err) return nil } - logger.Info("local-api leaf ready", "fingerprint_sha256", fp, "cert", cfg.LocalAPI.CertPath()) + if generated { + // B.1: a freshly-minted leaf invalidates every already-issued bootstrap pin. LOUD so an + // accidental regeneration (e.g. a state-dir move during a reinstall) is caught immediately. + logger.Warn("local-api leaf REGENERATED — any previously issued bootstrap pins are now INVALID; controllers will fail the pin check until re-bootstrapped (or restore the prior leaf)", + "fingerprint_sha256", fp, "cert", cfg.LocalAPI.CertPath()) + } else { + logger.Info("local-api leaf LOADED", "fingerprint_sha256", fp, "cert", cfg.LocalAPI.CertPath()) + } 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). @@ -1298,7 +1305,7 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L // The leaf fingerprint baked into the bootstrap must be the SAME leaf the daemon's local-API // server serves — so use the configured (persisted) cert path. EnsureLeaf generates it once. host, _, _ := net.SplitHostPort(cfg.LocalAPI.ListenAddr) - _, fingerprint, err := localapi.EnsureLeaf(cfg.LocalAPI.CertPath(), cfg.LocalAPI.KeyPath(), host) + _, fingerprint, _, err := localapi.EnsureLeaf(cfg.LocalAPI.CertPath(), cfg.LocalAPI.KeyPath(), host) if err != nil { fmt.Fprintln(os.Stderr, "selftest=provision: local-api leaf:", err) return 1 diff --git a/internal/localapi/cert.go b/internal/localapi/cert.go index a634700..5995b06 100644 --- a/internal/localapi/cert.go +++ b/internal/localapi/cert.go @@ -31,19 +31,24 @@ const certValidity = 10 * 365 * 24 * time.Hour // // Persisting the generated pair keeps the fingerprint STABLE across agent restarts — a fresh // cert each boot would silently invalidate every already-issued bootstrap's pin. -func EnsureLeaf(certPath, keyPath, bridgeHost string) (tls.Certificate, string, error) { +// The returned `generated` is false when an existing pair was LOADED (the fingerprint is stable) and +// true when a fresh leaf was GENERATED (every previously-issued bootstrap pin is now invalid — the +// caller logs this LOUD, B.1, so an accidental regeneration like the 2026-06-28 migration is visible +// immediately instead of silently breaking every controller's pin for days). +func EnsureLeaf(certPath, keyPath, bridgeHost string) (cert tls.Certificate, fingerprint string, generated bool, err error) { if fileExists(certPath) && fileExists(keyPath) { - cert, err := tls.LoadX509KeyPair(certPath, keyPath) + cert, err = tls.LoadX509KeyPair(certPath, keyPath) if err != nil { - return tls.Certificate{}, "", fmt.Errorf("localapi: load leaf %s: %w", certPath, err) + return tls.Certificate{}, "", false, fmt.Errorf("localapi: load leaf %s: %w", certPath, err) } - fp, err := leafFingerprint(cert) + fingerprint, err = leafFingerprint(cert) if err != nil { - return tls.Certificate{}, "", err + return tls.Certificate{}, "", false, err } - return cert, fp, nil + return cert, fingerprint, false, nil // LOADED } - return generateLeaf(certPath, keyPath, bridgeHost) + cert, fingerprint, err = generateLeaf(certPath, keyPath, bridgeHost) + return cert, fingerprint, true, err // GENERATED } // generateLeaf creates a self-signed ECDSA-P256 leaf, writes the cert (0644) + key (0600) to diff --git a/internal/localapi/cert_test.go b/internal/localapi/cert_test.go index 8730ada..5899f00 100644 --- a/internal/localapi/cert_test.go +++ b/internal/localapi/cert_test.go @@ -14,14 +14,23 @@ func TestEnsureLeaf_StableFingerprintAcrossReload(t *testing.T) { certPath := filepath.Join(dir, "leaf.crt") keyPath := filepath.Join(dir, "leaf.key") - cert1, fp1, err := EnsureLeaf(certPath, keyPath, "192.168.0.162") + cert1, fp1, gen1, err := EnsureLeaf(certPath, keyPath, "192.168.0.162") if err != nil { t.Fatalf("first ensure: %v", err) } - cert2, fp2, err := EnsureLeaf(certPath, keyPath, "192.168.0.162") + // B.1/B.3: first call GENERATES; second call LOADS (generated=false) with the SAME fingerprint — + // persistence keeps the pin stable. A regression that regenerated would flip gen2 true AND change + // the fp, failing both asserts (the prevention this change exists for). + if !gen1 { + t.Fatal("first EnsureLeaf must report generated=true") + } + cert2, fp2, gen2, err := EnsureLeaf(certPath, keyPath, "192.168.0.162") if err != nil { t.Fatalf("second ensure: %v", err) } + if gen2 { + t.Fatal("second EnsureLeaf must report generated=false (LOADED, not regenerated)") + } if fp1 != fp2 { t.Fatalf("fingerprint changed across reload: %s != %s", fp1, fp2) } @@ -40,7 +49,7 @@ func TestEnsureLeaf_StableFingerprintAcrossReload(t *testing.T) { // The generated leaf is a usable TLS server cert whose presented leaf matches the pin. func TestEnsureLeaf_ServesPinnableLeaf(t *testing.T) { dir := t.TempDir() - cert, fp, err := EnsureLeaf(filepath.Join(dir, "c"), filepath.Join(dir, "k"), "10.0.0.1") + cert, fp, _, err := EnsureLeaf(filepath.Join(dir, "c"), filepath.Join(dir, "k"), "10.0.0.1") if err != nil { t.Fatalf("ensure: %v", err) }