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 := dhcp.Gateway 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") if instance.Cloud.InternalDomain != "" { fmt.Fprintf(&sb, "# local=/%s/\n", instance.Cloud.InternalDomain) fmt.Fprintf(&sb, "# address=/%s/\n\n", instance.Cloud.InternalDomain) } if instance.Cloud.Domain != "" { fmt.Fprintf(&sb, "# address=/%s/\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 }