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