From 066cb53fe4782aafe6fbf4057c56b62fd2c6396e Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 9 Sep 2026 12:38:04 +0200 Subject: [PATCH] Pass the resolver port to systemd-resolved through SetDNSEx --- client/internal/dns/systemd_linux.go | 86 ++++++++++++++++++++--- client/internal/dns/systemd_linux_test.go | 77 ++++++++++++++++++++ 2 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 client/internal/dns/systemd_linux_test.go diff --git a/client/internal/dns/systemd_linux.go b/client/internal/dns/systemd_linux.go index bd301e177..c06f7091c 100644 --- a/client/internal/dns/systemd_linux.go +++ b/client/internal/dns/systemd_linux.go @@ -12,6 +12,7 @@ import ( "time" "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" @@ -29,6 +30,8 @@ const ( systemdDbusLinkInterface = "org.freedesktop.resolve1.Link" systemdDbusRevertMethodSuffix = systemdDbusLinkInterface + ".Revert" systemdDbusSetDNSMethodSuffix = systemdDbusLinkInterface + ".SetDNS" + setDNSExMethodName = "SetDNSEx" + systemdDbusSetDNSExMethodSuffix = systemdDbusLinkInterface + "." + setDNSExMethodName systemdDbusSetDefaultRouteMethodSuffix = systemdDbusLinkInterface + ".SetDefaultRoute" systemdDbusSetDomainsMethodSuffix = systemdDbusLinkInterface + ".SetDomains" systemdDbusSetDNSSECMethodSuffix = systemdDbusLinkInterface + ".SetDNSSEC" @@ -41,9 +44,13 @@ const ( ) type systemdDbusConfigurator struct { - dbusLinkObject dbus.ObjectPath - ifaceName string - wgIndex int + dbusLinkObject dbus.ObjectPath + ifaceName string + wgIndex int + // supportsDNSEx reports whether resolved exposes SetDNSEx on the link, which + // is the only way to hand it a resolver port. SetDNS carries the address + // alone, so on older resolved a resolver off port 53 cannot be advertised. + supportsDNSEx bool origNameservers []netip.Addr } @@ -61,6 +68,17 @@ type systemdDbusDNSInput struct { Address []byte } +// systemdDbusDNSInputEx maps to a (iayqs) dbus input for the SetDNSEx method, +// which takes a port and a server name on top of the address SetDNS accepts. A +// zero port means the default, and an empty name means no name is pinned for +// DNS-over-TLS validation. +type systemdDbusDNSInputEx struct { + Family int32 + Address []byte + Port uint16 + Name string +} + // systemdDbusLinkDomainsInput maps to a (sb) dbus input for SetDomains method type systemdDbusLinkDomainsInput struct { Domain string @@ -93,6 +111,12 @@ func newSystemdDbusConfigurator(wgInterface string) (*systemdDbusConfigurator, e wgIndex: iface.Index, } + c.supportsDNSEx = c.hasDNSExMethod() + if !c.supportsDNSEx { + log.Infof("systemd-resolved does not expose %s; a DNS resolver on a port other than %d cannot be advertised to it", + setDNSExMethodName, DefaultPort) + } + origNameservers, err := c.captureOriginalNameservers() switch { case err != nil: @@ -229,19 +253,65 @@ func (s *systemdDbusConfigurator) getOriginalNameservers() []netip.Addr { } func (s *systemdDbusConfigurator) supportCustomPort() bool { - return true + return s.supportsDNSEx } -func (s *systemdDbusConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error { +// hasDNSExMethod reports whether resolved exposes SetDNSEx on the link +// interface. It was added in systemd 240; before that, resolved has no way to +// accept a resolver port. +func (s *systemdDbusConfigurator) hasDNSExMethod() bool { + obj, closeConn, err := getDbusObject(systemdResolvedDest, s.dbusLinkObject) + if err != nil { + log.Debugf("failed to get dbus link object for introspection: %v", err) + return false + } + defer closeConn() + + node, err := introspect.Call(obj) + if err != nil { + log.Debugf("failed to introspect systemd-resolved link: %v", err) + return false + } + + for _, iface := range node.Interfaces { + if iface.Name != systemdDbusLinkInterface { + continue + } + return slices.ContainsFunc(iface.Methods, func(m introspect.Method) bool { + return m.Name == setDNSExMethodName + }) + } + + return false +} + +// dnsServerMethod picks the resolved method that can carry the resolver's +// address and port, and builds its input. SetDNS takes the address alone, so a +// resolver on a port other than 53 needs SetDNSEx; the caller is expected to +// have checked supportCustomPort before asking for one. +func (s *systemdDbusConfigurator) dnsServerMethod(config HostDNSConfig) (string, any) { family := int32(unix.AF_INET) if config.ServerIP.Is6() { family = unix.AF_INET6 } - defaultLinkInput := systemdDbusDNSInput{ + + if config.ServerPort == DefaultPort || !s.supportsDNSEx { + return systemdDbusSetDNSMethodSuffix, []systemdDbusDNSInput{{ + Family: family, + Address: config.ServerIP.AsSlice(), + }} + } + + return systemdDbusSetDNSExMethodSuffix, []systemdDbusDNSInputEx{{ Family: family, Address: config.ServerIP.AsSlice(), - } - if err := s.callLinkMethod(systemdDbusSetDNSMethodSuffix, []systemdDbusDNSInput{defaultLinkInput}); err != nil { + Port: uint16(config.ServerPort), + }} +} + +func (s *systemdDbusConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error { + method, input := s.dnsServerMethod(config) + if err := s.callLinkMethod(method, input); err != nil { return fmt.Errorf("set interface DNS server %s:%d: %w", config.ServerIP, config.ServerPort, err) } diff --git a/client/internal/dns/systemd_linux_test.go b/client/internal/dns/systemd_linux_test.go new file mode 100644 index 000000000..a67b5ff39 --- /dev/null +++ b/client/internal/dns/systemd_linux_test.go @@ -0,0 +1,77 @@ +//go:build !android + +package dns + +import ( + "net/netip" + "testing" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +// The dbus signatures are the contract with resolved: SetDNS has no port field, +// so a resolver on a fallback port can only be advertised through SetDNSEx. +func TestSystemdDNSInputSignatures(t *testing.T) { + assert.Equal(t, "(iay)", dbus.SignatureOf(systemdDbusDNSInput{}).String()) + assert.Equal(t, "(iayqs)", dbus.SignatureOf(systemdDbusDNSInputEx{}).String()) +} + +func TestSystemdDNSServerMethod(t *testing.T) { + serverIP := netip.MustParseAddr("100.66.100.1") + serverIPv6 := netip.MustParseAddr("fd00::1") + + t.Run("default port uses SetDNS", func(t *testing.T) { + c := &systemdDbusConfigurator{supportsDNSEx: true} + + method, input := c.dnsServerMethod(HostDNSConfig{ServerIP: serverIP, ServerPort: DefaultPort}) + + assert.Equal(t, systemdDbusSetDNSMethodSuffix, method) + in, ok := input.([]systemdDbusDNSInput) + require.True(t, ok, "input type %T", input) + require.Len(t, in, 1) + assert.Equal(t, int32(unix.AF_INET), in[0].Family) + assert.Equal(t, serverIP.AsSlice(), in[0].Address) + }) + + t.Run("fallback port carries the port through SetDNSEx", func(t *testing.T) { + c := &systemdDbusConfigurator{supportsDNSEx: true} + + method, input := c.dnsServerMethod(HostDNSConfig{ServerIP: serverIP, ServerPort: 5053}) + + assert.Equal(t, systemdDbusSetDNSExMethodSuffix, method) + in, ok := input.([]systemdDbusDNSInputEx) + require.True(t, ok, "input type %T", input) + require.Len(t, in, 1) + assert.Equal(t, int32(unix.AF_INET), in[0].Family) + assert.Equal(t, serverIP.AsSlice(), in[0].Address) + assert.Equal(t, uint16(5053), in[0].Port) + assert.Empty(t, in[0].Name) + }) + + t.Run("v6 resolver reports the v6 family", func(t *testing.T) { + c := &systemdDbusConfigurator{supportsDNSEx: true} + + _, input := c.dnsServerMethod(HostDNSConfig{ServerIP: serverIPv6, ServerPort: 5053}) + + in, ok := input.([]systemdDbusDNSInputEx) + require.True(t, ok, "input type %T", input) + require.Len(t, in, 1) + assert.Equal(t, int32(unix.AF_INET6), in[0].Family) + }) + + // Without SetDNSEx there is nothing to fall back to but the portless call. + // supportCustomPort reports false so the caller keeps primary DNS off the + // link instead of pointing it at a port resolved will not use. + t.Run("no SetDNSEx support falls back to SetDNS", func(t *testing.T) { + c := &systemdDbusConfigurator{supportsDNSEx: false} + + method, input := c.dnsServerMethod(HostDNSConfig{ServerIP: serverIP, ServerPort: 5053}) + + assert.Equal(t, systemdDbusSetDNSMethodSuffix, method) + assert.IsType(t, []systemdDbusDNSInput{}, input) + assert.False(t, c.supportCustomPort()) + }) +}