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,
StatePath: filepath.Join(dataDir, "state.yaml"),
GenerateAutheliaConfig: api.generateAutheliaConfig,
Certs: api.certbot,
GetCloudflareToken: api.getCloudflareToken,
}
// Wire up domain registration reconciliation: when domains change,

View File

@@ -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 —