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.
This commit is contained in:
2026-07-14 11:59:22 +00:00
parent 2bb162a794
commit a85bfbfe9e
2 changed files with 91 additions and 14 deletions

View File

@@ -102,6 +102,8 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
SSE: sseManager, SSE: sseManager,
StatePath: filepath.Join(dataDir, "state.yaml"), StatePath: filepath.Join(dataDir, "state.yaml"),
GenerateAutheliaConfig: api.generateAutheliaConfig, GenerateAutheliaConfig: api.generateAutheliaConfig,
Certs: api.certbot,
GetCloudflareToken: api.getCloudflareToken,
} }
// Wire up domain registration reconciliation: when domains change, // Wire up domain registration reconciliation: when domains change,

View File

@@ -50,6 +50,13 @@ type AuthManager interface {
RestartService() error 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. // DDNSRunner is the subset of ddns.Runner used by reconciliation.
type DDNSRunner interface { type DDNSRunner interface {
Trigger() Trigger()
@@ -80,8 +87,10 @@ type SubsystemHealth struct {
type Health struct { type Health struct {
HAProxy SubsystemHealth `json:"haproxy"` HAProxy SubsystemHealth `json:"haproxy"`
DNS SubsystemHealth `json:"dns"` DNS SubsystemHealth `json:"dns"`
TLS SubsystemHealth `json:"tls"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
ExcludedDomains []string `json:"excludedDomains,omitempty"` ExcludedDomains []string `json:"excludedDomains,omitempty"`
MissingCerts []string `json:"missingCerts,omitempty"`
} }
// Reconciler orchestrates config regeneration when domains change. // Reconciler orchestrates config regeneration when domains change.
@@ -92,11 +101,13 @@ type Reconciler struct {
HAProxy HAProxyManager HAProxy HAProxyManager
DNS DNSManager DNS DNSManager
Auth AuthManager Auth AuthManager
Certs CertManager
DDNS DDNSRunner DDNS DDNSRunner
DNSFilter DNSFilterManager DNSFilter DNSFilterManager
SSE EventBroadcaster SSE EventBroadcaster
StatePath string StatePath string
GenerateAutheliaConfig GenerateAutheliaConfigFn GenerateAutheliaConfig GenerateAutheliaConfigFn
GetCloudflareToken func() string // callback to read CF token from secrets
} }
// GetHealth returns a snapshot of the last reconciliation health state. // GetHealth returns a snapshot of the last reconciliation health state.
@@ -178,7 +189,19 @@ func (r *Reconciler) Reconcile() {
} }
if len(httpRoutes) > 0 { 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() r.DDNS.Trigger()
@@ -192,6 +215,9 @@ func (r *Reconciler) Reconcile() {
if previousHealth.DNS.Status == "error" && r.health.DNS.Status == "ok" { if previousHealth.DNS.Status == "error" && r.health.DNS.Status == "ok" {
r.broadcastEvent("dnsmasq:recovered", "DNS recovered") 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", slog.Info("networking updated", "component", "reconcile",
"domains", len(doms), "domains", len(doms),
@@ -199,6 +225,7 @@ func (r *Reconciler) Reconcile() {
"l7Routes", len(httpRoutes), "l7Routes", len(httpRoutes),
"haproxy", r.health.HAProxy.Status, "haproxy", r.health.HAProxy.Status,
"dns", r.health.DNS.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. // ensureTLSCerts checks for missing certificates and auto-provisions them
// Does NOT auto-provision — cert provisioning is user-initiated. // when Cloudflare credentials and operator email are available.
func ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) { // Returns the list of domains still missing certs after provisioning attempts.
func (r *Reconciler) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) []string {
gatewayDomain := "" gatewayDomain := ""
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 { if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1] gatewayDomain = parts[1]
} }
// Collect unique missing certs (wildcard or individual)
missing := 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
} }
// Check if covered by wildcard
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) { if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
wildcardCert := certbot.HAProxyCertPath(gatewayDomain) if _, err := os.Stat(certbot.HAProxyCertPath(gatewayDomain)); err != nil {
if _, err := os.Stat(wildcardCert); err != nil { missing["*."+gatewayDomain] = true
slog.Warn("no wildcard cert for gateway domain — provision via Certificates page", "component", "reconcile",
"domain", dom.DomainName, "needed", "*."+gatewayDomain)
} }
continue continue
} }
// Check individual cert
if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil { if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil {
slog.Warn("no cert for domain — provision via Certificates page", "component", "reconcile", missing[dom.DomainName] = true
"domain", dom.DomainName)
} }
} }
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 — // hasCertForDomain checks if a valid (non-empty) cert exists for a domain —