From a345a22a3be98893347dac9feddb6138c3cc5c62 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 13 May 2026 13:20:04 +0000 Subject: [PATCH] mapper, app: ship MagicDNS Routes as empty slices, not nil policyChangeResponse already includes everything else; carry DNSConfig too so the client's netmap DNS is anchored on every policy change rather than relying on the previous snapshot. Send the MagicDNS root domains as empty non-nil Resolver slices instead of nil values. tailcfg.DNSConfig.Clone and net/dns.Config.Clone in tailscale drop map entries whose value is nil (tailcfg_clone.go and dns_clone.go both contain `if sv == nil { continue }`). On a major LinkChange the client's wgengine handler clones lastDNSConfig and re-applies it; with nil values the cloned config has Routes:{}, dns.Set wipes Nameservers in /etc/resolv.conf, and curl-by-FQDN fails until the next route-changing netmap, typically about six minutes later. Empty slice survives Clone and carries the same "resolve locally" semantics for Routes entries. --- hscontrol/app.go | 14 +++++++++++++- hscontrol/mapper/mapper.go | 18 +++++++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/hscontrol/app.go b/hscontrol/app.go index 77b0c103..1dbf66b6 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -219,7 +219,19 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) { } for _, d := range magicDNSDomains { - app.cfg.TailcfgDNSConfig.Routes[d.WithoutTrailingDot()] = nil + // Empty non-nil slice rather than nil: tailcfg.DNSConfig.Clone + // and dns.Config.Clone in tailscale drop map entries whose + // value is nil (see tailscale.com/tailcfg/tailcfg_clone.go and + // tailscale.com/net/dns/dns_clone.go: `if sv == nil { continue }`). + // Sending nil here caused the client's wgengine LinkChange:major + // handler to clobber /etc/resolv.conf on every tunnel-IP rebind + // — the handler reapplies a Clone of lastDNSConfig and the magic + // DNS routes vanish, taking the resolver with them for ~6 min + // until the next route-changing netmap. Empty slice survives + // Clone and carries the same "resolve locally" semantics + // (tailscale.com/ipn/ipnlocal/node_backend.go:869 documents the + // empty-resolver Routes form for Issue 2706). + app.cfg.TailcfgDNSConfig.Routes[d.WithoutTrailingDot()] = []*dnstype.Resolver{} } } diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go index b90d45a8..4f7a17fd 100644 --- a/hscontrol/mapper/mapper.go +++ b/hscontrol/mapper/mapper.go @@ -314,11 +314,18 @@ func (m *mapper) selfMapResponse( // policyChangeResponse creates a MapResponse for policy changes. // It sends: -// - PeersRemoved for peers that are no longer visible after the policy change -// - PeersChanged for remaining peers (their AllowedIPs may have changed due to policy) -// - Updated PacketFilters -// - Updated SSHPolicy (SSH rules may reference users/groups that changed) -// - Optionally, the node's own self info (when includeSelf is true) +// - PeersRemoved for peers that are no longer visible after the policy change +// - PeersChanged for remaining peers (their AllowedIPs may have changed due to policy) +// - Updated PacketFilters +// - Updated SSHPolicy (SSH rules may reference users/groups that changed) +// - DNSConfig so the client's resolver state stays anchored even when a +// policy-triggered wgengine reconfigure races a netmon LinkChange (the +// LinkChange handler reapplies dns.Manager.Set with the engine's +// lastDNSConfig; if that snapshot is stale, the OS resolver loses the +// MagicDNS reverse-DNS routes and Nameservers and curl-by-FQDN stops +// resolving for the rest of the policy window). +// - Optionally, the node's own self info (when includeSelf is true) +// // This avoids the issue where an empty Peers slice is interpreted by Tailscale // clients as "no change" rather than "no peers". // When includeSelf is true, the node's self info is included so that a node @@ -334,6 +341,7 @@ func (m *mapper) policyChangeResponse( builder := m.NewMapResponseBuilder(nodeID). WithDebugType(policyResponseDebug). WithCapabilityVersion(capVer). + WithDNSConfig(). WithPacketFilters(). WithSSHPolicy()