diff --git a/cmd/doggo/cli.go b/cmd/doggo/cli.go index 2028037..c4ffa15 100644 --- a/cmd/doggo/cli.go +++ b/cmd/doggo/cli.go @@ -158,7 +158,7 @@ func setupFlags() *flag.FlagSet { f.Int("ndots", -1, "Specify the ndots parameter") f.BoolP("ipv4", "4", false, "Use IPv4 only") f.BoolP("ipv6", "6", false, "Use IPv6 only") - f.String("strategy", "all", "Strategy to query nameservers in resolv.conf file") + f.String("strategy", "all", "Strategy to query nameservers (all, random, first, internal)") f.String("tls-hostname", "", "Hostname for certificate verification") f.Bool("skip-hostname-verification", false, "Skip TLS Hostname Verification") diff --git a/cmd/doggo/completions.go b/cmd/doggo/completions.go index fc8ff01..04f6067 100644 --- a/cmd/doggo/completions.go +++ b/cmd/doggo/completions.go @@ -29,7 +29,7 @@ _doggo() { return 0 ;; --strategy) - COMPREPLY=( $(compgen -W "all random first" -- ${cur}) ) + COMPREPLY=( $(compgen -W "all random first internal" -- ${cur}) ) return 0 ;; --search|--color) @@ -65,7 +65,7 @@ _doggo() { '(-c --class)'{-c,--class}'[Network class of the DNS record being queried]:network class:(IN CH HS)' \ '(-r --reverse)'{-r,--reverse}'[Performs a DNS Lookup for an IPv4 or IPv6 address]' \ '--any[Query all supported DNS record types]' \ - '--strategy[Strategy to query nameserver listed in etc/resolv.conf]:strategy:(all random first)' \ + '--strategy[Strategy to query nameserver listed in etc/resolv.conf]:strategy:(all random first internal)' \ '--ndots[Number of required dots in hostname to assume FQDN]:number of dots' \ '--search[Use the search list defined in resolv.conf]:setting:(true false)' \ '--timeout[Timeout (in seconds) for the resolver to return a response]:seconds' \ @@ -128,7 +128,7 @@ complete -c doggo -n '__fish_doggo_no_subcommand' -s 'r' -l 'reverse' -d "Per complete -c doggo -n '__fish_doggo_no_subcommand' -l 'any' -d "Query all supported DNS record types" # Resolver options -complete -c doggo -n '__fish_doggo_no_subcommand' -l 'strategy' -d "Strategy to query nameserver listed in etc/resolv.conf" -x -a "all random first" +complete -c doggo -n '__fish_doggo_no_subcommand' -l 'strategy' -d "Strategy to query nameserver listed in etc/resolv.conf" -x -a "all random first internal" complete -c doggo -n '__fish_doggo_no_subcommand' -l 'ndots' -d "Specify ndots parameter" complete -c doggo -n '__fish_doggo_no_subcommand' -l 'search' -d "Use the search list defined in resolv.conf" -x -a "true false" complete -c doggo -n '__fish_doggo_no_subcommand' -l 'timeout' -d "Specify timeout (in seconds) for the resolver to return a response" diff --git a/cmd/doggo/help.go b/cmd/doggo/help.go index a27698d..75a422e 100644 --- a/cmd/doggo/help.go +++ b/cmd/doggo/help.go @@ -121,7 +121,7 @@ func renderCustomHelp() { {"--any", "Query all supported DNS record types (A, AAAA, CNAME, MX, NS, PTR, SOA, SRV, TXT, CAA)."}, }, "ResolverOptions": []Option{ - {"--strategy=STRATEGY", "Specify strategy to query nameserver listed in etc/resolv.conf. (all, random, first)."}, + {"--strategy=STRATEGY", "Specify strategy to query nameserver listed in etc/resolv.conf. Options: all, random, first, internal (RFC 1918/ULA private IPs only)."}, {"--ndots=INT", "Specify ndots parameter. Takes value from /etc/resolv.conf if using the system namesever or 1 otherwise."}, {"--search", "Use the search list defined in resolv.conf. Defaults to true. Set --search=false to disable search list."}, {"--timeout=DURATION", "Specify timeout for the resolver to return a response (e.g., 5s, 400ms, 1m)."}, diff --git a/internal/app/nameservers.go b/internal/app/nameservers.go index bc7047c..e69b6bb 100644 --- a/internal/app/nameservers.go +++ b/internal/app/nameservers.go @@ -261,6 +261,40 @@ func isIPv6(ipStr string) bool { return ip.To4() == nil } +// isPrivateIP checks if an IP address is in RFC 1918 private address space (IPv4) +// or RFC 4193 Unique Local Address space (IPv6) +func isPrivateIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + + // IPv4 RFC 1918 ranges + if ipv4 := ip.To4(); ipv4 != nil { + // 10.0.0.0/8 + if ipv4[0] == 10 { + return true + } + // 172.16.0.0/12 + if ipv4[0] == 172 && ipv4[1] >= 16 && ipv4[1] <= 31 { + return true + } + // 192.168.0.0/16 + if ipv4[0] == 192 && ipv4[1] == 168 { + return true + } + return false + } + + // IPv6 Unique Local Address (ULA) - RFC 4193 + // fd00::/8 range + if len(ip) == 16 && ip[0] == 0xfd { + return true + } + + return false +} + // filterNameserversByIPVersion filters nameservers based on IPv4/IPv6 flags func filterNameserversByIPVersion(servers []string, useIPv4, useIPv6 bool) []string { // If neither flag is set, return all servers @@ -324,6 +358,29 @@ func getDefaultServers(strategy string, useIPv4, useIPv6 bool) ([]models.Nameser } servers = append(servers, ns) + case "internal": + // Filter for nameservers with private IPs only (RFC 1918 / RFC 4193 ULA) + internalServers := make([]string, 0) + for _, srv := range dnsServers { + if isPrivateIP(srv) { + internalServers = append(internalServers, srv) + } + } + + // Return error if no internal servers found + if len(internalServers) == 0 { + return nil, ndots, search, fmt.Errorf("no internal (private IP) nameservers found in system configuration") + } + + // Return all internal servers + for _, s := range internalServers { + ns := models.Nameserver{ + Type: models.UDPResolver, + Address: net.JoinHostPort(s, models.DefaultUDPPort), + } + servers = append(servers, ns) + } + default: // Default behaviour is to load all nameservers. for _, s := range dnsServers { diff --git a/pkg/config/config_darwin.go b/pkg/config/config_darwin.go new file mode 100644 index 0000000..24e9362 --- /dev/null +++ b/pkg/config/config_darwin.go @@ -0,0 +1,219 @@ +// +build darwin + +package config + +import ( + "bytes" + "fmt" + "net" + "os/exec" + "regexp" + "strconv" + "strings" + + "github.com/miekg/dns" +) + +// scutilResolver represents a parsed resolver from scutil --dns output +type scutilResolver struct { + number int + nameservers []string + domain string + searchDomains []string + options []string +} + +// GetDefaultServers retrieves DNS configuration from macOS SystemConfiguration +// by parsing the output of 'scutil --dns'. Falls back to /etc/resolv.conf on failure. +func GetDefaultServers() ([]string, int, []string, error) { + // Try scutil first + resolvers, ndots, search, err := getResolversFromScutil() + if err != nil { + // Fallback to /etc/resolv.conf + return fallbackToResolvConf() + } + + return resolvers, ndots, search, nil +} + +// getResolversFromScutil executes scutil --dns and parses the output +func getResolversFromScutil() ([]string, int, []string, error) { + // Execute scutil --dns + cmd := exec.Command("scutil", "--dns") + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return nil, 0, nil, fmt.Errorf("scutil execution failed: %w", err) + } + + output := stdout.String() + if len(strings.TrimSpace(output)) == 0 { + return nil, 0, nil, fmt.Errorf("scutil returned empty output") + } + + // Parse the output + resolvers, err := parseScutilOutput(output) + if err != nil { + return nil, 0, nil, fmt.Errorf("failed to parse scutil output: %w", err) + } + + // Filter out mDNS resolvers + validResolvers := make([]scutilResolver, 0) + for _, r := range resolvers { + if !isMDNS(r) && len(r.nameservers) > 0 { + validResolvers = append(validResolvers, r) + } + } + + if len(validResolvers) == 0 { + return nil, 0, nil, fmt.Errorf("no valid resolvers found") + } + + // Aggregate nameservers from all valid resolvers + // This allows the "internal" strategy to find domain-specific corporate DNS servers + nameservers := make([]string, 0) + seen := make(map[string]bool) + + for _, resolver := range validResolvers { + for _, ns := range resolver.nameservers { + ip := net.ParseIP(ns) + // Skip link-local and duplicates + if isUnicastLinkLocal(ip) || seen[ns] { + continue + } + nameservers = append(nameservers, ns) + seen[ns] = true + } + } + + // Aggregate search domains from all valid resolvers + searchDomains := aggregateSearchDomains(validResolvers) + + // ndots: try to read from /etc/resolv.conf, default to 1 + ndots := 1 + if cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf"); err == nil { + ndots = cfg.Ndots + } + + return nameservers, ndots, searchDomains, nil +} + +// parseScutilOutput parses the output of scutil --dns +func parseScutilOutput(output string) ([]scutilResolver, error) { + lines := strings.Split(output, "\n") + resolvers := make([]scutilResolver, 0) + + var current *scutilResolver + resolverRe := regexp.MustCompile(`^resolver #(\d+)`) + nameserverRe := regexp.MustCompile(`^\s+nameserver\[\d+\]\s*:\s*(.+)`) + domainRe := regexp.MustCompile(`^\s+domain\s*:\s*(.+)`) + searchDomainRe := regexp.MustCompile(`^\s+search domain\[\d+\]\s*:\s*(.+)`) + optionsRe := regexp.MustCompile(`^\s+options\s*:\s*(.+)`) + + for _, line := range lines { + // Check for resolver start + if matches := resolverRe.FindStringSubmatch(line); matches != nil { + if current != nil { + resolvers = append(resolvers, *current) + } + num, _ := strconv.Atoi(matches[1]) + current = &scutilResolver{ + number: num, + nameservers: make([]string, 0), + searchDomains: make([]string, 0), + options: make([]string, 0), + } + continue + } + + if current == nil { + continue + } + + // Parse nameserver + if matches := nameserverRe.FindStringSubmatch(line); matches != nil { + current.nameservers = append(current.nameservers, strings.TrimSpace(matches[1])) + continue + } + + // Parse domain + if matches := domainRe.FindStringSubmatch(line); matches != nil { + current.domain = strings.TrimSpace(matches[1]) + continue + } + + // Parse search domain + if matches := searchDomainRe.FindStringSubmatch(line); matches != nil { + current.searchDomains = append(current.searchDomains, strings.TrimSpace(matches[1])) + continue + } + + // Parse options + if matches := optionsRe.FindStringSubmatch(line); matches != nil { + opts := strings.Fields(strings.TrimSpace(matches[1])) + current.options = append(current.options, opts...) + continue + } + } + + // Don't forget the last resolver + if current != nil { + resolvers = append(resolvers, *current) + } + + return resolvers, nil +} + +// isMDNS checks if a resolver is for mDNS (.local) +func isMDNS(r scutilResolver) bool { + for _, opt := range r.options { + if opt == "mdns" { + return true + } + } + return false +} + +// aggregateSearchDomains collects search domains from all resolvers +func aggregateSearchDomains(resolvers []scutilResolver) []string { + seen := make(map[string]bool) + result := make([]string, 0) + + for _, r := range resolvers { + // Add domain if present + if r.domain != "" && !seen[r.domain] { + result = append(result, r.domain) + seen[r.domain] = true + } + + // Add search domains + for _, sd := range r.searchDomains { + if !seen[sd] { + result = append(result, sd) + seen[sd] = true + } + } + } + + return result +} + +// fallbackToResolvConf falls back to the traditional /etc/resolv.conf +func fallbackToResolvConf() ([]string, int, []string, error) { + cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil { + return nil, 0, nil, err + } + + servers := make([]string, 0) + for _, server := range cfg.Servers { + ip := net.ParseIP(server) + if isUnicastLinkLocal(ip) { + continue + } + servers = append(servers, server) + } + + return servers, cfg.Ndots, cfg.Search, nil +} diff --git a/pkg/config/config_unix.go b/pkg/config/config_unix.go index 9e276d9..6a89eb6 100644 --- a/pkg/config/config_unix.go +++ b/pkg/config/config_unix.go @@ -1,4 +1,4 @@ -// +build !windows +// +build !windows,!darwin package config