policyutil: send approved exit nodes every rule

Tailscale SaaS treats exit routes like subnet routes when reducing
filters; 0.0.0.0/0 contains every dst, so exit nodes get all rules.

Updates #3493
This commit is contained in:
Kristoffer Dalby
2026-09-25 12:43:01 +00:00
parent 7efd22d0bb
commit 61d1599393
2 changed files with 66 additions and 9 deletions
+6 -9
View File
@@ -19,7 +19,7 @@ import (
func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcfg.FilterRule {
ret := []tailcfg.FilterRule{}
subnetRoutes := node.SubnetRoutes()
hasExitRoutes := node.IsExitNode()
exitRoutes := node.ExitRoutes()
for _, rule := range rules {
// Handle CapGrant rules separately — they use CapGrant[].Dsts
@@ -53,19 +53,16 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
// [types.NodeView.SubnetRoutes] returns only approved,
// non-exit routes — matching Tailscale SaaS behavior,
// which does not generate filter rules for
// advertised-but-unapproved routes. Exit routes
// (0.0.0.0/0, ::/0) are excluded by
// [types.NodeView.SubnetRoutes] and handled separately
// via AllowedIPs/routing.
// advertised-but-unapproved routes.
if slices.ContainsFunc(subnetRoutes, expanded.OverlapsPrefix) {
dests = append(dests, dest)
continue
}
// Exit-route advertisers need rules targeting the
// public internet so the kernel filter accepts
// traffic forwarded by autogroup:internet sources.
if hasExitRoutes && util.IPSetSubsetOf(expanded, util.TheInternet()) {
// Approved exit routes count like subnet routes. They
// contain every destination, so Tailscale SaaS sends an
// exit node every rule, not only internet ones.
if slices.ContainsFunc(exitRoutes, expanded.OverlapsPrefix) {
dests = append(dests, dest)
}
}
@@ -886,6 +886,66 @@ func TestReduceFilterRulesPartialApproval(t *testing.T) {
},
wantCount: 0,
},
{
// Tailscale SaaS delivers every rule to an approved exit
// node: its exit routes contain every destination.
name: "approved-exit-route-includes-tailnet-host",
node: &types.Node{
IPv4: ap("100.64.0.1"),
IPv6: ap("fd7a:115c:a1e0::1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: tsaddr.ExitRoutes()},
ApprovedRoutes: tsaddr.ExitRoutes(),
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.17"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.64.0.16", Ports: tailcfg.PortRange{First: 53, Last: 53}},
{IP: "fd7a:115c:a1e0::10", Ports: tailcfg.PortRange{First: 53, Last: 53}},
},
},
},
wantCount: 1,
wantRoutes: []string{"100.64.0.16", "fd7a:115c:a1e0::10"},
},
{
name: "approved-exit-route-includes-private-subnet",
node: &types.Node{
IPv4: ap("100.64.0.1"),
IPv6: ap("fd7a:115c:a1e0::1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: tsaddr.ExitRoutes()},
ApprovedRoutes: tsaddr.ExitRoutes(),
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.17"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
},
},
wantCount: 1,
wantRoutes: []string{"10.33.0.0/16"},
},
{
name: "unapproved-exit-route-excluded",
node: &types.Node{
IPv4: ap("100.64.0.1"),
IPv6: ap("fd7a:115c:a1e0::1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: tsaddr.ExitRoutes()},
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.17"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.64.0.16", Ports: tailcfg.PortRangeAny},
},
},
},
wantCount: 0,
},
}
for _, tt := range tests {