policy/v2: keep withdrawn approved routes in wildcard sources

As SaaS, which holds them until an admin removes the approval.
This commit is contained in:
Kristoffer Dalby committed 2026-11-03 17:05:00 +01:00
1 parent d2a85b1d16
commit 420293cecf
3 files changed
+39 -4

No files matched your search

+5
View File
@@ -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 {
+9 -4
View File
@@ -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)
@@ -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 {