From c18217944fd81ab6eda0fb2e3010f782a0a3b74a Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 9 Jul 2026 22:26:56 +0000 Subject: [PATCH] feat: Add EnsureCentralService + improved tests Linter-contributed improvements: - EnsureCentralService() method: registers Central's own domain as a service from config, with cleanup on domain change - Tests for EnsureCentralService: registration, idempotency, domain change cleanup, no-domain-is-noop - Improved reconciliation tests with Central as a registered service - Certbot handler cleanup Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/v1/handlers.go | 3 +- internal/api/v1/handlers_certbot.go | 33 ++++ .../api/v1/handlers_reconciliation_test.go | 180 ++++++++++++++---- internal/api/v1/helpers.go | 52 +++-- internal/services/manager.go | 2 +- main.go | 6 +- 6 files changed, 222 insertions(+), 54 deletions(-) diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index f965def..3115b74 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -320,8 +320,9 @@ func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) { slog.Info("global config updated") - // Reload DDNS if config changed + // Reload DDNS and re-register Central's service if config changed go api.reloadDDNSIfEnabled() + go api.EnsureCentralService() respondMessage(w, http.StatusOK, "Config updated successfully") } diff --git a/internal/api/v1/handlers_certbot.go b/internal/api/v1/handlers_certbot.go index f367b8e..ffbae56 100644 --- a/internal/api/v1/handlers_certbot.go +++ b/internal/api/v1/handlers_certbot.go @@ -3,8 +3,10 @@ package v1 import ( "fmt" "net/http" + "os" "strings" + "github.com/wild-cloud/wild-central/internal/certbot" "github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/services" ) @@ -58,6 +60,37 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) { certs = append(certs, entry) } + // Include standalone certs from disk (e.g. wildcard certs) not tied to a service. + certsDir := "/etc/haproxy/certs/" + if entries, err := os.ReadDir(certsDir); err == nil { + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".pem") { + continue + } + domain := strings.TrimSuffix(e.Name(), ".pem") + if seen[domain] { + continue + } + seen[domain] = true + status := api.certbot.GetStatus(domain) + if !status.Exists { + // PEM file exists but openssl couldn't parse it — skip + continue + } + // Use the certbot live directory to confirm this is a certbot-managed cert + source := "standalone" + certLive := certbot.CertPath(domain) + if _, err := os.Stat(certLive); err == nil { + source = "certbot" + } + certs = append(certs, map[string]any{ + "domain": domain, + "source": source, + "cert": status, + }) + } + } + respondJSON(w, http.StatusOK, map[string]any{ "configured": centralDomain != "", "domain": centralDomain, diff --git a/internal/api/v1/handlers_reconciliation_test.go b/internal/api/v1/handlers_reconciliation_test.go index b1b7186..be36ba1 100644 --- a/internal/api/v1/handlers_reconciliation_test.go +++ b/internal/api/v1/handlers_reconciliation_test.go @@ -2,6 +2,8 @@ package v1 import ( "fmt" + "os" + "path/filepath" "strings" "testing" @@ -81,13 +83,32 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { } } - // Add Central's own domain (as reconcileNetworking does from config) - centralDomain := "central.payne.io" - httpRoutes = append([]haproxy.HTTPRoute{{ - Name: "wild-central", - Domain: centralDomain, - Backend: "127.0.0.1:5055", - }}, httpRoutes...) + // Central registers itself as a service; add it to the test data + if err := api.services.Register(services.Service{ + Domain: "central.payne.io", + Source: "central", + Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP}, + Reach: services.ReachInternal, + TLS: services.TLSTerminate, + }); err != nil { + t.Fatalf("Register central failed: %v", err) + } + // Re-list to pick up Central + svcs, err = api.services.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + httpRoutes = nil + for _, svc := range svcs { + if svc.Backend.Type == services.BackendHTTP { + httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ + Name: svc.Domain, + Domain: svc.Domain, + Backend: svc.Backend.Address, + HealthPath: svc.Backend.Health, + }) + } + } cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{ HTTPRoutes: httpRoutes, @@ -97,7 +118,7 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { // --- Assertions on HAProxy config --- // L7 services have exact-match ACLs in the SNI frontend - if !strings.Contains(cfg, "acl is_l7_wild_central req_ssl_sni -m str central.payne.io") { + if !strings.Contains(cfg, "acl is_l7_central_payne_io req_ssl_sni -m str central.payne.io") { t.Errorf("expected L7 exact-match ACL for central.payne.io:\n%s", cfg) } if !strings.Contains(cfg, "acl is_l7_wild_cloud_payne_io req_ssl_sni -m str wild-cloud.payne.io") { @@ -123,7 +144,7 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { } // L7 ACLs appear BEFORE L4 wildcard ACLs - l7Pos := strings.Index(cfg, "is_l7_wild_central") + l7Pos := strings.Index(cfg, "is_l7_central_payne_io") l4WildcardPos := strings.Index(cfg, "req_ssl_sni -m end .cloud.payne.io") if l7Pos > l4WildcardPos { t.Errorf("L7 exact matches (pos %d) must appear before L4 wildcards (pos %d)", l7Pos, l4WildcardPos) @@ -244,12 +265,25 @@ func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) { } } -// TestReconciliation_CentralDomainInHAProxy verifies that the Central domain -// from global config is injected as the first L7 HTTP route. +// TestReconciliation_CentralDomainInHAProxy verifies that Central, registered +// as a service, produces correct HAProxy L7 routes. func TestReconciliation_CentralDomainInHAProxy(t *testing.T) { api, _ := setupTestAPI(t) - // Register one HTTP service + centralPort := 15055 + + // Register Central as a service (as EnsureCentralService does) + if err := api.services.Register(services.Service{ + Domain: "central.payne.io", + Source: "central", + Backend: services.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: services.BackendHTTP}, + Reach: services.ReachInternal, + TLS: services.TLSTerminate, + }); err != nil { + t.Fatalf("Register central failed: %v", err) + } + + // Register another HTTP service if err := api.services.Register(services.Service{ Domain: "my-app.payne.io", Source: "wild-works", @@ -272,16 +306,6 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) { } } - // Central domain goes first (as reconcileNetworking does). - // Uses port 15055 — must match the running port, NOT default 5055. - centralDomain := "central.payne.io" - centralPort := 15055 - httpRoutes = append([]haproxy.HTTPRoute{{ - Name: "wild-central", - Domain: centralDomain, - Backend: fmt.Sprintf("127.0.0.1:%d", centralPort), - }}, httpRoutes...) - cfg := api.haproxy.GenerateWithOpts(nil, nil, haproxy.GenerateOpts{ HTTPRoutes: httpRoutes, }) @@ -294,22 +318,9 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) { t.Errorf("expected Central domain Host ACL:\n%s", cfg) } - // Central backend must use the configured port (15055), not default 5055 - if !strings.Contains(cfg, "server s0 127.0.0.1:15055") { - t.Errorf("Central backend must use port 15055, not 5055:\n%s", cfg) - } - if strings.Contains(cfg, "server s0 127.0.0.1:5055") { - t.Errorf("Central backend must NOT use default port 5055:\n%s", cfg) - } - - // Central route should be the first L7 route - centralPos := strings.Index(cfg, "is_l7_wild_central") - appPos := strings.Index(cfg, "is_l7_my_app_payne_io") - if centralPos < 0 || appPos < 0 { - t.Fatalf("expected both L7 ACLs in config:\n%s", cfg) - } - if centralPos > appPos { - t.Errorf("Central domain (pos %d) should appear before app domain (pos %d)", centralPos, appPos) + // Central backend must use the configured port (15055) + if !strings.Contains(cfg, fmt.Sprintf("server s0 127.0.0.1:%d", centralPort)) { + t.Errorf("Central backend must use port %d:\n%s", centralPort, cfg) } } @@ -361,3 +372,96 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) { t.Errorf("tcp-passthrough DNS must NOT point to Central IP:\n%s", dnsmasqCfg) } } + +// writeTestConfig writes a minimal global config with the given Central domain. +func writeTestConfig(t *testing.T, dataDir, centralDomain string) { + t.Helper() + configPath := filepath.Join(dataDir, "config.yaml") + content := fmt.Sprintf("cloud:\n central:\n domain: %s\n", centralDomain) + if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { + t.Fatalf("write config: %v", err) + } +} + +func TestEnsureCentralService_RegistersWhenDomainConfigured(t *testing.T) { + api, dataDir := setupTestAPI(t) + api.SetPort(15055) + writeTestConfig(t, dataDir, "central.example.com") + + api.EnsureCentralService() + + svc, err := api.services.Get("central.example.com") + if err != nil { + t.Fatalf("expected Central service to be registered: %v", err) + } + if svc.Source != "central" { + t.Errorf("source = %q, want %q", svc.Source, "central") + } + if svc.Backend.Address != "127.0.0.1:15055" { + t.Errorf("backend = %q, want %q", svc.Backend.Address, "127.0.0.1:15055") + } + if svc.TLS != services.TLSTerminate { + t.Errorf("tls = %q, want %q", svc.TLS, services.TLSTerminate) + } +} + +func TestEnsureCentralService_Idempotent(t *testing.T) { + api, dataDir := setupTestAPI(t) + api.SetPort(15055) + writeTestConfig(t, dataDir, "central.example.com") + + api.EnsureCentralService() + api.EnsureCentralService() // second call should be a no-op + + svcs, _ := api.services.List() + count := 0 + for _, s := range svcs { + if s.Source == "central" { + count++ + } + } + if count != 1 { + t.Errorf("expected 1 central service, got %d", count) + } +} + +func TestEnsureCentralService_CleansUpOnDomainChange(t *testing.T) { + api, dataDir := setupTestAPI(t) + api.SetPort(15055) + + // Register with old domain + writeTestConfig(t, dataDir, "old.example.com") + api.EnsureCentralService() + + // Change domain + writeTestConfig(t, dataDir, "new.example.com") + api.EnsureCentralService() + + // Old domain should be gone + if _, err := api.services.Get("old.example.com"); err == nil { + t.Error("old Central service should have been deregistered") + } + // New domain should exist + svc, err := api.services.Get("new.example.com") + if err != nil { + t.Fatalf("new Central service should be registered: %v", err) + } + if svc.Source != "central" { + t.Errorf("source = %q, want %q", svc.Source, "central") + } +} + +func TestEnsureCentralService_NoDomainIsNoop(t *testing.T) { + api, _ := setupTestAPI(t) + api.SetPort(15055) + // Default config has no Central domain + + api.EnsureCentralService() + + svcs, _ := api.services.List() + for _, s := range svcs { + if s.Source == "central" { + t.Error("no Central service should be registered when domain is empty") + } + } +} diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index 2b31ef4..e3ee11f 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -16,6 +16,47 @@ import ( "github.com/wild-cloud/wild-central/internal/sse" ) +// EnsureCentralService registers (or updates) Central's own domain as a service +// so it participates in DNS, HAProxy routing, and TLS cert tracking like any +// other service. Called on startup and when global config changes. +func (api *API) EnsureCentralService() { + 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.services.Get(centralDomain); existing != nil && + existing.Source == "central" && + existing.Backend.Address == backend { + return + } + } + + // Clean up stale Central registrations (e.g. domain changed). + svcs, _ := api.services.List() + for _, svc := range svcs { + if svc.Source == "central" && svc.Domain != centralDomain { + _ = api.services.Deregister(svc.Domain) + } + } + + if centralDomain == "" { + return + } + + _ = api.services.Register(services.Service{ + Domain: centralDomain, + Source: "central", + Backend: services.Backend{ + Address: backend, + Type: services.BackendHTTP, + }, + Reach: services.ReachInternal, + TLS: services.TLSTerminate, + }) +} + // Reconcile runs networking reconciliation immediately. Called on startup // and automatically whenever a service is registered/updated/deregistered. func (api *API) Reconcile() { @@ -61,17 +102,6 @@ func (api *API) reconcileNetworking() { } } - // Inject Central's own domain as an HTTP route (from config, not registration). - // Central is just another L7 service from HAProxy's perspective. - if d := globalCfg.Cloud.Central.Domain; d != "" { - centralPort := api.getRunningPort() - httpRoutes = append([]haproxy.HTTPRoute{{ - Name: "wild-central", - Domain: d, - Backend: fmt.Sprintf("127.0.0.1:%d", centralPort), - }}, httpRoutes...) - } - // Only include L7 HTTP routes for services that have a cert. certsDir := "/etc/haproxy/certs/" var activeHTTPRoutes []haproxy.HTTPRoute diff --git a/internal/services/manager.go b/internal/services/manager.go index a529095..06925ae 100644 --- a/internal/services/manager.go +++ b/internal/services/manager.go @@ -3,7 +3,7 @@ // routing, TLS certificates, and optional public exposure via DDNS. // // Each registration is keyed by its domain — one registration per domain. -// Central's own services (its UI, VPN, firewall) come from config, not here. +// Central registers its own UI domain here on startup (source: "central"). package services import ( diff --git a/main.go b/main.go index e3b458c..1568d29 100644 --- a/main.go +++ b/main.go @@ -150,10 +150,10 @@ func main() { fmt.Sscanf(v, "%d", &port) } - // Tell the API what port it's running on and run initial reconciliation. - // Must happen AFTER port is known so Central's config-driven HAProxy - // route points to the correct port. + // Tell the API what port it's running on, register Central as a service + // (if a domain is configured), and reconcile all networking. api.SetPort(port) + api.EnsureCentralService() api.Reconcile() addr := fmt.Sprintf("%s:%d", host, port)