feat: Auto-renew TLS certs expiring within 30 days

ensureTLSCerts now checks certificate expiry in addition to existence.
Certs that expire within 30 days are scheduled for renewal alongside
missing certs.
This commit is contained in:
2026-10-15 07:23:55 +00:00
parent b45c0d6a81
commit 17748ce09c
2 changed files with 115 additions and 14 deletions

View File

@@ -4,6 +4,8 @@
package reconcile package reconcile
import ( import (
"crypto/x509"
"encoding/pem"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@@ -433,26 +435,31 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma
gatewayDomain = parts[1] gatewayDomain = parts[1]
} }
// Collect unique missing certs. Use hasCertForDomain which checks // Collect certs that need provisioning: missing OR expiring within 30 days.
// both individual certs AND wildcard coverage (e.g., *.civilsociety.dev // Uses hasCertForDomain for existence (checks individual + wildcard coverage)
// covers git.civilsociety.dev — no individual cert needed). // and certExpiresWithin for expiry checking.
missing := map[string]bool{} const renewThreshold = 30 * 24 * time.Hour
needsProvision := map[string]bool{}
for _, dom := range doms { for _, dom := range doms {
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" { if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
continue continue
} }
if hasCertForDomain(dom.DomainName) { 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 // Determine what cert to provision: wildcard for gateway subdomains, individual otherwise
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) { if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
missing["*."+gatewayDomain] = true needsProvision["*."+gatewayDomain] = true
} else { } else {
missing[dom.DomainName] = true needsProvision[dom.DomainName] = true
} }
} }
if len(missing) == 0 { if len(needsProvision) == 0 {
return nil return nil
} }
@@ -464,28 +471,28 @@ func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Doma
} }
if cfToken == "" || email == "" || r.Certs == nil { 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 var names []string
for domain := range missing { for domain := range needsProvision {
names = append(names, domain) 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) "component", "reconcile", "domain", domain)
} }
return names return names
} }
// Auto-provision missing certs // Auto-provision missing/expiring certs
if err := r.Certs.EnsureCredentials(cfToken); err != nil { if err := r.Certs.EnsureCredentials(cfToken); err != nil {
slog.Error("failed to write certbot credentials", "component", "reconcile", "error", err) slog.Error("failed to write certbot credentials", "component", "reconcile", "error", err)
var names []string var names []string
for domain := range missing { for domain := range needsProvision {
names = append(names, domain) names = append(names, domain)
} }
return names return names
} }
var stillMissing []string var stillMissing []string
for domain := range missing { for domain := range needsProvision {
provisionDomain := domain provisionDomain := domain
if strings.HasPrefix(domain, "*.") { if strings.HasPrefix(domain, "*.") {
provisionDomain = domain // certbot handles wildcard with -d *.example.com provisionDomain = domain // certbot handles wildcard with -d *.example.com
@@ -550,6 +557,38 @@ func isValidCertFile(path string) bool {
return err == nil && info.Size() > 0 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 { func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- { for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' { if addr[i] == ':' {

View File

@@ -1,9 +1,17 @@
package reconcile package reconcile
import ( import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsmasq" "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) { func TestExtractHost(t *testing.T) {
tests := []struct { tests := []struct {
input, want string input, want string