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:
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user