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.
This commit is contained in:
2026-07-11 20:42:33 +00:00
parent 1f3fcf50b4
commit f9d87ff975
10 changed files with 192 additions and 1517 deletions

View File

@@ -13,6 +13,17 @@ import (
"github.com/wild-cloud/wild-central/internal/network"
)
// DNSEntry represents a single domain-to-IP DNS mapping for dnsmasq.
// Each entry produces both local=/ and address=/ directives.
// local=/ makes dnsmasq authoritative for the domain, which prevents
// AAAA queries from leaking to upstream DNS. Without it, upstream could
// return public IPv6 records and browsers using Happy Eyeballs (RFC 8305)
// would try the unreachable IPv6 path first, adding latency on LAN.
type DNSEntry struct {
Domain string // FQDN to resolve (e.g. "cloud.payne.io")
IP string // target IPv4 address
}
// ConfigGenerator handles dnsmasq configuration generation
type ConfigGenerator struct {
configPath string
@@ -33,9 +44,9 @@ func (g *ConfigGenerator) GetConfigPath() string {
return g.configPath
}
// Generate creates a dnsmasq configuration from the app config
// It auto-detects the Wild Central server's IP address
func (g *ConfigGenerator) Generate(cfg *config.State, clouds []config.InstanceConfig) string {
// Generate creates a dnsmasq configuration from registered domain entries.
// It auto-detects the Wild Central server's IP address for the listen directive.
func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string {
// Use configured dnsmasq IP (bound to eth0), falling back to auto-detect.
// Auto-detect can pick wlan0 if that's the default route, which is wrong
// when dnsmasq is only listening on eth0.
@@ -53,30 +64,16 @@ func (g *ConfigGenerator) Generate(cfg *config.State, clouds []config.InstanceCo
}
resolution_section := ""
for _, cloud := range clouds {
// Point cloud domains to the cluster load balancer IP
loadBalancerIP := instanceLoadBalancerIP(cloud)
if loadBalancerIP == "" {
slog.Info("no load balancer IP configured, adding commented DNS config", "component", "dnsmasq", "instance", cloud.Cluster.Name)
// Add commented out entries for instances without load balancer
resolution_section += fmt.Sprintf("# No load balancer IP configured for instance %s\n", cloud.Cluster.Name)
if cloud.Cloud.InternalDomain != "" {
resolution_section += fmt.Sprintf("# local=/%s/\n# address=/%s/<load-balancer-ip>\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain)
}
if cloud.Cloud.Domain != "" {
resolution_section += fmt.Sprintf("# address=/%s/<load-balancer-ip>\n", cloud.Cloud.Domain)
}
for _, entry := range entries {
if entry.Domain == "" || entry.IP == "" {
continue
}
// Internal domain (.internal.cloud.example.tld) - local only, no external DNS
if cloud.Cloud.InternalDomain != "" {
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain, loadBalancerIP)
}
// External/primary domain - resolve to backend IP
if cloud.Cloud.Domain != "" {
resolution_section += fmt.Sprintf("address=/%s/%s\n", cloud.Cloud.Domain, loadBalancerIP)
}
// local=/ makes dnsmasq authoritative for the domain so AAAA queries
// return empty instead of leaking to upstream DNS. Without this,
// upstream could return public IPv6 records and browsers using Happy
// Eyeballs (RFC 8305) would try the unreachable IPv6 path first,
// adding 250ms-2s latency on LAN.
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", entry.Domain, entry.Domain, entry.IP)
}
template := `# Configuration file for dnsmasq.
@@ -104,8 +101,8 @@ log-dhcp
}
// WriteConfig writes the dnsmasq configuration to the specified path
func (g *ConfigGenerator) WriteConfig(cfg *config.State, clouds []config.InstanceConfig, configPath string) error {
configContent := g.Generate(cfg, clouds)
func (g *ConfigGenerator) WriteConfig(cfg *config.State, entries []DNSEntry, configPath string) error {
configContent := g.Generate(cfg, entries)
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath)
@@ -134,7 +131,7 @@ type ServiceStatus struct {
PID int `json:"pid"`
IP string `json:"ip"`
ConfigFile string `json:"config_file"`
InstancesConfigured int `json:"instances_configured"`
DomainsConfigured int `json:"domains_configured"`
LastRestart time.Time `json:"last_restart"`
}
@@ -190,13 +187,9 @@ func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
}
}
// Count instances in config (both active and commented)
// Count domains in config by counting local=/ directives
if data, err := os.ReadFile(g.configPath); err == nil {
// Count both "local=/" and "# local=/" occurrences
activeCount := strings.Count(string(data), "\nlocal=/")
commentedCount := strings.Count(string(data), "\n# local=/")
// Each instance creates 1 "local=/" entry (internal domain)
status.InstancesConfigured = activeCount + commentedCount
status.DomainsConfigured = strings.Count(string(data), "\nlocal=/")
}
return status, nil
@@ -211,10 +204,10 @@ func (g *ConfigGenerator) ReadConfig() (string, error) {
return string(data), nil
}
// UpdateConfig regenerates and writes the dnsmasq configuration for all instances
func (g *ConfigGenerator) UpdateConfig(cfg *config.State, instances []config.InstanceConfig, restart bool) error {
// UpdateConfig regenerates and writes the dnsmasq configuration and optionally restarts.
func (g *ConfigGenerator) UpdateConfig(cfg *config.State, entries []DNSEntry, restart bool) error {
// Generate fresh config from scratch
configContent := g.Generate(cfg, instances)
configContent := g.Generate(cfg, entries)
// Write config
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", g.configPath)

View File

@@ -3,21 +3,15 @@ package dnsmasq
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/network"
)
const (
instanceConfigDir = "/etc/dnsmasq.d/wild-cloud-instances"
)
// GenerateMainConfig creates the main dnsmasq configuration with global settings
// and a conf-dir directive to include per-instance configs
// and a conf-dir directive to include per-domain configs
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
// Get the Wild Central IP address
dnsIP, err := network.GetWildCentralIP()
@@ -29,8 +23,8 @@ func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
var sb strings.Builder
fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main)
# This file contains global settings. Instance-specific DNS entries are in:
# /etc/dnsmasq.d/wild-cloud-instances/*.conf
# This file contains global settings. Domain-specific DNS entries are
# generated by reconciliation into the monolithic config.
# Basic Settings
listen-address=%s
@@ -39,9 +33,6 @@ domain-needed
bogus-priv
no-resolv
# Include per-instance DNS configurations
conf-dir=%s,*.conf
# Upstream DNS servers
server=1.1.1.1
server=8.8.8.8
@@ -49,7 +40,7 @@ server=8.8.8.8
# Logging
log-queries
log-dhcp
`, dnsIP, instanceConfigDir)
`, dnsIP)
// DHCP section (only when explicitly enabled)
if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled {
@@ -82,57 +73,6 @@ log-dhcp
return sb.String()
}
// instanceLoadBalancerIP returns the load balancer IP for an instance.
// It checks cluster.loadBalancerIp first, then falls back to apps.metallb.loadBalancerIp,
// which is where the normal app install flow writes it.
func instanceLoadBalancerIP(instance config.InstanceConfig) string {
if instance.Cluster.LoadBalancerIp != "" {
return instance.Cluster.LoadBalancerIp
}
if metallb, ok := instance.Apps["metallb"].(map[string]any); ok {
if ip, ok := metallb["loadBalancerIp"].(string); ok {
return ip
}
}
return ""
}
// GenerateInstanceConfig creates a DNS configuration for a single instance
func (g *ConfigGenerator) GenerateInstanceConfig(instance config.InstanceConfig) string {
var sb strings.Builder
fmt.Fprintf(&sb, "# DNS configuration for instance: %s\n", instance.Cluster.Name)
sb.WriteString("# Generated by Wild Cloud\n\n")
loadBalancerIP := instanceLoadBalancerIP(instance)
if loadBalancerIP == "" {
sb.WriteString("# WARNING: No load balancer IP configured for this instance\n")
sb.WriteString("# DNS entries are commented out until load balancer IP is configured\n\n")
if instance.Cloud.InternalDomain != "" {
fmt.Fprintf(&sb, "# local=/%s/\n", instance.Cloud.InternalDomain)
fmt.Fprintf(&sb, "# address=/%s/<load-balancer-ip>\n\n", instance.Cloud.InternalDomain)
}
if instance.Cloud.Domain != "" {
fmt.Fprintf(&sb, "# address=/%s/<load-balancer-ip>\n", instance.Cloud.Domain)
}
} else {
// Internal domain (.internal.cloud.example.tld) - local only, no external DNS
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/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()
}
// ValidateConfig tests a dnsmasq configuration file for syntax errors
func (g *ConfigGenerator) ValidateConfig(configPath string) error {
// Use dnsmasq --test to validate the configuration
@@ -150,104 +90,6 @@ func (g *ConfigGenerator) ValidateConfig(configPath string) error {
return nil
}
// WriteInstanceConfig writes the DNS configuration for a single instance
func (g *ConfigGenerator) WriteInstanceConfig(instanceName string, instance config.InstanceConfig) error {
instanceFile := filepath.Join(instanceConfigDir, fmt.Sprintf("%s.conf", instanceName))
configContent := g.GenerateInstanceConfig(instance)
// Ensure directory exists
if err := os.MkdirAll(instanceConfigDir, 0755); err != nil {
return fmt.Errorf("creating instance config directory: %w", err)
}
// Write to temp file first
tempFile := instanceFile + ".tmp"
if err := os.WriteFile(tempFile, []byte(configContent), 0644); err != nil {
return fmt.Errorf("writing temp instance config: %w", err)
}
// Validate the temp config along with main config
if err := g.ValidateWithInstance(tempFile); err != nil {
os.Remove(tempFile) // Clean up temp file
return fmt.Errorf("instance config validation failed: %w", err)
}
// Move temp file to final location (atomic operation)
if err := os.Rename(tempFile, instanceFile); err != nil {
os.Remove(tempFile) // Clean up temp file
return fmt.Errorf("installing instance config: %w", err)
}
slog.Info("wrote instance DNS config", "component", "dnsmasq", "path", instanceFile)
return nil
}
// ValidateWithInstance validates the main config along with a specific instance config
func (g *ConfigGenerator) ValidateWithInstance(instanceConfigPath string) error {
// Create a temporary test directory
tempDir, err := os.MkdirTemp("", "dnsmasq-test-*")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
defer os.RemoveAll(tempDir)
// Copy main config to temp
mainContent, err := os.ReadFile(g.configPath)
if err != nil {
return fmt.Errorf("reading main config: %w", err)
}
tempMainConfig := filepath.Join(tempDir, "main.conf")
// Modify the conf-dir line to point to our temp instance dir
tempInstanceDir := filepath.Join(tempDir, "instances")
if err := os.MkdirAll(tempInstanceDir, 0755); err != nil {
return fmt.Errorf("creating temp instance dir: %w", err)
}
modifiedContent := strings.ReplaceAll(
string(mainContent),
fmt.Sprintf("conf-dir=%s,*.conf", instanceConfigDir),
fmt.Sprintf("conf-dir=%s,*.conf", tempInstanceDir),
)
if err := os.WriteFile(tempMainConfig, []byte(modifiedContent), 0644); err != nil {
return fmt.Errorf("writing temp main config: %w", err)
}
// Copy instance config to temp
instanceContent, err := os.ReadFile(instanceConfigPath)
if err != nil {
return fmt.Errorf("reading instance config: %w", err)
}
tempInstanceConfig := filepath.Join(tempInstanceDir, filepath.Base(instanceConfigPath))
if err := os.WriteFile(tempInstanceConfig, instanceContent, 0644); err != nil {
return fmt.Errorf("writing temp instance config: %w", err)
}
// Validate the combined configuration
return g.ValidateConfig(tempMainConfig)
}
// RemoveInstanceConfig removes the DNS configuration for an instance
func (g *ConfigGenerator) RemoveInstanceConfig(instanceName string) error {
instanceFile := filepath.Join(instanceConfigDir, fmt.Sprintf("%s.conf", instanceName))
// Check if file exists
if _, err := os.Stat(instanceFile); os.IsNotExist(err) {
slog.Info("instance DNS config does not exist", "component", "dnsmasq", "path", instanceFile)
return nil // Not an error, already removed
}
// Remove the file
if err := os.Remove(instanceFile); err != nil {
return fmt.Errorf("removing instance config: %w", err)
}
slog.Info("removed instance DNS config", "component", "dnsmasq", "path", instanceFile)
return nil
}
// ReloadService sends a SIGHUP to dnsmasq to reload configuration
// This is lighter weight than a full restart
func (g *ConfigGenerator) ReloadService() error {
@@ -262,81 +104,3 @@ func (g *ConfigGenerator) ReloadService() error {
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
return nil
}
// UpdateToModularConfig migrates from monolithic to modular configuration
func (g *ConfigGenerator) UpdateToModularConfig(cfg *config.State, instanceNames []string, instances []config.InstanceConfig) error {
slog.Info("migrating to modular configuration", "component", "dnsmasq")
// Ensure instance directory exists
if err := os.MkdirAll(instanceConfigDir, 0755); err != nil {
return fmt.Errorf("creating instance config directory: %w", err)
}
// Generate and write instance configs first (but don't reload yet)
for i, instance := range instances {
instanceName := instanceNames[i]
if err := g.WriteInstanceConfig(instanceName, instance); err != nil {
slog.Error("failed to write instance config", "component", "dnsmasq", "instance", instanceName, "error", err)
// Continue with other instances
}
}
// Generate new main config with conf-dir
mainConfig := g.GenerateMainConfig(cfg)
// Write to temp file first
tempFile := g.configPath + ".tmp"
if err := os.WriteFile(tempFile, []byte(mainConfig), 0644); err != nil {
return fmt.Errorf("writing temp main config: %w", err)
}
// Validate the new config
if err := g.ValidateConfig(tempFile); err != nil {
os.Remove(tempFile)
return fmt.Errorf("main config validation failed: %w", err)
}
// Backup current config
backupFile := g.configPath + ".pre-modular"
if err := os.Rename(g.configPath, backupFile); err != nil {
os.Remove(tempFile)
return fmt.Errorf("backing up current config: %w", err)
}
// Install new config
if err := os.Rename(tempFile, g.configPath); err != nil {
// Try to restore backup
_ = os.Rename(backupFile, g.configPath)
return fmt.Errorf("installing new config: %w", err)
}
// Reload dnsmasq
if err := g.ReloadService(); err != nil {
// Try to restore backup and reload
slog.Error("reload failed, restoring backup", "component", "dnsmasq", "error", err)
os.Remove(g.configPath)
_ = os.Rename(backupFile, g.configPath)
_ = g.ReloadService()
return fmt.Errorf("reloading with new config: %w", err)
}
slog.Info("migrated to modular configuration", "component", "dnsmasq")
return nil
}
// UpdateInstanceDNS updates DNS configuration for a single instance
// This is called when instance configuration changes (e.g., domain names)
func (g *ConfigGenerator) UpdateInstanceDNS(instanceName string, instance config.InstanceConfig) error {
// Write the new instance config
if err := g.WriteInstanceConfig(instanceName, instance); err != nil {
return fmt.Errorf("writing instance DNS config: %w", err)
}
// Reload dnsmasq to pick up changes
if err := g.ReloadService(); err != nil {
return fmt.Errorf("reloading dnsmasq: %w", err)
}
slog.Info("DNS updated for instance", "component", "dnsmasq", "instance", instanceName)
return nil
}

View File

@@ -8,141 +8,42 @@ import (
"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
func entry(domain, ip string) DNSEntry {
return DNSEntry{Domain: domain, IP: ip}
}
// 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) {
// Test: Generate with entries produces expected DNS entries
func TestGenerate_WithEntries(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)
entries := []DNSEntry{
entry("cloud.example.com", "192.168.1.10"),
entry("internal.example.com", "192.168.1.10"),
}
if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") {
t.Errorf("expected address entry for internal domain, got:\n%s", out)
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 external domain, got:\n%s", out)
t.Errorf("expected address entry for 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) {
// 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, []config.InstanceConfig{})
out := g.Generate(nil, []DNSEntry{})
if !strings.Contains(out, "bind-interfaces") {
t.Errorf("expected bind-interfaces in config, got:\n%s", out)
@@ -155,31 +56,23 @@ func TestGenerate_NoInstances(t *testing.T) {
}
}
// Test: Generate with instance missing LB IP produces commented entries
func TestGenerate_InstanceWithoutLBIP(t *testing.T) {
// Test: Generate skips entries with empty IP
func TestGenerate_EntryWithoutIP(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})
out := g.Generate(nil, []DNSEntry{entry("cloud.example.com", "")})
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)
if strings.Contains(out, "address=/") {
t.Errorf("expected no address= entries for empty IP, got:\n%s", out)
}
}
// Test: GenerateMainConfig produces conf-dir directive
// 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, "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)
}
@@ -228,7 +121,6 @@ func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
// No gateway set
out := g.GenerateMainConfig(cfg)
@@ -282,7 +174,6 @@ func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
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)
@@ -307,181 +198,117 @@ func TestNewConfigGenerator_DefaultPath(t *testing.T) {
}
}
// Test: Service registration — domain set but no internal domain
func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) {
// Test: Domain-only entry (no internal domain pair)
func TestGenerate_DomainOnly(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
// 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})
out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")})
// 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)
t.Errorf("expected address entry for 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: reach:internal → local=/ directive present (prevents upstream DNS forwarding)
func TestGenerate_ReachInternal_HasLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
// 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)
t.Errorf("expected local=/ entry for domain, 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)
// 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: reach:public → no local=/ directive (allows upstream DNS forwarding)
func TestGenerate_ReachPublic_NoLocal(t *testing.T) {
// 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{}
// Simulate reconciliation for a public-reach service:
// Domain is set (reach:public), InternalDomain is empty
inst := instanceWith("cloud.payne.io", "", "192.168.8.240")
// 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, []config.InstanceConfig{inst})
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("reach:public must produce address entry, got:\n%s", out)
t.Errorf("expected address for cloud domain, 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.State{}
// 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")
globalCfg := &config.State{}
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, "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 instances list produces base config with no address= entries
func TestGenerate_EmptyInstances(t *testing.T) {
// 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, []config.InstanceConfig{})
out := g.Generate(globalCfg, []DNSEntry{})
// Should have base config skeleton
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)
}
// Should not have any address= entries
if strings.Contains(out, "address=/") {
t.Errorf("expected no address= entries with empty instances, got:\n%s", out)
t.Errorf("expected no address= entries with empty entries, got:\n%s", out)
}
// Should not have any local=/ entries
if strings.Contains(out, "local=/") {
t.Errorf("expected no local=/ entries with empty instances, got:\n%s", out)
t.Errorf("expected no local=/ entries with empty entries, got:\n%s", out)
}
}
// Test: multiple instances each get their own entries
func TestGenerate_MultipleInstances(t *testing.T) {
// Test: multiple entries each get their own DNS records
func TestGenerate_MultipleEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
instances := []config.InstanceConfig{
instanceWith("cloud1.example.com", "internal1.example.com", "10.0.0.1"),
instanceWith("cloud2.example.com", "internal2.example.com", "10.0.0.2"),
instanceWith("cloud3.example.com", "internal3.example.com", "10.0.0.3"),
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, instances)
out := g.Generate(globalCfg, entries)
for i, inst := range instances {
ip := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}[i]
if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", inst.Cloud.Domain, ip)) {
t.Errorf("expected address entry for %s, got:\n%s", inst.Cloud.Domain, out)
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("address=/%s/%s", inst.Cloud.InternalDomain, ip)) {
t.Errorf("expected address entry for %s, got:\n%s", inst.Cloud.InternalDomain, out)
}
if !strings.Contains(out, fmt.Sprintf("local=/%s/", inst.Cloud.InternalDomain)) {
t.Errorf("expected local=/ entry for %s, got:\n%s", inst.Cloud.InternalDomain, out)
if !strings.Contains(out, fmt.Sprintf("local=/%s/", e.Domain)) {
t.Errorf("expected local=/ entry for %s, got:\n%s", e.Domain, out)
}
}
}
@@ -490,8 +317,7 @@ func TestGenerate_MultipleInstances(t *testing.T) {
func TestGenerate_NilConfig(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
// Should not panic with nil config
out := g.Generate(nil, []config.InstanceConfig{})
out := g.Generate(nil, []DNSEntry{})
if !strings.Contains(out, "bind-interfaces") {
t.Errorf("expected valid config with nil cfg, got:\n%s", out)
@@ -507,32 +333,31 @@ func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
cfg := &config.State{}
cfg.Cloud.Dnsmasq.IP = "192.168.8.100"
out := g.Generate(cfg, []config.InstanceConfig{})
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 in any test output
// 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
instances []config.InstanceConfig
name string
entries []DNSEntry
}{
{"empty", nil},
{"domain_only", []config.InstanceConfig{instanceWith("example.com", "", "10.0.0.1")}},
{"internal_only", []config.InstanceConfig{instanceWith("", "internal.example.com", "10.0.0.1")}},
{"both", []config.InstanceConfig{instanceWith("example.com", "internal.example.com", "10.0.0.1")}},
{"no_lb_ip", []config.InstanceConfig{instanceWith("example.com", "internal.example.com", "")}},
{"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.instances)
out := g.Generate(cfg, tc.entries)
if strings.Contains(out, "address=//") {
t.Errorf("leaked empty address=// in output:\n%s", out)
}
@@ -542,23 +367,3 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
})
}
}
// Test: GenerateInstanceConfig with all fields empty doesn't produce broken entries
func TestGenerateInstanceConfig_EmptyFields(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
var inst config.InstanceConfig
// All fields empty: no domain, no internal domain, no LB IP
out := g.GenerateInstanceConfig(inst)
if strings.Contains(out, "address=//") {
t.Errorf("empty fields produced broken address=// entry:\n%s", out)
}
if strings.Contains(out, "local=//") {
t.Errorf("empty fields produced broken local=// entry:\n%s", out)
}
// Should still contain the header comment
if !strings.Contains(out, "DNS configuration for instance") {
t.Errorf("expected header comment in output, got:\n%s", out)
}
}