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.
This commit is contained in:
Zoltán Papp
2026-04-24 18:01:26 +02:00
parent 77ec25796e
commit 14be474e3d

View File

@@ -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