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:
@@ -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) {
|
||||
// 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] == ':' {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user