feat: Wire up TLS cert provisioning in reconciliation

When HTTP services are registered (where Central terminates TLS),
reconciliation now checks for and provisions TLS certificates:

- Derives gateway domain from central domain (e.g., payne.io)
- Checks for existing wildcard cert (*.payne.io)
- If no wildcard exists and Cloudflare token + operator email are
  configured, provisions one via certbot DNS-01
- For services outside the gateway domain, provisions individual certs
- Services with TLS passthrough (k8s) are skipped (backend handles TLS)
- Graceful: skips silently if no Cloudflare token or email configured

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 13:36:49 +00:00
parent 75a82becb9
commit c7a0227e03

View File

@@ -3,9 +3,12 @@ package v1
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/wild-cloud/wild-central/internal/certbot"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/services" "github.com/wild-cloud/wild-central/internal/services"
@@ -97,6 +100,13 @@ func (api *API) reconcileNetworking() {
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered services") api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered services")
} }
// Ensure TLS certs exist for HTTP services (where Central terminates TLS).
// For services under the gateway domain, a wildcard cert covers them all.
// For others, provision individual certs.
if len(httpRoutes) > 0 {
api.ensureTLSCerts(globalCfg, svcs)
}
slog.Info("reconcile: networking updated", slog.Info("reconcile: networking updated",
"services", len(svcs), "services", len(svcs),
"l4Routes", len(instanceRoutes), "l4Routes", len(instanceRoutes),
@@ -104,6 +114,83 @@ func (api *API) reconcileNetworking() {
) )
} }
// ensureTLSCerts checks that TLS certificates exist for HTTP services
// that need Central to terminate TLS. Uses wildcard certs where possible.
func (api *API) ensureTLSCerts(globalCfg *config.GlobalConfig, svcs []services.Service) {
// Determine the gateway domain from central domain (e.g., central.payne.io → payne.io)
centralDomain := globalCfg.Cloud.Central.Domain
gatewayDomain := ""
if parts := strings.SplitN(centralDomain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1] // e.g., "payne.io"
}
// Check if we have a Cloudflare token for cert provisioning
cfToken := api.getCloudflareToken()
if cfToken == "" {
slog.Debug("reconcile/tls: no Cloudflare token, skipping cert provisioning")
return
}
email := globalCfg.Operator.Email
if email == "" {
slog.Debug("reconcile/tls: no operator email, skipping cert provisioning")
return
}
// Check if wildcard cert exists for the gateway domain
wildcardCertPath := "/etc/haproxy/certs/wildcard.pem"
if gatewayDomain != "" {
wildcardDomain := "*." + gatewayDomain
// Check if cert exists at the standard wildcard path or per-domain path
certPath := certbot.HAProxyCertPath(gatewayDomain)
if _, err := os.Stat(certPath); err == nil {
// Per-domain cert exists, use it as wildcard
slog.Debug("reconcile/tls: wildcard cert exists", "domain", gatewayDomain, "path", certPath)
} else if _, err := os.Stat(wildcardCertPath); err == nil {
slog.Debug("reconcile/tls: wildcard cert exists at default path")
} else {
// No wildcard cert — provision one
slog.Info("reconcile/tls: provisioning wildcard cert", "domain", wildcardDomain)
if err := api.certbot.EnsureCredentials(cfToken); err != nil {
slog.Error("reconcile/tls: failed to write certbot credentials", "error", err)
return
}
if err := api.certbot.Provision(wildcardDomain, email); err != nil {
slog.Warn("reconcile/tls: failed to provision wildcard cert", "domain", wildcardDomain, "error", err)
// Fall through — try per-domain certs
}
}
}
// For services NOT under the gateway domain, provision individual certs
for _, svc := range svcs {
if svc.TLS != services.TLSTerminate || svc.Domain == "" {
continue
}
// Check if this domain is covered by the gateway wildcard
if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) {
continue // Covered by wildcard
}
// Check if per-domain cert exists
certPath := certbot.HAProxyCertPath(svc.Domain)
if _, err := os.Stat(certPath); err == nil {
continue // Cert already exists
}
// Provision individual cert
slog.Info("reconcile/tls: provisioning cert", "domain", svc.Domain)
if err := api.certbot.EnsureCredentials(cfToken); err != nil {
slog.Error("reconcile/tls: failed to write certbot credentials", "error", err)
continue
}
if err := api.certbot.Provision(svc.Domain, email); err != nil {
slog.Warn("reconcile/tls: failed to provision cert", "domain", svc.Domain, "error", err)
}
}
}
// extractHost gets the host part from a host:port string // extractHost gets the host part from a host:port string
func extractHost(addr string) string { func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- { for i := len(addr) - 1; i >= 0; i-- {