diff --git a/hscontrol/policy/v2/policy_test.go b/hscontrol/policy/v2/policy_test.go index 916fb9996..0f9fccc41 100644 --- a/hscontrol/policy/v2/policy_test.go +++ b/hscontrol/policy/v2/policy_test.go @@ -44,6 +44,11 @@ func TestApprovedSubnetRoutesIgnoreNodeOrder(t *testing.T) { want := []string{"10.1.0.0/16", "10.2.0.0/16"} assert.Equal(t, want, approvedSubnetRoutes(types.Nodes{a, b}.ViewSlice())) assert.Equal(t, want, approvedSubnetRoutes(types.Nodes{b, a}.ViewSlice())) + + // A route stays after its node withdraws it, as on SaaS. An exit route stays out. + b.Hostinfo = &tailcfg.Hostinfo{} + b.ApprovedRoutes = append(b.ApprovedRoutes, netip.MustParsePrefix("0.0.0.0/0")) + assert.Equal(t, want, approvedSubnetRoutes(types.Nodes{a, b}.ViewSlice())) } func node(name, ipv4, ipv6 string, user types.User) *types.Node { diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 28deffcb1..4aa492d62 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -364,9 +364,10 @@ func (a Asterix) resolve(_ *Policy, _ types.Users, _ views.Slice[types.NodeView] return asterixResolved(), nil } -// approvedSubnetRoutes collects all approved non-exit subnet routes -// advertised across all nodes. Per Tailscale documentation, wildcard -// (*) SrcIPs include "any approved subnets". +// approvedSubnetRoutes collects all approved non-exit subnet routes across all +// nodes, advertised or not: SaaS keeps a route an admin approved after its node +// withdraws it. Per Tailscale documentation, wildcard (*) SrcIPs include "any +// approved subnets". // // These are collected separately from the Asterix IPSet because // IPSet merges overlapping ranges (e.g. 10.0.0.0/8 absorbs @@ -377,7 +378,11 @@ func approvedSubnetRoutes(nodes views.Slice[types.NodeView]) []string { var routes []string for _, node := range nodes.All() { - for _, route := range node.SubnetRoutes() { + for _, route := range node.ApprovedRoutes().All() { + if tsaddr.IsExitRoute(route) { + continue + } + s := route.String() if !seen.Contains(s) { seen.Add(s) diff --git a/hscontrol/servertest/lifecycle_compat_test.go b/hscontrol/servertest/lifecycle_compat_test.go index b22ef322f..b40e9eca4 100644 --- a/hscontrol/servertest/lifecycle_compat_test.go +++ b/hscontrol/servertest/lifecycle_compat_test.go @@ -25,6 +25,7 @@ import ( "github.com/juanfont/headscale/hscontrol/types/testcapture" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/types/key" "tailscale.com/types/netmap" @@ -183,6 +184,9 @@ func runRouteLifecycleCompat(t *testing.T, id, observer string, skips map[string return "user " + nodes[0].User().Name() }}, + {"filter-routes", filterRouteSources(step.Captures[observer].Netmap), func() string { + return filterRouteSources(r.clients[observer].Netmap()) + }}, {"device", deviceState( countDevicesOnMachine(t, step, dev.MachineKey), dev.ID == join.saasID, sortedStrings(dev.Addresses) == join.saasAddrs, @@ -364,6 +368,27 @@ func (r *stepReplay) hsDeviceRoutes(t *testing.T, alias string) (deviceRoutes, b return out, true } +// filterRouteSources lists the sources in nm's packet filter that are no tailnet +// addresses: the routes a wildcard source stands for. +func filterRouteSources(nm *netmap.NetworkMap) string { + if nm == nil { + return "no netmap" + } + + seen := map[string]bool{} + + for _, m := range nm.PacketFilter { + for _, src := range m.Srcs { + tailnet := tsaddr.CGNATRange().Contains(src.Addr()) || tsaddr.TailscaleULARange().Contains(src.Addr()) + if !tailnet { + seen[src.String()] = true + } + } + } + + return fmt.Sprint(slices.Sorted(maps.Keys(seen))) +} + // saasDevice is the part of a SaaS device-list entry the checks read. // Keys and IDs are the capture's labels. type saasDevice struct {