From 14be474e3d728898639e5bd27cf9b6c0cdafcd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 24 Apr 2026 18:01:26 +0200 Subject: [PATCH] client/dns: register pool-root domains as host-manager match domains The subdomain-matching handler chain entry only helps when the OS DNS manager routes the query to the daemon's listener. On systems using systemd-resolved (Ubuntu 20.04 / 22.04, most modern Debian/RHEL-family distros), the daemon delegates a *specific set* of match domains to the wt0 link via D-Bus. Any FQDN outside that set is answered from the host's global upstream DNS, never touching the handler chain. With only 'netbird.cloud' and the in-addr.arpa reverse zone advertised as match domains, a relay instance FQDN like streamline-de-fra1-0.relay.netbird.io is resolved globally. When an exit-node default route is active on wt0 but the peer is not yet live, that global lookup times out on the local stub (127.0.0.53) because systemd-resolved's uplink DNS is unreachable through the overlay, and the foreign-relay dial fails exactly the way the ENOKEY path did on file-manager hosts. Bump the pool-root domains (ServerDomains.Relay entries) into extraDomains so applyHostConfig adds them as match-only domains alongside 'netbird.cloud'. systemd-resolved then delegates *.relay.netbird.io to the wt0 link, queries hit the daemon's DNS listener, and the on-demand resolve path via the bypass resolver runs as intended. Tracking map mgmtPoolRoots isolates this refcount from the RegisterHandler path so updates across successive mgmt syncs increment/decrement only the changed set. --- client/internal/dns/server.go | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index 36ebd6b45..75bd10dfe 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -117,6 +117,11 @@ type DefaultServer struct { batchMode bool mgmtCacheResolver *mgmt.Resolver + // mgmtPoolRoots tracks pool-root domains currently contributed to + // extraDomains by the mgmt cache, so the next UpdateServerConfig can + // decrement the old set before incrementing the new one without + // disturbing unrelated registerHandler callers. + mgmtPoolRoots map[domain.Domain]struct{} // permanent related properties permanent bool @@ -251,6 +256,7 @@ func newDefaultServer( hostsDNSHolder: newHostsDNSHolder(), hostManager: &noopHostConfigurator{}, mgmtCacheResolver: mgmtCacheResolver, + mgmtPoolRoots: make(map[domain.Domain]struct{}), currentConfigHash: ^uint64(0), // Initialize to max uint64 to ensure first config is always applied } @@ -646,6 +652,39 @@ func (s *DefaultServer) UpdateServerConfig(domains dnsconfig.ServerDomains) erro if len(exactDomains) > 0 { s.registerHandler(exactDomains.ToPunycodeList(), s.mgmtCacheResolver, PriorityMgmtCache) } + + // Reconcile extraDomains with the current pool-root set. Pool + // roots registered here are *match* domains for the host DNS + // manager (systemd-resolved, NetworkManager, etc.), so that + // instance subdomain queries like streamline-* are delegated to + // the wt0 link where the daemon's DNS listener sits. Without + // this, systemd-resolved answers them from the host's global + // upstream, skipping our handler chain entirely. + // + // Use a dedicated tracking map so that increments/decrements + // here don't collide with RegisterHandler's refcounting. + newPoolRoots := make(map[domain.Domain]struct{}, len(poolRoots)) + for _, d := range poolRoots { + zone := toZone(d) + newPoolRoots[zone] = struct{}{} + if _, already := s.mgmtPoolRoots[zone]; !already { + s.extraDomains[zone]++ + } + } + for zone := range s.mgmtPoolRoots { + if _, keep := newPoolRoots[zone]; keep { + continue + } + s.extraDomains[zone]-- + if s.extraDomains[zone] <= 0 { + delete(s.extraDomains, zone) + } + } + s.mgmtPoolRoots = newPoolRoots + + if !s.batchMode { + s.applyHostConfig() + } } return nil