diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index 103d44c..6d8f059 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -3,9 +3,12 @@ package v1 import ( "fmt" "log/slog" + "os" "path/filepath" + "strings" "time" + "github.com/wild-cloud/wild-central/internal/certbot" "github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/haproxy" "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") } + // 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", "services", len(svcs), "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 func extractHost(addr string) string { for i := len(addr) - 1; i >= 0; i-- {