From 61d159939382e01a3a248701bd25beae859a7915 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 25 Sep 2026 12:43:01 +0000 Subject: [PATCH] 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 --- hscontrol/policy/policyutil/reduce.go | 15 +++--- hscontrol/policy/policyutil/reduce_test.go | 60 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/hscontrol/policy/policyutil/reduce.go b/hscontrol/policy/policyutil/reduce.go index fc662aacb..61d6b6949 100644 --- a/hscontrol/policy/policyutil/reduce.go +++ b/hscontrol/policy/policyutil/reduce.go @@ -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) } } diff --git a/hscontrol/policy/policyutil/reduce_test.go b/hscontrol/policy/policyutil/reduce_test.go index 33b7ce81b..4a51bb5e6 100644 --- a/hscontrol/policy/policyutil/reduce_test.go +++ b/hscontrol/policy/policyutil/reduce_test.go @@ -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 {