Extract the Central networking functionality from wild-cloud/api into a standalone service. Wild Central manages DNS (dnsmasq), gateway (HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot), security (CrowdSec), and DDNS — all the network appliance concerns. All Cloud-specific code (instances, clusters, nodes, apps, backups, operations, kubectl/talosctl tooling) has been removed. The API struct and route registration contain only Central endpoints. Tests updated to match the new API signature. Builds and all tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
313 lines
11 KiB
Go
313 lines
11 KiB
Go
package dnsmasq
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/wild-cloud/wild-central/internal/config"
|
|
)
|
|
|
|
func instanceWith(domain, internalDomain, lbIP string) config.InstanceConfig {
|
|
var inst config.InstanceConfig
|
|
inst.Cluster.Name = "test"
|
|
inst.Cloud.Domain = domain
|
|
inst.Cloud.InternalDomain = internalDomain
|
|
inst.Cluster.LoadBalancerIp = lbIP
|
|
return inst
|
|
}
|
|
|
|
// Test: instanceLoadBalancerIP prefers cluster.loadBalancerIp
|
|
func TestInstanceLoadBalancerIP_ClusterField(t *testing.T) {
|
|
inst := instanceWith("cloud.example.com", "internal.example.com", "10.0.0.5")
|
|
if got := instanceLoadBalancerIP(inst); got != "10.0.0.5" {
|
|
t.Errorf("got %q, want %q", got, "10.0.0.5")
|
|
}
|
|
}
|
|
|
|
// Test: instanceLoadBalancerIP falls back to apps.metallb.loadBalancerIp
|
|
func TestInstanceLoadBalancerIP_MetalLBFallback(t *testing.T) {
|
|
var inst config.InstanceConfig
|
|
inst.Apps = map[string]any{
|
|
"metallb": map[string]any{
|
|
"loadBalancerIp": "10.0.0.9",
|
|
},
|
|
}
|
|
if got := instanceLoadBalancerIP(inst); got != "10.0.0.9" {
|
|
t.Errorf("got %q, want %q", got, "10.0.0.9")
|
|
}
|
|
}
|
|
|
|
// Test: instanceLoadBalancerIP returns empty string when nothing is set
|
|
func TestInstanceLoadBalancerIP_Empty(t *testing.T) {
|
|
var inst config.InstanceConfig
|
|
if got := instanceLoadBalancerIP(inst); got != "" {
|
|
t.Errorf("got %q, want empty string", got)
|
|
}
|
|
}
|
|
|
|
// Test: cluster.loadBalancerIp takes precedence over metallb
|
|
func TestInstanceLoadBalancerIP_ClusterTakesPrecedence(t *testing.T) {
|
|
inst := instanceWith("cloud.example.com", "internal.example.com", "10.0.0.5")
|
|
inst.Apps = map[string]any{
|
|
"metallb": map[string]any{
|
|
"loadBalancerIp": "10.0.0.9",
|
|
},
|
|
}
|
|
if got := instanceLoadBalancerIP(inst); got != "10.0.0.5" {
|
|
t.Errorf("got %q, want cluster IP %q", got, "10.0.0.5")
|
|
}
|
|
}
|
|
|
|
// Test: GenerateInstanceConfig with load balancer IP produces active DNS entries
|
|
func TestGenerateInstanceConfig_WithLBIP(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10")
|
|
|
|
out := g.GenerateInstanceConfig(inst)
|
|
|
|
if !strings.Contains(out, "local=/internal.example.com/") {
|
|
t.Errorf("expected local=/ directive 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, "address=/cloud.example.com/192.168.1.10") {
|
|
t.Errorf("expected address entry for external domain, got:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "WARNING") {
|
|
t.Errorf("unexpected WARNING in output:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Test: GenerateInstanceConfig without load balancer IP produces commented entries
|
|
func TestGenerateInstanceConfig_NoLBIP(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
inst := instanceWith("cloud.example.com", "internal.example.com", "")
|
|
|
|
out := g.GenerateInstanceConfig(inst)
|
|
|
|
if !strings.Contains(out, "WARNING") {
|
|
t.Errorf("expected WARNING in output when no LB IP, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "# local=/internal.example.com/") {
|
|
t.Errorf("expected commented local=/ directive, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "# address=/internal.example.com/<load-balancer-ip>") {
|
|
t.Errorf("expected commented address entry for internal domain, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "# address=/cloud.example.com/<load-balancer-ip>") {
|
|
t.Errorf("expected commented address entry for external domain, got:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "\nlocal=/") {
|
|
t.Errorf("unexpected active local=/ directive when no LB IP, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Test: GenerateInstanceConfig includes instance name in header comment
|
|
func TestGenerateInstanceConfig_IncludesInstanceName(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10")
|
|
inst.Cluster.Name = "my-instance"
|
|
|
|
out := g.GenerateInstanceConfig(inst)
|
|
|
|
if !strings.Contains(out, "my-instance") {
|
|
t.Errorf("expected instance name in config header, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Test: Generate (monolithic) with instances produces expected DNS entries
|
|
func TestGenerate_WithInstances(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10")
|
|
|
|
out := g.Generate(nil, []config.InstanceConfig{inst})
|
|
|
|
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, "address=/cloud.example.com/192.168.1.10") {
|
|
t.Errorf("expected address entry for external 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 instances still produces valid config skeleton
|
|
func TestGenerate_NoInstances(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
|
|
out := g.Generate(nil, []config.InstanceConfig{})
|
|
|
|
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 with instance missing LB IP produces commented entries
|
|
func TestGenerate_InstanceWithoutLBIP(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
inst := instanceWith("cloud.example.com", "internal.example.com", "")
|
|
inst.Cluster.Name = "no-lb-instance"
|
|
|
|
out := g.Generate(nil, []config.InstanceConfig{inst})
|
|
|
|
if !strings.Contains(out, "# No load balancer IP configured for instance no-lb-instance") {
|
|
t.Errorf("expected comment about missing LB IP, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Test: GenerateMainConfig produces conf-dir directive
|
|
func TestGenerateMainConfig(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
|
|
out := g.GenerateMainConfig(nil)
|
|
|
|
if !strings.Contains(out, "conf-dir=") {
|
|
t.Errorf("expected conf-dir directive in main config, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, instanceConfigDir) {
|
|
t.Errorf("expected instance config dir %q in conf-dir, got:\n%s", instanceConfigDir, out)
|
|
}
|
|
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.GlobalConfig{}
|
|
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.GlobalConfig{}
|
|
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.Router.IP = "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 gateway falls back to router IP when not set
|
|
func TestGenerateMainConfig_DHCPGatewayFallback(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
cfg := &config.GlobalConfig{}
|
|
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.Router.IP = "192.168.8.1"
|
|
// Gateway not set — should fall back to router IP
|
|
|
|
out := g.GenerateMainConfig(cfg)
|
|
|
|
if !strings.Contains(out, "dhcp-option=3,192.168.8.1") {
|
|
t.Errorf("expected router IP as fallback gateway, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Test: DHCP explicit gateway takes precedence over router IP
|
|
func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
|
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
cfg := &config.GlobalConfig{}
|
|
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"
|
|
cfg.Cloud.Router.IP = "192.168.8.1"
|
|
|
|
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)
|
|
}
|
|
if strings.Contains(out, "dhcp-option=3,192.168.8.1") {
|
|
t.Errorf("router IP should not appear as gateway when explicit gateway set, 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.GlobalConfig{}
|
|
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.GlobalConfig{}
|
|
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
|
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
|
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
|
// LeaseTime not set
|
|
|
|
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())
|
|
}
|
|
}
|