Files
wild-central/internal/dnsmasq/config_test.go
Paul Payne f9d87ff975 Replace InstanceConfig with DNSEntry for dnsmasq config generation
InstanceConfig was a ~50-field struct designed for Wild Cloud k8s instances,
but only 3 fields were ever read by dnsmasq (the sole consumer). The
reconciliation bridge constructed fake InstanceConfig objects from registered
domains just to satisfy this interface.

Replace with DNSEntry{Domain, IP} — a 2-field struct purpose-built for
dnsmasq. All domains get local=/ directives to prevent AAAA queries from
leaking to upstream DNS (Happy Eyeballs / RFC 8305 latency on LAN).

Removed dead code: InstanceConfig, NodeConfig, LoadCloudConfig,
SaveCloudConfig, DeepMerge, LoadMergedInstanceConfig, EnsureInstanceConfig,
instance path helpers, instanceLoadBalancerIP, GenerateInstanceConfig,
and all modular per-instance dnsmasq methods.
2026-07-11 20:42:33 +00:00

370 lines
12 KiB
Go

package dnsmasq
import (
"fmt"
"strings"
"testing"
"github.com/wild-cloud/wild-central/internal/config"
)
func entry(domain, ip string) DNSEntry {
return DNSEntry{Domain: domain, IP: ip}
}
// Test: Generate with entries produces expected DNS entries
func TestGenerate_WithEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
entries := []DNSEntry{
entry("cloud.example.com", "192.168.1.10"),
entry("internal.example.com", "192.168.1.10"),
}
out := g.Generate(nil, entries)
if !strings.Contains(out, "local=/cloud.example.com/") {
t.Errorf("expected local=/ for domain, got:\n%s", out)
}
if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") {
t.Errorf("expected address entry for domain, got:\n%s", out)
}
if !strings.Contains(out, "local=/internal.example.com/") {
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
}
if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") {
t.Errorf("expected address entry for internal domain, got:\n%s", out)
}
if !strings.Contains(out, "server=1.1.1.1") {
t.Errorf("expected upstream DNS server, got:\n%s", out)
}
}
// Test: Generate with no entries still produces valid config skeleton
func TestGenerate_NoEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
out := g.Generate(nil, []DNSEntry{})
if !strings.Contains(out, "bind-interfaces") {
t.Errorf("expected bind-interfaces in config, got:\n%s", out)
}
if !strings.Contains(out, "server=1.1.1.1") {
t.Errorf("expected upstream DNS server, got:\n%s", out)
}
if !strings.Contains(out, "server=8.8.8.8") {
t.Errorf("expected upstream DNS server 8.8.8.8, got:\n%s", out)
}
}
// Test: Generate skips entries with empty IP
func TestGenerate_EntryWithoutIP(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
out := g.Generate(nil, []DNSEntry{entry("cloud.example.com", "")})
if strings.Contains(out, "address=/") {
t.Errorf("expected no address= entries for empty IP, got:\n%s", out)
}
}
// Test: GenerateMainConfig produces valid base config
func TestGenerateMainConfig(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
out := g.GenerateMainConfig(nil)
if !strings.Contains(out, "bind-interfaces") {
t.Errorf("expected bind-interfaces in main config, got:\n%s", out)
}
if !strings.Contains(out, "server=1.1.1.1") {
t.Errorf("expected upstream DNS server in main config, got:\n%s", out)
}
}
// Test: GenerateMainConfig with DHCP disabled does not include dhcp-range
func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = false
out := g.GenerateMainConfig(cfg)
if strings.Contains(out, "dhcp-range") {
t.Errorf("expected no dhcp-range when DHCP disabled, got:\n%s", out)
}
}
// Test: GenerateMainConfig with DHCP enabled includes dhcp-range
func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
cfg.Cloud.Dnsmasq.DHCP.LeaseTime = "12h"
cfg.Cloud.Dnsmasq.DHCP.Gateway = "192.168.8.1"
out := g.GenerateMainConfig(cfg)
if !strings.Contains(out, "dhcp-range=192.168.8.100,192.168.8.200,12h") {
t.Errorf("expected dhcp-range directive, got:\n%s", out)
}
if !strings.Contains(out, "dhcp-option=3,192.168.8.1") {
t.Errorf("expected gateway dhcp-option=3, got:\n%s", out)
}
}
// Test: DHCP without gateway omits dhcp-option=3
func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
out := g.GenerateMainConfig(cfg)
if strings.Contains(out, "dhcp-option=3,") {
t.Errorf("expected no gateway dhcp-option when gateway not set, got:\n%s", out)
}
}
// Test: DHCP explicit gateway is used
func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
cfg.Cloud.Dnsmasq.DHCP.Gateway = "192.168.8.254"
out := g.GenerateMainConfig(cfg)
if !strings.Contains(out, "dhcp-option=3,192.168.8.254") {
t.Errorf("expected explicit gateway in dhcp-option=3, got:\n%s", out)
}
}
// Test: DHCP static leases appear in output
func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
cfg.Cloud.Dnsmasq.DHCP.StaticLeases = []config.DHCPStaticLease{
{MAC: "aa:bb:cc:dd:ee:01", IP: "192.168.8.10", Hostname: "node1"},
{MAC: "aa:bb:cc:dd:ee:02", IP: "192.168.8.11"},
}
out := g.GenerateMainConfig(cfg)
if !strings.Contains(out, "dhcp-host=aa:bb:cc:dd:ee:01,192.168.8.10,node1") {
t.Errorf("expected static lease with hostname, got:\n%s", out)
}
if !strings.Contains(out, "dhcp-host=aa:bb:cc:dd:ee:02,192.168.8.11") {
t.Errorf("expected static lease without hostname, got:\n%s", out)
}
}
// Test: DHCP lease time defaults to 24h when not specified
func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
out := g.GenerateMainConfig(cfg)
if !strings.Contains(out, "dhcp-range=192.168.8.100,192.168.8.200,24h") {
t.Errorf("expected default lease time of 24h, got:\n%s", out)
}
}
// Test: NewConfigGenerator uses provided path
func TestNewConfigGenerator_CustomPath(t *testing.T) {
g := NewConfigGenerator("/custom/path/dnsmasq.conf")
if g.GetConfigPath() != "/custom/path/dnsmasq.conf" {
t.Errorf("got %q, want /custom/path/dnsmasq.conf", g.GetConfigPath())
}
}
// Test: NewConfigGenerator uses default path when empty
func TestNewConfigGenerator_DefaultPath(t *testing.T) {
g := NewConfigGenerator("")
if g.GetConfigPath() != "/etc/dnsmasq.d/wild-cloud.conf" {
t.Errorf("got %q, want /etc/dnsmasq.d/wild-cloud.conf", g.GetConfigPath())
}
}
// Test: Domain-only entry (no internal domain pair)
func TestGenerate_DomainOnly(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")})
if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") {
t.Errorf("expected address entry for domain, got:\n%s", out)
}
if !strings.Contains(out, "local=/my-api.payne.io/") {
t.Errorf("expected local=/ entry for domain, got:\n%s", out)
}
// Must NOT have empty entries
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: All domains get local=/ to prevent AAAA leaking upstream
func TestGenerate_AllDomainsGetLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
// Both "internal" and "public" domains should get local=/ —
// prevents AAAA queries from leaking to upstream DNS (Happy Eyeballs).
entries := []DNSEntry{
entry("internal-api.payne.io", "192.168.8.151"),
entry("cloud.payne.io", "192.168.8.240"),
}
out := g.Generate(globalCfg, entries)
if !strings.Contains(out, "local=/internal-api.payne.io/") {
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
}
if !strings.Contains(out, "local=/cloud.payne.io/") {
t.Errorf("expected local=/ for public domain (prevents AAAA leaking), got:\n%s", out)
}
}
// Test: Mix of domains each get their own entries
func TestGenerate_MixedDomains(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
entries := []DNSEntry{
entry("cloud.payne.io", "192.168.8.240"), // k8s passthrough
entry("central.payne.io", "192.168.8.151"), // Central HTTP
entry("wild-cloud.payne.io", "192.168.8.151"), // service HTTP
}
out := g.Generate(globalCfg, entries)
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("expected address for cloud domain, got:\n%s", out)
}
if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") {
t.Errorf("expected address for central, got:\n%s", out)
}
if !strings.Contains(out, "address=/wild-cloud.payne.io/192.168.8.151") {
t.Errorf("expected address for wild-cloud, got:\n%s", out)
}
if strings.Contains(out, "local=//") {
t.Errorf("unexpected empty local=// in output:\n%s", out)
}
}
// Test: empty entries list produces base config with no address= entries
func TestGenerate_EmptyEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
out := g.Generate(globalCfg, []DNSEntry{})
if !strings.Contains(out, "bind-interfaces") {
t.Errorf("expected bind-interfaces in config, got:\n%s", out)
}
if !strings.Contains(out, "server=1.1.1.1") {
t.Errorf("expected upstream DNS server, got:\n%s", out)
}
if strings.Contains(out, "address=/") {
t.Errorf("expected no address= entries with empty entries, got:\n%s", out)
}
if strings.Contains(out, "local=/") {
t.Errorf("expected no local=/ entries with empty entries, got:\n%s", out)
}
}
// Test: multiple entries each get their own DNS records
func TestGenerate_MultipleEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
entries := []DNSEntry{
entry("cloud1.example.com", "10.0.0.1"),
entry("cloud2.example.com", "10.0.0.2"),
entry("cloud3.example.com", "10.0.0.3"),
}
out := g.Generate(globalCfg, entries)
for _, e := range entries {
if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", e.Domain, e.IP)) {
t.Errorf("expected address entry for %s, got:\n%s", e.Domain, out)
}
if !strings.Contains(out, fmt.Sprintf("local=/%s/", e.Domain)) {
t.Errorf("expected local=/ entry for %s, got:\n%s", e.Domain, out)
}
}
}
// Test: nil config doesn't panic, uses auto-detect for IP
func TestGenerate_NilConfig(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
out := g.Generate(nil, []DNSEntry{})
if !strings.Contains(out, "bind-interfaces") {
t.Errorf("expected valid config with nil cfg, got:\n%s", out)
}
if !strings.Contains(out, "server=1.1.1.1") {
t.Errorf("expected upstream DNS server with nil cfg, got:\n%s", out)
}
}
// Test: when cfg.Cloud.Dnsmasq.IP is set, uses that instead of auto-detect
func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cfg.Cloud.Dnsmasq.IP = "192.168.8.100"
out := g.Generate(cfg, []DNSEntry{})
if !strings.Contains(out, "listen-address=192.168.8.100") {
t.Errorf("expected configured dnsmasq IP in listen-address, got:\n%s", out)
}
}
// Test: verify no address=// or local=// entries appear
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
cases := []struct {
name string
entries []DNSEntry
}{
{"empty", nil},
{"domain_only", []DNSEntry{entry("example.com", "10.0.0.1")}},
{"no_ip", []DNSEntry{entry("example.com", "")}},
{"no_domain", []DNSEntry{entry("", "10.0.0.1")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := g.Generate(cfg, tc.entries)
if strings.Contains(out, "address=//") {
t.Errorf("leaked empty address=// in output:\n%s", out)
}
if strings.Contains(out, "local=//") {
t.Errorf("leaked empty local=// in output:\n%s", out)
}
})
}
}