package v1 import ( "fmt" "os" "path/filepath" "strings" "testing" "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" ) // TestReconciliation_HAProxyConfigFromDomains verifies that the reconciliation // logic correctly maps registered domains to HAProxy configuration. This // replicates the route-building logic from reconcileNetworking() and asserts // on the generated config without requiring haproxy or dnsmasq binaries. func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) { api, _ := setupTestAPI(t) // Register a mix of domains like a real deployment testDomains := []domains.Domain{ { DomainName: "cloud.payne.io", Source: "wild-cloud", Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough}, Subdomains: true, Public: true, }, { DomainName: "payne.io", Source: "wild-cloud", Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough}, Subdomains: false, Public: true, }, { DomainName: "wild-cloud.payne.io", Source: "wild-works", Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP}, }, { DomainName: "my-api.payne.io", Source: "wild-works", Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP, Health: "/health"}, }, } for _, dom := range testDomains { if err := api.domains.Register(dom); err != nil { t.Fatalf("Register %s failed: %v", dom.DomainName, err) } } // Replicate reconcileNetworking route-building logic doms, err := api.domains.List() if err != nil { t.Fatalf("List failed: %v", err) } var instanceRoutes []haproxy.L4Route var httpRoutes []haproxy.HTTPRoute for _, dom := range doms { switch dom.Backend.Type { case domains.BackendTCPPassthrough: instanceRoutes = append(instanceRoutes, haproxy.L4Route{ Name: dom.DomainName, Domain: dom.DomainName, BackendIP: extractHost(dom.Backend.Address), Subdomains: dom.Subdomains, }) case domains.BackendHTTP: httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ Name: dom.DomainName, Domain: dom.DomainName, Routes: []haproxy.HTTPRouteBackend{{ Backend: dom.Backend.Address, HealthPath: dom.Backend.Health, }}, }) } } // Central registers itself as a domain; add it to the test data if err := api.domains.Register(domains.Domain{ DomainName: "central.payne.io", Source: "central", Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP}, TLS: domains.TLSTerminate, }); err != nil { t.Fatalf("Register central failed: %v", err) } // Re-list to pick up Central doms, err = api.domains.List() if err != nil { t.Fatalf("List failed: %v", err) } httpRoutes = nil for _, dom := range doms { if dom.Backend.Type == domains.BackendHTTP { httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ Name: dom.DomainName, Domain: dom.DomainName, Routes: []haproxy.HTTPRouteBackend{{ Backend: dom.Backend.Address, HealthPath: dom.Backend.Health, }}, }) } } cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{ HTTPRoutes: httpRoutes, CertsDir: "/etc/haproxy/certs/", }) // --- Assertions on HAProxy config --- // L7 domains have exact-match ACLs in the SNI frontend 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") { t.Errorf("expected L7 exact-match ACL for wild-cloud.payne.io:\n%s", cfg) } // L4 domains with subdomains:true have wildcard ACLs if !strings.Contains(cfg, "req_ssl_sni -m end .cloud.payne.io") { t.Errorf("expected L4 wildcard ACL for cloud.payne.io (subdomains:true):\n%s", cfg) } // L4 domains with subdomains:false have exact-match only if !strings.Contains(cfg, "req_ssl_sni -m str payne.io") { t.Errorf("expected L4 exact-match ACL for payne.io:\n%s", cfg) } if strings.Contains(cfg, "req_ssl_sni -m end .payne.io") { t.Errorf("payne.io (subdomains:false) must NOT have wildcard ACL:\n%s", cfg) } // Central's config domain appears as an L7 route if !strings.Contains(cfg, "hdr(host) -i central.payne.io") { t.Errorf("expected Host header ACL for central.payne.io in L7 frontend:\n%s", cfg) } // L7 ACLs appear BEFORE L4 wildcard ACLs 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) } // L4 exact (payne.io) appears before L4 wildcard (*.cloud.payne.io) l4ExactPos := strings.Index(cfg, "acl is_payne_io req_ssl_sni -m str payne.io") if l4ExactPos < 0 { t.Fatalf("expected L4 exact ACL for payne.io:\n%s", cfg) } if l4ExactPos > l4WildcardPos { t.Errorf("L4 exact match (pos %d) must come before L4 wildcard (pos %d)", l4ExactPos, l4WildcardPos) } // Health check path is present for my-api if !strings.Contains(cfg, "option httpchk GET /health") { t.Errorf("expected health check for my-api:\n%s", cfg) } } // TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation // correctly generates dnsmasq config from registered domains, including // the critical reach-based local=/ directives. func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) { api, _ := setupTestAPI(t) centralIP := "192.168.8.151" // Register domains with different reach and backend types testDomains := []domains.Domain{ { // tcp-passthrough, public → DNS points to k8s LB IP, no local=/ DomainName: "cloud.payne.io", Source: "wild-cloud", Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough}, Subdomains: true, Public: true, }, { // http, internal → DNS points to Central IP, has local=/ DomainName: "my-api.payne.io", Source: "wild-works", Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP}, }, { // http, public → DNS points to Central IP, NO local=/ DomainName: "public-app.payne.io", Source: "wild-works", Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP}, Public: true, }, } for _, dom := range testDomains { if err := api.domains.Register(dom); err != nil { t.Fatalf("Register %s failed: %v", dom.DomainName, err) } } // Build dnsmasq entries the same way reconcileNetworking does doms, err := api.domains.List() if err != nil { t.Fatalf("List failed: %v", err) } globalCfg := &config.State{} var dnsEntries []dnsmasq.DNSEntry for _, dom := range doms { if dom.DomainName == "" { continue } dnsIP := centralIP if dom.Backend.Type == domains.BackendTCPPassthrough { dnsIP = extractHost(dom.Backend.Address) } dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ Domain: dom.DomainName, IP: dnsIP, }) } dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries) // --- TCP passthrough (public) → backend IP --- if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") { t.Errorf("tcp-passthrough domain must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg) } // --- HTTP domains → Central IP --- if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") { t.Errorf("http/internal domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) } if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") { t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) } // --- All domains get local=/ to prevent AAAA leaking upstream (Happy Eyeballs) --- if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") { t.Errorf("domain must have local=/ entry:\n%s", dnsmasqCfg) } if !strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") { t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg) } if !strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") { t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg) } } // TestReconciliation_CentralDomainInHAProxy verifies that Central, registered // as a domain, produces correct HAProxy L7 routes. func TestReconciliation_CentralDomainInHAProxy(t *testing.T) { api, _ := setupTestAPI(t) centralPort := 15055 // Register Central as a domain (as EnsureCentralDomain does) if err := api.domains.Register(domains.Domain{ DomainName: "central.payne.io", Source: "central", Backend: domains.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: domains.BackendHTTP}, TLS: domains.TLSTerminate, }); err != nil { t.Fatalf("Register central failed: %v", err) } // Register another HTTP domain if err := api.domains.Register(domains.Domain{ DomainName: "my-app.payne.io", Source: "wild-works", Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP}, }); err != nil { t.Fatalf("Register failed: %v", err) } doms, _ := api.domains.List() var httpRoutes []haproxy.HTTPRoute for _, dom := range doms { if dom.Backend.Type == domains.BackendHTTP { httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ Name: dom.DomainName, Domain: dom.DomainName, Routes: []haproxy.HTTPRouteBackend{{ Backend: dom.Backend.Address, }}, }) } } cfg := api.haproxy.GenerateWithOpts(nil, nil, haproxy.GenerateOpts{ HTTPRoutes: httpRoutes, }) // Central domain should be present as both SNI ACL and Host header ACL if !strings.Contains(cfg, "req_ssl_sni -m str central.payne.io") { t.Errorf("expected Central domain SNI ACL:\n%s", cfg) } if !strings.Contains(cfg, "hdr(host) -i central.payne.io") { t.Errorf("expected Central domain Host ACL:\n%s", cfg) } // 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) } } // TestReconciliation_TCPPassthroughDNSTarget verifies that tcp-passthrough // domains get DNS pointing to the backend IP, not Central's IP. func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) { api, _ := setupTestAPI(t) centralIP := "192.168.8.151" // Register a k8s instance with tcp-passthrough if err := api.domains.Register(domains.Domain{ DomainName: "cloud.payne.io", Source: "wild-cloud", Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough}, Subdomains: true, Public: true, }); err != nil { t.Fatalf("Register failed: %v", err) } doms, _ := api.domains.List() globalCfg := &config.State{} var dnsEntries []dnsmasq.DNSEntry for _, dom := range doms { dnsIP := centralIP if dom.Backend.Type == domains.BackendTCPPassthrough { dnsIP = extractHost(dom.Backend.Address) } dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ Domain: dom.DomainName, IP: dnsIP, }) } dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries) // TCP passthrough → DNS points to backend (k8s LB), NOT central if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") { t.Errorf("tcp-passthrough DNS must point to backend IP 192.168.8.240:\n%s", dnsmasqCfg) } if strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.151") { t.Errorf("tcp-passthrough DNS must NOT point to Central IP:\n%s", dnsmasqCfg) } } // TestReconciliation_DNSOnlyHasNoDNSButNoProxy verifies that dns-only domains // get DNS entries but no HAProxy routes. func TestReconciliation_DNSOnlyNoGateway(t *testing.T) { api, _ := setupTestAPI(t) centralIP := "192.168.8.151" // Register a dns-only domain (e.g. for SSH access) if err := api.domains.Register(domains.Domain{ DomainName: "dev.payne.io", Source: "manual", Backend: domains.Backend{Address: "192.168.8.222", Type: domains.BackendDNSOnly}, Public: true, }); err != nil { t.Fatalf("Register failed: %v", err) } // Also register a normal HTTP domain to verify it still works if err := api.domains.Register(domains.Domain{ DomainName: "app.payne.io", Source: "wild-works", Backend: domains.Backend{Address: "127.0.0.1:8080", Type: domains.BackendHTTP}, }); err != nil { t.Fatalf("Register failed: %v", err) } doms, _ := api.domains.List() // Build HAProxy routes — dns-only should be absent var instanceRoutes []haproxy.L4Route var httpRoutes []haproxy.HTTPRoute for _, dom := range doms { switch dom.Backend.Type { case domains.BackendTCPPassthrough: instanceRoutes = append(instanceRoutes, haproxy.L4Route{ Name: dom.DomainName, Domain: dom.DomainName, BackendIP: extractHost(dom.Backend.Address), }) case domains.BackendDNSOnly: // dns-only: no gateway routing case domains.BackendHTTP: httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ Name: dom.DomainName, Domain: dom.DomainName, Routes: []haproxy.HTTPRouteBackend{{Backend: dom.Backend.Address}}, }) } } cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{ HTTPRoutes: httpRoutes, CertsDir: "/etc/haproxy/certs/", }) // dns-only domain must NOT appear in HAProxy config if strings.Contains(cfg, "dev.payne.io") { t.Errorf("dns-only domain must NOT appear in HAProxy config:\n%s", cfg) } // HTTP domain should appear if !strings.Contains(cfg, "app.payne.io") { t.Errorf("http domain should appear in HAProxy config:\n%s", cfg) } // Build DNS config — dns-only should point to its backend IP globalCfg := &config.State{} var dnsEntries []dnsmasq.DNSEntry for _, dom := range doms { dnsIP := centralIP bt := dom.Backend.Type if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly { dnsIP = extractHost(dom.Backend.Address) } dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ Domain: dom.DomainName, IP: dnsIP, }) } dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries) // dns-only domain should resolve to its backend IP (192.168.8.222) if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") { t.Errorf("dns-only domain must point DNS to backend IP:\n%s", dnsmasqCfg) } // HTTP domain should resolve to Central IP if !strings.Contains(dnsmasqCfg, "address=/app.payne.io/192.168.8.151") { t.Errorf("http domain must point DNS 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, "state.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 TestEnsureCentralDomain_RegistersWhenDomainConfigured(t *testing.T) { api, dataDir := setupTestAPI(t) api.SetPort(15055) writeTestConfig(t, dataDir, "central.example.com") api.EnsureCentralDomain() dom, err := api.domains.Get("central.example.com") if err != nil { t.Fatalf("expected Central domain to be registered: %v", err) } if dom.Source != "central" { t.Errorf("source = %q, want %q", dom.Source, "central") } if dom.Backend.Address != "127.0.0.1:15055" { t.Errorf("backend = %q, want %q", dom.Backend.Address, "127.0.0.1:15055") } if dom.TLS != domains.TLSTerminate { t.Errorf("tls = %q, want %q", dom.TLS, domains.TLSTerminate) } } func TestEnsureCentralDomain_Idempotent(t *testing.T) { api, dataDir := setupTestAPI(t) api.SetPort(15055) writeTestConfig(t, dataDir, "central.example.com") api.EnsureCentralDomain() api.EnsureCentralDomain() // second call should be a no-op doms, _ := api.domains.List() count := 0 for _, d := range doms { if d.Source == "central" { count++ } } if count != 1 { t.Errorf("expected 1 central domain, got %d", count) } } func TestEnsureCentralDomain_CleansUpOnDomainChange(t *testing.T) { api, dataDir := setupTestAPI(t) api.SetPort(15055) // Register with old domain writeTestConfig(t, dataDir, "old.example.com") api.EnsureCentralDomain() // Change domain writeTestConfig(t, dataDir, "new.example.com") api.EnsureCentralDomain() // Old domain should be gone if _, err := api.domains.Get("old.example.com"); err == nil { t.Error("old Central domain should have been deregistered") } // New domain should exist dom, err := api.domains.Get("new.example.com") if err != nil { t.Fatalf("new Central domain should be registered: %v", err) } if dom.Source != "central" { t.Errorf("source = %q, want %q", dom.Source, "central") } } func TestEnsureCentralDomain_NoDomainIsNoop(t *testing.T) { api, _ := setupTestAPI(t) api.SetPort(15055) // Default config has no Central domain api.EnsureCentralDomain() doms, _ := api.domains.List() for _, d := range doms { if d.Source == "central" { t.Error("no Central domain should be registered when domain is empty") } } }