diff --git a/dns/dns_proxy.go b/dns/dns_proxy.go index 40afb44..a78992a 100644 --- a/dns/dns_proxy.go +++ b/dns/dns_proxy.go @@ -741,6 +741,16 @@ func (p *DNSProxy) SetJITHandler(handler func(siteId int)) { p.jitHandler = handler } +// SetUpstreamDNS replaces the list of upstream DNS servers used to forward +// queries that are not served by local records. The servers must be in +// "host:port" format (e.g. "8.8.8.8:53"). +func (p *DNSProxy) SetUpstreamDNS(servers []string) { + if len(servers) == 0 { + return + } + p.upstreamDNS = servers +} + // AddDNSRecord adds a DNS record to the local store // domain should be a domain name (e.g., "example.com" or "example.com.") // ip should be a valid IPv4 or IPv6 address diff --git a/dns/platform/network_manager.go b/dns/platform/network_manager.go index a95f180..437129f 100644 --- a/dns/platform/network_manager.go +++ b/dns/platform/network_manager.go @@ -4,6 +4,7 @@ package dns import ( "context" + "encoding/binary" "errors" "fmt" "net/netip" @@ -16,12 +17,25 @@ import ( const ( // NetworkManager D-Bus constants - networkManagerDest = "org.freedesktop.NetworkManager" - networkManagerDbusObjectNode = "/org/freedesktop/NetworkManager" - networkManagerDbusDNSManagerInterface = "org.freedesktop.NetworkManager.DnsManager" - networkManagerDbusDNSManagerObjectNode = networkManagerDbusObjectNode + "/DnsManager" - networkManagerDbusDNSManagerModeProperty = networkManagerDbusDNSManagerInterface + ".Mode" - networkManagerDbusVersionProperty = "org.freedesktop.NetworkManager.Version" + networkManagerDest = "org.freedesktop.NetworkManager" + networkManagerDbusObjectNode = "/org/freedesktop/NetworkManager" + networkManagerDbusDNSManagerInterface = "org.freedesktop.NetworkManager.DnsManager" + networkManagerDbusDNSManagerObjectNode = networkManagerDbusObjectNode + "/DnsManager" + networkManagerDbusDNSManagerModeProperty = networkManagerDbusDNSManagerInterface + ".Mode" + networkManagerDbusVersionProperty = "org.freedesktop.NetworkManager.Version" + networkManagerDbusActiveConnsProperty = networkManagerDest + ".ActiveConnections" + networkManagerDbusActiveInterface = "org.freedesktop.NetworkManager.Connection.Active" + networkManagerDbusActiveIP4ConfigProperty = networkManagerDbusActiveInterface + ".Ip4Config" + networkManagerDbusActiveIP6ConfigProperty = networkManagerDbusActiveInterface + ".Ip6Config" + networkManagerDbusActiveDevicesProperty = networkManagerDbusActiveInterface + ".Devices" + networkManagerDbusIP4ConfigInterface = "org.freedesktop.NetworkManager.IP4Config" + networkManagerDbusIP6ConfigInterface = "org.freedesktop.NetworkManager.IP6Config" + networkManagerDbusDeviceInterface = "org.freedesktop.NetworkManager.Device" + networkManagerDbusDeviceDhcp4ConfigProp = networkManagerDbusDeviceInterface + ".Dhcp4Config" + networkManagerDbusDeviceDhcp6ConfigProp = networkManagerDbusDeviceInterface + ".Dhcp6Config" + networkManagerDbusDhcp4ConfigInterface = "org.freedesktop.NetworkManager.DHCP4Config" + networkManagerDbusDhcp6ConfigInterface = "org.freedesktop.NetworkManager.DHCP6Config" + networkManagerDbusGetAppliedConnMethod = networkManagerDbusDeviceInterface + ".GetAppliedConnection" // NetworkManager dispatcher script path networkManagerDispatcherDir = "/etc/NetworkManager/dispatcher.d" @@ -301,6 +315,220 @@ func GetNetworkManagerDNSMode() (string, error) { return mode, nil } +// GetNetworkManagerNameservers returns the DNS servers NetworkManager knows +// about for every active connection, read live via D-Bus. +// +// olm's own NetworkManager DNS override (see NetworkManagerDNSConfigurator) +// works by writing a [global-dns-domain-*] section to +// /etc/NetworkManager/conf.d/olm-dns.conf and reloading NetworkManager. That +// is NetworkManager's global DNS override mechanism: it replaces the DNS +// servers NetworkManager's DnsManager computes as "effective" system-wide, +// for every connection - not just what gets written to /etc/resolv.conf. So +// once olm's override is active, even each connection's merged +// IP4Config/IP6Config.NameserverData (the previous, sole source used here) +// can end up reporting olm's own proxy address instead of the real network +// DNS. +// +// To recover the real DNS regardless, this also reads two further sources +// that NetworkManager's DNS merging - and therefore olm's global-dns override +// - never touches, since both are populated independently of it: +// - Dhcp4Config/Dhcp6Config.Options["*name_servers"]: the raw nameserver +// list straight from the DHCP lease. +// - Device.GetAppliedConnection()'s ipv4.dns/ipv6.dns: the DNS servers +// explicitly configured on the connection profile itself, e.g. a static +// DNS override set by the user directly in NetworkManager (the +// NetworkManager equivalent of a manually-set Windows adapter DNS). +// +// IP4Config/IP6Config.NameserverData is still queried too, as a fallback for +// setups the other two don't cover. Any of olm's own address that leaks +// through any of these sources is expected to be dropped by the caller via +// SystemDNSMonitor.SetExcludeIP. +func GetNetworkManagerNameservers() ([]netip.Addr, error) { + conn, err := dbus.SystemBus() + if err != nil { + return nil, fmt.Errorf("connect to system bus: %w", err) + } + defer conn.Close() + + nm := conn.Object(networkManagerDest, networkManagerDbusObjectNode) + + activeVariant, err := nm.GetProperty(networkManagerDbusActiveConnsProperty) + if err != nil { + return nil, fmt.Errorf("get active connections: %w", err) + } + activePaths, ok := activeVariant.Value().([]dbus.ObjectPath) + if !ok { + return nil, errors.New("ActiveConnections is not a list of object paths") + } + + ipConfigSources := []struct { + activeProperty string + configIface string + }{ + {networkManagerDbusActiveIP4ConfigProperty, networkManagerDbusIP4ConfigInterface}, + {networkManagerDbusActiveIP6ConfigProperty, networkManagerDbusIP6ConfigInterface}, + } + + seen := make(map[netip.Addr]bool) + var servers []netip.Addr + add := func(addr netip.Addr) { + addr = addr.Unmap() + if !addr.IsValid() || addr.IsLoopback() || addr.IsLinkLocalUnicast() { + return + } + if !seen[addr] { + seen[addr] = true + servers = append(servers, addr) + } + } + + for _, activePath := range activePaths { + active := conn.Object(networkManagerDest, activePath) + + for _, src := range ipConfigSources { + cfgVariant, err := active.GetProperty(src.activeProperty) + if err != nil { + continue + } + cfgPath, ok := cfgVariant.Value().(dbus.ObjectPath) + if !ok || cfgPath == "" || cfgPath == "/" { + continue + } + + nsVariant, err := conn.Object(networkManagerDest, cfgPath).GetProperty(src.configIface + ".NameserverData") + if err != nil { + continue + } + entries, ok := nsVariant.Value().([]map[string]dbus.Variant) + if !ok { + continue + } + for _, entry := range entries { + addrVariant, ok := entry["address"] + if !ok { + continue + } + addrStr, ok := addrVariant.Value().(string) + if !ok { + continue + } + if addr, err := netip.ParseAddr(addrStr); err == nil { + add(addr) + } + } + } + + devicesVariant, err := active.GetProperty(networkManagerDbusActiveDevicesProperty) + if err != nil { + continue + } + devicePaths, ok := devicesVariant.Value().([]dbus.ObjectPath) + if !ok { + continue + } + + for _, devicePath := range devicePaths { + device := conn.Object(networkManagerDest, devicePath) + + for _, addr := range dhcpLeaseNameservers(conn, device, networkManagerDbusDeviceDhcp4ConfigProp, networkManagerDbusDhcp4ConfigInterface, "domain_name_servers") { + add(addr) + } + for _, addr := range dhcpLeaseNameservers(conn, device, networkManagerDbusDeviceDhcp6ConfigProp, networkManagerDbusDhcp6ConfigInterface, "dhcp6_name_servers") { + add(addr) + } + for _, addr := range appliedConnectionNameservers(device) { + add(addr) + } + } + } + + return servers, nil +} + +// dhcpLeaseNameservers reads a space-separated nameserver list out of a +// device's Dhcp4Config/Dhcp6Config Options, straight from the DHCP lease - +// data NetworkManager's DNS merging (and therefore olm's own global-dns +// override) never touches. +func dhcpLeaseNameservers(conn *dbus.Conn, device dbus.BusObject, configProperty, configIface, optionsKey string) []netip.Addr { + cfgVariant, err := device.GetProperty(configProperty) + if err != nil { + return nil + } + cfgPath, ok := cfgVariant.Value().(dbus.ObjectPath) + if !ok || cfgPath == "" || cfgPath == "/" { + return nil + } + + optsVariant, err := conn.Object(networkManagerDest, cfgPath).GetProperty(configIface + ".Options") + if err != nil { + return nil + } + opts, ok := optsVariant.Value().(map[string]dbus.Variant) + if !ok { + return nil + } + raw, ok := opts[optionsKey] + if !ok { + return nil + } + str, ok := raw.Value().(string) + if !ok { + return nil + } + + var addrs []netip.Addr + for _, field := range strings.Fields(str) { + if addr, err := netip.ParseAddr(field); err == nil { + addrs = append(addrs, addr) + } + } + return addrs +} + +// appliedConnectionNameservers reads the ipv4.dns/ipv6.dns servers configured +// on the device's currently-applied connection profile - e.g. a static DNS +// override set by the user directly in NetworkManager - independent of DHCP +// and of olm's own global-dns override. +func appliedConnectionNameservers(device dbus.BusObject) []netip.Addr { + var settings map[string]map[string]dbus.Variant + var versionID uint64 + if err := device.Call(networkManagerDbusGetAppliedConnMethod, 0, uint32(0)).Store(&settings, &versionID); err != nil { + return nil + } + + var addrs []netip.Addr + + if ipv4, ok := settings["ipv4"]; ok { + if dnsVariant, ok := ipv4["dns"]; ok { + if raw, ok := dnsVariant.Value().([]uint32); ok { + for _, v := range raw { + var b [4]byte + // NetworkManager encodes IPv4 addresses in this setting as + // network-byte-order bytes reinterpreted as a native uint32. + binary.LittleEndian.PutUint32(b[:], v) + addrs = append(addrs, netip.AddrFrom4(b)) + } + } + } + } + + if ipv6, ok := settings["ipv6"]; ok { + if dnsVariant, ok := ipv6["dns"]; ok { + if raw, ok := dnsVariant.Value().([][]byte); ok { + for _, b := range raw { + if len(b) == 16 { + var arr [16]byte + copy(arr[:], b) + addrs = append(addrs, netip.AddrFrom16(arr)) + } + } + } + } + } + + return addrs +} + // GetNetworkManagerVersion returns the version of NetworkManager func GetNetworkManagerVersion() (string, error) { conn, err := dbus.SystemBus() diff --git a/dns/sysresolver.go b/dns/sysresolver.go new file mode 100644 index 0000000..10dfe2b --- /dev/null +++ b/dns/sysresolver.go @@ -0,0 +1,274 @@ +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 +} diff --git a/dns/sysresolver_android.go b/dns/sysresolver_android.go new file mode 100644 index 0000000..523c98a --- /dev/null +++ b/dns/sysresolver_android.go @@ -0,0 +1,18 @@ +//go:build android + +package dns + +// readSystemDNS returns nil on Android: olm cannot read the OS's DNS +// configuration itself here, so the app detects it (via ConnectivityManager) +// and pushes it in through Olm.SetSystemDNS instead (see SystemDnsMonitor.java). +// +// This is a dedicated file (rather than falling through the general +// sysresolver_stub.go catch-all) because wireguard-android's build passes +// "-tags linux" to share Linux netlink code with Android, and that custom tag +// makes "!linux" evaluate to false even on a real GOOS=android build, which +// would otherwise make sysresolver_stub.go stop applying and leave +// readSystemDNS undefined. An explicit "android" constraint isn't affected by +// that, since nothing passes a conflicting "-tags android". +func readSystemDNS() []string { + return nil +} diff --git a/dns/sysresolver_darwin.go b/dns/sysresolver_darwin.go new file mode 100644 index 0000000..35d66d0 --- /dev/null +++ b/dns/sysresolver_darwin.go @@ -0,0 +1,116 @@ +//go:build darwin && !ios && !nosysresolver + +package dns + +import ( + "bufio" + "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. +// +// 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 { + return nil + } + defer f.Close() + + var result []string + seen := make(map[string]bool) + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !strings.HasPrefix(line, "nameserver") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + addr, err := netip.ParseAddr(fields[1]) + if err != nil { + continue + } + if addr.IsLoopback() || addr.IsLinkLocalUnicast() { + continue + } + s := net.JoinHostPort(addr.String(), "53") + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + return result +} diff --git a/dns/sysresolver_darwin_nosysresolver.go b/dns/sysresolver_darwin_nosysresolver.go new file mode 100644 index 0000000..8a16ae4 --- /dev/null +++ b/dns/sysresolver_darwin_nosysresolver.go @@ -0,0 +1,17 @@ +//go:build darwin && !ios && nosysresolver + +package dns + +// readSystemDNS is disabled by the nosysresolver build tag. This is used for +// the macOS app build: unlike the CLI, the app's PacketTunnel system +// extension additionally applies NEDNSSettings (see apple/PacketTunnel), +// which can become the system's primary resolver and make /etc/resolv.conf +// reflect olm's own proxy IP instead of the real upstream DNS. Rather than +// have olm poll a value that may be self-referential, the app pushes the +// real DNS servers in via SetSystemDNS (detected in Swift via +// SCDynamicStore) exactly like Android and iOS. The CLI keeps the real +// implementation in sysresolver_darwin.go, since it has no such override +// mechanism and /etc/resolv.conf always reflects the physical network there. +func readSystemDNS() []string { + return nil +} diff --git a/dns/sysresolver_ios.go b/dns/sysresolver_ios.go new file mode 100644 index 0000000..ade5993 --- /dev/null +++ b/dns/sysresolver_ios.go @@ -0,0 +1,18 @@ +//go:build ios + +package dns + +// readSystemDNS returns nil on iOS: olm cannot read the OS's DNS +// configuration itself here, so the app must detect it and push it in +// through the equivalent of Olm.SetSystemDNS instead. +// +// This is a dedicated file (rather than falling through the general +// sysresolver_stub.go catch-all) because Go's build constraint evaluator +// treats GOOS=ios as implicitly satisfying the "darwin" tag as well as +// "ios". sysresolver_stub.go excludes with "!darwin", which is false for an +// iOS build, so the stub silently stops applying and would leave +// readSystemDNS undefined. An explicit "ios" constraint isn't affected by +// that ambiguity. +func readSystemDNS() []string { + return nil +} diff --git a/dns/sysresolver_linux.go b/dns/sysresolver_linux.go new file mode 100644 index 0000000..fd6cabc --- /dev/null +++ b/dns/sysresolver_linux.go @@ -0,0 +1,129 @@ +//go:build linux && !android + +package dns + +import ( + "bufio" + "net" + "net/netip" + "os" + "strings" + + platform "github.com/fosrl/olm/dns/platform" +) + +// readSystemDNS returns the current system DNS servers in "host:53" format. +// +// Resolution order: +// 1. /run/systemd/resolve/resolv.conf, but only when systemd-resolved is +// actually running (checked live via D-Bus) — the file lives in /run and +// can linger there, frozen at whatever it last contained, long after the +// service that maintained it has stopped (e.g. it ran earlier in the +// boot and was since disabled). Trusting its mere existence would report +// that stale snapshot forever instead of falling through to a live +// source. When the service is actually up the file is maintained with +// the real per-link DNS servers, updated on every DHCP change and never +// touched by olm's D-Bus DNS override. +// 2. NetworkManager, queried live over D-Bus — NetworkManager's own view of +// each active connection's DNS servers, independent of what is currently +// written to /etc/resolv.conf. This covers NetworkManager's "dnsmasq" and +// "unbound" DNS modes, where /etc/resolv.conf only contains a loopback +// stub address, and stays accurate even if olm's own override has +// directly overwritten /etc/resolv.conf, without going stale the way a +// one-time backup snapshot would if the real DNS changes mid-override +// (e.g. the user switches WiFi networks). olm's own NetworkManager +// override is itself a NetworkManager-level global DNS override (see +// platform.GetNetworkManagerNameservers), so this also reads each +// device's raw DHCP lease and applied-connection settings, which that +// override does not touch, to recover the real servers. +// 3. /etc/resolv.conf.olm.backup — written by olm before it overrides +// /etc/resolv.conf on non-systemd systems, for when NetworkManager isn't +// in use at all. +// 4. /etc/resolv.conf — plain fallback. +// +// Loopback and link-local addresses (e.g. 127.0.0.53, ::1) are excluded +// because they are stub resolver addresses, not real upstream servers. +func readSystemDNS() []string { + // Prefer systemd-resolved's resolved (non-stub) resolv.conf, but only if + // systemd-resolved is actually alive right now - see resolution order + // note above on why the file's existence alone isn't enough. + if platform.IsSystemdResolvedAvailable() { + if servers := parseResolvConf("/run/systemd/resolve/resolv.conf"); len(servers) > 0 { + return servers + } + } + + if servers := readNetworkManagerDNS(); len(servers) > 0 { + return servers + } + + // If olm has already overridden /etc/resolv.conf the backup holds the + // original pre-override DNS servers. + if _, err := os.Stat("/etc/resolv.conf.olm.backup"); err == nil { + if servers := parseResolvConf("/etc/resolv.conf.olm.backup"); len(servers) > 0 { + return servers + } + } + + return parseResolvConf("/etc/resolv.conf") +} + +// readNetworkManagerDNS returns the DNS servers NetworkManager reports over +// D-Bus for its active connections, in "host:53" format. Returns nil if +// NetworkManager isn't running or reports nothing usable. +func readNetworkManagerDNS() []string { + addrs, err := platform.GetNetworkManagerNameservers() + if err != nil || len(addrs) == 0 { + return nil + } + + result := make([]string, 0, len(addrs)) + for _, addr := range addrs { + result = append(result, addrToHostPort(addr)) + } + return result +} + +// parseResolvConf reads nameserver lines from a resolv.conf-style file, +// skipping loopback and link-local addresses. +func parseResolvConf(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var result []string + seen := make(map[string]bool) + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !strings.HasPrefix(line, "nameserver") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + addr, err := netip.ParseAddr(fields[1]) + if err != nil { + continue + } + if addr.IsLoopback() || addr.IsLinkLocalUnicast() { + continue + } + s := addrToHostPort(addr) + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + return result +} + +// addrToHostPort converts a netip.Addr to "addr:53" format, wrapping IPv6 +// addresses in brackets as required by net.JoinHostPort. +func addrToHostPort(addr netip.Addr) string { + return net.JoinHostPort(addr.String(), "53") +} diff --git a/dns/sysresolver_stub.go b/dns/sysresolver_stub.go new file mode 100644 index 0000000..a72b4f0 --- /dev/null +++ b/dns/sysresolver_stub.go @@ -0,0 +1,23 @@ +//go:build !linux && !darwin && !windows && !android + +package dns + +// readSystemDNS returns nil on platforms where automatic DNS discovery is not +// implemented (freebsd, etc.). Callers should fall back to a statically +// configured DNS server. +// +// android and ios are excluded from this constraint (and have their own +// sysresolver_android.go / sysresolver_ios.go with an explicit "android" / +// "ios" tag) rather than falling through the "!linux" / "!darwin" catch-all +// here: +// - wireguard-android's build passes "-tags linux" to share Linux netlink code +// (a deliberate, long-standing convention, since Android's kernel is Linux), and Go's +// build constraint evaluator can't distinguish a custom "-tags linux" from the real +// GOOS=linux - so with that tag set, "!linux" is false even though GOOS is actually +// android, and this file would silently stop applying, leaving readSystemDNS undefined. +// - Go's build constraint evaluator treats GOOS=ios as implicitly satisfying the +// "darwin" tag as well as "ios", so "!darwin" is false on an iOS build too, which +// would otherwise leave readSystemDNS undefined there as well. +func readSystemDNS() []string { + return nil +} diff --git a/dns/sysresolver_test.go b/dns/sysresolver_test.go new file mode 100644 index 0000000..27d5a86 --- /dev/null +++ b/dns/sysresolver_test.go @@ -0,0 +1,149 @@ +package dns + +import ( + "net/netip" + "reflect" + "testing" +) + +// stubReachable overrides dnsServerReachable for the duration of the test so +// tests don't depend on real network access, restoring the original on +// cleanup. +func stubReachable(t *testing.T, fn func(server string) bool) { + t.Helper() + orig := dnsServerReachable + dnsServerReachable = func(server string) (bool, error) { return fn(server), nil } + t.Cleanup(func() { dnsServerReachable = orig }) +} + +func allReachable(t *testing.T) { + stubReachable(t, func(string) bool { return true }) +} + +func TestReportExternalFiltersExcludedIP(t *testing.T) { + allReachable(t) + + var got []string + m := &SystemDNSMonitor{ + excludeIPs: make(map[netip.Addr]bool), + onChange: func(servers []string) { + got = servers + }, + } + m.SetExcludeIP(netip.MustParseAddr("10.0.0.1")) + + m.ReportExternal([]string{"10.0.0.1:53", "8.8.8.8:53"}) + + want := []string{"8.8.8.8:53"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("onChange servers = %v, want %v", got, want) + } + if !reflect.DeepEqual(m.Current(), want) { + t.Fatalf("Current() = %v, want %v", m.Current(), want) + } +} + +func TestReportExternalAllExcludedIsNoop(t *testing.T) { + allReachable(t) + + called := false + m := &SystemDNSMonitor{ + excludeIPs: make(map[netip.Addr]bool), + current: []string{"1.1.1.1:53"}, + onChange: func(servers []string) { + called = true + }, + } + m.SetExcludeIP(netip.MustParseAddr("10.0.0.1")) + + m.ReportExternal([]string{"10.0.0.1:53"}) + + if called { + t.Fatal("onChange should not fire when all reported servers are excluded") + } + want := []string{"1.1.1.1:53"} + if !reflect.DeepEqual(m.Current(), want) { + t.Fatalf("Current() = %v, want unchanged %v", m.Current(), want) + } +} + +func TestReportExternalOnlyFiresOnChange(t *testing.T) { + allReachable(t) + + calls := 0 + m := &SystemDNSMonitor{ + excludeIPs: make(map[netip.Addr]bool), + onChange: func(servers []string) { + calls++ + }, + } + + m.ReportExternal([]string{"8.8.8.8:53"}) + m.ReportExternal([]string{"8.8.8.8:53"}) + + if calls != 1 { + t.Fatalf("onChange fired %d times, want 1", calls) + } +} + +func TestReportExternalDropsUnreachableServer(t *testing.T) { + // Simulates e.g. T-Mobile's private ULA DNS64 resolver: technically "the + // system DNS" per the OS, but doesn't actually answer queries. + stubReachable(t, func(server string) bool { + return server != "[fd00:976a::9]:53" + }) + + var got []string + m := &SystemDNSMonitor{ + excludeIPs: make(map[netip.Addr]bool), + onChange: func(servers []string) { + got = servers + }, + } + + m.ReportExternal([]string{"[fd00:976a::9]:53", "8.8.8.8:53"}) + + want := []string{"8.8.8.8:53"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("onChange servers = %v, want %v", got, want) + } +} + +func TestReportExternalKeepsPreviousValueWhenAllUnreachable(t *testing.T) { + stubReachable(t, func(string) bool { return true }) + + called := false + m := &SystemDNSMonitor{ + excludeIPs: make(map[netip.Addr]bool), + current: []string{"1.1.1.1:53"}, + onChange: func(servers []string) { + called = true + }, + } + + // Now simulate every candidate failing the health check (e.g. moved to a + // network where none of the reported servers actually respond). + stubReachable(t, func(string) bool { return false }) + + m.ReportExternal([]string{"[fd00:976a::9]:53", "[fd00:976a::10]:53"}) + + if called { + t.Fatal("onChange should not fire when no candidate passes the health check") + } + want := []string{"1.1.1.1:53"} + if !reflect.DeepEqual(m.Current(), want) { + t.Fatalf("Current() = %v, want unchanged %v", m.Current(), want) + } +} + +func TestFilterUnreachable(t *testing.T) { + stubReachable(t, func(server string) bool { + return server == "8.8.8.8:53" + }) + + got := filterUnreachable([]string{"10.0.0.1:53", "8.8.8.8:53", "9.9.9.9:53"}) + want := []string{"8.8.8.8:53"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("filterUnreachable() = %v, want %v", got, want) + } +} diff --git a/dns/sysresolver_windows.go b/dns/sysresolver_windows.go new file mode 100644 index 0000000..8d78325 --- /dev/null +++ b/dns/sysresolver_windows.go @@ -0,0 +1,110 @@ +//go:build windows + +package dns + +import ( + "fmt" + "net" + "net/netip" + + "golang.org/x/sys/windows/registry" +) + +const ( + tcpipInterfacesPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` + dhcpNameServerKey = "DhcpNameServer" + staticNameServerKey = "NameServer" +) + +// readSystemDNS returns the current system DNS servers in "host:53" format by +// enumerating every network adapter in the Windows registry. +// +// For each adapter olm reads the effective DNS servers: static (NameServer) +// if set, since a static entry overrides DHCP for that adapter and is what +// the OS resolver actually uses, otherwise falling back to the DHCP-assigned +// servers (DhcpNameServer). This also picks up olm's own WireGuard adapter, +// which olm points at its local DNS proxy via a static NameServer entry; that +// address is expected to be filtered out by the caller via +// SystemDNSMonitor.SetExcludeIP. Loopback and link-local addresses are +// excluded. +func readSystemDNS() []string { + key, err := registry.OpenKey(registry.LOCAL_MACHINE, tcpipInterfacesPath, registry.ENUMERATE_SUB_KEYS) + if err != nil { + return nil + } + defer key.Close() + + subkeys, err := key.ReadSubKeyNames(-1) + if err != nil { + return nil + } + + seen := make(map[string]bool) + var result []string + + for _, guid := range subkeys { + path := fmt.Sprintf(`%s\%s`, tcpipInterfacesPath, guid) + iKey, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) + if err != nil { + continue + } + + servers, _, err := iKey.GetStringValue(staticNameServerKey) + if err != nil || servers == "" { + servers, _, err = iKey.GetStringValue(dhcpNameServerKey) + } + iKey.Close() + if err != nil || servers == "" { + continue + } + + for _, s := range splitWinDNSList(servers) { + addr, err := netip.ParseAddr(s) + 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 +} + +// splitWinDNSList splits a Windows DNS server list that may be comma- or +// space-separated. +func splitWinDNSList(s string) []string { + var out []string + for _, part := range splitByRunes(s, []rune{',', ' '}) { + if part != "" { + out = append(out, part) + } + } + return out +} + +func splitByRunes(s string, delims []rune) []string { + var result []string + start := 0 + for i, r := range s { + for _, d := range delims { + if r == d { + if i > start { + result = append(result, s[start:i]) + } + start = i + len(string(r)) + break + } + } + } + if start < len(s) { + result = append(result, s[start:]) + } + return result +} diff --git a/go.mod b/go.mod index ce838f7..4510632 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,15 @@ go 1.25.0 require ( github.com/Microsoft/go-winio v0.6.2 - github.com/fosrl/newt v1.13.0 + github.com/fosrl/newt v1.14.0 github.com/godbus/dbus/v5 v5.2.2 github.com/gorilla/websocket v1.5.3 github.com/miekg/dns v1.1.70 - golang.org/x/sys v0.45.0 + golang.org/x/sys v0.46.0 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c - software.sslmate.com/src/go-pkcs12 v0.7.1 + software.sslmate.com/src/go-pkcs12 v0.7.3 ) require ( @@ -20,10 +20,10 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect - golang.org/x/crypto v0.52.0 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.43.0 // indirect diff --git a/go.sum b/go.sum index fa4910f..3273e4a 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/fosrl/newt v1.13.0 h1:XKpII/ijhXMb8WFN4X3rE0GTY/qCYb4iqNBGdNG5cMI= -github.com/fosrl/newt v1.13.0/go.mod h1:jUIZKuHyGFet/J0Op2AuJfp7U6+1Ip6MiSMC3BLT8mE= +github.com/fosrl/newt v1.14.0 h1:9jpyfCNAtsH7rPojyIGJwqOqnLZdxm8b+njY5ZuY/6c= +github.com/fosrl/newt v1.14.0/go.mod h1:l6kWoZPSaXT+ZRUjiyPgwflRqZWYaXpUj9oQ0sOPh4o= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= @@ -16,20 +16,20 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= @@ -44,5 +44,5 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= -software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8= -software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU= +software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/olm/connect.go b/olm/connect.go index 55114d6..6337290 100644 --- a/olm/connect.go +++ b/olm/connect.go @@ -150,6 +150,14 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) { logger.Error("Failed to create DNS proxy: %v", err) } + // Tell the system DNS monitor to exclude the proxy IP so that subsequent + // polls never mistake the proxy for a real upstream server (on Linux the OS + // DNS is overridden to point at this IP, which would otherwise feed back + // into UpstreamDNS or PublicDNS on the next poll). + if o.dnsMonitor != nil && o.dnsProxy != nil { + o.dnsMonitor.SetExcludeIP(o.dnsProxy.GetProxyIP()) + } + if err = network.ConfigureInterface(o.tunnelConfig.InterfaceName, wgData.TunnelIP, o.tunnelConfig.MTU); err != nil { logger.Error("Failed to o.tunnelConfigure interface: %v", err) } diff --git a/olm/olm.go b/olm/olm.go index 50a2f0e..ef00414 100644 --- a/olm/olm.go +++ b/olm/olm.go @@ -43,12 +43,18 @@ type Olm struct { middleDev *olmDevice.MiddleDevice sharedBind *bind.SharedBind - dnsProxy *dns.DNSProxy - apiServer *api.API - websocket *websocket.Client - holePunchManager *holepunch.Manager - peerManager *peers.PeerManager - peerManagerMu sync.RWMutex + dnsProxy *dns.DNSProxy + dnsMonitor *dns.SystemDNSMonitor + // pendingSystemDNS holds a SetSystemDNS report received before dnsMonitor exists + // (e.g. Android/iOS push a value while the tunnel is still starting up), so it + // isn't silently dropped. Drained into dnsMonitor as soon as StartTunnel creates it. + pendingSystemDNSMu sync.Mutex + pendingSystemDNS []string + apiServer *api.API + websocket *websocket.Client + holePunchManager *holepunch.Manager + peerManager *peers.PeerManager + peerManagerMu sync.RWMutex // Power mode management currentPowerMode string powerModeMu sync.Mutex @@ -392,11 +398,66 @@ func (o *Olm) StartTunnel(config TunnelConfig) { o.tunnelRunning = true // Also set it here in case it is called externally o.tunnelConfig = config - // TODO: we are hardcoding this for now but we should really pull it from the current config of the system - if o.tunnelConfig.DNS != "" { - o.tunnelConfig.PublicDNS = []string{o.tunnelConfig.DNS + ":53"} - } else { - o.tunnelConfig.PublicDNS = []string{"8.8.8.8:53"} + // Determine whether the system DNS monitor should also manage UpstreamDNS. + // If the caller did not provide an explicit UpstreamDNS (it was defaulted to + // 8.8.8.8:53 by the API handler), we want the monitor to keep it updated + // with whatever DNS the host network is currently using. + upstreamFromConfig := len(config.UpstreamDNS) > 0 && + !(len(config.UpstreamDNS) == 1 && config.UpstreamDNS[0] == "8.8.8.8:53") + if upstreamFromConfig { + logger.Info("UpstreamDNS is statically configured (%v); automatic system DNS detection will only update PublicDNS, DNS forwarding will keep using the configured value even if it becomes unreachable on a new network", config.UpstreamDNS) + } + + // Start the system DNS monitor. The callback fires synchronously once with + // the initial values so that PublicDNS (and optionally UpstreamDNS) are + // populated before the tunnel goroutine proceeds. + o.dnsMonitor = dns.NewSystemDNSMonitor(0, func(servers []string) { + if len(servers) == 0 { + return + } + logger.Info("Applying system DNS: %v", servers) + + // PublicDNS must always reflect the physical-network DNS so that + // WireGuard endpoint hostnames and hole-punch targets can be resolved + // even after the system resolver has been overridden by olm. + o.tunnelConfig.PublicDNS = servers + if o.holePunchManager != nil { + o.holePunchManager.SetPublicDNS(servers) + } + if pm := o.getPeerManager(); pm != nil { + pm.SetPublicDNS(servers) + } + + // UpstreamDNS is updated only when the caller did not supply an + // explicit value; dynamic updates keep the proxy forwarding to the + // network's real resolver as the host moves between networks. + if !upstreamFromConfig { + o.tunnelConfig.UpstreamDNS = servers + if o.dnsProxy != nil { + o.dnsProxy.SetUpstreamDNS(servers) + } + } else { + logger.Debug("Not updating UpstreamDNS: statically configured to %v", config.UpstreamDNS) + } + }) + o.dnsMonitor.Start(o.olmCtx) + + // Apply any SetSystemDNS report that arrived before dnsMonitor existed (e.g. an + // Android/iOS push that raced ahead of this goroutine). + if pending := o.takePendingSystemDNS(); len(pending) > 0 { + o.dnsMonitor.ReportExternal(pending) + } + + // Fall back to hardcoded DNS if the system monitor could not detect any. + if len(o.tunnelConfig.PublicDNS) == 0 { + if o.tunnelConfig.DNS != "" { + o.tunnelConfig.PublicDNS = []string{o.tunnelConfig.DNS + ":53"} + } else { + o.tunnelConfig.PublicDNS = []string{"8.8.8.8:53"} + } + } + if len(o.tunnelConfig.UpstreamDNS) == 0 { + o.tunnelConfig.UpstreamDNS = []string{"8.8.8.8:53"} } // Reset terminated status when tunnel starts @@ -659,6 +720,13 @@ func (o *Olm) Close() { o.holePunchManager = nil } + // Stop the system DNS monitor after hole punch is stopped (it feeds + // publicDNS into the hole punch manager). + if o.dnsMonitor != nil { + o.dnsMonitor.Stop() + o.dnsMonitor = nil + } + // Close() also calls Stop() internally o.peerManagerMu.Lock() if o.peerManager != nil { @@ -827,6 +895,38 @@ func (o *Olm) SetPostures(data map[string]any) { o.postures = data } +// SetSystemDNS reports DNS servers observed by platform-native code. On +// Android and iOS olm cannot read the OS's DNS configuration itself (unlike +// Linux/macOS/Windows, see dns.readSystemDNS), so the app/extension detects +// the real pre-override DNS servers and pushes them here as the network +// changes. The list is applied through the same exclude-IP filtering and +// change detection as the internally-polled SystemDNSMonitor. +func (o *Olm) SetSystemDNS(servers []string) { + logger.Info("SetSystemDNS called with: %v", servers) + if o.dnsMonitor == nil { + // StartTunnel hasn't created the monitor yet (mobile platforms may push a + // value the moment they start observing, before the tunnel goroutine has + // gotten far enough to construct it). Stash it so StartTunnel can apply it + // instead of falling back to a hardcoded default DNS server. + o.pendingSystemDNSMu.Lock() + o.pendingSystemDNS = servers + o.pendingSystemDNSMu.Unlock() + logger.Debug("dnsMonitor not yet started, queued SetSystemDNS value") + return + } + o.dnsMonitor.ReportExternal(servers) +} + +// takePendingSystemDNS returns and clears any SetSystemDNS value reported before +// dnsMonitor existed. +func (o *Olm) takePendingSystemDNS() []string { + o.pendingSystemDNSMu.Lock() + defer o.pendingSystemDNSMu.Unlock() + pending := o.pendingSystemDNS + o.pendingSystemDNS = nil + return pending +} + // SetPowerMode switches between normal and low power modes // In low power mode: websocket is closed (stopping pings) and monitoring intervals are set to 10 minutes // In normal power mode: websocket is reconnected (restarting pings) and monitoring intervals are restored diff --git a/peers/manager.go b/peers/manager.go index f043373..8af5805 100644 --- a/peers/manager.go +++ b/peers/manager.go @@ -103,6 +103,21 @@ func (pm *PeerManager) GetPeerMonitor() *monitor.PeerMonitor { return pm.peerMonitor } +// SetPublicDNS replaces the DNS servers used to resolve WireGuard peer +// endpoints and hole-punch targets. The servers must be in "host:port" format +// (e.g. "8.8.8.8:53"). The change takes effect for all future peer +// configuration calls; existing WireGuard peers are not re-resolved. +func (pm *PeerManager) SetPublicDNS(servers []string) { + pm.mu.Lock() + pm.publicDNS = servers + mon := pm.peerMonitor + pm.mu.Unlock() + + if mon != nil { + mon.SetPublicDNS(servers) + } +} + func (pm *PeerManager) GetAllPeers() []SiteConfig { pm.mu.RLock() defer pm.mu.RUnlock() diff --git a/peers/monitor/monitor.go b/peers/monitor/monitor.go index b7af451..f49b397 100644 --- a/peers/monitor/monitor.go +++ b/peers/monitor/monitor.go @@ -36,7 +36,7 @@ type PeerMonitor struct { timeout time.Duration maxAttempts int wsClient *websocket.Client - publicDNS []string + publicDNS []string // Relay sender tracking relaySends map[string]func() @@ -85,8 +85,8 @@ type PeerMonitor struct { apiServer *api.API // WG connection status tracking - wgConnectionStatus map[int]bool // siteID -> WG connected status - wgConnectionRTT map[int]time.Duration // siteID -> last known RTT + wgConnectionStatus map[int]bool // siteID -> WG connected status + wgConnectionRTT map[int]time.Duration // siteID -> last known RTT statusChangeCallback func(siteId int) // called when any peer's connection status changes } @@ -106,7 +106,7 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe wsClient: wsClient, middleDev: middleDev, localIP: localIP, - publicDNS: publicDNS, + publicDNS: publicDNS, activePorts: make(map[uint16]bool), nsCtx: ctx, nsCancel: cancel, @@ -148,6 +148,19 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe return pm } +// SetPublicDNS replaces the DNS servers used to resolve peer endpoints and +// hole-punch exit nodes. The servers must be in "host:port" format. +func (pm *PeerMonitor) SetPublicDNS(servers []string) { + pm.mutex.Lock() + pm.publicDNS = servers + tester := pm.holepunchTester + pm.mutex.Unlock() + + if tester != nil { + tester.SetPublicDNS(servers) + } +} + // SetInterval changes how frequently peers are checked func (pm *PeerMonitor) SetPeerInterval(minInterval, maxInterval time.Duration) { pm.mutex.Lock()