From 929f183ed07e7a7fe22c037796e5136815da43cd Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 7 Jul 2026 20:38:06 -0400 Subject: [PATCH] Improve linux to use networkmanager correctly --- dns/platform/network_manager.go | 173 ++++++++++++++++++++++++++++---- dns/sysresolver_linux.go | 26 +++-- 2 files changed, 176 insertions(+), 23 deletions(-) diff --git a/dns/platform/network_manager.go b/dns/platform/network_manager.go index e127036..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" @@ -26,8 +27,15 @@ const ( 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" @@ -307,17 +315,34 @@ func GetNetworkManagerDNSMode() (string, error) { return mode, nil } -// GetNetworkManagerNameservers returns the DNS servers NetworkManager has -// computed for every active connection, read live via D-Bus from each -// connection's IP4Config/IP6Config NameserverData property. +// GetNetworkManagerNameservers returns the DNS servers NetworkManager knows +// about for every active connection, read live via D-Bus. // -// Unlike reading /etc/resolv.conf, this reflects NetworkManager's own view of -// DNS regardless of its DnsManager.Mode: in "dnsmasq" or "unbound" mode -// /etc/resolv.conf only contains a loopback stub address pointing at -// NetworkManager's local resolver, not the real upstream servers, and even in -// "default" mode this stays live and unaffected if something else (such as -// olm's own override) has since overwritten /etc/resolv.conf directly instead -// of going through NetworkManager. +// 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 { @@ -346,8 +371,20 @@ func GetNetworkManagerNameservers() ([]netip.Addr, error) { 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 { @@ -375,21 +412,123 @@ func GetNetworkManagerNameservers() ([]netip.Addr, error) { if !ok { continue } - addr, err := netip.ParseAddr(addrStr) - if err != nil || addr.IsLoopback() || addr.IsLinkLocalUnicast() { - continue - } - if !seen[addr] { - seen[addr] = true - servers = append(servers, addr) + 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_linux.go b/dns/sysresolver_linux.go index a622a3b..fd6cabc 100644 --- a/dns/sysresolver_linux.go +++ b/dns/sysresolver_linux.go @@ -15,8 +15,14 @@ import ( // 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 +// 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 @@ -25,7 +31,11 @@ import ( // 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). +// (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. @@ -34,9 +44,13 @@ import ( // 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 + // 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 {