fix(cert): reload PEM on warm restart and rebuild ACME on config change

This commit is contained in:
xboard
2026-04-20 04:07:55 +08:00
parent 68ca2d2716
commit 0a29338e1f
2 changed files with 361 additions and 39 deletions
+125 -39
View File
@@ -5,15 +5,18 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/hex"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"math/big" "math/big"
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -41,20 +44,21 @@ import (
// //
// Priority Logic (when CertMode is empty): // Priority Logic (when CertMode is empty):
// //
// 1. "http" (if auto_tls is true) // 1. "http" (if auto_tls is true)
// 2. "content" (if both CertContent and KeyContent are provided) // 2. "content" (if both CertContent and KeyContent are provided)
// 3. "file" (if both CertFile and KeyFile paths are provided) // 3. "file" (if both CertFile and KeyFile paths are provided)
// 4. "none" (default) // 4. "none" (default)
type Manager struct { type Manager struct {
cfg config.CertConfig cfg config.CertConfig
// Cert material stored atomically so the ACME renewal goroutine can // Atomic so the ACME renewal goroutine can swap without racing readers.
// swap it without racing with TLSCert() readers.
mat atomic.Pointer[certMaterial] mat atomic.Pointer[certMaterial]
magic *certmagic.Config magic *certmagic.Config
renewed atomic.Bool renewed atomic.Bool
acmeStarted bool acmeStarted bool
acmeFingerprint string
acmeCancel context.CancelFunc
} }
// certMaterial is an immutable snapshot of PEM-encoded cert + key. // certMaterial is an immutable snapshot of PEM-encoded cert + key.
@@ -131,14 +135,22 @@ func (m *Manager) loadPersistedPEM() bool {
} }
// Reconfigure applies a new cert configuration at runtime (e.g. from panel push). // Reconfigure applies a new cert configuration at runtime (e.g. from panel push).
// For ACME modes that are already running this is a no-op — a process restart
// is required to change ACME settings.
// Returns true when the PEM material changed (caller should restart kernel). // Returns true when the PEM material changed (caller should restart kernel).
func (m *Manager) Reconfigure(ctx context.Context, newCfg config.CertConfig) (bool, error) { func (m *Manager) Reconfigure(ctx context.Context, newCfg config.CertConfig) (bool, error) {
if newCfg.CertDir == "" { if newCfg.CertDir == "" {
newCfg.CertDir = m.cfg.CertDir newCfg.CertDir = m.cfg.CertDir
} }
// If ACME is running and the new config materially differs (or switches
// away from ACME), tear down the old certmagic instance first.
if m.acmeStarted {
newFp := acmeFingerprint(newCfg)
newMode := resolveModeFor(newCfg)
if newMode != "http" && newMode != "dns" || newFp != m.acmeFingerprint {
m.tearDownACME()
}
}
oldTLS := m.TLSCert() oldTLS := m.TLSCert()
m.cfg = newCfg m.cfg = newCfg
@@ -150,6 +162,65 @@ func (m *Manager) Reconfigure(ctx context.Context, newCfg config.CertConfig) (bo
return !pemEqual(oldTLS, newTLS), nil return !pemEqual(oldTLS, newTLS), nil
} }
// tearDownACME cancels the running certmagic background goroutines and clears
// related state so a fresh ACME init can run.
func (m *Manager) tearDownACME() {
if m.acmeCancel != nil {
m.acmeCancel()
m.acmeCancel = nil
}
m.magic = nil
m.acmeStarted = false
m.acmeFingerprint = ""
m.mat.Store(nil)
}
// acmeFingerprint produces a stable string capturing every config field that
// requires re-running ACME when changed.
func acmeFingerprint(cfg config.CertConfig) string {
keys := make([]string, 0, len(cfg.DNSEnv))
for k := range cfg.DNSEnv {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString(strings.ToLower(strings.TrimSpace(cfg.CertMode)))
b.WriteByte('|')
b.WriteString(strings.TrimSpace(cfg.Domain))
b.WriteByte('|')
b.WriteString(strings.TrimSpace(cfg.Email))
b.WriteByte('|')
b.WriteString(strings.TrimSpace(cfg.DNSProvider))
b.WriteByte('|')
for _, k := range keys {
b.WriteString(k)
b.WriteByte('=')
b.WriteString(cfg.DNSEnv[k])
b.WriteByte(';')
}
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
// resolveModeFor mirrors (*Manager).resolveMode() for an arbitrary cfg, used
// by Reconfigure to decide tear-down without mutating manager state.
func resolveModeFor(cfg config.CertConfig) string {
mode := strings.ToLower(strings.TrimSpace(cfg.CertMode))
if mode != "" {
return mode
}
if cfg.AutoTLS {
return "http"
}
if cfg.CertContent != "" && cfg.KeyContent != "" {
return "content"
}
if cfg.CertFile != "" && cfg.KeyFile != "" {
return "file"
}
return "none"
}
// resolveMode returns the effective cert mode, handling backward compat for auto_tls. // resolveMode returns the effective cert mode, handling backward compat for auto_tls.
func (m *Manager) resolveMode() string { func (m *Manager) resolveMode() string {
mode := strings.ToLower(strings.TrimSpace(m.cfg.CertMode)) mode := strings.ToLower(strings.TrimSpace(m.cfg.CertMode))
@@ -320,10 +391,8 @@ func (m *Manager) startContent() error {
// ─── Mode: http / dns (ACME) ────────────────────────────────────────────── // ─── Mode: http / dns (ACME) ──────────────────────────────────────────────
func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solver) error { func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solver) error {
// Guard: certmagic's background goroutines must only be started once. // Idempotent for unchanged config: Reconfigure already tore down stale ACME state.
// A process restart is required to change ACME settings at runtime.
if m.acmeStarted { if m.acmeStarted {
nlog.Core().Info("cert: ACME already active, skipping re-init (restart to change ACME settings)")
return nil return nil
} }
@@ -347,9 +416,23 @@ func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solve
magic = certmagic.New(cache, certmagic.Config{ magic = certmagic.New(cache, certmagic.Config{
Storage: storage, Storage: storage,
OnEvent: func(evtCtx context.Context, event string, data map[string]any) error { OnEvent: func(evtCtx context.Context, event string, data map[string]any) error {
if event == "cert_obtained" { // Only react to renewals; initial load happens explicitly after ObtainCertSync.
m.loadCertFromStorage(evtCtx, storage, data) if event != "cert_obtained" {
return nil
} }
if renewed, _ := data["renewal"].(bool); !renewed {
return nil
}
issuerKey, _ := data["issuer"].(string)
if issuerKey == "" {
return nil
}
if err := m.loadPEMFromStorage(evtCtx, storage, issuerKey, m.cfg.Domain); err != nil {
nlog.Core().Error("failed to reload cert after renewal", "error", err)
return nil
}
m.renewed.Store(true)
nlog.Core().Info("TLS certificate reloaded after renewal", "domain", m.cfg.Domain)
return nil return nil
}, },
}) })
@@ -379,19 +462,27 @@ func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solve
} }
m.magic = magic m.magic = magic
if err := magic.ObtainCertSync(ctx, m.cfg.Domain); err != nil { // Derived ctx so Reconfigure can cancel ACME background goroutines.
acmeCtx, acmeCancel := context.WithCancel(ctx)
if err := magic.ObtainCertSync(acmeCtx, m.cfg.Domain); err != nil {
acmeCancel()
return fmt.Errorf("obtain certificate: %w", err) return fmt.Errorf("obtain certificate: %w", err)
} }
// The cert_obtained event callback should have loaded PEM. Verify. issuerKey := magic.Issuers[0].IssuerKey()
if !m.HasCert() { if err := m.loadPEMFromStorage(acmeCtx, storage, issuerKey, m.cfg.Domain); err != nil {
return fmt.Errorf("ACME obtained certificate but PEM was not loaded; check storage") acmeCancel()
return fmt.Errorf("load cert from storage: %w", err)
} }
if err := magic.ManageAsync(ctx, []string{m.cfg.Domain}); err != nil { if err := magic.ManageAsync(acmeCtx, []string{m.cfg.Domain}); err != nil {
acmeCancel()
return fmt.Errorf("start cert manager: %w", err) return fmt.Errorf("start cert manager: %w", err)
} }
m.acmeCancel = acmeCancel
m.acmeFingerprint = acmeFingerprint(m.cfg)
m.acmeStarted = true m.acmeStarted = true
return nil return nil
} }
@@ -433,31 +524,26 @@ func (m *Manager) newDNSProvider() (certmagic.DNSProvider, error) {
// ─── Helpers ─────────────────────────────────────────────────────────────── // ─── Helpers ───────────────────────────────────────────────────────────────
// loadCertFromStorage reads ACME cert/key from certmagic's storage into memory. // loadPEMFromStorage reads cert + key for domain from certmagic storage into
func (m *Manager) loadCertFromStorage(ctx context.Context, storage certmagic.Storage, data map[string]any) { // the in-memory cache. Single entry point for both initial load and renewal refresh.
certKey, _ := data["certificate_path"].(string) func (m *Manager) loadPEMFromStorage(ctx context.Context, storage certmagic.Storage, issuerKey, domain string) error {
keyKey, _ := data["private_key_path"].(string) if issuerKey == "" || domain == "" {
if certKey == "" || keyKey == "" { return fmt.Errorf("loadPEMFromStorage: issuerKey and domain are required")
nlog.Core().Warn("cert_obtained event missing storage paths", "data", data)
return
} }
certKey := certmagic.StorageKeys.SiteCert(issuerKey, domain)
keyKey := certmagic.StorageKeys.SitePrivateKey(issuerKey, domain)
certPEM, err := storage.Load(ctx, certKey) certPEM, err := storage.Load(ctx, certKey)
if err != nil { if err != nil {
nlog.Core().Error("failed to load cert from ACME storage", "error", err) return fmt.Errorf("load cert %q: %w", certKey, err)
return
} }
keyPEM, err := storage.Load(ctx, keyKey) keyPEM, err := storage.Load(ctx, keyKey)
if err != nil { if err != nil {
nlog.Core().Error("failed to load key from ACME storage", "error", err) return fmt.Errorf("load key %q: %w", keyKey, err)
return }
if err := validateKeyPair(certPEM, keyPEM); err != nil {
return fmt.Errorf("invalid cert/key pair on disk: %w", err)
} }
m.storePEM(certPEM, keyPEM) m.storePEM(certPEM, keyPEM)
return nil
renewal, _ := data["renewal"].(bool)
nlog.Core().Info("TLS certificate loaded from ACME storage", "domain", m.cfg.Domain, "renewal", renewal)
if renewal {
m.renewed.Store(true)
}
} }
+236
View File
@@ -0,0 +1,236 @@
package cert
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"path/filepath"
"testing"
"time"
"github.com/caddyserver/certmagic"
"github.com/cedar2025/xboard-node/internal/config"
)
// generateSelfSignedPair returns a fresh self-signed certificate and matching
// private key in PEM form, suitable for storing as a libdns/certmagic site
// resource in tests.
func generateSelfSignedPair(t *testing.T, domain string) (certPEM, keyPEM []byte) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: domain},
DNSNames: []string{domain},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create cert: %v", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return
}
// TestLoadPEMFromStorage_RestartScenario simulates the warm-restart bug fix:
// certmagic.ObtainCertSync returns nil without firing cert_obtained when a
// valid cert already exists in storage. The manager must still populate its
// in-memory PEM cache by reading storage explicitly.
func TestLoadPEMFromStorage_RestartScenario(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
const domain = "example.test"
const issuerKey = "test-issuer"
certPEM, keyPEM := generateSelfSignedPair(t, domain)
ctx := context.Background()
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certPEM); err != nil {
t.Fatalf("seed cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyPEM); err != nil {
t.Fatalf("seed key: %v", err)
}
m := NewManager(config.CertConfig{})
if m.HasCert() {
t.Fatal("manager should start with no cert in memory")
}
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
t.Fatalf("loadPEMFromStorage: %v", err)
}
if !m.HasCert() {
t.Fatal("expected HasCert() == true after explicit load")
}
got := m.TLSCert()
if string(got.CertPEM) != string(certPEM) {
t.Errorf("certPEM mismatch")
}
if string(got.KeyPEM) != string(keyPEM) {
t.Errorf("keyPEM mismatch")
}
}
// TestLoadPEMFromStorage_RenewalRefresh confirms that calling load a second
// time with newer bytes on disk swaps the in-memory cache atomically — this
// is the renewal hot-reload path triggered from the cert_obtained event.
func TestLoadPEMFromStorage_RenewalRefresh(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
const domain = "example.test"
const issuerKey = "test-issuer"
ctx := context.Background()
certV1, keyV1 := generateSelfSignedPair(t, domain)
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certV1); err != nil {
t.Fatalf("seed v1 cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyV1); err != nil {
t.Fatalf("seed v1 key: %v", err)
}
m := NewManager(config.CertConfig{})
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
t.Fatalf("initial load: %v", err)
}
certV2, keyV2 := generateSelfSignedPair(t, domain)
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certV2); err != nil {
t.Fatalf("seed v2 cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyV2); err != nil {
t.Fatalf("seed v2 key: %v", err)
}
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
t.Fatalf("refresh load: %v", err)
}
got := m.TLSCert()
if string(got.CertPEM) != string(certV2) {
t.Error("expected v2 cert after refresh")
}
if string(got.KeyPEM) != string(keyV2) {
t.Error("expected v2 key after refresh")
}
}
// TestLoadPEMFromStorage_MissingFiles ensures we surface a clear error when
// storage doesn't have the expected keys (defensive — should not happen in
// practice because ObtainCertSync guarantees presence on success).
func TestLoadPEMFromStorage_MissingFiles(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
m := NewManager(config.CertConfig{})
err := m.loadPEMFromStorage(context.Background(), storage, "test-issuer", "example.test")
if err == nil {
t.Fatal("expected error when storage is empty")
}
}
// TestLoadPEMFromStorage_RejectsMismatchedPair guards against silently loading
// a cert and key that do not pair (e.g. partial write or external tampering).
func TestLoadPEMFromStorage_RejectsMismatchedPair(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
const domain = "example.test"
const issuerKey = "test-issuer"
ctx := context.Background()
certA, _ := generateSelfSignedPair(t, domain)
_, keyB := generateSelfSignedPair(t, domain)
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certA); err != nil {
t.Fatalf("seed cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyB); err != nil {
t.Fatalf("seed key: %v", err)
}
m := NewManager(config.CertConfig{})
err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain)
if err == nil {
t.Fatal("expected error for mismatched cert/key pair")
}
if m.HasCert() {
t.Error("manager should not store invalid pair")
}
_ = filepath.Join(dir, "stub")
}
// TestACMEFingerprint covers the config-equality logic that decides whether
// Reconfigure must tear down a running certmagic instance.
func TestACMEFingerprint(t *testing.T) {
base := config.CertConfig{
CertMode: "dns",
Domain: "a.example.com",
Email: "ops@example.com",
DNSProvider: "cloudflare",
DNSEnv: map[string]string{"CLOUDFLARE_API_TOKEN": "tok"},
}
cases := []struct {
name string
mutate func(*config.CertConfig)
differs bool
}{
{"same", func(*config.CertConfig) {}, false},
{"domain", func(c *config.CertConfig) { c.Domain = "b.example.com" }, true},
{"mode", func(c *config.CertConfig) { c.CertMode = "http" }, true},
{"email", func(c *config.CertConfig) { c.Email = "other@example.com" }, true},
{"provider", func(c *config.CertConfig) { c.DNSProvider = "alidns" }, true},
{"env_value", func(c *config.CertConfig) { c.DNSEnv = map[string]string{"CLOUDFLARE_API_TOKEN": "other"} }, true},
{"env_key_only", func(c *config.CertConfig) { c.DNSEnv = map[string]string{"OTHER": "tok"} }, true},
}
baseFp := acmeFingerprint(base)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := base
if tc.mutate != nil {
// deep-copy DNSEnv before mutation to avoid sharing
cfg.DNSEnv = map[string]string{}
for k, v := range base.DNSEnv {
cfg.DNSEnv[k] = v
}
tc.mutate(&cfg)
}
got := acmeFingerprint(cfg)
if (got != baseFp) != tc.differs {
t.Errorf("differs=%v but got==base=%v", tc.differs, got == baseFp)
}
})
}
}
// TestACMEFingerprintEnvOrderStable ensures map iteration order does not
// affect the fingerprint (must be sorted internally).
func TestACMEFingerprintEnvOrderStable(t *testing.T) {
a := config.CertConfig{
CertMode: "dns", Domain: "x", DNSProvider: "p",
DNSEnv: map[string]string{"A": "1", "B": "2", "C": "3"},
}
b := config.CertConfig{
CertMode: "dns", Domain: "x", DNSProvider: "p",
DNSEnv: map[string]string{"C": "3", "A": "1", "B": "2"},
}
if acmeFingerprint(a) != acmeFingerprint(b) {
t.Fatal("fingerprint not order-independent")
}
}