Files
wild-central/internal/dnsmasq/config_modular.go
Paul Payne e4e1a15f92 fix: Prevent broken dnsmasq entries for services without internal domain
Services registered via the service API have a domain but no internal
domain (that's a Wild Cloud instance concept). The dnsmasq generator
was producing broken entries like `local=//` and `address=//`.

Fix: skip internal domain entries when InternalDomain is empty, in
both config.go and config_modular.go. Also fix DNS entries to point
to Central's IP (where HAProxy listens) rather than the backend
address — all traffic flows through Central.

Added 3 tests covering: service-only, mixed services+instances.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 14:11:24 +00:00

343 lines
11 KiB
Go

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
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.GlobalConfig) string {
// Get the Wild Central IP address
dnsIP, err := network.GetWildCentralIP()
if err != nil {
slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "error", err)
dnsIP = ""
}
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
# Basic Settings
listen-address=%s
bind-interfaces
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
# Logging
log-queries
log-dhcp
`, dnsIP, instanceConfigDir)
// DHCP section (only when explicitly enabled)
if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled {
dhcp := cfg.Cloud.Dnsmasq.DHCP
sb.WriteString("\n# DHCP Configuration\n")
leaseTime := dhcp.LeaseTime
if leaseTime == "" {
leaseTime = "24h"
}
fmt.Fprintf(&sb, "dhcp-range=%s,%s,%s\n", dhcp.RangeStart, dhcp.RangeEnd, leaseTime)
// Gateway: use explicit DHCP gateway, fall back to router IP
gateway := dhcp.Gateway
if gateway == "" {
gateway = cfg.Cloud.Router.IP
}
if gateway != "" {
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", gateway) // default gateway
}
if dnsIP != "" {
fmt.Fprintf(&sb, "dhcp-option=6,%s\n", dnsIP) // DNS server
}
for _, lease := range dhcp.StaticLeases {
if lease.Hostname != "" {
fmt.Fprintf(&sb, "dhcp-host=%s,%s,%s\n", lease.MAC, lease.IP, lease.Hostname)
} else {
fmt.Fprintf(&sb, "dhcp-host=%s,%s\n", lease.MAC, lease.IP)
}
}
}
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")
fmt.Fprintf(&sb, "# local=/%s/\n", instance.Cloud.InternalDomain)
fmt.Fprintf(&sb, "# address=/%s/<load-balancer-ip>\n\n", instance.Cloud.InternalDomain)
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
cmd := exec.Command("dnsmasq", "--test", "-C", configPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("config validation failed: %w (output: %s)", err, string(output))
}
// Check if output contains "syntax check OK"
if !strings.Contains(string(output), "syntax check OK") {
return fmt.Errorf("config validation did not report OK: %s", string(output))
}
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 {
// Use systemctl reload which sends SIGHUP
cmd := exec.Command("systemctl", "reload", "dnsmasq.service")
_, err := cmd.CombinedOutput()
if err != nil {
// If reload fails, try restart as fallback
slog.Error("reload failed, attempting restart", "component", "dnsmasq", "error", err)
return g.RestartService()
}
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
return nil
}
// UpdateToModularConfig migrates from monolithic to modular configuration
func (g *ConfigGenerator) UpdateToModularConfig(cfg *config.GlobalConfig, 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
}