From e4e1a15f92d6b651a59d31e7393b3c587f5f5b9b Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 9 Jul 2026 14:11:24 +0000 Subject: [PATCH] fix: Prevent broken dnsmasq entries for services without internal domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services registered via the service API have a domain but no internal domain (that's a Wild Cloud instance concept). The dnsmasq generator was producing broken entries like `local=//` and `address=//`. Fix: skip internal domain entries when InternalDomain is empty, in both config.go and config_modular.go. Also fix DNS entries to point to Central's IP (where HAProxy listens) rather than the backend address — all traffic flows through Central. Added 3 tests covering: service-only, mixed services+instances. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/v1/helpers.go | 13 ++++-- internal/dnsmasq/config.go | 11 +++-- internal/dnsmasq/config_modular.go | 16 ++++--- internal/dnsmasq/config_test.go | 68 ++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 13 deletions(-) diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index 6d8f059..a680dbf 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -11,6 +11,7 @@ import ( "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/network" "github.com/wild-cloud/wild-central/internal/services" "github.com/wild-cloud/wild-central/internal/sse" ) @@ -81,8 +82,14 @@ func (api *API) reconcileNetworking() { } } - // Generate dnsmasq DNS entries from registered services - // Build instance configs for backward compatibility with dnsmasq generator + // Generate dnsmasq DNS entries from registered services. + // All service domains resolve to Central's IP (where HAProxy listens). + // HAProxy then routes to the actual backend based on SNI or Host header. + centralIP, _ := network.GetWildCentralIP() + if centralIP == "" { + centralIP = "127.0.0.1" + } + var instanceConfigs []config.InstanceConfig for _, svc := range svcs { if svc.Reach == services.ReachOff || svc.Domain == "" { @@ -90,7 +97,7 @@ func (api *API) reconcileNetworking() { } ic := config.InstanceConfig{} ic.Cloud.Domain = svc.Domain - ic.Cluster.LoadBalancerIp = extractHost(svc.Backend.Address) + ic.Cluster.LoadBalancerIp = centralIP // DNS points to Central, not the backend instanceConfigs = append(instanceConfigs, ic) } diff --git a/internal/dnsmasq/config.go b/internal/dnsmasq/config.go index fb83a71..1add4c1 100644 --- a/internal/dnsmasq/config.go +++ b/internal/dnsmasq/config.go @@ -57,11 +57,14 @@ func (g *ConfigGenerator) Generate(cfg *config.GlobalConfig, clouds []config.Ins continue } // Internal domain (.internal.cloud.example.tld) - local only, no external DNS - resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain, loadBalancerIP) + if cloud.Cloud.InternalDomain != "" { + resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain, loadBalancerIP) + } - // External domain (cloud.example.tld) - resolve to load balancer IP without external DNS lookup - // This makes LAN traffic go directly to load balancer instead of routing through external DNS first - resolution_section += fmt.Sprintf("address=/%s/%s\n", cloud.Cloud.Domain, loadBalancerIP) + // External/primary domain - resolve to backend IP + if cloud.Cloud.Domain != "" { + resolution_section += fmt.Sprintf("address=/%s/%s\n", cloud.Cloud.Domain, loadBalancerIP) + } } template := `# Configuration file for dnsmasq. diff --git a/internal/dnsmasq/config_modular.go b/internal/dnsmasq/config_modular.go index 806d98e..8d011dd 100644 --- a/internal/dnsmasq/config_modular.go +++ b/internal/dnsmasq/config_modular.go @@ -117,13 +117,17 @@ func (g *ConfigGenerator) GenerateInstanceConfig(instance config.InstanceConfig) fmt.Fprintf(&sb, "# address=/%s/\n", instance.Cloud.Domain) } else { // Internal domain (.internal.cloud.example.tld) - local only, no external DNS - sb.WriteString("# Internal domain (LAN-only)\n") - fmt.Fprintf(&sb, "local=/%s/\n", instance.Cloud.InternalDomain) - fmt.Fprintf(&sb, "address=/%s/%s\n\n", instance.Cloud.InternalDomain, loadBalancerIP) + if instance.Cloud.InternalDomain != "" { + sb.WriteString("# Internal domain (LAN-only)\n") + fmt.Fprintf(&sb, "local=/%s/\n", instance.Cloud.InternalDomain) + fmt.Fprintf(&sb, "address=/%s/%s\n\n", instance.Cloud.InternalDomain, loadBalancerIP) + } - // External domain (cloud.example.tld) - resolve to load balancer IP - sb.WriteString("# Public domain (resolved locally to avoid external DNS)\n") - fmt.Fprintf(&sb, "address=/%s/%s\n", instance.Cloud.Domain, loadBalancerIP) + // External/primary domain - resolve to backend IP + if instance.Cloud.Domain != "" { + sb.WriteString("# Public domain (resolved locally to avoid external DNS)\n") + fmt.Fprintf(&sb, "address=/%s/%s\n", instance.Cloud.Domain, loadBalancerIP) + } } return sb.String() diff --git a/internal/dnsmasq/config_test.go b/internal/dnsmasq/config_test.go index a373f6b..ddc7c7f 100644 --- a/internal/dnsmasq/config_test.go +++ b/internal/dnsmasq/config_test.go @@ -310,3 +310,71 @@ func TestNewConfigGenerator_DefaultPath(t *testing.T) { t.Errorf("got %q, want /etc/dnsmasq.d/wild-cloud.conf", g.GetConfigPath()) } } + +// Test: Service registration — domain set but no internal domain +func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) { + g := NewConfigGenerator("/tmp/test-dnsmasq.conf") + globalCfg := &config.GlobalConfig{} + + // Service registration: only domain + backend IP, no internal domain + inst := instanceWith("my-api.payne.io", "", "192.168.8.151") + out := g.Generate(globalCfg, []config.InstanceConfig{inst}) + + // Should have address entry for the domain + if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") { + t.Errorf("expected address entry for service domain, got:\n%s", out) + } + // Must NOT have empty local=// entry + if strings.Contains(out, "local=//") { + t.Errorf("unexpected empty local=// in output:\n%s", out) + } + // Must NOT have empty address=// entry + if strings.Contains(out, "address=//") { + t.Errorf("unexpected empty address=// in output:\n%s", out) + } +} + +// Test: GenerateInstanceConfig — service without internal domain +func TestGenerateInstanceConfig_ServiceWithoutInternalDomain(t *testing.T) { + g := NewConfigGenerator("/tmp/test-dnsmasq.conf") + inst := instanceWith("central.payne.io", "", "192.168.8.151") + + out := g.GenerateInstanceConfig(inst) + + if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") { + t.Errorf("expected address entry, got:\n%s", out) + } + if strings.Contains(out, "local=//") { + t.Errorf("unexpected empty local=// in output:\n%s", out) + } + if strings.Contains(out, "address=//") { + t.Errorf("unexpected empty address=// in output:\n%s", out) + } +} + +// Test: Mix of instances with and without internal domains +func TestGenerate_MixedServicesAndInstances(t *testing.T) { + g := NewConfigGenerator("/tmp/test-dnsmasq.conf") + globalCfg := &config.GlobalConfig{} + + instances := []config.InstanceConfig{ + instanceWith("cloud.payne.io", "internal.cloud.payne.io", "192.168.8.240"), // k8s instance + instanceWith("central.payne.io", "", "192.168.8.151"), // service (no internal domain) + instanceWith("wild-cloud.payne.io", "", "192.168.8.151"), // service (no internal domain) + } + + out := g.Generate(globalCfg, instances) + + // k8s instance should have both local= and address= for internal domain + if !strings.Contains(out, "local=/internal.cloud.payne.io/") { + t.Errorf("expected local=/ for k8s internal domain, got:\n%s", out) + } + // Services should have address= entries + if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") { + t.Errorf("expected address for central, got:\n%s", out) + } + // No broken empty entries + if strings.Contains(out, "local=//") { + t.Errorf("unexpected empty local=// in output:\n%s", out) + } +}