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/dnsmasq" "github.com/wild-cloud/wild-central/internal/domains" "github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/network" "github.com/wild-cloud/wild-central/internal/sse" ) // EnsureCentralDomain registers (or updates) Central's own domain as a domain // so it participates in DNS, HAProxy routing, and TLS cert tracking like any // other domain. Called on startup and when global config changes. func (api *API) EnsureCentralDomain() { centralDomain := api.getCentralDomain() port := api.getRunningPort() backend := fmt.Sprintf("127.0.0.1:%d", port) // Check if the current registration already matches — skip if so. if centralDomain != "" { if existing, _ := api.domains.Get(centralDomain); existing != nil && existing.Source == "central" && existing.Backend.Address == backend { return } } // Clean up stale Central registrations (e.g. domain changed). doms, _ := api.domains.List() for _, dom := range doms { if dom.Source == "central" && dom.DomainName != centralDomain { _ = api.domains.Deregister(dom.DomainName) } } if centralDomain == "" { return } _ = api.domains.Register(domains.Domain{ DomainName: centralDomain, Source: "central", Backend: domains.Backend{ Address: backend, Type: domains.BackendHTTP, }, TLS: domains.TLSTerminate, }) } // Reconcile runs networking reconciliation immediately. Called on startup // and automatically whenever a domain is registered/updated/deregistered. func (api *API) Reconcile() { api.reconcileNetworking() } // reconcileNetworking reads all registered domains and regenerates // dnsmasq DNS entries and HAProxy routes to match. func (api *API) reconcileNetworking() { doms, err := api.domains.List() if err != nil { slog.Error("reconcile: failed to list domains", "error", err) return } globalCfg, err := config.LoadState(api.statePath()) if err != nil { slog.Warn("reconcile: failed to load state, using empty", "error", err) globalCfg = &config.State{} } // Build HAProxy routes from registered domains var instanceRoutes []haproxy.InstanceRoute var httpRoutes []haproxy.HTTPRoute for _, dom := range doms { switch dom.EffectiveBackendType() { case domains.BackendTCPPassthrough: instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{ Name: dom.DomainName, Domain: dom.DomainName, BackendIP: extractHost(dom.EffectiveBackendAddress()), Subdomains: dom.Subdomains, }) case domains.BackendDNSOnly: // dns-only: no gateway routing, just DNS resolution case domains.BackendHTTP: var routeBackends []haproxy.HTTPRouteBackend for _, r := range dom.EffectiveRoutes() { routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{ Paths: r.Paths, Backend: r.Backend.Address, HealthPath: r.Backend.Health, Headers: r.Headers, IPAllow: r.IPAllow, }) } httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ Name: dom.DomainName, Domain: dom.DomainName, Subdomains: dom.Subdomains, Routes: routeBackends, }) } } // Clean up any 0-byte cert files that would poison HAProxy validation. // A 0-byte .pem in the certs directory causes the global `bind ssl crt` // to fail, taking down ALL L7 routes — not just the broken domain. certsDir := "/etc/haproxy/certs/" if entries, err := os.ReadDir(certsDir); err == nil { for _, e := range entries { if strings.HasSuffix(e.Name(), ".pem") { if info, err := e.Info(); err == nil && info.Size() == 0 { slog.Warn("reconcile: removing 0-byte cert file", "file", e.Name()) _ = os.Remove(filepath.Join(certsDir, e.Name())) } } } } // Only include L7 HTTP routes for domains that have a valid cert. var activeHTTPRoutes []haproxy.HTTPRoute for _, route := range httpRoutes { if hasCertForDomain(certsDir, route.Domain) { activeHTTPRoutes = append(activeHTTPRoutes, route) } else { slog.Warn("reconcile: skipping L7 route (no cert)", "domain", route.Domain) } } // Generate and write HAProxy config genOpts := haproxy.GenerateOpts{ HTTPRoutes: activeHTTPRoutes, CertsDir: certsDir, } haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, genOpts) if err := api.haproxy.WriteConfig(haproxyCfg); err != nil { // Validation failed — identify and exclude broken domains, then retry brokenDomains := haproxy.FindBrokenServices(haproxyCfg, haproxy.ParseValidationErrors(err.Error())) if len(brokenDomains) > 0 { slog.Error("reconcile: excluding broken domains and retrying", "broken", brokenDomains, "error", err) exclude := map[string]bool{} for _, d := range brokenDomains { exclude[d] = true } var filteredInstances []haproxy.InstanceRoute for _, r := range instanceRoutes { if !exclude[r.Domain] { filteredInstances = append(filteredInstances, r) } } var filteredHTTP []haproxy.HTTPRoute for _, r := range activeHTTPRoutes { if !exclude[r.Domain] { filteredHTTP = append(filteredHTTP, r) } } genOpts.HTTPRoutes = filteredHTTP haproxyCfg = api.haproxy.GenerateWithOpts(filteredInstances, nil, genOpts) if err := api.haproxy.WriteConfig(haproxyCfg); err != nil { slog.Error("reconcile: retry also failed", "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 (excluded broken domains)") } } } else { slog.Error("reconcile: failed to write HAProxy config (no broken domains identified)", "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 domains") } } // Build dnsmasq DNS entries from registered domains. // DNS target IP depends on backend type: // tcp-passthrough/dns-only → backend IP (LAN traffic goes direct) // http → Central IP (HAProxy terminates TLS) // Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected // (auto-detect can pick wlan0 if that's the default route). centralIP := globalCfg.Cloud.Dnsmasq.IP if centralIP == "" { centralIP, _ = network.GetWildCentralIP() } if centralIP == "" { centralIP = "127.0.0.1" } var dnsEntries []dnsmasq.DNSEntry for _, dom := range doms { if dom.DomainName == "" { continue } dnsIP := centralIP bt := dom.EffectiveBackendType() if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly { dnsIP = extractHost(dom.EffectiveBackendAddress()) } dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ Domain: dom.DomainName, IP: dnsIP, }) } if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil { slog.Error("reconcile: failed to update dnsmasq", "error", err) } else { api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains") } // Ensure TLS certs exist for HTTP domains (where Central terminates TLS). // For domains under the gateway domain, a wildcard cert covers them all. // For others, provision individual certs. if len(httpRoutes) > 0 { api.ensureTLSCerts(globalCfg, doms) } // Nudge DDNS to pick up any domain changes (public toggle, new domains). // The runner reads fresh params on each check via its callback. api.ddns.Trigger() slog.Info("reconcile: networking updated", "domains", len(doms), "l4Routes", len(instanceRoutes), "l7Routes", len(httpRoutes), ) } // ensureTLSCerts logs which certificates are missing for registered domains. // Does NOT auto-provision — cert provisioning is user-initiated via the // Certificates page. This just logs warnings so the operator knows what's needed. func (api *API) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) { gatewayDomain := "" if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 { gatewayDomain = parts[1] } for _, dom := range doms { if dom.TLS != domains.TLSTerminate || dom.DomainName == "" { continue } // Check if covered by wildcard if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) { wildcardCert := certbot.HAProxyCertPath(gatewayDomain) if _, err := os.Stat(wildcardCert); err != nil { slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page", "domain", dom.DomainName, "needed", "*."+gatewayDomain) } continue } // Check individual cert if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil { slog.Warn("reconcile/tls: no cert for domain — provision via Certificates page", "domain", dom.DomainName) } } } // hasCertForDomain checks if a valid (non-empty) cert exists for a domain — // either an individual cert (.pem) or a wildcard cert that covers it. func hasCertForDomain(_, domain string) bool { // Check individual cert if isValidCertFile(certbot.HAProxyCertPath(domain)) { return true } // Check wildcard certs — a *.example.com cert covers foo.example.com parts := strings.SplitN(domain, ".", 2) if len(parts) == 2 { wildcardBase := parts[1] if isValidCertFile(certbot.HAProxyCertPath(wildcardBase)) { return true } } return false } // isValidCertFile checks that a cert file exists and is non-empty. func isValidCertFile(path string) bool { info, err := os.Stat(path) return err == nil && info.Size() > 0 } // 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", 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) }