Skip to content

Commit 39bae49

Browse files
phrawztymr-karan
authored andcommitted
feat: add macOS SystemConfiguration DNS support and internal strategy (#193)
1 parent 47136e4 commit 39bae49

6 files changed

Lines changed: 290 additions & 6 deletions

File tree

cmd/doggo/cli.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ func setupFlags() *flag.FlagSet {
158158
f.Int("ndots", -1, "Specify the ndots parameter")
159159
f.BoolP("ipv4", "4", false, "Use IPv4 only")
160160
f.BoolP("ipv6", "6", false, "Use IPv6 only")
161-
f.String("strategy", "all", "Strategy to query nameservers in resolv.conf file")
161+
f.String("strategy", "all", "Strategy to query nameservers (all, random, first, internal)")
162162
f.String("tls-hostname", "", "Hostname for certificate verification")
163163
f.Bool("skip-hostname-verification", false, "Skip TLS Hostname Verification")
164164

cmd/doggo/completions.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ _doggo() {
2929
return 0
3030
;;
3131
--strategy)
32-
COMPREPLY=( $(compgen -W "all random first" -- ${cur}) )
32+
COMPREPLY=( $(compgen -W "all random first internal" -- ${cur}) )
3333
return 0
3434
;;
3535
--search|--color)
@@ -65,7 +65,7 @@ _doggo() {
6565
'(-c --class)'{-c,--class}'[Network class of the DNS record being queried]:network class:(IN CH HS)' \
6666
'(-r --reverse)'{-r,--reverse}'[Performs a DNS Lookup for an IPv4 or IPv6 address]' \
6767
'--any[Query all supported DNS record types]' \
68-
'--strategy[Strategy to query nameserver listed in etc/resolv.conf]:strategy:(all random first)' \
68+
'--strategy[Strategy to query nameserver listed in etc/resolv.conf]:strategy:(all random first internal)' \
6969
'--ndots[Number of required dots in hostname to assume FQDN]:number of dots' \
7070
'--search[Use the search list defined in resolv.conf]:setting:(true false)' \
7171
'--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
128128
complete -c doggo -n '__fish_doggo_no_subcommand' -l 'any' -d "Query all supported DNS record types"
129129
130130
# Resolver options
131-
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"
131+
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"
132132
complete -c doggo -n '__fish_doggo_no_subcommand' -l 'ndots' -d "Specify ndots parameter"
133133
complete -c doggo -n '__fish_doggo_no_subcommand' -l 'search' -d "Use the search list defined in resolv.conf" -x -a "true false"
134134
complete -c doggo -n '__fish_doggo_no_subcommand' -l 'timeout' -d "Specify timeout (in seconds) for the resolver to return a response"

cmd/doggo/help.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func renderCustomHelp() {
121121
{"--any", "Query all supported DNS record types (A, AAAA, CNAME, MX, NS, PTR, SOA, SRV, TXT, CAA)."},
122122
},
123123
"ResolverOptions": []Option{
124-
{"--strategy=STRATEGY", "Specify strategy to query nameserver listed in etc/resolv.conf. (all, random, first)."},
124+
{"--strategy=STRATEGY", "Specify strategy to query nameserver listed in etc/resolv.conf. Options: all, random, first, internal (RFC 1918/ULA private IPs only)."},
125125
{"--ndots=INT", "Specify ndots parameter. Takes value from /etc/resolv.conf if using the system namesever or 1 otherwise."},
126126
{"--search", "Use the search list defined in resolv.conf. Defaults to true. Set --search=false to disable search list."},
127127
{"--timeout=DURATION", "Specify timeout for the resolver to return a response (e.g., 5s, 400ms, 1m)."},

internal/app/nameservers.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,40 @@ func isIPv6(ipStr string) bool {
261261
return ip.To4() == nil
262262
}
263263

264+
// isPrivateIP checks if an IP address is in RFC 1918 private address space (IPv4)
265+
// or RFC 4193 Unique Local Address space (IPv6)
266+
func isPrivateIP(ipStr string) bool {
267+
ip := net.ParseIP(ipStr)
268+
if ip == nil {
269+
return false
270+
}
271+
272+
// IPv4 RFC 1918 ranges
273+
if ipv4 := ip.To4(); ipv4 != nil {
274+
// 10.0.0.0/8
275+
if ipv4[0] == 10 {
276+
return true
277+
}
278+
// 172.16.0.0/12
279+
if ipv4[0] == 172 && ipv4[1] >= 16 && ipv4[1] <= 31 {
280+
return true
281+
}
282+
// 192.168.0.0/16
283+
if ipv4[0] == 192 && ipv4[1] == 168 {
284+
return true
285+
}
286+
return false
287+
}
288+
289+
// IPv6 Unique Local Address (ULA) - RFC 4193
290+
// fd00::/8 range
291+
if len(ip) == 16 && ip[0] == 0xfd {
292+
return true
293+
}
294+
295+
return false
296+
}
297+
264298
// filterNameserversByIPVersion filters nameservers based on IPv4/IPv6 flags
265299
func filterNameserversByIPVersion(servers []string, useIPv4, useIPv6 bool) []string {
266300
// If neither flag is set, return all servers
@@ -324,6 +358,34 @@ func getDefaultServers(strategy string, useIPv4, useIPv6 bool) ([]models.Nameser
324358
}
325359
servers = append(servers, ns)
326360

361+
case "internal":
362+
// Filter for nameservers with private IPs only (RFC 1918 / RFC 4193 ULA)
363+
internalServers := make([]string, 0)
364+
for _, srv := range dnsServers {
365+
if isPrivateIP(srv) {
366+
internalServers = append(internalServers, srv)
367+
}
368+
}
369+
370+
// Apply IPv4/IPv6 filtering
371+
internalServers = filterNameserversByIPVersion(internalServers, useIPv4, useIPv6)
372+
373+
// Warn and fall back to public DNS if no internal servers found
374+
if len(internalServers) == 0 {
375+
// Note: app.Logger is not available here, so we'll just fall back silently
376+
// The user will see public DNS being used
377+
internalServers = filterNameserversByIPVersion(dnsServers, useIPv4, useIPv6)
378+
}
379+
380+
// Return all internal servers (or all servers if fallback occurred)
381+
for _, s := range internalServers {
382+
ns := models.Nameserver{
383+
Type: models.UDPResolver,
384+
Address: net.JoinHostPort(s, models.DefaultUDPPort),
385+
}
386+
servers = append(servers, ns)
387+
}
388+
327389
default:
328390
// Default behaviour is to load all nameservers.
329391
for _, s := range dnsServers {

pkg/config/config_darwin.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// +build darwin
2+
3+
package config
4+
5+
import (
6+
"bytes"
7+
"fmt"
8+
"net"
9+
"os/exec"
10+
"regexp"
11+
"strconv"
12+
"strings"
13+
14+
"github.com/miekg/dns"
15+
)
16+
17+
// scutilResolver represents a parsed resolver from scutil --dns output
18+
type scutilResolver struct {
19+
number int
20+
nameservers []string
21+
domain string
22+
searchDomains []string
23+
options []string
24+
ifIndex string
25+
flags string
26+
}
27+
28+
// GetDefaultServers retrieves DNS configuration from macOS SystemConfiguration
29+
// by parsing the output of 'scutil --dns'. Falls back to /etc/resolv.conf on failure.
30+
func GetDefaultServers() ([]string, int, []string, error) {
31+
// Try scutil first
32+
resolvers, ndots, search, err := getResolversFromScutil()
33+
if err != nil {
34+
// Fallback to /etc/resolv.conf
35+
return fallbackToResolvConf()
36+
}
37+
38+
return resolvers, ndots, search, nil
39+
}
40+
41+
// getResolversFromScutil executes scutil --dns and parses the output
42+
func getResolversFromScutil() ([]string, int, []string, error) {
43+
// Execute scutil --dns
44+
cmd := exec.Command("scutil", "--dns")
45+
var stdout, stderr bytes.Buffer
46+
cmd.Stdout = &stdout
47+
cmd.Stderr = &stderr
48+
49+
if err := cmd.Run(); err != nil {
50+
return nil, 0, nil, fmt.Errorf("scutil execution failed: %w", err)
51+
}
52+
53+
output := stdout.String()
54+
if len(strings.TrimSpace(output)) == 0 {
55+
return nil, 0, nil, fmt.Errorf("scutil returned empty output")
56+
}
57+
58+
// Parse the output
59+
resolvers, err := parseScutilOutput(output)
60+
if err != nil {
61+
return nil, 0, nil, fmt.Errorf("failed to parse scutil output: %w", err)
62+
}
63+
64+
// Filter out mDNS resolvers
65+
validResolvers := make([]scutilResolver, 0)
66+
for _, r := range resolvers {
67+
if !isMDNS(r) && len(r.nameservers) > 0 {
68+
validResolvers = append(validResolvers, r)
69+
}
70+
}
71+
72+
if len(validResolvers) == 0 {
73+
return nil, 0, nil, fmt.Errorf("no valid resolvers found")
74+
}
75+
76+
// Aggregate nameservers from all valid resolvers
77+
// This allows the "internal" strategy to find domain-specific corporate DNS servers
78+
nameservers := make([]string, 0)
79+
seen := make(map[string]bool)
80+
81+
for _, resolver := range validResolvers {
82+
for _, ns := range resolver.nameservers {
83+
ip := net.ParseIP(ns)
84+
// Skip link-local and duplicates
85+
if isUnicastLinkLocal(ip) || seen[ns] {
86+
continue
87+
}
88+
nameservers = append(nameservers, ns)
89+
seen[ns] = true
90+
}
91+
}
92+
93+
// Aggregate search domains from all valid resolvers
94+
searchDomains := aggregateSearchDomains(validResolvers)
95+
96+
// ndots: try to read from /etc/resolv.conf, default to 1
97+
ndots := 1
98+
if cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf"); err == nil {
99+
ndots = cfg.Ndots
100+
}
101+
102+
return nameservers, ndots, searchDomains, nil
103+
}
104+
105+
// parseScutilOutput parses the output of scutil --dns
106+
func parseScutilOutput(output string) ([]scutilResolver, error) {
107+
lines := strings.Split(output, "\n")
108+
resolvers := make([]scutilResolver, 0)
109+
110+
var current *scutilResolver
111+
resolverRe := regexp.MustCompile(`^resolver #(\d+)`)
112+
nameserverRe := regexp.MustCompile(`^\s+nameserver\[\d+\]\s*:\s*(.+)`)
113+
domainRe := regexp.MustCompile(`^\s+domain\s*:\s*(.+)`)
114+
searchDomainRe := regexp.MustCompile(`^\s+search domain\[\d+\]\s*:\s*(.+)`)
115+
optionsRe := regexp.MustCompile(`^\s+options\s*:\s*(.+)`)
116+
117+
for _, line := range lines {
118+
// Check for resolver start
119+
if matches := resolverRe.FindStringSubmatch(line); matches != nil {
120+
if current != nil {
121+
resolvers = append(resolvers, *current)
122+
}
123+
num, _ := strconv.Atoi(matches[1])
124+
current = &scutilResolver{
125+
number: num,
126+
nameservers: make([]string, 0),
127+
searchDomains: make([]string, 0),
128+
options: make([]string, 0),
129+
}
130+
continue
131+
}
132+
133+
if current == nil {
134+
continue
135+
}
136+
137+
// Parse nameserver
138+
if matches := nameserverRe.FindStringSubmatch(line); matches != nil {
139+
current.nameservers = append(current.nameservers, strings.TrimSpace(matches[1]))
140+
continue
141+
}
142+
143+
// Parse domain
144+
if matches := domainRe.FindStringSubmatch(line); matches != nil {
145+
current.domain = strings.TrimSpace(matches[1])
146+
continue
147+
}
148+
149+
// Parse search domain
150+
if matches := searchDomainRe.FindStringSubmatch(line); matches != nil {
151+
current.searchDomains = append(current.searchDomains, strings.TrimSpace(matches[1]))
152+
continue
153+
}
154+
155+
// Parse options
156+
if matches := optionsRe.FindStringSubmatch(line); matches != nil {
157+
opts := strings.Fields(strings.TrimSpace(matches[1]))
158+
current.options = append(current.options, opts...)
159+
continue
160+
}
161+
}
162+
163+
// Don't forget the last resolver
164+
if current != nil {
165+
resolvers = append(resolvers, *current)
166+
}
167+
168+
return resolvers, nil
169+
}
170+
171+
// isMDNS checks if a resolver is for mDNS (.local)
172+
func isMDNS(r scutilResolver) bool {
173+
for _, opt := range r.options {
174+
if opt == "mdns" {
175+
return true
176+
}
177+
}
178+
return false
179+
}
180+
181+
// aggregateSearchDomains collects search domains from all resolvers
182+
func aggregateSearchDomains(resolvers []scutilResolver) []string {
183+
seen := make(map[string]bool)
184+
result := make([]string, 0)
185+
186+
for _, r := range resolvers {
187+
// Add domain if present
188+
if r.domain != "" && !seen[r.domain] {
189+
result = append(result, r.domain)
190+
seen[r.domain] = true
191+
}
192+
193+
// Add search domains
194+
for _, sd := range r.searchDomains {
195+
if !seen[sd] {
196+
result = append(result, sd)
197+
seen[sd] = true
198+
}
199+
}
200+
}
201+
202+
return result
203+
}
204+
205+
// fallbackToResolvConf falls back to the traditional /etc/resolv.conf
206+
func fallbackToResolvConf() ([]string, int, []string, error) {
207+
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
208+
if err != nil {
209+
return nil, 0, nil, err
210+
}
211+
212+
servers := make([]string, 0)
213+
for _, server := range cfg.Servers {
214+
ip := net.ParseIP(server)
215+
if isUnicastLinkLocal(ip) {
216+
continue
217+
}
218+
servers = append(servers, server)
219+
}
220+
221+
return servers, cfg.Ndots, cfg.Search, nil
222+
}

pkg/config/config_unix.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// +build !windows
1+
// +build !windows,!darwin
22

33
package config
44

0 commit comments

Comments
 (0)