Files
wild-central/internal/api/v1/helpers.go
Paul Payne 7d5e4f4e29 fix: Skip L7 routes and central TLS in HAProxy when certs don't exist
HAProxy validation fails if the config references cert files that
don't exist (wildcard.pem for L7 routes, domain.pem for central TLS).

Now reconciliation checks for cert existence before including:
- L7 HTTP routes: only generated when wildcard cert exists
- Central domain TLS frontend: only generated when domain cert exists

This ensures HAProxy never enters a failure state due to missing
certs. Once certs are provisioned (via certbot), the next
reconciliation picks them up automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 14:15:32 +00:00

304 lines
9.5 KiB
Go

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/network"
"github.com/wild-cloud/wild-central/internal/services"
"github.com/wild-cloud/wild-central/internal/sse"
)
// reconcileNetworking reads all registered services and regenerates
// dnsmasq DNS entries and HAProxy routes to match. Called automatically
// whenever a service is registered, updated, or deregistered.
func (api *API) reconcileNetworking() {
svcs, err := api.services.List()
if err != nil {
slog.Error("reconcile: failed to list services", "error", err)
return
}
globalConfigPath := filepath.Join(api.dataDir, "config.yaml")
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
if err != nil {
slog.Warn("reconcile: failed to load global config, using empty", "error", err)
globalCfg = &config.GlobalConfig{}
}
// Build HAProxy routes from registered services
var instanceRoutes []haproxy.InstanceRoute
var httpRoutes []haproxy.HTTPRoute
for _, svc := range svcs {
if svc.Reach == services.ReachOff {
continue
}
switch svc.Backend.Type {
case services.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
Name: svc.Name,
Domain: svc.Domain,
BackendIP: extractHost(svc.Backend.Address),
ExtraDomains: svc.ExtraDomains,
})
case services.BackendHTTP, services.BackendStatic:
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Name,
Domain: svc.Domain,
Backend: svc.Backend.Address,
HealthPath: svc.Backend.Health,
})
}
}
// Determine central domain for HAProxy — only if its cert exists
centralDomain := ""
if d := globalCfg.Cloud.Central.Domain; d != "" {
if _, err := os.Stat(certbot.HAProxyCertPath(d)); err == nil {
centralDomain = d
} else {
slog.Debug("reconcile: skipping central domain in HAProxy (no cert)", "domain", d)
}
}
// Only include L7 HTTP routes if a wildcard cert exists for TLS termination.
// Without it, HAProxy validation fails and the entire config is rejected.
wildcardCert := "/etc/haproxy/certs/wildcard.pem"
gatewayDomain := ""
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1]
// Also check per-domain cert path
if _, err := os.Stat(certbot.HAProxyCertPath(gatewayDomain)); err == nil {
wildcardCert = certbot.HAProxyCertPath(gatewayDomain)
}
}
activeHTTPRoutes := httpRoutes
if _, err := os.Stat(wildcardCert); err != nil {
if len(httpRoutes) > 0 {
slog.Warn("reconcile: skipping L7 HTTP routes in HAProxy (no wildcard cert)", "path", wildcardCert)
}
activeHTTPRoutes = nil
}
// Generate and write HAProxy config
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
CentralDomain: centralDomain,
HTTPRoutes: activeHTTPRoutes,
WildcardCert: wildcardCert,
})
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
slog.Error("reconcile: failed to write HAProxy config", "error", err)
} else {
if err := api.haproxy.ReloadService(); err != nil {
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
} else {
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered services")
}
}
// Generate dnsmasq DNS entries from registered services.
// All service domains resolve to Central's IP (where HAProxy listens).
// HAProxy then routes to the actual backend based on SNI or Host header.
centralIP, _ := network.GetWildCentralIP()
if centralIP == "" {
centralIP = "127.0.0.1"
}
var instanceConfigs []config.InstanceConfig
for _, svc := range svcs {
if svc.Reach == services.ReachOff || svc.Domain == "" {
continue
}
ic := config.InstanceConfig{}
ic.Cloud.Domain = svc.Domain
ic.Cluster.LoadBalancerIp = centralIP // DNS points to Central, not the backend
instanceConfigs = append(instanceConfigs, ic)
}
if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil {
slog.Error("reconcile: failed to update dnsmasq", "error", err)
} else {
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),
"l7Routes", len(httpRoutes),
)
}
// 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-- {
if addr[i] == ':' {
return addr[:i]
}
}
return addr
}
// broadcastDnsmasqEvent broadcasts SSE events for dnsmasq status changes
func (api *API) broadcastDnsmasqEvent(eventType string, message string) {
if api.sseManager == nil {
return
}
// Get current dnsmasq status
status, err := api.dnsmasq.GetStatus()
if err != nil {
status = nil
}
event := &sse.Event{
ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global", // dnsmasq is global/central-level, not instance-specific
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
"status": status,
},
}
api.sseManager.Broadcast(event)
}
// broadcastHaproxyEvent broadcasts SSE events for HAProxy status changes
func (api *API) broadcastHaproxyEvent(eventType string, message string) {
if api.sseManager == nil {
return
}
event := &sse.Event{
ID: fmt.Sprintf("haproxy-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
},
}
api.sseManager.Broadcast(event)
}
// broadcastCentralStatusEvent broadcasts SSE events for central status changes
func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
if api.sseManager == nil {
return
}
uptime := time.Since(startTime)
event := &sse.Event{
ID: fmt.Sprintf("central-%d", time.Now().UnixNano()),
Type: "central:status",
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"status": "running",
"version": api.version,
"uptime": uptime.String(),
"uptimeSeconds": int(uptime.Seconds()),
},
}
api.sseManager.Broadcast(event)
}