diff --git a/dns/sysresolver.go b/dns/sysresolver.go index 66393cd..10dfe2b 100644 --- a/dns/sysresolver.go +++ b/dns/sysresolver.go @@ -27,8 +27,10 @@ const dnsHealthCheckTimeout = 2 * time.Second // systemd-resolved on every DHCP change), then falls back to // /etc/resolv.conf.olm.backup (written before olm overrides DNS), and // finally /etc/resolv.conf. -// - macOS: reads /etc/resolv.conf, which is never modified by olm's -// supplemental scutil DNS override. +// - macOS: reads the unscoped resolvers from `scutil --dns`, falling back +// to /etc/resolv.conf if scutil is unavailable. This includes olm's own +// supplemental scutil DNS override entry, which is expected to be +// filtered out via SetExcludeIP. // - Windows: enumerates every network adapter's effective DNS servers // (static if set, else DHCP-assigned) from the registry. // - Other platforms: returns an empty list (no-op monitor). diff --git a/dns/sysresolver_darwin.go b/dns/sysresolver_darwin.go index 73c89ab..35d66d0 100644 --- a/dns/sysresolver_darwin.go +++ b/dns/sysresolver_darwin.go @@ -7,20 +7,78 @@ import ( "net" "net/netip" "os" + "os/exec" "strings" ) +// scutilPath is the well-known location of scutil on macOS. +const scutilPath = "/usr/sbin/scutil" + // readSystemDNS returns the current system DNS servers in "host:53" format. // -// On macOS, olm adds supplemental DNS entries via scutil without modifying -// /etc/resolv.conf or the primary network service DNS. Reading /etc/resolv.conf -// therefore always yields the physical-network DNS supplied by DHCP or the user. -// /etc/resolv.conf on macOS is a regular file managed by mDNSResponder and is -// updated whenever the network configuration changes. +// olm's own DNS override is itself a scutil supplemental resolver (see +// dns/platform/darwin.go), and macOS gives supplemental resolvers priority +// over the primary network service's resolver when generating the merged +// configuration - which is also what gets mirrored into /etc/resolv.conf. So +// once olm's override is active, /etc/resolv.conf (and a naive read of just +// the top of "scutil --dns") reflects olm's own proxy address, not the +// physical network's real DNS. +// +// Instead this reads every resolver in the unscoped "DNS configuration" +// section of `scutil --dns` (the "(for scoped queries)" section that follows +// only duplicates per-interface resolvers and is skipped), which includes +// both the real physical-network resolver and olm's own supplemental one. +// olm's own address is expected to be filtered out by the caller via +// SystemDNSMonitor.SetExcludeIP, the same mechanism used on Windows to drop +// olm's own adapter DNS entry. +// +// /etc/resolv.conf is kept as a fallback for when scutil is unavailable. func readSystemDNS() []string { + if out, err := exec.Command(scutilPath, "--dns").Output(); err == nil { + if servers := parseScutilDNS(string(out)); len(servers) > 0 { + return servers + } + } return parseMacResolvConf("/etc/resolv.conf") } +// parseScutilDNS extracts nameserver addresses from the unscoped "DNS +// configuration" section at the top of `scutil --dns` output, stopping at +// the "DNS configuration (for scoped queries)" section that follows it. +func parseScutilDNS(output string) []string { + var result []string + seen := make(map[string]bool) + + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "DNS configuration (for scoped queries)") { + break + } + if !strings.HasPrefix(line, "nameserver[") { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + addr, err := netip.ParseAddr(strings.TrimSpace(parts[1])) + if err != nil { + continue + } + if addr.IsLoopback() || addr.IsLinkLocalUnicast() { + continue + } + hp := net.JoinHostPort(addr.String(), "53") + if !seen[hp] { + seen[hp] = true + result = append(result, hp) + } + } + return result +} + func parseMacResolvConf(path string) []string { f, err := os.Open(path) if err != nil {