types/node, mapper: strip own IPv4 from emission when node has disable-ipv4 cap

When a node carries the disable-ipv4 nodeAttr documented at
https://tailscale.com/docs/reference/troubleshooting/network-configuration/cgnat-conflicts,
SaaS stops sending the node's CGNAT IPv4 prefix in MapResponse. The
allocator keeps assigning IPv4 server-side; only the wire-shape
delivery is filtered. Subnet routes the node advertises -- including
IPv4 prefixes -- survive in AllowedIPs and PrimaryRoutes.

TailNode now drops Is4 prefixes from Addresses and from the node's
own /32 slot in AllowedIPs when selfPolicyCaps carries
disable-ipv4. Mapper.buildTailPeers passes each peer's policy
CapMap so the filter applies in viewer netmaps too; the CapMap
merge that follows is overwritten by PeerCapMap so only the address
filter survives on the peer path.

Two captures land in testdata/nodeattrs_results to anchor the
behaviour:

  - nodeattrs-attr-c15-disable-ipv4         (on tag:client)
  - nodeattrs-attr-c16-disable-ipv4-router  (on tag:router, which
    advertises 10.33.0.0/16, confirming subnet routes survive)
This commit is contained in:
Kristoffer Dalby 2026-05-13 09:58:11 +00:00
parent 64d13f77e8
commit 5d502bfb88
5 changed files with 19639 additions and 3 deletions

View file

@ -267,9 +267,14 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
tailPeers := make([]*tailcfg.Node, 0, changedViews.Len())
for _, peer := range changedViews.All() {
// Pass the peer's policy CapMap as selfPolicyCaps so per-peer
// address-shape rules (today: disable-ipv4) apply consistently
// in the viewer's netmap. The CapMap merge into tn.CapMap is
// overwritten by the PeerCapMap call below; only the address
// filtering side-effect inside TailNode survives.
tn, err := peer.TailNode(b.capVer, func(_ types.NodeID) []netip.Prefix {
return b.mapper.state.RoutesForPeer(node, peer, matchers)
}, b.mapper.cfg, nil)
}, b.mapper.cfg, allCapMaps[peer.ID()])
if err != nil {
return nil, err
}

View file

@ -345,6 +345,112 @@ func TestTailNodeBaselineGates(t *testing.T) {
}
}
// TestTailNodeDisableIPv4 asserts that a node with the disable-ipv4
// nodeAttr has its own IPv4 (the CGNAT /32) stripped from Addresses
// and AllowedIPs, while subnet routes the node advertises -- even
// IPv4 ones -- remain in AllowedIPs and PrimaryRoutes. Matches the
// SaaS behaviour captured in
// hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c1{5,6}-disable-ipv4*.hujson.
func TestTailNodeDisableIPv4(t *testing.T) {
t.Parallel()
const NodeAttrDisableIPv4 tailcfg.NodeCapability = "disable-ipv4"
v4 := iap("100.64.0.1")
v6Addr := netip.MustParseAddr("fd7a:115c:a1e0::1")
v6 := &v6Addr
subnet := netip.MustParsePrefix("10.33.0.0/16")
tests := []struct {
name string
hasCap bool
approved []netip.Prefix
wantAllowed []netip.Prefix
wantPrimary []netip.Prefix
wantAddrs []netip.Prefix
}{
{
name: "no-cap_emits_both_families",
hasCap: false,
wantAllowed: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
wantAddrs: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
},
{
name: "cap_strips_own_ipv4",
hasCap: true,
wantAllowed: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
wantAddrs: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
},
{
name: "cap_keeps_advertised_subnet_route",
hasCap: true,
approved: []netip.Prefix{subnet},
// AllowedIPs is sorted by netip.Prefix.Compare so IPv4
// sorts before IPv6.
wantAllowed: []netip.Prefix{
subnet,
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
},
wantPrimary: []netip.Prefix{subnet},
wantAddrs: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
node := &types.Node{
GivenName: "ipv4-disabled-node",
IPv4: v4,
IPv6: v6,
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: tt.approved,
},
ApprovedRoutes: tt.approved,
}
var selfCaps tailcfg.NodeCapMap
if tt.hasCap {
selfCaps = tailcfg.NodeCapMap{NodeAttrDisableIPv4: nil}
}
got, err := node.View().TailNode(
0,
func(types.NodeID) []netip.Prefix {
return tt.approved
},
&types.Config{Taildrop: types.TaildropConfig{Enabled: true}},
selfCaps,
)
if err != nil {
t.Fatalf("TailNode: %v", err)
}
prefStrings := func(ps []netip.Prefix) []string {
out := make([]string, len(ps))
for i, p := range ps {
out[i] = p.String()
}
return out
}
if diff := cmp.Diff(prefStrings(tt.wantAddrs), prefStrings(got.Addresses), cmpopts.EquateEmpty()); diff != "" {
t.Errorf("Addresses (-want +got):\n%s", diff)
}
if diff := cmp.Diff(prefStrings(tt.wantAllowed), prefStrings(got.AllowedIPs), cmpopts.EquateEmpty()); diff != "" {
t.Errorf("AllowedIPs (-want +got):\n%s", diff)
}
if diff := cmp.Diff(prefStrings(tt.wantPrimary), prefStrings(got.PrimaryRoutes), cmpopts.EquateEmpty()); diff != "" {
t.Errorf("PrimaryRoutes (-want +got):\n%s", diff)
}
})
}
}
func TestNodeExpiry(t *testing.T) {
tp := func(t time.Time) *time.Time {
return &t