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:
parent
64d13f77e8
commit
5d502bfb88
@ -267,9 +267,14 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
|
|||||||
tailPeers := make([]*tailcfg.Node, 0, changedViews.Len())
|
tailPeers := make([]*tailcfg.Node, 0, changedViews.Len())
|
||||||
|
|
||||||
for _, peer := range changedViews.All() {
|
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 {
|
tn, err := peer.TailNode(b.capVer, func(_ types.NodeID) []netip.Prefix {
|
||||||
return b.mapper.state.RoutesForPeer(node, peer, matchers)
|
return b.mapper.state.RoutesForPeer(node, peer, matchers)
|
||||||
}, b.mapper.cfg, nil)
|
}, b.mapper.cfg, allCapMaps[peer.ID()])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
func TestNodeExpiry(t *testing.T) {
|
||||||
tp := func(t time.Time) *time.Time {
|
tp := func(t time.Time) *time.Time {
|
||||||
return &t
|
return &t
|
||||||
|
|||||||
9725
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c15-disable-ipv4.hujson
vendored
Normal file
9725
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c15-disable-ipv4.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
9766
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c16-disable-ipv4-router.hujson
vendored
Normal file
9766
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c16-disable-ipv4-router.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@ -36,6 +36,27 @@ var (
|
|||||||
// netip.Prefixes representing the routes for that node.
|
// netip.Prefixes representing the routes for that node.
|
||||||
type RouteFunc func(id NodeID) []netip.Prefix
|
type RouteFunc func(id NodeID) []netip.Prefix
|
||||||
|
|
||||||
|
// 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 tailcfg.NodeCapability = "disable-ipv4"
|
||||||
|
|
||||||
|
// filterIPv4 returns ps with every IPv4 prefix dropped. Used by
|
||||||
|
// [NodeView.TailNode] when the node carries the disable-ipv4 nodeAttr.
|
||||||
|
func filterIPv4(ps []netip.Prefix) []netip.Prefix {
|
||||||
|
out := ps[:0:0]
|
||||||
|
for _, p := range ps {
|
||||||
|
if p.Addr().Is4() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ViaRouteResult describes via grant effects for a viewer-peer pair.
|
// ViaRouteResult describes via grant effects for a viewer-peer pair.
|
||||||
// UsePrimary is always a subset of Include: it marks which included
|
// UsePrimary is always a subset of Include: it marks which included
|
||||||
// prefixes must additionally defer to HA primary election.
|
// prefixes must additionally defer to HA primary election.
|
||||||
@ -1177,9 +1198,22 @@ func (nv NodeView) TailNode(
|
|||||||
keyExpiry = nv.Expiry().Get()
|
keyExpiry = nv.Expiry().Get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// disable-ipv4 (https://tailscale.com/docs/reference/troubleshooting/network-configuration/cgnat-conflicts)
|
||||||
|
// drops the node's own IPv4 CGNAT prefix from Addresses and from
|
||||||
|
// the AllowedIPs slot the node's own /32 occupies. Advertised
|
||||||
|
// 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]
|
||||||
|
|
||||||
|
addresses := nv.Prefixes()
|
||||||
|
if ipv4Disabled {
|
||||||
|
addresses = filterIPv4(addresses)
|
||||||
|
}
|
||||||
|
|
||||||
// routeFunc returns ALL routes (subnet + exit) for this node.
|
// routeFunc returns ALL routes (subnet + exit) for this node.
|
||||||
allRoutes := primaryRouteFunc(nv.ID())
|
allRoutes := primaryRouteFunc(nv.ID())
|
||||||
allowedIPs := slices.Concat(nv.Prefixes(), allRoutes)
|
allowedIPs := slices.Concat(addresses, allRoutes)
|
||||||
slices.SortFunc(allowedIPs, netip.Prefix.Compare)
|
slices.SortFunc(allowedIPs, netip.Prefix.Compare)
|
||||||
|
|
||||||
// PrimaryRoutes only includes non-exit subnet routes for HA tracking.
|
// PrimaryRoutes only includes non-exit subnet routes for HA tracking.
|
||||||
@ -1234,7 +1268,7 @@ func (nv NodeView) TailNode(
|
|||||||
|
|
||||||
Machine: nv.MachineKey(),
|
Machine: nv.MachineKey(),
|
||||||
DiscoKey: nv.DiscoKey(),
|
DiscoKey: nv.DiscoKey(),
|
||||||
Addresses: nv.Prefixes(),
|
Addresses: addresses,
|
||||||
PrimaryRoutes: primaryRoutes,
|
PrimaryRoutes: primaryRoutes,
|
||||||
AllowedIPs: allowedIPs,
|
AllowedIPs: allowedIPs,
|
||||||
Endpoints: nv.Endpoints().AsSlice(),
|
Endpoints: nv.Endpoints().AsSlice(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user