package dnsmasq import ( "fmt" "log/slog" "os" "os/exec" "strconv" "strings" "time" "github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/network" ) // ConfigGenerator handles dnsmasq configuration generation type ConfigGenerator struct { configPath string } // NewConfigGenerator creates a new dnsmasq config generator func NewConfigGenerator(configPath string) *ConfigGenerator { if configPath == "" { configPath = "/etc/dnsmasq.d/wild-cloud.conf" } return &ConfigGenerator{ configPath: configPath, } } // GetConfigPath returns the dnsmasq config file path 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 { // 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. dnsIP := "" if cfg != nil { dnsIP = cfg.Cloud.Dnsmasq.IP } if dnsIP == "" { var err error dnsIP, err = network.GetWildCentralIP() if err != nil { slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "op", "generate", "error", err) dnsIP = "" } } 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/\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain) } if cloud.Cloud.Domain != "" { resolution_section += fmt.Sprintf("# address=/%s/\n", cloud.Cloud.Domain) } 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) } } template := `# Configuration file for dnsmasq. # Basic Settings listen-address=%s bind-interfaces domain-needed bogus-priv no-resolv # DNS Local Resolution - Central server handles these domains authoritatively %s server=1.1.1.1 server=8.8.8.8 log-queries log-dhcp ` return fmt.Sprintf(template, dnsIP, resolution_section, ) } // 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) slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath) if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { return fmt.Errorf("writing dnsmasq config: %w", err) } return nil } // RestartService reloads the dnsmasq service (SIGHUP — no downtime). // Falls back to restart if reload fails. func (g *ConfigGenerator) RestartService() error { cmd := exec.Command("systemctl", "reload-or-restart", "dnsmasq.service") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to reload dnsmasq: %w (output: %s)", err, string(output)) } slog.Info("dnsmasq service reloaded", "component", "dnsmasq") return nil } // ServiceStatus represents the status of the dnsmasq service type ServiceStatus struct { Status string `json:"status"` PID int `json:"pid"` IP string `json:"ip"` ConfigFile string `json:"config_file"` InstancesConfigured int `json:"instances_configured"` LastRestart time.Time `json:"last_restart"` } // GetStatus checks the status of the dnsmasq service func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) { // Get the Wild Central IP address dnsIP, err := network.GetWildCentralIP() if err != nil { slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "op", "getStatus", "error", err) dnsIP = "" } status := &ServiceStatus{ ConfigFile: g.configPath, IP: dnsIP, } // Check if service is active cmd := exec.Command("systemctl", "is-active", "dnsmasq.service") output, err := cmd.Output() if err != nil { status.Status = "inactive" return status, nil } statusStr := strings.TrimSpace(string(output)) status.Status = statusStr // Get PID if running if statusStr == "active" { cmd = exec.Command("systemctl", "show", "dnsmasq.service", "--property=MainPID") output, err := cmd.Output() if err == nil { parts := strings.Split(strings.TrimSpace(string(output)), "=") if len(parts) == 2 { if pid, err := strconv.Atoi(parts[1]); err == nil { status.PID = pid } } } // Get last restart time cmd = exec.Command("systemctl", "show", "dnsmasq.service", "--property=ActiveEnterTimestamp") output, err = cmd.Output() if err == nil { parts := strings.Split(strings.TrimSpace(string(output)), "=") if len(parts) == 2 { // Parse systemd timestamp format if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", parts[1]); err == nil { status.LastRestart = t } } } } // Count instances in config (both active and commented) 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 } return status, nil } // ReadConfig reads the current dnsmasq configuration func (g *ConfigGenerator) ReadConfig() (string, error) { data, err := os.ReadFile(g.configPath) if err != nil { return "", fmt.Errorf("reading dnsmasq config: %w", err) } 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 { // Generate fresh config from scratch configContent := g.Generate(cfg, instances) // Write config slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", g.configPath) if err := os.WriteFile(g.configPath, []byte(configContent), 0644); err != nil { return fmt.Errorf("writing dnsmasq config: %w", err) } // Restart service to apply changes if requested if restart { return g.RestartService() } return nil } // ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server // This should only be called on first start of dnsmasq func (g *ConfigGenerator) ConfigureSystemDNS() error { // Auto-detect network info to get the DNS IP netInfo, err := network.DetectNetworkInfo() if err != nil { return fmt.Errorf("failed to detect network info: %w", err) } dnsIP := netInfo.PrimaryIP // Write systemd-resolved configuration to file owned by wildcloud user // (created during package installation in postinst) resolvedConfPath := "/etc/systemd/resolved.conf.d/wild-cloud.conf" resolvedConf := fmt.Sprintf("[Resolve]\nDNS=%s\nDomains=~.\n", dnsIP) if err := os.WriteFile(resolvedConfPath, []byte(resolvedConf), 0644); err != nil { return fmt.Errorf("failed to write resolved.conf: %w", err) } slog.Info("configured systemd-resolved", "component", "dnsmasq", "dnsIP", dnsIP) // Restart systemd-resolved to apply changes (via polkit) cmd := exec.Command("systemctl", "restart", "systemd-resolved") if output, err := cmd.CombinedOutput(); err != nil { slog.Error("failed to restart systemd-resolved", "component", "dnsmasq", "error", err, "output", string(output)) // Don't return error - the config was written successfully } return nil }