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" "github.com/wild-cloud/wild-central/internal/storage" ) // DNSEntry represents a single domain-to-IP DNS mapping for dnsmasq. // Exact-match domains use host-record= (SOA/NS queries forwarded upstream, // enabling ACME DNS-01 validation). Wildcard domains use address=/ to resolve // all subdomains. Happy Eyeballs (AAAA leaking) is handled globally by // filter-AAAA in the config template. type DNSEntry struct { Domain string // FQDN to resolve (e.g. "cloud.payne.io") IP string // target IPv4 address Wildcard bool // true: address=/ (all subdomains); false: host-record= (exact match) } // Manager handles dnsmasq configuration generation type Manager struct { configPath string filterConfPath string // path to addn-hosts file for DNS filtering } // NewManager creates a new dnsmasq config generator func NewManager(configPath string) *Manager { if configPath == "" { configPath = "/etc/dnsmasq.d/wild-central.conf" } return &Manager{ configPath: configPath, } } // GetConfigPath returns the dnsmasq config file path func (g *Manager) GetConfigPath() string { return g.configPath } // SetFilterConfPath sets the path to an additional hosts file for DNS filtering. // When set, the generated config includes an addn-hosts= directive. func (g *Manager) SetFilterConfPath(path string) { g.filterConfPath = path } // 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 *Manager) 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. 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 _, entry := range entries { if entry.Domain == "" || entry.IP == "" { continue } if entry.Wildcard { // Wildcard: resolves domain + all subdomains to this IP. // Note: address=/ blocks forwarding for the entire subtree, // so SOA walks stop here. For non-apex domains this is fine — // the walk continues up to the parent (which uses host-record=). resolution_section += fmt.Sprintf("address=/%s/%s\n", entry.Domain, entry.IP) } else { // Exact match: only this specific name. SOA/NS/TXT queries // are forwarded upstream, enabling ACME DNS-01 validation. resolution_section += fmt.Sprintf("host-record=%s,%s\n", entry.Domain, entry.IP) } } template := `# Configuration file for dnsmasq. # Basic Settings listen-address=%s bind-interfaces domain-needed bogus-priv no-resolv filter-AAAA # DNS Local Resolution - registered domain A records %s server=1.1.1.1 server=8.8.8.8 log-queries log-dhcp ` result := fmt.Sprintf(template, dnsIP, resolution_section, ) if g.filterConfPath != "" { result += fmt.Sprintf("\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath) } return result } // RestartService fully restarts the dnsmasq service. // A full restart is required because SIGHUP (reload) does not re-read // host-record= directives — only address=/ and a few other directives // are reloaded on SIGHUP. func (g *Manager) RestartService() error { cmd := exec.Command("systemctl", "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 } // Status represents the status of the dnsmasq service type Status struct { Status string `json:"status"` PID int `json:"pid"` IP string `json:"ip"` ConfigFile string `json:"configFile"` DomainsConfigured int `json:"domainsConfigured"` LastRestart time.Time `json:"lastRestart"` } // GetStatus checks the status of the dnsmasq service func (g *Manager) GetStatus() (*Status, 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 := &Status{ 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 domains in config by counting host-record= and address=/ directives if data, err := os.ReadFile(g.configPath); err == nil { config := string(data) status.DomainsConfigured = strings.Count(config, "host-record=") + strings.Count(config, "\naddress=/") } return status, nil } // ReadConfig reads the current dnsmasq configuration func (g *Manager) 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 and optionally restarts. func (g *Manager) UpdateConfig(cfg *config.State, entries []DNSEntry, restart bool) error { // Generate fresh config from scratch configContent := g.Generate(cfg, entries) // Write config slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", g.configPath) if err := storage.WriteFileAtomic(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 } // GenerateMainConfig creates the main dnsmasq configuration with global settings. // Used by the DnsmasqGenerate handler to preview/apply the base config independently // of domain-specific DNS entries (which are handled by Generate + UpdateConfig). func (g *Manager) GenerateMainConfig(cfg *config.State) string { 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. Domain-specific DNS entries are # generated by reconciliation into the monolithic config. # Basic Settings listen-address=%s bind-interfaces domain-needed bogus-priv no-resolv # Upstream DNS servers server=1.1.1.1 server=8.8.8.8 # Logging log-queries log-dhcp `, dnsIP) // 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) } if dnsIP != "" { fmt.Fprintf(&sb, "dhcp-option=6,%s\n", dnsIP) } 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) } } } if g.filterConfPath != "" { fmt.Fprintf(&sb, "\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath) } return sb.String() } // SafeApply validates, backs up, writes, restarts, and verifies the dnsmasq config. // On restart or verification failure, rolls back to the previous config. func (g *Manager) SafeApply(content string) error { // Validate via temp file tmpFile := g.configPath + ".validate" if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { return fmt.Errorf("writing validation file: %w", err) } defer os.Remove(tmpFile) if err := g.ValidateConfig(tmpFile); err != nil { return fmt.Errorf("validation failed: %w", err) } // Backup current config if storage.FileExists(g.configPath) { _ = storage.CopyFile(g.configPath, g.configPath+".bak") } // Write atomically if err := storage.WriteFileAtomic(g.configPath, []byte(content), 0644); err != nil { return fmt.Errorf("write failed: %w", err) } // Restart daemon if err := g.RestartService(); err != nil { g.rollback() return fmt.Errorf("restart failed (rolled back): %w", err) } // Verify daemon is running if err := g.verify(); err != nil { g.rollback() return fmt.Errorf("health check failed (rolled back): %w", err) } return nil } func (g *Manager) rollback() { bakPath := g.configPath + ".bak" if storage.FileExists(bakPath) { _ = os.Rename(bakPath, g.configPath) _ = g.RestartService() slog.Warn("rolled back to previous config", "component", "dnsmasq", "path", g.configPath) } } func (g *Manager) verify() error { cmd := exec.Command("systemctl", "is-active", "--quiet", "dnsmasq.service") if err := cmd.Run(); err != nil { return fmt.Errorf("dnsmasq not active after restart") } return nil } // ValidateConfig tests a dnsmasq configuration file for syntax errors func (g *Manager) ValidateConfig(configPath string) error { 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)) } if !strings.Contains(string(output), "syntax check OK") { return fmt.Errorf("config validation did not report OK: %s", string(output)) } return nil } // ReloadService restarts dnsmasq to apply configuration changes. // Uses a full restart because SIGHUP does not re-read host-record= directives. func (g *Manager) ReloadService() error { return g.RestartService() } // ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server // This should only be called on first start of dnsmasq func (g *Manager) 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-central.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 }