//go:build (linux && !android) || freebsd package dns import ( "bytes" "fmt" "net/netip" "os/exec" "strings" ) const resolvconfCommand = "resolvconf" // ResolvconfDNSConfigurator manages DNS settings using the resolvconf utility type ResolvconfDNSConfigurator struct { ifaceName string implType string originalState *DNSState } // NewResolvconfDNSConfigurator creates a new resolvconf DNS configurator func NewResolvconfDNSConfigurator(ifaceName string) (*ResolvconfDNSConfigurator, error) { if ifaceName == "" { return nil, fmt.Errorf("interface name is required") } // Detect resolvconf implementation type implType, err := detectResolvconfType() if err != nil { return nil, fmt.Errorf("detect resolvconf type: %w", err) } configurator := &ResolvconfDNSConfigurator{ ifaceName: ifaceName, implType: implType, } // Call cleanup function to remove any stale DNS config for this interface if err := configurator.CleanupUncleanShutdown(); err != nil { return nil, fmt.Errorf("cleanup unclean shutdown: %w", err) } return configurator, nil } // Name returns the configurator name func (r *ResolvconfDNSConfigurator) Name() string { return fmt.Sprintf("resolvconf-%s", r.implType) } // SetDNS sets the DNS servers and returns the original servers func (r *ResolvconfDNSConfigurator) SetDNS(servers []netip.Addr) ([]netip.Addr, error) { // Get current DNS settings before overriding originalServers, err := r.GetCurrentDNS() if err != nil { // If we can't get current DNS, proceed anyway originalServers = []netip.Addr{} } // Store original state r.originalState = &DNSState{ OriginalServers: originalServers, ConfiguratorName: r.Name(), } // Apply new DNS servers if err := r.applyDNSServers(servers); err != nil { return nil, fmt.Errorf("apply DNS servers: %w", err) } return originalServers, nil } // RestoreDNS restores the original DNS configuration func (r *ResolvconfDNSConfigurator) RestoreDNS() error { var cmd *exec.Cmd switch r.implType { case "openresolv": // Force delete with -f cmd = exec.Command(resolvconfCommand, "-f", "-d", r.ifaceName) default: cmd = exec.Command(resolvconfCommand, "-d", r.ifaceName) } if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("delete resolvconf config: %w, output: %s", err, out) } return nil } // CleanupUncleanShutdown removes any DNS configuration left over from a previous crash // For resolvconf, we attempt to delete any entry for the interface name. // This ensures that if the process crashed while DNS was configured, the stale // entry is removed on the next startup. func (r *ResolvconfDNSConfigurator) CleanupUncleanShutdown() error { // Try to delete any existing entry for this interface // This is idempotent - if no entry exists, resolvconf will just return success var cmd *exec.Cmd switch r.implType { case "openresolv": cmd = exec.Command(resolvconfCommand, "-f", "-d", r.ifaceName) default: cmd = exec.Command(resolvconfCommand, "-d", r.ifaceName) } // Ignore errors - the entry may not exist, which is fine _ = cmd.Run() return nil } // GetCurrentDNS returns the currently configured DNS servers func (r *ResolvconfDNSConfigurator) GetCurrentDNS() ([]netip.Addr, error) { // resolvconf doesn't provide a direct way to query per-interface DNS // We can try to read /etc/resolv.conf but it's merged from all sources content, err := exec.Command(resolvconfCommand, "-l").CombinedOutput() if err != nil { // Fall back to reading resolv.conf return readResolvConfServers() } // Parse the output (format varies by implementation) return parseResolvconfOutput(string(content)), nil } // applyDNSServers applies DNS server configuration via resolvconf func (r *ResolvconfDNSConfigurator) applyDNSServers(servers []netip.Addr) error { if len(servers) == 0 { return fmt.Errorf("no DNS servers provided") } // Build resolv.conf content var content bytes.Buffer content.WriteString("# Generated by Olm DNS Manager\n\n") for _, server := range servers { content.WriteString("nameserver ") content.WriteString(server.String()) content.WriteString("\n") } // Apply via resolvconf var cmd *exec.Cmd switch r.implType { case "openresolv": // OpenResolv supports exclusive mode with -x cmd = exec.Command(resolvconfCommand, "-x", "-a", r.ifaceName) default: cmd = exec.Command(resolvconfCommand, "-a", r.ifaceName) } cmd.Stdin = &content if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("apply resolvconf config: %w, output: %s", err, out) } return nil } // detectResolvconfType detects which resolvconf implementation is being used func detectResolvconfType() (string, error) { cmd := exec.Command(resolvconfCommand, "--version") out, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("detect resolvconf type: %w", err) } if strings.Contains(string(out), "openresolv") { return "openresolv", nil } return "resolvconf", nil } // parseResolvconfOutput parses resolvconf -l output for DNS servers func parseResolvconfOutput(output string) []netip.Addr { var servers []netip.Addr lines := strings.Split(output, "\n") for _, line := range lines { line = strings.TrimSpace(line) // Skip comments and empty lines if line == "" || strings.HasPrefix(line, "#") { continue } // Look for nameserver lines if strings.HasPrefix(line, "nameserver") { fields := strings.Fields(line) if len(fields) >= 2 { if addr, err := netip.ParseAddr(fields[1]); err == nil { servers = append(servers, addr) } } } } return servers } // readResolvConfServers reads DNS servers from /etc/resolv.conf func readResolvConfServers() ([]netip.Addr, error) { cmd := exec.Command("cat", "/etc/resolv.conf") out, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("read resolv.conf: %w", err) } return parseResolvconfOutput(string(out)), nil } // IsResolvconfAvailable checks if resolvconf is available func IsResolvconfAvailable() bool { cmd := exec.Command(resolvconfCommand, "--version") return cmd.Run() == nil }