agent: EnsureLeaf signals + loud-WARNs a regenerated leaf (prevention B.1) v0.46.0
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
This commit is contained in:
@@ -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
|
# Changelog
|
||||||
|
|
||||||
All notable changes to **felhom-agent** are recorded here. Update on every code
|
All notable changes to **felhom-agent** are recorded here. Update on every code
|
||||||
|
|||||||
@@ -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.45.0"
|
var version = "0.46.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
|
||||||
@@ -710,12 +710,19 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
|
|||||||
}
|
}
|
||||||
*outTokens = tokens
|
*outTokens = tokens
|
||||||
host, _, _ := net.SplitHostPort(cfg.LocalAPI.ListenAddr)
|
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 {
|
if err != nil {
|
||||||
logger.Warn("daemon: local-api disabled (leaf cert)", "err", err)
|
logger.Warn("daemon: local-api disabled (leaf cert)", "err", err)
|
||||||
return nil
|
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)
|
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).
|
||||||
@@ -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
|
// 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.
|
// server serves — so use the configured (persisted) cert path. EnsureLeaf generates it once.
|
||||||
host, _, _ := net.SplitHostPort(cfg.LocalAPI.ListenAddr)
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "selftest=provision: local-api leaf:", err)
|
fmt.Fprintln(os.Stderr, "selftest=provision: local-api leaf:", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -31,19 +31,24 @@ const certValidity = 10 * 365 * 24 * time.Hour
|
|||||||
//
|
//
|
||||||
// Persisting the generated pair keeps the fingerprint STABLE across agent restarts — a fresh
|
// 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.
|
// 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) {
|
if fileExists(certPath) && fileExists(keyPath) {
|
||||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
cert, err = tls.LoadX509KeyPair(certPath, keyPath)
|
||||||
if err != nil {
|
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 {
|
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
|
// generateLeaf creates a self-signed ECDSA-P256 leaf, writes the cert (0644) + key (0600) to
|
||||||
|
|||||||
@@ -14,14 +14,23 @@ func TestEnsureLeaf_StableFingerprintAcrossReload(t *testing.T) {
|
|||||||
certPath := filepath.Join(dir, "leaf.crt")
|
certPath := filepath.Join(dir, "leaf.crt")
|
||||||
keyPath := filepath.Join(dir, "leaf.key")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("first ensure: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("second ensure: %v", err)
|
t.Fatalf("second ensure: %v", err)
|
||||||
}
|
}
|
||||||
|
if gen2 {
|
||||||
|
t.Fatal("second EnsureLeaf must report generated=false (LOADED, not regenerated)")
|
||||||
|
}
|
||||||
if fp1 != fp2 {
|
if fp1 != fp2 {
|
||||||
t.Fatalf("fingerprint changed across reload: %s != %s", 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.
|
// The generated leaf is a usable TLS server cert whose presented leaf matches the pin.
|
||||||
func TestEnsureLeaf_ServesPinnableLeaf(t *testing.T) {
|
func TestEnsureLeaf_ServesPinnableLeaf(t *testing.T) {
|
||||||
dir := t.TempDir()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ensure: %v", err)
|
t.Fatalf("ensure: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user