package dns import ( "encoding/json" "fmt" "log" "net" "os" "os/exec" "path/filepath" "strings" "sync" "time" ) const ( SettingsStorePath = "/etc/debtik/dns/settings.json" DnsmasqDNSConf = "/etc/dnsmasq.d/debtik_dns.conf" SystemResolvConf = "/etc/resolv.conf" ) // Manager coordinates Router DNS forwarder, upstream resolvers, and static DNS mapping. type Manager struct { mu sync.RWMutex settings DNSSettings dynamicServers []string } func NewManager() *Manager { m := &Manager{ dynamicServers: make([]string, 0), settings: DNSSettings{ Servers: []string{"1.1.1.1", "8.8.8.8"}, DynamicServers: make([]string, 0), AllowRemoteRequests: true, CacheSize: 2048, Domain: "", StaticRecords: make([]StaticRecord, 0), }, } m.load() if err := m.applyLocked(); err != nil { log.Printf("[DNS] Error applying initial DNS configuration: %v", err) } return m } func (m *Manager) load() { m.mu.Lock() defer m.mu.Unlock() data, err := os.ReadFile(SettingsStorePath) if err != nil { // If no settings file, read existing resolv.conf nameservers if any if curNameservers := readSystemNameservers(); len(curNameservers) > 0 { m.settings.Servers = curNameservers } return } var s DNSSettings if err := json.Unmarshal(data, &s); err == nil { if len(s.Servers) > 0 { m.settings.Servers = s.Servers } m.settings.AllowRemoteRequests = s.AllowRemoteRequests if s.CacheSize > 0 { m.settings.CacheSize = s.CacheSize } m.settings.Domain = s.Domain m.settings.DoHServer = s.DoHServer m.settings.VerifyDoHCert = s.VerifyDoHCert if s.StaticRecords != nil { m.settings.StaticRecords = s.StaticRecords } } } func (m *Manager) saveLocked() error { dir := filepath.Dir(SettingsStorePath) if err := os.MkdirAll(dir, 0755); err != nil { return err } data, err := json.MarshalIndent(m.settings, "", " ") if err != nil { return err } return os.WriteFile(SettingsStorePath, data, 0644) } // applyLocked renders the dnsmasq DNS configuration and updates /etc/resolv.conf. func (m *Manager) applyLocked() (result error) { oldConfig, configErr := os.ReadFile(DnsmasqDNSConf) oldResolver, resolverErr := os.ReadFile(SystemResolvConf) wasRunning := exec.Command("systemctl", "is-active", "--quiet", "dnsmasq").Run() == nil defer func() { if result != nil { if configErr == nil { _ = os.WriteFile(DnsmasqDNSConf, oldConfig, 0644) } else if os.IsNotExist(configErr) { _ = os.Remove(DnsmasqDNSConf) } if resolverErr == nil { _ = os.WriteFile(SystemResolvConf, oldResolver, 0644) } if wasRunning { if err := exec.Command("systemctl", "restart", "dnsmasq").Run(); err != nil { result = fmt.Errorf("DNS apply failed: %v; previous config restart failed: %w", result, err) } } } }() var sb strings.Builder sb.WriteString("# ==================================================================\n") sb.WriteString("# Enterprise Router DNS Forwarder & Cache Configuration\n") sb.WriteString("# Auto-generated by Network Appliance Controller. DO NOT EDIT DIRECTLY.\n") sb.WriteString("# ==================================================================\n\n") cacheSize := m.settings.CacheSize if cacheSize <= 0 { cacheSize = 2048 } sb.WriteString(fmt.Sprintf("cache-size=%d\n", cacheSize)) // Do not read /etc/resolv.conf in dnsmasq (prevents loop with nameserver 127.0.0.1) sb.WriteString("no-resolv\n") // Upstream DNS Servers (Configured + Dynamic) allUpstreams := make([]string, 0) seen := make(map[string]bool) for _, s := range m.settings.Servers { trimmed := strings.TrimSpace(s) if trimmed != "" && !seen[trimmed] { allUpstreams = append(allUpstreams, trimmed) seen[trimmed] = true } } for _, s := range m.dynamicServers { trimmed := strings.TrimSpace(s) if trimmed != "" && !seen[trimmed] { allUpstreams = append(allUpstreams, trimmed) seen[trimmed] = true } } if len(allUpstreams) == 0 { allUpstreams = append(allUpstreams, "1.1.1.1", "8.8.8.8") } sb.WriteString("\n# Upstream DNS Forwarders\n") for _, s := range allUpstreams { sb.WriteString(fmt.Sprintf("server=%s\n", s)) } // Domain domain := strings.TrimSpace(m.settings.Domain) if domain != "" { sb.WriteString(fmt.Sprintf("\ndomain=%s\n", domain)) sb.WriteString(fmt.Sprintf("local=/%s/\n", domain)) } // Remote requests restriction if !m.settings.AllowRemoteRequests { sb.WriteString("\n# Remote requests disabled: only listen on loopback for host\n") sb.WriteString("listen-address=127.0.0.1\n") } // Static DNS Records sb.WriteString("\n# Local Static DNS Records (/ip dns static)\n") for _, rec := range m.settings.StaticRecords { if rec.Disabled { continue } name := strings.TrimSpace(rec.Name) addr := strings.TrimSpace(rec.Address) if name == "" || addr == "" { continue } recType := strings.ToUpper(strings.TrimSpace(rec.Type)) switch recType { case "CNAME": sb.WriteString(fmt.Sprintf("cname=%s,%s\n", name, addr)) case "TXT": sb.WriteString(fmt.Sprintf("txt-record=%s,%q\n", name, addr)) default: // "A" or "AAAA" sb.WriteString(fmt.Sprintf("address=/%s/%s\n", name, addr)) } } // Write dnsmasq config _ = os.MkdirAll(filepath.Dir(DnsmasqDNSConf), 0755) if err := os.WriteFile(DnsmasqDNSConf, []byte(sb.String()), 0644); err != nil { return fmt.Errorf("failed to write %s: %w", DnsmasqDNSConf, err) } // Test dnsmasq syntax testOut, testErr := exec.Command("dnsmasq", "--test").CombinedOutput() if testErr != nil { log.Printf("[DNS] dnsmasq syntax check failed: %v, output: %s", testErr, string(testOut)) return fmt.Errorf("dnsmasq configuration test failed: %s", string(testOut)) } // Update host /etc/resolv.conf var rsb strings.Builder rsb.WriteString("# ==================================================================\n") rsb.WriteString("# Network Appliance System Resolver\n") rsb.WriteString("# Managed by DebTik DNS Controller\n") rsb.WriteString("# ==================================================================\n\n") rsb.WriteString("nameserver 127.0.0.1\n") for _, s := range allUpstreams { rsb.WriteString(fmt.Sprintf("nameserver %s\n", s)) } if domain != "" { rsb.WriteString(fmt.Sprintf("search %s\n", domain)) } if err := os.WriteFile(SystemResolvConf, []byte(rsb.String()), 0644); err != nil { return err } // Restart dnsmasq to re-read all configuration files if err := exec.Command("systemctl", "restart", "dnsmasq").Run(); err != nil { return fmt.Errorf("failed to restart dnsmasq: %w", err) } return nil } // GetSettings returns a snapshot of current DNS settings with dynamic servers populated. func (m *Manager) GetSettings() DNSSettings { m.mu.RLock() defer m.mu.RUnlock() s := m.settings s.DynamicServers = make([]string, len(m.dynamicServers)) copy(s.DynamicServers, m.dynamicServers) return s } // SaveSettings updates the DNS configuration and applies it immediately. func (m *Manager) SaveSettings(newSettings DNSSettings) error { m.mu.Lock() defer m.mu.Unlock() cleanedServers := make([]string, 0) for _, s := range newSettings.Servers { s = strings.TrimSpace(s) if s != "" { if ip := net.ParseIP(s); ip == nil { return fmt.Errorf("invalid upstream DNS IP address %q", s) } cleanedServers = append(cleanedServers, s) } } if len(cleanedServers) == 0 { return fmt.Errorf("at least one valid upstream DNS server is required") } m.settings.Servers = cleanedServers m.settings.AllowRemoteRequests = newSettings.AllowRemoteRequests if newSettings.CacheSize > 0 { if newSettings.CacheSize > 50000 { m.settings.CacheSize = 50000 } else { m.settings.CacheSize = newSettings.CacheSize } } m.settings.Domain = strings.TrimSpace(newSettings.Domain) m.settings.DoHServer = strings.TrimSpace(newSettings.DoHServer) m.settings.VerifyDoHCert = newSettings.VerifyDoHCert if err := m.saveLocked(); err != nil { return err } return m.applyLocked() } // SetDynamicServers sets peer DNS servers received dynamically from DHCP or PPP. func (m *Manager) SetDynamicServers(servers []string) { m.mu.Lock() defer m.mu.Unlock() changed := false if len(m.dynamicServers) != len(servers) { changed = true } else { for i := range servers { if m.dynamicServers[i] != servers[i] { changed = true break } } } if changed { m.dynamicServers = make([]string, 0, len(servers)) for _, s := range servers { trimmed := strings.TrimSpace(s) if trimmed != "" { m.dynamicServers = append(m.dynamicServers, trimmed) } } _ = m.applyLocked() } } // ListStaticRecords returns all user-defined static DNS host mappings. func (m *Manager) ListStaticRecords() []StaticRecord { m.mu.RLock() defer m.mu.RUnlock() res := make([]StaticRecord, len(m.settings.StaticRecords)) copy(res, m.settings.StaticRecords) return res } // SaveStaticRecord adds or updates a static DNS record. func (m *Manager) SaveStaticRecord(rec StaticRecord) error { m.mu.Lock() defer m.mu.Unlock() rec.Name = strings.TrimSpace(rec.Name) rec.Address = strings.TrimSpace(rec.Address) if rec.Name == "" { return fmt.Errorf("DNS name (hostname) is required") } if rec.Address == "" { return fmt.Errorf("target address is required") } rec.Type = strings.ToUpper(strings.TrimSpace(rec.Type)) if rec.Type == "" { rec.Type = "A" } if rec.Type != "A" && rec.Type != "AAAA" && rec.Type != "CNAME" && rec.Type != "TXT" { return fmt.Errorf("unsupported record type %q: must be A, AAAA, CNAME, or TXT", rec.Type) } if rec.Type == "A" || rec.Type == "AAAA" { if ip := net.ParseIP(rec.Address); ip == nil { return fmt.Errorf("invalid IP address %q for record type %s", rec.Address, rec.Type) } } if rec.ID == "" { rec.ID = fmt.Sprintf("dns-rec-%d", time.Now().UnixNano()) m.settings.StaticRecords = append(m.settings.StaticRecords, rec) } else { found := false for i, existing := range m.settings.StaticRecords { if existing.ID == rec.ID { m.settings.StaticRecords[i] = rec found = true break } } if !found { m.settings.StaticRecords = append(m.settings.StaticRecords, rec) } } if err := m.saveLocked(); err != nil { return err } return m.applyLocked() } // DeleteStaticRecord removes a static DNS record by ID. func (m *Manager) DeleteStaticRecord(id string) error { m.mu.Lock() defer m.mu.Unlock() newRecords := make([]StaticRecord, 0, len(m.settings.StaticRecords)) found := false for _, rec := range m.settings.StaticRecords { if rec.ID == id { found = true continue } newRecords = append(newRecords, rec) } if !found { return fmt.Errorf("static DNS record %q not found", id) } m.settings.StaticRecords = newRecords if err := m.saveLocked(); err != nil { return err } return m.applyLocked() } // ToggleStaticRecord enables or disables a static DNS record. func (m *Manager) ToggleStaticRecord(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() found := false for i := range m.settings.StaticRecords { if m.settings.StaticRecords[i].ID == id { m.settings.StaticRecords[i].Disabled = disabled found = true break } } if !found { return fmt.Errorf("static DNS record %q not found", id) } if err := m.saveLocked(); err != nil { return err } return m.applyLocked() } // FlushCache clears dnsmasq DNS cache by sending SIGHUP. func (m *Manager) FlushCache() error { cmd := exec.Command("pkill", "-HUP", "dnsmasq") if out, err := cmd.CombinedOutput(); err != nil { // Fallback to reload _ = exec.Command("systemctl", "reload", "dnsmasq").Run() log.Printf("[DNS] pkill -HUP result: %v (%s), fallback reload triggered", err, string(out)) } return nil } // GetStatus returns the operational status of the DNS subsystem. func (m *Manager) GetStatus() DNSStatus { m.mu.RLock() defer m.mu.RUnlock() resolvBytes, _ := os.ReadFile(SystemResolvConf) // Check if dnsmasq is running isRunning := false if err := exec.Command("systemctl", "is-active", "--quiet", "dnsmasq").Run(); err == nil { isRunning = true } activeServers := make([]string, 0, len(m.settings.Servers)+len(m.dynamicServers)) activeServers = append(activeServers, m.settings.Servers...) activeServers = append(activeServers, m.dynamicServers...) return DNSStatus{ ActiveServers: activeServers, AllowRemoteRequests: m.settings.AllowRemoteRequests, CacheSize: m.settings.CacheSize, StaticCount: len(m.settings.StaticRecords), Domain: m.settings.Domain, ResolvConfContent: string(resolvBytes), ServiceRunning: isRunning, } } // Helper: read existing nameservers from /etc/resolv.conf func readSystemNameservers() []string { data, err := os.ReadFile(SystemResolvConf) if err != nil { return nil } var servers []string lines := strings.Split(string(data), "\n") for _, l := range lines { l = strings.TrimSpace(l) if strings.HasPrefix(l, "nameserver") { parts := strings.Fields(l) if len(parts) >= 2 && parts[1] != "127.0.0.1" && parts[1] != "::1" { servers = append(servers, parts[1]) } } } return servers }