Architecture: - Extract reconcileNetworking into internal/reconcile package with 7 consumer-side interfaces - Add locked modifyState helper to fix state.yaml read-modify-write race condition - Extract CrowdSec and VPN handler groups with interfaces documenting dependency surface - Replace raw map[string]any YAML manipulation with typed AddDHCPStaticLease/RemoveDHCPStaticLease - Extract Cloudflare API functions into cfClient struct, eliminating repeated auth boilerplate Complexity reduction: - haproxy.GenerateWithOpts: 50 → 6 (extracted 8 focused helpers) - reconcile.Reconcile: 42 → 11 (extracted buildRoutes, buildDNSEntries, writeHAProxyConfig) - AutheliaUpdateConfig: 25 → 10 (extracted enableAuthelia/disableAuthelia) Test coverage improvements: - reconcile: 4.5% → 56.8% (stub-based tests for route building, DNS entries, orchestration) - dnsfilter: 10.7% → 45.6% (Manager.Compile, AddList, ToggleList, custom entries) - config: 67.3% → 89.8% (DHCP static lease mutation tests)
426 lines
13 KiB
Go
426 lines
13 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}
|
|
}
|
|
|
|
func wildcardEntry(domain, ip string) DNSEntry {
|
|
return DNSEntry{Domain: domain, IP: ip, Wildcard: true}
|
|
}
|
|
|
|
// Test: Generate with exact-match entries produces host-record= directives
|
|
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, "host-record=cloud.example.com,192.168.1.10") {
|
|
t.Errorf("expected host-record for domain, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "host-record=internal.example.com,192.168.1.10") {
|
|
t.Errorf("expected host-record 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, "host-record=") {
|
|
t.Errorf("expected no host-record 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: Exact-match domain produces host-record=
|
|
func TestGenerate_ExactMatchDomain(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, "host-record=my-api.payne.io,192.168.8.151") {
|
|
t.Errorf("expected host-record entry for domain, got:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "address=/") {
|
|
t.Errorf("exact-match domain must not produce address=/ directive:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "local=/") {
|
|
t.Errorf("must not produce local=/ directive:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Test: Wildcard domain produces address=/ without local=/
|
|
func TestGenerate_WildcardDomain(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
globalCfg := &config.State{}
|
|
|
|
out := g.Generate(globalCfg, []DNSEntry{wildcardEntry("cloud.payne.io", "192.168.8.240")})
|
|
|
|
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
|
|
t.Errorf("expected address=/ entry for wildcard domain, got:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "host-record=") {
|
|
t.Errorf("wildcard domain must not produce host-record= directive:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "local=/") {
|
|
t.Errorf("must not produce local=/ directive:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Test: No local=/ directives are ever generated
|
|
func TestGenerate_NoLocalDirectives(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
globalCfg := &config.State{}
|
|
|
|
entries := []DNSEntry{
|
|
entry("internal-api.payne.io", "192.168.8.151"),
|
|
wildcardEntry("cloud.payne.io", "192.168.8.240"),
|
|
}
|
|
|
|
out := g.Generate(globalCfg, entries)
|
|
|
|
if strings.Contains(out, "local=/") {
|
|
t.Errorf("must never produce local=/ directives:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Test: filter-AAAA appears in generated config
|
|
func TestGenerate_FilterAAAA(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
|
|
out := g.Generate(nil, nil)
|
|
|
|
if !strings.Contains(out, "filter-AAAA") {
|
|
t.Errorf("expected filter-AAAA in config, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Test: Mix of exact and wildcard domains
|
|
func TestGenerate_MixedDomains(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
globalCfg := &config.State{}
|
|
|
|
entries := []DNSEntry{
|
|
wildcardEntry("cloud.payne.io", "192.168.8.240"), // wildcard
|
|
entry("central.payne.io", "192.168.8.151"), // exact
|
|
entry("wild-cloud.payne.io", "192.168.8.151"), // exact
|
|
}
|
|
|
|
out := g.Generate(globalCfg, entries)
|
|
|
|
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
|
|
t.Errorf("expected address=/ for wildcard domain, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "host-record=central.payne.io,192.168.8.151") {
|
|
t.Errorf("expected host-record for exact domain, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "host-record=wild-cloud.payne.io,192.168.8.151") {
|
|
t.Errorf("expected host-record for exact domain, got:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "local=/") {
|
|
t.Errorf("must not produce local=/ directives:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Test: empty entries list produces base config with no DNS 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, "host-record=") {
|
|
t.Errorf("expected no host-record entries with empty entries, got:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "address=/") {
|
|
t.Errorf("expected no address=/ entries with empty entries, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Test: multiple exact-match 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("host-record=%s,%s", e.Domain, e.IP)) {
|
|
t.Errorf("expected host-record entry for %s, got:\n%s", e.Domain, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerate_ConfFile(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
g.SetFilterConfPath("/var/lib/wild-central/dns-filter/hosts.blocked")
|
|
|
|
out := g.Generate(nil, nil)
|
|
|
|
if !strings.Contains(out, "conf-file=/var/lib/wild-central/dns-filter/hosts.blocked") {
|
|
t.Errorf("expected addn-hosts directive, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "# DNS Filtering") {
|
|
t.Errorf("expected DNS Filtering comment, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestGenerate_NoConfFileWhenEmpty(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
// addnHostsPath is empty by default
|
|
|
|
out := g.Generate(nil, nil)
|
|
|
|
if strings.Contains(out, "addn-hosts") {
|
|
t.Errorf("should not contain addn-hosts when path is empty, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestGenerateMainConfig_ConfFile(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
g.SetFilterConfPath("/tmp/hosts.blocked")
|
|
|
|
cfg := &config.State{}
|
|
out := g.GenerateMainConfig(cfg)
|
|
|
|
if !strings.Contains(out, "conf-file=/tmp/hosts.blocked") {
|
|
t.Errorf("expected addn-hosts in main config, got:\n%s", 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 leaked empty 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},
|
|
{"exact_domain", []DNSEntry{entry("example.com", "10.0.0.1")}},
|
|
{"wildcard_domain", []DNSEntry{wildcardEntry("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, "host-record=,") {
|
|
t.Errorf("leaked empty host-record in output:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "address=//") {
|
|
t.Errorf("leaked empty address=// in output:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "local=/") {
|
|
t.Errorf("must not produce local=/ directives:\n%s", out)
|
|
}
|
|
})
|
|
}
|
|
}
|