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