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/sysresolver.go b/dns/sysresolver.go new file mode 100644 index 0000000..0a1c8d7 --- /dev/null +++ b/dns/sysresolver.go @@ -0,0 +1,177 @@ +package dns + +import ( + "context" + "net" + "net/netip" + "sort" + "sync" + "time" + + "github.com/fosrl/newt/logger" +) + +const defaultPollInterval = 30 * 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 /etc/resolv.conf, which is never modified by olm's +// supplemental scutil DNS override. +// - Windows: enumerates DHCP-assigned DNS servers from every network adapter +// in the registry. +// - Other platforms: returns an empty list (no-op monitor). +type SystemDNSMonitor struct { + mu sync.RWMutex + current []string + 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) { + servers := m.readFiltered() + m.mu.Lock() + m.current = servers + m.mu.Unlock() + + if m.onChange != nil && len(servers) > 0 { + m.onChange(servers) + } + + 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 { + raw := readSystemDNS() + + m.excludeMu.RLock() + excludeIPs := m.excludeIPs + m.excludeMu.RUnlock() + + if len(excludeIPs) == 0 { + return raw + } + + var filtered []string + for _, s := range raw { + 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 +} + +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: + servers := m.readFiltered() + if len(servers) == 0 { + continue + } + + m.mu.Lock() + changed := !dnsSlicesEqual(m.current, servers) + if changed { + m.current = servers + } + m.mu.Unlock() + + if changed && m.onChange != nil { + logger.Info("System DNS changed: %v", servers) + m.onChange(servers) + } + } + } +} + +// 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_darwin.go b/dns/sysresolver_darwin.go new file mode 100644 index 0000000..f381dbf --- /dev/null +++ b/dns/sysresolver_darwin.go @@ -0,0 +1,58 @@ +//go:build darwin && !ios + +package dns + +import ( + "bufio" + "net" + "net/netip" + "os" + "strings" +) + +// 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. +func readSystemDNS() []string { + return parseMacResolvConf("/etc/resolv.conf") +} + +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_linux.go b/dns/sysresolver_linux.go new file mode 100644 index 0000000..6a48e17 --- /dev/null +++ b/dns/sysresolver_linux.go @@ -0,0 +1,84 @@ +//go:build linux && !android + +package dns + +import ( + "bufio" + "net" + "net/netip" + "os" + "strings" +) + +// readSystemDNS returns the current system DNS servers in "host:53" format. +// +// Resolution order: +// 1. /run/systemd/resolve/resolv.conf — maintained by systemd-resolved with +// the real per-link DNS servers; updated on every DHCP change and never +// touched by olm's D-Bus DNS override. +// 2. /etc/resolv.conf.olm.backup — written by olm before it overrides +// /etc/resolv.conf on non-systemd systems. +// 3. /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. + if servers := parseResolvConf("/run/systemd/resolve/resolv.conf"); 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") +} + +// 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..d22774d --- /dev/null +++ b/dns/sysresolver_stub.go @@ -0,0 +1,10 @@ +//go:build !linux && !darwin && !windows + +package dns + +// readSystemDNS returns nil on platforms where automatic DNS discovery is not +// implemented (android, ios, freebsd, etc.). Callers should fall back to a +// statically configured DNS server. +func readSystemDNS() []string { + return nil +} diff --git a/dns/sysresolver_windows.go b/dns/sysresolver_windows.go new file mode 100644 index 0000000..ce38515 --- /dev/null +++ b/dns/sysresolver_windows.go @@ -0,0 +1,103 @@ +//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 DHCP-assigned DNS servers (DhcpNameServer). +// Static DNS (NameServer) is ignored on the assumption that it belongs to the +// olm WireGuard adapter or another VPN; DHCP-assigned servers always reflect +// the physical network's DNS. 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 + } + + dhcp, _, err := iKey.GetStringValue(dhcpNameServerKey) + iKey.Close() + if err != nil || dhcp == "" { + continue + } + + for _, s := range splitWinDNSList(dhcp) { + 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..b4aa9f1 100644 --- a/go.mod +++ b/go.mod @@ -32,4 +32,4 @@ require ( ) // To be used ONLY for local development -// replace github.com/fosrl/newt => ../newt +replace github.com/fosrl/newt => ../newt 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..bc205b7 100644 --- a/olm/olm.go +++ b/olm/olm.go @@ -44,6 +44,7 @@ type Olm struct { sharedBind *bind.SharedBind dnsProxy *dns.DNSProxy + dnsMonitor *dns.SystemDNSMonitor apiServer *api.API websocket *websocket.Client holePunchManager *holepunch.Manager @@ -392,11 +393,55 @@ 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") + + // 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) + } + } + }) + o.dnsMonitor.Start(o.olmCtx) + + // 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 +704,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 { 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()