mirror of
https://github.com/fosrl/olm.git
synced 2026-07-15 13:32:22 -05:00
275 lines
8.7 KiB
Go
275 lines
8.7 KiB
Go
package dns
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/netip"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/fosrl/newt/logger"
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
const defaultPollInterval = 30 * time.Second
|
|
|
|
// dnsHealthCheckTimeout bounds how long we wait for a candidate DNS server to
|
|
// answer a health-check query before considering it unusable.
|
|
const dnsHealthCheckTimeout = 2 * time.Second
|
|
|
|
// SystemDNSMonitor monitors the host system's DNS configuration and notifies
|
|
// callers when it changes. The reported servers are in "host:port" format
|
|
// (e.g. "8.8.8.8:53") and can be used directly as UpstreamDNS and PublicDNS.
|
|
//
|
|
// Platform behaviour:
|
|
// - Linux: reads /run/systemd/resolve/resolv.conf when present (updated by
|
|
// 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 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).
|
|
type SystemDNSMonitor struct {
|
|
mu sync.RWMutex
|
|
current []string // last health-checked, applied server list
|
|
lastRaw []string // last raw (exclude-filtered but unvalidated) candidate list seen
|
|
onChange func(servers []string)
|
|
interval time.Duration
|
|
stopCh chan struct{}
|
|
excludeMu sync.RWMutex
|
|
excludeIPs map[netip.Addr]bool
|
|
}
|
|
|
|
// NewSystemDNSMonitor creates a new monitor. onChange is called with the new
|
|
// server list whenever a change is detected; it is also called once from Start
|
|
// with the initial values. A zero interval uses the 30-second default.
|
|
func NewSystemDNSMonitor(interval time.Duration, onChange func(servers []string)) *SystemDNSMonitor {
|
|
if interval <= 0 {
|
|
interval = defaultPollInterval
|
|
}
|
|
return &SystemDNSMonitor{
|
|
interval: interval,
|
|
onChange: onChange,
|
|
stopCh: make(chan struct{}),
|
|
excludeIPs: make(map[netip.Addr]bool),
|
|
}
|
|
}
|
|
|
|
// SetExcludeIP registers an IP address that must never appear in the reported
|
|
// DNS server list. Call this after olm's DNS proxy is created to prevent the
|
|
// proxy's own IP from being returned as an upstream server when the OS DNS has
|
|
// been overridden to point at the proxy.
|
|
func (m *SystemDNSMonitor) SetExcludeIP(ip netip.Addr) {
|
|
m.excludeMu.Lock()
|
|
m.excludeIPs[ip.Unmap()] = true
|
|
m.excludeMu.Unlock()
|
|
}
|
|
|
|
// Start reads the current system DNS immediately, fires onChange, then polls
|
|
// in the background until Stop is called or ctx is cancelled.
|
|
func (m *SystemDNSMonitor) Start(ctx context.Context) {
|
|
m.applyCandidates(m.readFiltered())
|
|
go m.run(ctx)
|
|
}
|
|
|
|
// Stop halts the background polling goroutine.
|
|
func (m *SystemDNSMonitor) Stop() {
|
|
select {
|
|
case <-m.stopCh:
|
|
default:
|
|
close(m.stopCh)
|
|
}
|
|
}
|
|
|
|
// Current returns the most recently observed system DNS servers.
|
|
func (m *SystemDNSMonitor) Current() []string {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
out := make([]string, len(m.current))
|
|
copy(out, m.current)
|
|
return out
|
|
}
|
|
|
|
// readFiltered calls the platform-specific readSystemDNS and removes any
|
|
// addresses that have been excluded via SetExcludeIP. If all addresses are
|
|
// excluded the function returns nil so the caller can retain the last
|
|
// known-good value.
|
|
func (m *SystemDNSMonitor) readFiltered() []string {
|
|
return m.filterExcluded(readSystemDNS())
|
|
}
|
|
|
|
// filterExcluded removes any addresses that have been excluded via
|
|
// SetExcludeIP from servers. Used both for the internally-polled server list
|
|
// (readFiltered) and for server lists reported externally (ReportExternal) by
|
|
// platforms - Android, iOS - where olm cannot read the OS's DNS configuration
|
|
// itself.
|
|
func (m *SystemDNSMonitor) filterExcluded(servers []string) []string {
|
|
m.excludeMu.RLock()
|
|
excludeIPs := m.excludeIPs
|
|
m.excludeMu.RUnlock()
|
|
|
|
if len(excludeIPs) == 0 {
|
|
return servers
|
|
}
|
|
|
|
var filtered []string
|
|
for _, s := range servers {
|
|
host, _, err := net.SplitHostPort(s)
|
|
if err != nil {
|
|
filtered = append(filtered, s)
|
|
continue
|
|
}
|
|
addr, err := netip.ParseAddr(host)
|
|
if err != nil || excludeIPs[addr.Unmap()] {
|
|
continue
|
|
}
|
|
filtered = append(filtered, s)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// ReportExternal applies an externally-observed DNS server list (e.g. from
|
|
// Android's ConnectivityManager or iOS's SCDynamicStore, where the platform
|
|
// itself - not olm - must detect the OS's real DNS configuration) through the
|
|
// same exclude-IP filtering, health-check validation, and change-detection as
|
|
// the internal poll loop, firing onChange if the result differs from the last
|
|
// known value.
|
|
func (m *SystemDNSMonitor) ReportExternal(servers []string) {
|
|
m.applyCandidates(m.filterExcluded(servers))
|
|
}
|
|
|
|
func (m *SystemDNSMonitor) run(ctx context.Context) {
|
|
ticker := time.NewTicker(m.interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-m.stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
m.applyCandidates(m.readFiltered())
|
|
}
|
|
}
|
|
}
|
|
|
|
// applyCandidates takes an exclude-filtered (but not yet health-checked) list
|
|
// of candidate DNS servers - from either the internal poll loop or
|
|
// ReportExternal - and, only if it differs from the last raw list seen (to
|
|
// avoid re-running network health checks on every 30-second poll tick when
|
|
// nothing has actually changed), health-checks it via filterUnreachable and
|
|
// applies whatever passes, firing onChange if the result changed.
|
|
//
|
|
// If none of the candidates pass the health check, the previous known-good
|
|
// value is retained rather than clobbered - this is what protects against
|
|
// e.g. a carrier reporting a DNS server (such as T-Mobile's internal ULA
|
|
// DNS64 resolvers) that is technically "the system DNS" but not actually
|
|
// reachable/usable from wherever queries are sent.
|
|
func (m *SystemDNSMonitor) applyCandidates(raw []string) {
|
|
if len(raw) == 0 {
|
|
return
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if dnsSlicesEqual(m.lastRaw, raw) {
|
|
m.mu.Unlock()
|
|
logger.Debug("System DNS candidates unchanged, skipping health check: %v", raw)
|
|
return
|
|
}
|
|
m.lastRaw = raw
|
|
m.mu.Unlock()
|
|
|
|
logger.Debug("System DNS candidates changed, health-checking: %v", raw)
|
|
validated := filterUnreachable(raw)
|
|
if len(validated) == 0 {
|
|
logger.Warn("None of the detected DNS servers answered a health-check query, keeping previous value: %v", raw)
|
|
return
|
|
}
|
|
|
|
m.mu.Lock()
|
|
changed := !dnsSlicesEqual(m.current, validated)
|
|
if changed {
|
|
m.current = validated
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
if changed && m.onChange != nil {
|
|
logger.Info("System DNS changed: %v", validated)
|
|
m.onChange(validated)
|
|
}
|
|
}
|
|
|
|
// dnsServerReachable is a seam for tests; production code always uses probeDNSServerErr.
|
|
var dnsServerReachable = probeDNSServerErr
|
|
|
|
// filterUnreachable validates that each candidate server actually answers a
|
|
// DNS query before it's trusted, rather than statically guessing from the
|
|
// address (e.g. rejecting all private/ULA addresses, which would also reject
|
|
// a perfectly valid home router forwarding to a real resolver). Checks run
|
|
// concurrently so multiple candidates don't serialize the timeout.
|
|
func filterUnreachable(servers []string) []string {
|
|
if len(servers) == 0 {
|
|
return servers
|
|
}
|
|
|
|
reachable := make([]bool, len(servers))
|
|
errs := make([]error, len(servers))
|
|
var wg sync.WaitGroup
|
|
for i, server := range servers {
|
|
wg.Add(1)
|
|
go func(i int, server string) {
|
|
defer wg.Done()
|
|
reachable[i], errs[i] = dnsServerReachable(server)
|
|
}(i, server)
|
|
}
|
|
wg.Wait()
|
|
|
|
var result []string
|
|
for i, server := range servers {
|
|
if reachable[i] {
|
|
result = append(result, server)
|
|
} else {
|
|
logger.Debug("Discarding DNS server %s: failed health check: %v", server, errs[i])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// probeDNSServerErr sends a minimal root NS query to confirm a candidate server
|
|
// actually answers, without depending on any specific external hostname being
|
|
// reachable (which could itself be blocked/filtered independently of whether
|
|
// the resolver works). The returned error is kept (rather than just a bool) so
|
|
// callers can log why a candidate was rejected (unreachable route, timeout, etc.).
|
|
func probeDNSServerErr(server string) (bool, error) {
|
|
client := &dns.Client{Timeout: dnsHealthCheckTimeout}
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion(".", dns.TypeNS)
|
|
_, _, err := client.Exchange(msg, server)
|
|
return err == nil, err
|
|
}
|
|
|
|
// dnsSlicesEqual reports whether two server lists are equal regardless of order.
|
|
func dnsSlicesEqual(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
ac := make([]string, len(a))
|
|
bc := make([]string, len(b))
|
|
copy(ac, a)
|
|
copy(bc, b)
|
|
sort.Strings(ac)
|
|
sort.Strings(bc)
|
|
for i := range ac {
|
|
if ac[i] != bc[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|