diff --git a/internal/reconcile/reconciler.go b/internal/reconcile/reconciler.go index c93af2d..9584593 100644 --- a/internal/reconcile/reconciler.go +++ b/internal/reconcile/reconciler.go @@ -4,6 +4,8 @@ package reconcile import ( + "crypto/x509" + "encoding/pem" "fmt" "log/slog" "os" @@ -433,26 +435,31 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma gatewayDomain = parts[1] } - // Collect unique missing certs. Use hasCertForDomain which checks - // both individual certs AND wildcard coverage (e.g., *.civilsociety.dev - // covers git.civilsociety.dev — no individual cert needed). - missing := map[string]bool{} + // Collect certs that need provisioning: missing OR expiring within 30 days. + // Uses hasCertForDomain for existence (checks individual + wildcard coverage) + // and certExpiresWithin for expiry checking. + const renewThreshold = 30 * 24 * time.Hour + needsProvision := map[string]bool{} for _, dom := range doms { if dom.TLS != domains.TLSTerminate || dom.DomainName == "" { continue } if hasCertForDomain(dom.DomainName) { - continue + // Cert exists — check if it's expiring soon + if path := certPathForDomain(dom.DomainName); path != "" && !certExpiresWithin(path, renewThreshold) { + continue + } + slog.Info("cert expired or expiring soon, scheduling renewal", "component", "reconcile", "domain", dom.DomainName) } // Determine what cert to provision: wildcard for gateway subdomains, individual otherwise if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) { - missing["*."+gatewayDomain] = true + needsProvision["*."+gatewayDomain] = true } else { - missing[dom.DomainName] = true + needsProvision[dom.DomainName] = true } } - if len(missing) == 0 { + if len(needsProvision) == 0 { return nil } @@ -464,28 +471,28 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma } if cfToken == "" || email == "" || r.Certs == nil { - // Can't auto-provision — report what's missing + // Can't auto-provision — report what's needed var names []string - for domain := range missing { + for domain := range needsProvision { names = append(names, domain) - slog.Warn("missing cert (auto-provision unavailable — configure Cloudflare token and operator email)", + slog.Warn("cert needs provisioning (auto-provision unavailable — configure Cloudflare token and operator email)", "component", "reconcile", "domain", domain) } return names } - // Auto-provision missing certs + // Auto-provision missing/expiring certs if err := r.Certs.EnsureCredentials(cfToken); err != nil { slog.Error("failed to write certbot credentials", "component", "reconcile", "error", err) var names []string - for domain := range missing { + for domain := range needsProvision { names = append(names, domain) } return names } var stillMissing []string - for domain := range missing { + for domain := range needsProvision { provisionDomain := domain if strings.HasPrefix(domain, "*.") { provisionDomain = domain // certbot handles wildcard with -d *.example.com @@ -550,6 +557,38 @@ func isValidCertFile(path string) bool { return err == nil && info.Size() > 0 } +// certPathForDomain returns the HAProxy PEM path covering a domain, or "" if none exists. +// Checks individual cert first, then wildcard. +func certPathForDomain(domain string) string { + if path := certbot.HAProxyCertPath(domain); isValidCertFile(path) { + return path + } + if parts := strings.SplitN(domain, ".", 2); len(parts) == 2 { + if path := certbot.HAProxyCertPath(parts[1]); isValidCertFile(path) { + return path + } + } + return "" +} + +// certExpiresWithin checks if the cert at path expires within the given threshold. +// Returns true if the cert is expired or will expire within the threshold. +func certExpiresWithin(path string, threshold time.Duration) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + block, _ := pem.Decode(data) + if block == nil { + return false + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false + } + return time.Until(cert.NotAfter) < threshold +} + func extractHost(addr string) string { for i := len(addr) - 1; i >= 0; i-- { if addr[i] == ':' { diff --git a/internal/reconcile/reconciler_test.go b/internal/reconcile/reconciler_test.go index 36abd3b..16b70e2 100644 --- a/internal/reconcile/reconciler_test.go +++ b/internal/reconcile/reconciler_test.go @@ -1,9 +1,17 @@ package reconcile import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "os" "path/filepath" "testing" + "time" "github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/dnsmasq" @@ -310,6 +318,60 @@ func TestIsValidCertFile_Missing(t *testing.T) { } } +func writeSelfSignedCert(t *testing.T, path string, notAfter time.Time) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: notAfter, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestCertExpiresWithin_Expired(t *testing.T) { + path := filepath.Join(t.TempDir(), "expired.pem") + writeSelfSignedCert(t, path, time.Now().Add(-24*time.Hour)) + if !certExpiresWithin(path, 30*24*time.Hour) { + t.Error("expected expired cert to return true") + } +} + +func TestCertExpiresWithin_ExpiringSoon(t *testing.T) { + path := filepath.Join(t.TempDir(), "expiring.pem") + writeSelfSignedCert(t, path, time.Now().Add(10*24*time.Hour)) + if !certExpiresWithin(path, 30*24*time.Hour) { + t.Error("expected cert expiring in 10 days to return true for 30-day threshold") + } +} + +func TestCertExpiresWithin_Valid(t *testing.T) { + path := filepath.Join(t.TempDir(), "valid.pem") + writeSelfSignedCert(t, path, time.Now().Add(60*24*time.Hour)) + if certExpiresWithin(path, 30*24*time.Hour) { + t.Error("expected cert with 60 days left to return false for 30-day threshold") + } +} + +func TestCertExpiresWithin_Missing(t *testing.T) { + if certExpiresWithin("/nonexistent/cert.pem", 30*24*time.Hour) { + t.Error("expected missing file to return false") + } +} + func TestExtractHost(t *testing.T) { tests := []struct { input, want string