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" "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 centralDomain := "" if globalCfg.Cloud.Central.Domain != "" { centralDomain = globalCfg.Cloud.Central.Domain } // Generate and write HAProxy config haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{ CentralDomain: centralDomain, HTTPRoutes: httpRoutes, }) 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 // Build instance configs for backward compatibility with dnsmasq generator 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 = extractHost(svc.Backend.Address) 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) }