diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 37a6d745e..f1843db9f 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -22,6 +22,7 @@ import ( "tailscale.com/types/key" "tailscale.com/types/views" "tailscale.com/util/dnsname" + "tailscale.com/util/set" ) var ( @@ -341,15 +342,7 @@ func (node *Node) Prefixes() []netip.Prefix { // viewer uses an exit node; excluded from [Node.CanAccessRoute] which only // handles non-exit routing. func (node *Node) ExitRoutes() []netip.Prefix { - var routes []netip.Prefix - - for _, route := range node.AnnouncedRoutes() { - if tsaddr.IsExitRoute(route) && slices.Contains(node.ApprovedRoutes, route) { - routes = append(routes, route) - } - } - - return routes + return node.approvedRoutes(true) } // IsExitNode reports whether the node has any approved exit routes. @@ -579,15 +572,34 @@ func (node *Node) AnnouncedRoutes() []netip.Prefix { // responses that need the routes actively served by the node must // populate SubnetRoutes from the primary-route election instead. func (node *Node) SubnetRoutes() []netip.Prefix { + return node.approvedRoutes(false) +} + +func (node *Node) approvedRoutes(exit bool) []netip.Prefix { + // Only the requested kind goes in the set, so finding the exit routes of + // a large subnet router stays cheap. + var approved set.Set[netip.Prefix] + + for _, route := range node.ApprovedRoutes { + if tsaddr.IsExitRoute(route) == exit { + if approved == nil { + approved = make(set.Set[netip.Prefix]) + } + + approved.Add(route) + } + } + + if len(approved) == 0 { + return nil + } + var routes []netip.Prefix for _, route := range node.AnnouncedRoutes() { - if tsaddr.IsExitRoute(route) { - continue - } - - if slices.Contains(node.ApprovedRoutes, route) { + if approved.Contains(route) { routes = append(routes, route) + approved.Delete(route) } } diff --git a/hscontrol/types/node_benchmark_test.go b/hscontrol/types/node_benchmark_test.go index e73203797..0b97075bc 100644 --- a/hscontrol/types/node_benchmark_test.go +++ b/hscontrol/types/node_benchmark_test.go @@ -3,6 +3,7 @@ package types import ( "fmt" "net/netip" + "slices" "testing" "github.com/juanfont/headscale/hscontrol/policy/matcher" @@ -71,3 +72,60 @@ func BenchmarkNodeViewCanAccess(b *testing.B) { } }) } + +func BenchmarkNodeDerivedRoutes(b *testing.B) { + for _, count := range []int{1024, 8192} { + announced := make([]netip.Prefix, count, count+2) + + ip := netip.MustParseAddr("10.0.0.0") + for i := range announced { + announced[i] = netip.PrefixFrom(ip, 32) + ip = ip.Next() + } + + announced = append(announced, + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")) + + for _, approved := range []bool{false, true} { + name := fmt.Sprintf("approved=%t/routes=%d", approved, count) + b.Run(name, func(b *testing.B) { + n := Node{Hostinfo: &tailcfg.Hostinfo{RoutableIPs: announced}} + if approved { + n.ApprovedRoutes = slices.Clone(announced) + } + + var subnets, exits []netip.Prefix + + b.ReportAllocs() + + for b.Loop() { + subnets = n.SubnetRoutes() + exits = n.ExitRoutes() + } + + if approved { + if len(subnets) != count || len(exits) != 2 { + b.Fatalf("unexpected derived routes: %d subnets, %d exits", + len(subnets), len(exits)) + } + } else if len(subnets) != 0 || len(exits) != 0 { + b.Fatal("unapproved routes must not be served") + } + }) + } + } +} + +func BenchmarkNodeIsExitNodeSubnetRouter(b *testing.B) { + for _, count := range []int{4096, 16384} { + b.Run(fmt.Sprintf("routes=%d", count), func(b *testing.B) { + n := subnetRouter(count) + + b.ReportAllocs() + + for b.Loop() { + n.IsExitNode() + } + }) + } +} diff --git a/hscontrol/types/node_test.go b/hscontrol/types/node_test.go index 5982c146a..c8cd3acfb 100644 --- a/hscontrol/types/node_test.go +++ b/hscontrol/types/node_test.go @@ -3,6 +3,7 @@ package types import ( "fmt" "net/netip" + "slices" "strings" "testing" "time" @@ -1121,3 +1122,103 @@ func TestHasPolicyChangeSharedRouteInputs(t *testing.T) { require.False(t, changed) require.Zero(t, allocs, "unchanged route inputs should not derive route lists") } + +func TestNodeDerivedRoutes(t *testing.T) { + a := netip.MustParsePrefix("10.0.0.0/24") + b := netip.MustParsePrefix("10.1.0.0/24") + x := netip.MustParsePrefix("10.2.0.0/24") + exit4 := netip.MustParsePrefix("0.0.0.0/0") + exit6 := netip.MustParsePrefix("::/0") + + for _, tt := range []struct { + name string + announced, approved, subnets, exits []netip.Prefix + }{ + { + name: "subnets follow announcement order without duplicates", + announced: []netip.Prefix{b, a, x, b, a, b}, + approved: []netip.Prefix{a, b, b}, + subnets: []netip.Prefix{b, a}, + }, + { + name: "exit routes follow announcement order without duplicates", + announced: []netip.Prefix{exit6, exit4, exit6, exit4, a}, + approved: []netip.Prefix{exit4, exit6, exit4}, + exits: []netip.Prefix{exit6, exit4}, + }, + { + name: "mixed routes", + announced: []netip.Prefix{b, exit6, x, exit6, a, b, exit4}, + approved: []netip.Prefix{exit4, a, exit6, b}, + subnets: []netip.Prefix{b, a}, + exits: []netip.Prefix{exit6, exit4}, + }, + {name: "nothing approved", announced: []netip.Prefix{b, exit6, a, exit4}}, + { + name: "empty approvals", + announced: []netip.Prefix{a, exit4}, + approved: []netip.Prefix{}, + }, + {name: "nothing announced", approved: []netip.Prefix{a, exit4}}, + { + name: "unannounced approvals", + announced: []netip.Prefix{a}, + approved: []netip.Prefix{b, exit4}, + }, + { + name: "exit families differ", + announced: []netip.Prefix{exit4}, + approved: []netip.Prefix{exit6}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + announced := slices.Clone(tt.announced) + approved := slices.Clone(tt.approved) + n := Node{ + Hostinfo: &tailcfg.Hostinfo{RoutableIPs: tt.announced}, + ApprovedRoutes: tt.approved, + } + + require.Equal(t, tt.subnets, n.SubnetRoutes()) + require.Equal(t, tt.exits, n.ExitRoutes()) + require.Equal(t, announced, n.AnnouncedRoutes(), + "announcements must remain immutable") + require.Equal(t, Prefixes(approved), n.ApprovedRoutes, + "approvals must remain immutable") + }) + } + + n := Node{ApprovedRoutes: []netip.Prefix{a, exit4}} + require.Nil(t, n.SubnetRoutes()) + require.Nil(t, n.ExitRoutes()) +} + +func subnetRouter(count int) Node { + routes := make([]netip.Prefix, count) + + ip := netip.MustParseAddr("10.0.0.0") + for i := range routes { + routes[i] = netip.PrefixFrom(ip, 32) + ip = ip.Next() + } + + return Node{ + Hostinfo: &tailcfg.Hostinfo{RoutableIPs: routes}, + ApprovedRoutes: routes, + } +} + +// IsExitNode runs per viewer and peer pair; a subnet router must answer it +// without collecting its subnet routes. +func TestIsExitNodeSubnetRouterAllocs(t *testing.T) { + n := subnetRouter(4096) + + var exit bool + + allocs := testing.AllocsPerRun(10, func() { + exit = n.IsExitNode() + }) + + require.False(t, exit) + require.Zero(t, allocs) +}