From a85bfbfe9e6111a8e44226eb9f9ed6b9e6c5f3d0 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 14 Jul 2026 11:59:22 +0000 Subject: [PATCH] Auto-provision TLS certificates during reconciliation Replace the passive "log warning about missing certs" approach with active auto-provisioning: - When Cloudflare token and operator email are configured, automatically provision missing certs via certbot DNS-01 during reconciliation - When credentials aren't available, log actionable guidance (no longer references a non-existent "Certificates page") - Track TLS health in reconciler Health struct (ok/degraded with missing cert list) - Broadcast tls:recovered SSE event when all certs become available - Add CertManager interface and GetCloudflareToken callback to reconciler The convergence loop (5 min) continuously retries failed provisions, so transient DNS-01 failures self-heal on the next tick. --- internal/api/v1/handlers.go | 2 + internal/reconcile/reconciler.go | 103 ++++++++++++++++++++++++++----- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index de4ddb2..2bf8b18 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -102,6 +102,8 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) { SSE: sseManager, StatePath: filepath.Join(dataDir, "state.yaml"), GenerateAutheliaConfig: api.generateAutheliaConfig, + Certs: api.certbot, + GetCloudflareToken: api.getCloudflareToken, } // Wire up domain registration reconciliation: when domains change, diff --git a/internal/reconcile/reconciler.go b/internal/reconcile/reconciler.go index 1cad5d3..9653447 100644 --- a/internal/reconcile/reconciler.go +++ b/internal/reconcile/reconciler.go @@ -50,6 +50,13 @@ type AuthManager interface { RestartService() error } +// CertManager is the subset of certbot.Manager used by reconciliation. +type CertManager interface { + EnsureCredentials(apiToken string) error + Provision(domain, email string) error + BuildHAProxyCert(domain string) error +} + // DDNSRunner is the subset of ddns.Runner used by reconciliation. type DDNSRunner interface { Trigger() @@ -80,8 +87,10 @@ type SubsystemHealth struct { type Health struct { HAProxy SubsystemHealth `json:"haproxy"` DNS SubsystemHealth `json:"dns"` + TLS SubsystemHealth `json:"tls"` UpdatedAt time.Time `json:"updatedAt"` ExcludedDomains []string `json:"excludedDomains,omitempty"` + MissingCerts []string `json:"missingCerts,omitempty"` } // Reconciler orchestrates config regeneration when domains change. @@ -92,11 +101,13 @@ type Reconciler struct { HAProxy HAProxyManager DNS DNSManager Auth AuthManager + Certs CertManager DDNS DDNSRunner DNSFilter DNSFilterManager SSE EventBroadcaster StatePath string GenerateAutheliaConfig GenerateAutheliaConfigFn + GetCloudflareToken func() string // callback to read CF token from secrets } // GetHealth returns a snapshot of the last reconciliation health state. @@ -178,7 +189,19 @@ func (r *Reconciler) Reconcile() { } if len(httpRoutes) > 0 { - ensureTLSCerts(globalCfg, doms) + missingCerts := r.ensureTLSCerts(globalCfg, doms) + r.health.MissingCerts = missingCerts + if len(missingCerts) > 0 { + r.health.TLS = SubsystemHealth{ + Status: "degraded", + Message: fmt.Sprintf("%d certs missing: %v", len(missingCerts), missingCerts), + } + } else { + r.health.TLS = SubsystemHealth{Status: "ok"} + } + } else { + r.health.TLS = SubsystemHealth{Status: "ok"} + r.health.MissingCerts = nil } r.DDNS.Trigger() @@ -192,6 +215,9 @@ func (r *Reconciler) Reconcile() { if previousHealth.DNS.Status == "error" && r.health.DNS.Status == "ok" { r.broadcastEvent("dnsmasq:recovered", "DNS recovered") } + if previousHealth.TLS.Status != "ok" && r.health.TLS.Status == "ok" { + r.broadcastEvent("tls:recovered", "All TLS certificates provisioned") + } slog.Info("networking updated", "component", "reconcile", "domains", len(doms), @@ -199,6 +225,7 @@ func (r *Reconciler) Reconcile() { "l7Routes", len(httpRoutes), "haproxy", r.health.HAProxy.Status, "dns", r.health.DNS.Status, + "tls", r.health.TLS.Status, ) } @@ -395,35 +422,83 @@ func (r *Reconciler) broadcastDNSEvent(eventType, message string) { }) } -// ensureTLSCerts logs which certificates are missing for registered domains. -// Does NOT auto-provision — cert provisioning is user-initiated. -func ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) { +// ensureTLSCerts checks for missing certificates and auto-provisions them +// when Cloudflare credentials and operator email are available. +// Returns the list of domains still missing certs after provisioning attempts. +func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) []string { gatewayDomain := "" if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 { gatewayDomain = parts[1] } + // Collect unique missing certs (wildcard or individual) + missing := map[string]bool{} for _, dom := range doms { if dom.TLS != domains.TLSTerminate || dom.DomainName == "" { continue } - - // Check if covered by wildcard if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) { - wildcardCert := certbot.HAProxyCertPath(gatewayDomain) - if _, err := os.Stat(wildcardCert); err != nil { - slog.Warn("no wildcard cert for gateway domain — provision via Certificates page", "component", "reconcile", - "domain", dom.DomainName, "needed", "*."+gatewayDomain) + if _, err := os.Stat(certbot.HAProxyCertPath(gatewayDomain)); err != nil { + missing["*."+gatewayDomain] = true } continue } - - // Check individual cert if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil { - slog.Warn("no cert for domain — provision via Certificates page", "component", "reconcile", - "domain", dom.DomainName) + missing[dom.DomainName] = true } } + + if len(missing) == 0 { + return nil + } + + // Can we auto-provision? + email := globalCfg.Operator.Email + cfToken := "" + if r.GetCloudflareToken != nil { + cfToken = r.GetCloudflareToken() + } + + if cfToken == "" || email == "" || r.Certs == nil { + // Can't auto-provision — report what's missing + var names []string + for domain := range missing { + names = append(names, domain) + slog.Warn("missing cert (auto-provision unavailable — configure Cloudflare token and operator email)", + "component", "reconcile", "domain", domain) + } + return names + } + + // Auto-provision missing 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 { + names = append(names, domain) + } + return names + } + + var stillMissing []string + for domain := range missing { + provisionDomain := domain + if strings.HasPrefix(domain, "*.") { + provisionDomain = domain // certbot handles wildcard with -d *.example.com + } + + slog.Info("auto-provisioning certificate", "component", "reconcile", "domain", provisionDomain) + if err := r.Certs.Provision(provisionDomain, email); err != nil { + slog.Error("cert auto-provision failed", "component", "reconcile", "domain", provisionDomain, "error", err) + stillMissing = append(stillMissing, domain) + } else { + slog.Info("certificate provisioned", "component", "reconcile", "domain", provisionDomain) + // Build HAProxy PEM for non-wildcard certs + baseDomain := strings.TrimPrefix(domain, "*.") + _ = r.Certs.BuildHAProxyCert(baseDomain) + } + } + return stillMissing } // hasCertForDomain checks if a valid (non-empty) cert exists for a domain —