237 lines
7.9 KiB
Go
237 lines
7.9 KiB
Go
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")
|
|
}
|
|
}
|