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