mapper: drop DNSConfig from policy responses

It forced every client into a full netmap rebuild; the resolver race it
guarded against was a client bug (tailscale/tailscale#19749).
This commit is contained in:
Kristoffer Dalby
2026-09-28 13:18:01 +00:00
committed by Kristoffer Dalby
parent 357f34a778
commit eeaac680be
4 changed files with 31 additions and 7 deletions
+25
View File
@@ -2446,3 +2446,28 @@ func TestHandleNodeChangeRetryAfterRemoval(t *testing.T) {
assert.Empty(t, sent[1].PeersRemoved)
assert.NotEmpty(t, sent[1].PacketFilters)
}
// TestDNSConfigOnlyWithSelfRefresh checks policy responses leave DNSConfig
// out, which clients read as unchanged, while self refreshes carry it: a
// node's DNS config derives from its own CapMap and Hostinfo, and a
// DNSConfig forces clients into a full netmap rebuild.
func TestDNSConfigOnlyWithSelfRefresh(t *testing.T) {
testData, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 1, 2, normalBufferSize)
defer cleanup()
testData.Config.TailcfgDNSConfig = &tailcfg.DNSConfig{
Proxied: true,
Domains: []string{"headscale.test"},
}
self := testData.Nodes[0].n.ID
mc := newMockNodeConnection(self)
require.NoError(t, handleNodeChange(mc, testData.Batcher.mapper, change.PolicyChange()))
require.NoError(t, handleNodeChange(mc, testData.Batcher.mapper, change.SelfUpdate(self)))
sent := mc.getSent()
require.Len(t, sent, 2)
assert.Nil(t, sent[0].DNSConfig, "policy response must not carry DNSConfig")
assert.NotNil(t, sent[1].DNSConfig, "self refresh must carry DNSConfig")
}
+4 -7
View File
@@ -309,14 +309,12 @@ func (m *mapper) selfMapResponse(
// - 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)
//
// DNSConfig is left out: it forces clients into a full netmap rebuild, and
// the node's DNS config inputs arrive with its own [change.SelfUpdate], see
// [state.State.DrainSelfRefreshes].
//
// 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
@@ -331,7 +329,6 @@ func (m *mapper) policyChangeResponse(
builder := m.NewMapResponseBuilder(nodeID).
WithDebugType(policyResponseDebug).
WithCapabilityVersion(capVer).
WithDNSConfig().
WithPacketFilters().
WithSSHPolicy()
+1
View File
@@ -477,6 +477,7 @@ func firstResolver(nm *netmap.NetworkMap) string {
// TestNodeAttrsNextDNS checks a node's DNS config follows each of its
// inputs: the NextDNS profile from nodeAttrs, whether reached through a
// policy reload or a tag change, and the device metadata from its Hostinfo.
// Policy responses do not carry DNSConfig, so each must arrive on its own.
func TestNodeAttrsNextDNS(t *testing.T) {
t.Parallel()