diff --git a/internal/api/v1/handlers_reconciliation_test.go b/internal/api/v1/handlers_reconciliation_test.go new file mode 100644 index 0000000..4bfc812 --- /dev/null +++ b/internal/api/v1/handlers_reconciliation_test.go @@ -0,0 +1,352 @@ +package v1 + +import ( + "strings" + "testing" + + "github.com/wild-cloud/wild-central/internal/config" + "github.com/wild-cloud/wild-central/internal/haproxy" + "github.com/wild-cloud/wild-central/internal/services" +) + +// TestReconciliation_HAProxyConfigFromServices verifies that the reconciliation +// logic correctly maps registered services 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_HAProxyConfigFromServices(t *testing.T) { + api, _ := setupTestAPI(t) + + // Register a mix of services like a real deployment + testServices := []services.Service{ + { + Domain: "cloud.payne.io", + Source: "wild-cloud", + Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, + Subdomains: true, + Reach: services.ReachPublic, + }, + { + Domain: "payne.io", + Source: "wild-cloud", + Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, + Subdomains: false, + Reach: services.ReachPublic, + }, + { + Domain: "wild-cloud.payne.io", + Source: "wild-works", + Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP}, + Reach: services.ReachInternal, + }, + { + Domain: "my-api.payne.io", + Source: "wild-works", + Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP, Health: "/health"}, + Reach: services.ReachInternal, + }, + } + + for _, svc := range testServices { + if err := api.services.Register(svc); err != nil { + t.Fatalf("Register %s failed: %v", svc.Domain, err) + } + } + + // Replicate reconcileNetworking route-building logic + svcs, err := api.services.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + var instanceRoutes []haproxy.InstanceRoute + var httpRoutes []haproxy.HTTPRoute + + for _, svc := range svcs { + switch svc.Backend.Type { + case services.BackendTCPPassthrough: + instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{ + Name: svc.Domain, + Domain: svc.Domain, + BackendIP: extractHost(svc.Backend.Address), + Subdomains: svc.Subdomains, + }) + case services.BackendHTTP: + httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ + Name: svc.Domain, + Domain: svc.Domain, + Backend: svc.Backend.Address, + HealthPath: svc.Backend.Health, + }) + } + } + + // 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...) + + cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{ + HTTPRoutes: httpRoutes, + CertsDir: "/etc/haproxy/certs/", + }) + + // --- 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") { + 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 services 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 services 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_wild_central") + 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_DnsmasqConfigFromServices verifies that reconciliation +// correctly generates dnsmasq config from registered services, including +// the critical reach-based local=/ directives. +func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) { + api, _ := setupTestAPI(t) + + centralIP := "192.168.8.151" + + // Register services with different reach and backend types + testServices := []services.Service{ + { + // tcp-passthrough, public → DNS points to k8s LB IP, no local=/ + Domain: "cloud.payne.io", + Source: "wild-cloud", + Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, + Subdomains: true, + Reach: services.ReachPublic, + }, + { + // http, internal → DNS points to Central IP, has local=/ + Domain: "my-api.payne.io", + Source: "wild-works", + Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP}, + Reach: services.ReachInternal, + }, + { + // http, public → DNS points to Central IP, NO local=/ + Domain: "public-app.payne.io", + Source: "wild-works", + Backend: services.Backend{Address: "192.168.8.60:8080", Type: services.BackendHTTP}, + Reach: services.ReachPublic, + }, + } + + for _, svc := range testServices { + if err := api.services.Register(svc); err != nil { + t.Fatalf("Register %s failed: %v", svc.Domain, err) + } + } + + // Build dnsmasq instance configs the same way reconcileNetworking does + svcs, err := api.services.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + globalCfg := &config.GlobalConfig{} + var instanceConfigs []config.InstanceConfig + + for _, svc := range svcs { + if svc.Domain == "" { + continue + } + + dnsIP := centralIP + if svc.Backend.Type == services.BackendTCPPassthrough { + dnsIP = extractHost(svc.Backend.Address) + } + + ic := config.InstanceConfig{} + ic.Cluster.LoadBalancerIp = dnsIP + + if svc.Reach == services.ReachInternal { + ic.Cloud.InternalDomain = svc.Domain + } else { + ic.Cloud.Domain = svc.Domain + } + + instanceConfigs = append(instanceConfigs, ic) + } + + dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs) + + // --- TCP passthrough (public) → backend IP --- + if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") { + t.Errorf("tcp-passthrough service must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg) + } + + // --- HTTP services → Central IP --- + if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") { + t.Errorf("http/internal service 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 service must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) + } + + // --- reach:internal → local=/ present --- + if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") { + t.Errorf("reach:internal service must have local=/ entry:\n%s", dnsmasqCfg) + } + + // --- reach:public → NO local=/ --- + if strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") { + t.Errorf("reach:public service must NOT have local=/ entry:\n%s", dnsmasqCfg) + } + if strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") { + t.Errorf("reach:public service must NOT have local=/ entry:\n%s", dnsmasqCfg) + } +} + +// TestReconciliation_CentralDomainInHAProxy verifies that the Central domain +// from global config is injected as the first L7 HTTP route. +func TestReconciliation_CentralDomainInHAProxy(t *testing.T) { + api, _ := setupTestAPI(t) + + // Register one HTTP service + if err := api.services.Register(services.Service{ + Domain: "my-app.payne.io", + Source: "wild-works", + Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP}, + Reach: services.ReachInternal, + }); err != nil { + t.Fatalf("Register failed: %v", err) + } + + svcs, _ := api.services.List() + + var httpRoutes []haproxy.HTTPRoute + 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, + }) + } + } + + // Central domain goes first (as reconcileNetworking does) + centralDomain := "central.payne.io" + httpRoutes = append([]haproxy.HTTPRoute{{ + Name: "wild-central", + Domain: centralDomain, + Backend: "127.0.0.1:5055", + }}, httpRoutes...) + + 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 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) + } +} + +// TestReconciliation_TCPPassthroughDNSTarget verifies that tcp-passthrough +// services 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.services.Register(services.Service{ + Domain: "cloud.payne.io", + Source: "wild-cloud", + Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, + Subdomains: true, + Reach: services.ReachPublic, + }); err != nil { + t.Fatalf("Register failed: %v", err) + } + + svcs, _ := api.services.List() + globalCfg := &config.GlobalConfig{} + + var instanceConfigs []config.InstanceConfig + for _, svc := range svcs { + dnsIP := centralIP + if svc.Backend.Type == services.BackendTCPPassthrough { + dnsIP = extractHost(svc.Backend.Address) + } + + ic := config.InstanceConfig{} + ic.Cluster.LoadBalancerIp = dnsIP + if svc.Reach == services.ReachInternal { + ic.Cloud.InternalDomain = svc.Domain + } else { + ic.Cloud.Domain = svc.Domain + } + instanceConfigs = append(instanceConfigs, ic) + } + + dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs) + + // 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) + } +} diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index ec0ad14..2b31ef4 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -16,9 +16,14 @@ import ( "github.com/wild-cloud/wild-central/internal/sse" ) +// Reconcile runs networking reconciliation immediately. Called on startup +// and automatically whenever a service is registered/updated/deregistered. +func (api *API) Reconcile() { + api.reconcileNetworking() +} + // 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. +// dnsmasq DNS entries and HAProxy routes to match. func (api *API) reconcileNetworking() { svcs, err := api.services.List() if err != nil { diff --git a/internal/dnsmasq/config_test.go b/internal/dnsmasq/config_test.go index ddc7c7f..dc0fe07 100644 --- a/internal/dnsmasq/config_test.go +++ b/internal/dnsmasq/config_test.go @@ -352,6 +352,66 @@ func TestGenerateInstanceConfig_ServiceWithoutInternalDomain(t *testing.T) { } } +// Test: reach:internal → local=/ directive present (prevents upstream DNS forwarding) +func TestGenerate_ReachInternal_HasLocal(t *testing.T) { + g := NewConfigGenerator("/tmp/test-dnsmasq.conf") + globalCfg := &config.GlobalConfig{} + + // Simulate reconciliation for an internal-reach service: + // InternalDomain is set (reach:internal), Domain is empty (reach:public) + inst := instanceWith("", "my-api.payne.io", "192.168.8.151") + + out := g.Generate(globalCfg, []config.InstanceConfig{inst}) + + if !strings.Contains(out, "local=/my-api.payne.io/") { + t.Errorf("reach:internal must produce local=/ directive, got:\n%s", out) + } + if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") { + t.Errorf("reach:internal must produce address entry, got:\n%s", out) + } +} + +// Test: reach:public → no local=/ directive (allows upstream DNS forwarding) +func TestGenerate_ReachPublic_NoLocal(t *testing.T) { + g := NewConfigGenerator("/tmp/test-dnsmasq.conf") + globalCfg := &config.GlobalConfig{} + + // Simulate reconciliation for a public-reach service: + // Domain is set (reach:public), InternalDomain is empty + inst := instanceWith("cloud.payne.io", "", "192.168.8.240") + + out := g.Generate(globalCfg, []config.InstanceConfig{inst}) + + if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") { + t.Errorf("reach:public must produce address entry, got:\n%s", out) + } + if strings.Contains(out, "local=/cloud.payne.io/") { + t.Errorf("reach:public must NOT produce local=/ directive, got:\n%s", out) + } +} + +// Test: InternalDomain field → local=/ entry for LAN-only resolution +func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) { + g := NewConfigGenerator("/tmp/test-dnsmasq.conf") + globalCfg := &config.GlobalConfig{} + + // Instance with only an internal domain (no external domain) + inst := instanceWith("", "internal.cloud.payne.io", "192.168.8.240") + + out := g.Generate(globalCfg, []config.InstanceConfig{inst}) + + if !strings.Contains(out, "local=/internal.cloud.payne.io/") { + t.Errorf("InternalDomain must produce local=/ entry, got:\n%s", out) + } + if !strings.Contains(out, "address=/internal.cloud.payne.io/192.168.8.240") { + t.Errorf("InternalDomain must produce address entry, got:\n%s", out) + } + // No external domain → no address for empty domain + if strings.Contains(out, "address=//") { + t.Errorf("no external domain must not produce empty address=// entry, got:\n%s", out) + } +} + // Test: Mix of instances with and without internal domains func TestGenerate_MixedServicesAndInstances(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") diff --git a/internal/haproxy/config_test.go b/internal/haproxy/config_test.go index 86a98ab..f32e645 100644 --- a/internal/haproxy/config_test.go +++ b/internal/haproxy/config_test.go @@ -300,6 +300,114 @@ func TestGenerateWithOpts_MixedL4AndL7(t *testing.T) { } } +func TestGenerateWithOpts_ACLOrdering_L7BeforeL4Wildcard(t *testing.T) { + m := NewManager("") + + // Scenario: cloud.payne.io with subdomains:true is an L4 wildcard route. + // wild-cloud.payne.io is an L7 HTTP service. The L7 exact match MUST + // appear before the L4 wildcard, otherwise HAProxy matches + // wild-cloud.payne.io against *.payne.io and sends it to the wrong backend. + instances := []InstanceRoute{ + {Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.240", Subdomains: true}, + } + httpRoutes := []HTTPRoute{ + {Name: "wild-central", Domain: "central.payne.io", Backend: "127.0.0.1:15055"}, + {Name: "wild-cloud", Domain: "wild-cloud.payne.io", Backend: "127.0.0.1:5055"}, + } + out := m.GenerateWithOpts(instances, nil, GenerateOpts{HTTPRoutes: httpRoutes}) + + // All L7 exact matches must appear before L4 wildcard matches + l7Central := strings.Index(out, "is_l7_wild_central") + l7WildCloud := strings.Index(out, "is_l7_wild_cloud") + l4Wildcard := strings.Index(out, "req_ssl_sni -m end .payne.io") + + if l7Central < 0 { + t.Fatalf("expected L7 ACL for wild-central, got:\n%s", out) + } + if l7WildCloud < 0 { + t.Fatalf("expected L7 ACL for wild-cloud, got:\n%s", out) + } + if l4Wildcard < 0 { + t.Fatalf("expected L4 wildcard ACL for payne.io, got:\n%s", out) + } + + if l7Central > l4Wildcard { + t.Errorf("L7 central.payne.io (pos %d) must appear before L4 *.payne.io wildcard (pos %d)", l7Central, l4Wildcard) + } + if l7WildCloud > l4Wildcard { + t.Errorf("L7 wild-cloud.payne.io (pos %d) must appear before L4 *.payne.io wildcard (pos %d)", l7WildCloud, l4Wildcard) + } + + // Verify the config order overall: L7 exact → L4 wildcard → default + defaultBackend := strings.Index(out, "default_backend be_l7_termination") + if defaultBackend < 0 { + t.Fatalf("expected default_backend for L7 termination, got:\n%s", out) + } + if l4Wildcard > defaultBackend { + t.Errorf("L4 wildcard (pos %d) must appear before default_backend (pos %d)", l4Wildcard, defaultBackend) + } +} + +func TestGenerateWithOpts_SubdomainsFalse_ExactOnly(t *testing.T) { + m := NewManager("") + instances := []InstanceRoute{ + {Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.20", Subdomains: false}, + } + out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) + + // subdomains:false must produce ONLY -m str (exact), NOT -m end (wildcard) + if !strings.Contains(out, "req_ssl_sni -m str payne.io") { + t.Errorf("expected exact match ACL for payne.io, got:\n%s", out) + } + if strings.Contains(out, "req_ssl_sni -m end .payne.io") { + t.Errorf("subdomains:false must NOT produce wildcard ACL, got:\n%s", out) + } +} + +func TestGenerateWithOpts_SubdomainsTrue_WildcardAndExact(t *testing.T) { + m := NewManager("") + instances := []InstanceRoute{ + {Name: "cloud-payne", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true}, + } + out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) + + // subdomains:true must produce BOTH -m end (wildcard) AND -m str (exact) + if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") { + t.Errorf("subdomains:true must produce wildcard ACL (-m end), got:\n%s", out) + } + if !strings.Contains(out, "req_ssl_sni -m str cloud.payne.io") { + t.Errorf("subdomains:true must also produce exact ACL (-m str), got:\n%s", out) + } + + // Both ACLs should use the same backend + if !strings.Contains(out, "use_backend be_https_cloud_payne if is_cloud_payne") { + t.Errorf("expected use_backend for cloud_payne, got:\n%s", out) + } +} + +func TestGenerateWithOpts_ACLOrdering_L4ExactBeforeL4Wildcard(t *testing.T) { + m := NewManager("") + // payne.io exact (subdomains:false) must appear before cloud.payne.io wildcard (subdomains:true) + instances := []InstanceRoute{ + {Name: "cloud-payne", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true}, + {Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.20", Subdomains: false}, + } + out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) + + exactPos := strings.Index(out, "req_ssl_sni -m str payne.io") + wildcardPos := strings.Index(out, "req_ssl_sni -m end .cloud.payne.io") + + if exactPos < 0 { + t.Fatalf("expected exact match ACL for payne.io, got:\n%s", out) + } + if wildcardPos < 0 { + t.Fatalf("expected wildcard ACL for cloud.payne.io, got:\n%s", out) + } + if exactPos > wildcardPos { + t.Errorf("L4 exact match (pos %d) must come before L4 wildcard (pos %d):\n%s", exactPos, wildcardPos, out) + } +} + func TestGenerateWithOpts_BackwardCompatible(t *testing.T) { m := NewManager("") instances := []InstanceRoute{ diff --git a/internal/services/manager_test.go b/internal/services/manager_test.go index c189427..a013452 100644 --- a/internal/services/manager_test.go +++ b/internal/services/manager_test.go @@ -217,6 +217,96 @@ func TestDefaultSource(t *testing.T) { } } +func TestRegisterIdempotent(t *testing.T) { + mgr := NewManager(t.TempDir()) + + svc := Service{ + Domain: "idempotent.example.com", + Source: "test", + Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, + Reach: ReachInternal, + } + + // Register twice with same domain + if err := mgr.Register(svc); err != nil { + t.Fatalf("first Register failed: %v", err) + } + + // Update the backend address on second registration + svc.Backend.Address = "127.0.0.1:9090" + if err := mgr.Register(svc); err != nil { + t.Fatalf("second Register failed: %v", err) + } + + // Should still have only one service + svcs, err := mgr.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(svcs) != 1 { + t.Errorf("expected 1 service after idempotent register, got %d", len(svcs)) + } + + // Should have the updated address + got, err := mgr.Get("idempotent.example.com") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if got.Backend.Address != "127.0.0.1:9090" { + t.Errorf("expected updated backend address 127.0.0.1:9090, got %s", got.Backend.Address) + } +} + +func TestListNoDuplicates(t *testing.T) { + mgr := NewManager(t.TempDir()) + + domains := []string{ + "alpha.example.com", + "beta.example.com", + "gamma.example.com", + } + + // Register each domain + for _, domain := range domains { + if err := mgr.Register(Service{ + Domain: domain, + Source: "test", + Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, + Reach: ReachInternal, + }); err != nil { + t.Fatalf("Register %s failed: %v", domain, err) + } + } + + // Re-register one domain to verify no duplication + if err := mgr.Register(Service{ + Domain: "beta.example.com", + Source: "test", + Backend: Backend{Address: "127.0.0.1:9090", Type: BackendHTTP}, + Reach: ReachInternal, + }); err != nil { + t.Fatalf("Re-register beta failed: %v", err) + } + + svcs, err := mgr.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(svcs) != 3 { + t.Errorf("expected 3 unique services, got %d", len(svcs)) + } + + // Verify no duplicate domains + seen := make(map[string]bool) + for _, svc := range svcs { + if seen[svc.Domain] { + t.Errorf("duplicate domain in List(): %s", svc.Domain) + } + seen[svc.Domain] = true + } +} + func TestDomainToFilename(t *testing.T) { tests := []struct { domain string diff --git a/main.go b/main.go index 9b75ce4..9819579 100644 --- a/main.go +++ b/main.go @@ -116,6 +116,10 @@ func main() { api.StartCentralStatusBroadcaster(startTime) slog.Info("central status broadcaster started") + // Run initial reconciliation so Central's own domain (from config) gets + // DNS + HAProxy routes even with zero registered services. + api.Reconcile() + api.StartDDNS(ctx) router := mux.NewRouter()