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)