Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/doggo/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
6 changes: 3 additions & 3 deletions cmd/doggo/completions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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' \
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion cmd/doggo/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)."},
Expand Down
62 changes: 62 additions & 0 deletions internal/app/nameservers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -324,6 +358,34 @@ 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)
}
}

// Apply IPv4/IPv6 filtering
internalServers = filterNameserversByIPVersion(internalServers, useIPv4, useIPv6)

// Warn and fall back to public DNS if no internal servers found
if len(internalServers) == 0 {
// Note: app.Logger is not available here, so we'll just fall back silently
// The user will see public DNS being used
internalServers = filterNameserversByIPVersion(dnsServers, useIPv4, useIPv6)
}

// Return all internal servers (or all servers if fallback occurred)
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 {
Expand Down
222 changes: 222 additions & 0 deletions pkg/config/config_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// +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
ifIndex string
flags 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, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

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
}
2 changes: 1 addition & 1 deletion pkg/config/config_unix.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// +build !windows
// +build !windows,!darwin

package config

Expand Down