From b04bbd4317dffb9f8c4bb143675f3b633cab3a6a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 31 Oct 2026 14:07:39 +0000 Subject: [PATCH] mapper: send a disable-ipv4 holder its peers over IPv6 only As SaaS. Peers' 100.x addresses routed into tailscale0 and hid a local host with the same address, which is what the attr is meant to avoid. --- hscontrol/mapper/builder.go | 12 ++++++++++++ hscontrol/mapper/mapper_test.go | 8 ++++++++ hscontrol/servertest/via_compat_test.go | 23 +++++++++++++++++++++++ hscontrol/types/node.go | 6 +++--- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index 12487ee6e..b37cba928 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -281,6 +281,8 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ( // Snapshot these peers' policy caps under one policy-manager lock. peerCapMaps := b.mapper.state.NodeCapMapsForPeers(peers) + _, ipv4Disabled := b.mapper.state.NodeCapMap(node.ID())[types.NodeAttrDisableIPv4] + // Build tail nodes with per-peer via-aware route function. tailPeers := make([]*tailcfg.Node, 0, peers.Len()) @@ -327,6 +329,16 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ( tn.CapMap = policyv2.PeerCapMap(peer, peerCapMaps[peer.ID()]) tn.Hostinfo = types.PeerHostinfo(tn.Hostinfo) + // A disable-ipv4 holder sees its peers over IPv6 only, as on SaaS, so + // their 100.x addresses stay off its interface. Routes stay. + if ipv4Disabled { + ownIPv4 := func(p netip.Prefix) bool { + return p.Addr().Is4() && slices.Contains(tn.Addresses, p) + } + tn.AllowedIPs = slices.DeleteFunc(tn.AllowedIPs, ownIPv4) + tn.Addresses = slices.DeleteFunc(tn.Addresses, func(p netip.Prefix) bool { return p.Addr().Is4() }) + } + // As on Tailscale SaaS, a connected expired peer shows online, but // without the endpoints and home DERP region that would reach it. if peer.IsExpired() { diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go index d0edcdf4a..b58393c1d 100644 --- a/hscontrol/mapper/mapper_test.go +++ b/hscontrol/mapper/mapper_test.go @@ -1812,6 +1812,14 @@ func (b *MapResponseBuilder) buildTailPeersCapMapsReference(peers views.Slice[ty tn.CapMap = policyv2.PeerCapMap(peer, allCapMaps[peer.ID()]) tn.Hostinfo = types.PeerHostinfo(tn.Hostinfo) + if _, ipv4Disabled := allCapMaps[node.ID()][types.NodeAttrDisableIPv4]; ipv4Disabled { + ownIPv4 := func(p netip.Prefix) bool { + return p.Addr().Is4() && slices.Contains(tn.Addresses, p) + } + tn.AllowedIPs = slices.DeleteFunc(tn.AllowedIPs, ownIPv4) + tn.Addresses = slices.DeleteFunc(tn.Addresses, func(p netip.Prefix) bool { return p.Addr().Is4() }) + } + if peer.IsExpired() { if peer.ActiveSessions() > 0 { tn.Online, tn.LastSeen = new(true), nil diff --git a/hscontrol/servertest/via_compat_test.go b/hscontrol/servertest/via_compat_test.go index d9bfdc656..c1ee5f1c7 100644 --- a/hscontrol/servertest/via_compat_test.go +++ b/hscontrol/servertest/via_compat_test.go @@ -353,6 +353,29 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) { compareViaMapCompat(t, c, clients) } +// TestDisableIPv4PeersCompat: a disable-ipv4 holder sees its peers over IPv6 +// only, in both recordings of such a viewer. +func TestDisableIPv4PeersCompat(t *testing.T) { + t.Parallel() + + for _, id := range []string{"nodeattrs-attr-c15-disable-ipv4", "nodeattrs-attr-c16-disable-ipv4-router"} { + t.Run(id, func(t *testing.T) { + t.Parallel() + + c, err := testcapture.Read(filepath.Join("..", "policy", "v2", "testdata", "nodeattrs_results", id+".hujson")) + require.NoError(t, err) + + _, clients := startViaMapCompat(t, c, true) + + for viewer, want := range c.Captures { + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + comparePeers(collect, clients[viewer].Netmap(), want, clients, id+"/"+viewer, nil) + }, 20*time.Second, 200*time.Millisecond, "viewer %s", viewer) + } + }) + } +} + // startViaMapCompat starts a server holding the capture's policy and a // client per captured node, approving routes as [startCaptureClients] does, // and waits for every client's first netmap and the peers SaaS gave it. diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 31bfe569f..1ed99a2ce 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -44,11 +44,11 @@ var ( // [netip.Prefix] values representing the routes for that node. type RouteFunc func(id NodeID) []netip.Prefix -// nodeAttrDisableIPv4 is the policy nodeAttr key that suppresses the +// NodeAttrDisableIPv4 is the policy nodeAttr key that suppresses the // node's own IPv4 CGNAT prefix in [tailcfg.Node.Addresses] and // [tailcfg.Node.AllowedIPs]. Subnet routes the node advertises remain. // See https://tailscale.com/docs/reference/troubleshooting/network-configuration/cgnat-conflicts. -const nodeAttrDisableIPv4 nodecap.Cap = "disable-ipv4" +const NodeAttrDisableIPv4 nodecap.Cap = "disable-ipv4" // filterIPv4 returns ps with every IPv4 prefix dropped. Used by // [NodeView.TailNode] when the node carries the disable-ipv4 nodeAttr. @@ -1402,7 +1402,7 @@ func (nv NodeView) TailNode( // subnet routes -- even IPv4 ones -- survive: routes belong to // the routing layer, not the node's identity. Mirrors the SaaS // captures in testdata/nodeattrs_results/nodeattrs-attr-c1{5,6}-disable-ipv4*. - _, ipv4Disabled := selfPolicyCaps[nodeAttrDisableIPv4] + _, ipv4Disabled := selfPolicyCaps[NodeAttrDisableIPv4] addresses := nv.Prefixes() if ipv4Disabled {