From 3f97e68a208e505780fa041d1869252d37a0132d Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 17 Oct 2026 10:24:44 +0000 Subject: [PATCH] policy/matcher, types: compare a node's routes to matchers as ranges --- hscontrol/policy/matcher/matcher.go | 20 ++ hscontrol/policy/v2/canaccess_equiv_test.go | 270 ++++++++++++++++++-- hscontrol/policy/v2/peermap_scan_test.go | 7 +- hscontrol/policy/v2/policy.go | 12 +- hscontrol/types/node.go | 36 ++- 5 files changed, 304 insertions(+), 41 deletions(-) diff --git a/hscontrol/policy/matcher/matcher.go b/hscontrol/policy/matcher/matcher.go index 1061cae01..f93cfe54d 100644 --- a/hscontrol/policy/matcher/matcher.go +++ b/hscontrol/policy/matcher/matcher.go @@ -119,6 +119,26 @@ func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool { }) } +// SrcsOverlapsRanges reports whether the sources overlap sorted, disjoint ranges. +func (m *Match) SrcsOverlapsRanges(ranges []netipx.IPRange) bool { + return rangesOverlap(m.srcRanges, ranges) +} + +// DestsOverlapsRanges reports whether the destinations overlap sorted, disjoint ranges. +func (m *Match) DestsOverlapsRanges(ranges []netipx.IPRange) bool { + return rangesOverlap(m.destRanges, ranges) +} + +func rangesOverlap(a, b []netipx.IPRange) bool { + if len(a) > len(b) { + a, b = b, a + } + + return slices.ContainsFunc(a, func(r netipx.IPRange) bool { + return overlaps(b, r) + }) +} + func overlaps(ranges []netipx.IPRange, r netipx.IPRange) bool { if !r.IsValid() { return false diff --git a/hscontrol/policy/v2/canaccess_equiv_test.go b/hscontrol/policy/v2/canaccess_equiv_test.go index 198ec41ab..a9d65d1ac 100644 --- a/hscontrol/policy/v2/canaccess_equiv_test.go +++ b/hscontrol/policy/v2/canaccess_equiv_test.go @@ -1,50 +1,266 @@ package v2 import ( + "fmt" "net/netip" + "slices" "testing" "github.com/juanfont/headscale/hscontrol/policy/matcher" "github.com/juanfont/headscale/hscontrol/types" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go4.org/netipx" + "pgregory.net/rapid" "tailscale.com/tailcfg" ) -// TestCanAccessWithRoutesMatchesCanAccess guards the peer-map optimization: -// CanAccessWithRoutes, fed the same route data CanAccess computes internally, -// must produce identical results. BuildPeerMap relies on this to precompute -// each node's routes once instead of per pair. -func TestCanAccessWithRoutesMatchesCanAccess(t *testing.T) { - user := types.User{Name: "u"} +func TestRouteRanges(t *testing.T) { + prefixes := []netip.Prefix{ + netip.MustParsePrefix("2001:db8::2/128"), + netip.MustParsePrefix("10.0.0.1/32"), + {}, + netip.MustParsePrefix("10.0.0.0/31"), + netip.MustParsePrefix("2001:db8::1/128"), + netip.MustParsePrefix("10.0.0.1/32"), + } + before := slices.Clone(prefixes) + want := []netipx.IPRange{ + netipx.IPRangeFrom( + netip.MustParseAddr("10.0.0.0"), netip.MustParseAddr("10.0.0.1")), + netipx.IPRangeFrom( + netip.MustParseAddr("2001:db8::1"), netip.MustParseAddr("2001:db8::2")), + } - subnetRouter := node("subnet", "100.64.0.1", "fd7a:115c:a1e0::1", user) - subnetRouter.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}} - subnetRouter.ApprovedRoutes = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")} + require.Equal(t, want, types.RouteRanges(prefixes)) + require.Equal(t, before, prefixes) + require.Empty(t, types.RouteRanges(nil)) + require.Empty(t, types.RouteRanges([]netip.Prefix{{}})) +} - exitNode := node("exit", "100.64.0.2", "fd7a:115c:a1e0::2", user) - exitNode.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}} - exitNode.ApprovedRoutes = []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")} +func TestMatcherRouteRangesMatchPrefixes(t *testing.T) { + prefixStrings := []string{ + "10.0.0.0/32", "10.0.0.1/32", "10.0.0.3/32", "10.0.0.0/24", + "10.0.2.0/24", "10.0.0.7/24", "192.168.0.0/16", "100.64.0.1/32", + "2001:db8::1/128", "2001:db8::2/128", "2001:db8::4/128", + "2001:db8::/64", "2001:db8:1::/64", "::ffff:192.0.2.0/120", + "0.0.0.0/0", "::/0", + } - plain := node("plain", "100.64.0.3", "fd7a:115c:a1e0::3", user) + prefixes := make([]netip.Prefix, len(prefixStrings)+1) + for i, text := range prefixStrings { + prefixes[i+1] = netip.MustParsePrefix(text) + } - matchers := matcher.MatchesFromFilterRules([]tailcfg.FilterRule{ - { - SrcIPs: []string{"*"}, - DstPorts: []tailcfg.NetPortRange{{IP: "*", Ports: tailcfg.PortRangeAny}}, - }, + rapid.Check(t, func(rt *rapid.T) { + sources := rapid.SliceOfN(rapid.SampledFrom(prefixStrings), 0, 12). + Draw(rt, "sources") + destinations := rapid.SliceOfN(rapid.SampledFrom(prefixStrings), 0, 12). + Draw(rt, "destinations") + routes := rapid.SliceOfN(rapid.SampledFrom(prefixes), 0, 32).Draw(rt, "routes") + before := slices.Clone(routes) + ranges := types.RouteRanges(routes) + rule := tailcfg.FilterRule{SrcIPs: sources} + + for _, destination := range destinations { + rule.DstPorts = append(rule.DstPorts, tailcfg.NetPortRange{ + IP: destination, Ports: tailcfg.PortRangeAny, + }) + } + + m := matcher.MatchFromFilterRule(rule) + require.Equal(rt, m.SrcsOverlapsPrefixes(routes...), + m.SrcsOverlapsRanges(ranges)) + require.Equal(rt, m.DestsOverlapsPrefixes(routes...), + m.DestsOverlapsRanges(ranges)) + require.Equal(rt, before, routes) }) +} - views := []types.NodeView{subnetRouter.View(), exitNode.View(), plain.View()} +func TestBuildPeerMapMatchesPrefixReference(t *testing.T) { + users := []types.User{ + {ID: 1, Name: "u1"}, {ID: 2, Name: "u2"}, {ID: 3, Name: "u3"}, + } + routePool := []netip.Prefix{ + netip.MustParsePrefix("10.1.0.0/24"), + netip.MustParsePrefix("10.1.1.0/24"), + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("2001:db8::/64"), + netip.MustParsePrefix("2001:db8:1::/64"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("0.0.0.0/0"), + netip.MustParsePrefix("::/0"), + } + policies := []struct{ name, policy string }{ + {name: "global", policy: benchPolicies[0].policy}, + {name: "self", policy: benchPolicies[1].policy}, + {name: "via", policy: benchPolicies[2].policy}, + {name: "subnet source", policy: `{ + "acls": [{"action": "accept", "src": ["10.0.0.0/8", "2001:db8::/32"], + "dst": ["u2@:*"]}]}`}, + {name: "allow all", policy: `{ + "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]}`}, + } - for _, a := range views { - for _, b := range views { - if a.ID() == b.ID() { - continue + rapid.Check(t, func(rt *rapid.T) { + nodes := make(types.Nodes, rapid.IntRange(5, 12).Draw(rt, "nodes")) + for i := range nodes { + u := users[rapid.IntRange(0, len(users)-1).Draw(rt, "user")] + + switch i { + case 0, 4: + u = users[0] + case 2: + u = users[1] } - want := a.CanAccess(matchers, b) - got := a.CanAccessWithRoutes(matchers, b, a.SubnetRoutes(), b.SubnetRoutes(), b.IsExitNode()) - assert.Equalf(t, want, got, "%s -> %s", a.Hostname(), b.Hostname()) + n := node(fmt.Sprint("n", i), fmt.Sprint("100.64.0.", i+1), + fmt.Sprintf("fd7a:115c:a1e0::%x", i+1), u) + n.ID = types.NodeID(i + 1) //nolint:gosec + + switch rapid.IntRange(0, 2).Draw(rt, "family") { + case 1: + n.IPv4 = nil + case 2: + n.IPv6 = nil + } + + switch i { + case 1: + n.Tags = []string{"tag:router"} + case 3: + n.Tags = []string{"tag:srv"} + } + + if n.IsTagged() { + n.User, n.UserID = nil, nil + } + + n.Hostinfo = &tailcfg.Hostinfo{ + RoutableIPs: rapid.SliceOfN(rapid.SampledFrom(routePool), 0, 8). + Draw(rt, "announced"), + } + n.ApprovedRoutes = rapid.SliceOfN(rapid.SampledFrom(routePool), 0, 8). + Draw(rt, "approved") + + switch i { + case 1: + n.Hostinfo.RoutableIPs = append(n.Hostinfo.RoutableIPs, + routePool[0], routePool[5]) + n.ApprovedRoutes = append(n.ApprovedRoutes, routePool[0], routePool[5]) + case 2: + n.Hostinfo.RoutableIPs = append(n.Hostinfo.RoutableIPs, + routePool[7:]...) + n.ApprovedRoutes = append(n.ApprovedRoutes, routePool[7:]...) + } + + nodes[i] = n + } + + for _, pol := range policies { + pm, err := NewPolicyManager([]byte(pol.policy), users, nodes.ViewSlice()) + require.NoError(rt, err) + require.Equal(rt, peerMapPrefixReference(pm, nodes), + pm.BuildPeerMap(nodes.ViewSlice()), pol.name) + } + }) +} + +func peerMapPrefixReference( + pm *PolicyManager, + nodes types.Nodes, +) map[types.NodeID][]types.NodeID { + pm.mu.RLock() + defer pm.mu.RUnlock() + + nodeMatchers := make(map[types.NodeID][]matcher.Match, len(nodes)) + for _, n := range nodes { + if pm.needsPerNodeFilter { + nodeMatchers[n.ID] = matcher.MatchesFromFilterRules( + pm.filterRulesForNodeLocked(n.View())) + } else { + nodeMatchers[n.ID] = pm.matchers + } + } + + peers := make(map[types.NodeID][]types.NodeID) + + for i, a := range nodes { + for _, b := range nodes[i+1:] { + mi, mj := nodeMatchers[a.ID], nodeMatchers[b.ID] + if canAccessPrefixReference(a, b, mi) || + canAccessPrefixReference(b, a, mi) || + canAccessPrefixReference(a, b, mj) || + canAccessPrefixReference(b, a, mj) { + peers[a.ID] = append(peers[a.ID], b.ID) + peers[b.ID] = append(peers[b.ID], a.ID) + } + } + } + + return peers +} + +func canAccessPrefixReference(a, b *types.Node, matchers []matcher.Match) bool { + src, dst := a.IPs(), b.IPs() + srcRoutes, dstRoutes := a.SubnetRoutes(), b.SubnetRoutes() + + for _, m := range matchers { + srcMatchesIP := m.SrcsContainsIPs(src...) + srcMatchesRoutes := len(srcRoutes) > 0 && m.SrcsOverlapsPrefixes(srcRoutes...) + + if !srcMatchesIP && !srcMatchesRoutes { + continue + } + + if m.DestsContainsIP(dst...) { + return true + } + + if len(dstRoutes) > 0 && m.DestsOverlapsPrefixes(dstRoutes...) { + return true + } + + if b.IsExitNode() && m.DestsIsTheInternet() { + return true + } + } + + return false +} + +func BenchmarkBuildPeerMapManyRoutes(b *testing.B) { + for _, pol := range benchPolicies { + for _, n := range []int{64, 128} { + b.Run(fmt.Sprintf("%s/n=%d", pol.name, n), func(b *testing.B) { + users, nodes := benchNodes(n) + for _, node := range nodes { + node.Hostinfo, node.ApprovedRoutes = nil, nil + } + + routes := make([]netip.Prefix, 10000) + for i := range routes { + ip := netip.AddrFrom4([4]byte{ + 10, 100, byte((i >> 7) & 0xff), byte((i << 1) & 0xff), + }) + routes[i] = netip.PrefixFrom(ip, 32) + } + + nodes[0].Hostinfo = &tailcfg.Hostinfo{RoutableIPs: routes} + nodes[0].ApprovedRoutes = routes + views := nodes.ViewSlice() + pm, err := NewPolicyManager([]byte(pol.policy), users, views) + require.NoError(b, err) + + rules, err := pm.FilterForNode(nodes[0].View()) + require.NoError(b, err) + require.Equal(b, pol.routerFiltered, len(rules) > 0) + b.ReportAllocs() + + for b.Loop() { + pm.BuildPeerMap(views) + } + }) } } } diff --git a/hscontrol/policy/v2/peermap_scan_test.go b/hscontrol/policy/v2/peermap_scan_test.go index 29ebc7e0a..19bdd12d3 100644 --- a/hscontrol/policy/v2/peermap_scan_test.go +++ b/hscontrol/policy/v2/peermap_scan_test.go @@ -10,6 +10,7 @@ import ( "github.com/juanfont/headscale/hscontrol/policy/matcher" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/require" + "go4.org/netipx" "tailscale.com/tailcfg" "tailscale.com/types/views" ) @@ -22,13 +23,15 @@ func buildPeerMapPairScan(pm *PolicyManager, nodes views.Slice[types.NodeView]) defer pm.mu.RUnlock() type nodeRoutes struct { - subnet []netip.Prefix + subnet []netipx.IPRange isExit bool } routeInfo := make(map[types.NodeID]nodeRoutes, nodes.Len()) for _, n := range nodes.All() { - routeInfo[n.ID()] = nodeRoutes{subnet: n.SubnetRoutes(), isExit: n.IsExitNode()} + routeInfo[n.ID()] = nodeRoutes{ + subnet: types.RouteRanges(n.SubnetRoutes()), isExit: n.IsExitNode(), + } } ret := make(map[types.NodeID][]types.NodeID, nodes.Len()) diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index e8f1d7c9c..ab08b3a4f 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -781,16 +781,18 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ pm.mu.RLock() defer pm.mu.RUnlock() - // Precompute each node's subnet routes and exit-node status once; the + // Precompute each node's route ranges and exit-node status once; the // O(n^2) pair scans below would otherwise recompute them for every pair. type nodeRoutes struct { - subnet []netip.Prefix + subnet []netipx.IPRange isExit bool } routeInfo := make(map[types.NodeID]nodeRoutes, nodes.Len()) for _, n := range nodes.All() { - routeInfo[n.ID()] = nodeRoutes{subnet: n.SubnetRoutes(), isExit: n.IsExitNode()} + routeInfo[n.ID()] = nodeRoutes{ + subnet: types.RouteRanges(n.SubnetRoutes()), isExit: n.IsExitNode(), + } } // If we have a global filter, use it for all nodes (normal case). @@ -825,7 +827,7 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ for k := range pm.matchers { m := &pm.matchers[k] - if m.SrcsContainsIPs(ips...) || (routed && m.SrcsOverlapsPrefixes(ri.subnet...)) { + if m.SrcsContainsIPs(ips...) || (routed && m.SrcsOverlapsRanges(ri.subnet)) { if srcOf[i] == nil { srcOf[i] = make([]uint64, words) } @@ -833,7 +835,7 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ srcOf[i][k/64] |= 1 << (k % 64) //nolint:mnd } - if m.DestsContainsIP(ips...) || (routed && m.DestsOverlapsPrefixes(ri.subnet...)) || + if m.DestsContainsIP(ips...) || (routed && m.DestsOverlapsRanges(ri.subnet)) || (ri.isExit && m.DestsIsTheInternet()) { if dstOf[i] == nil { dstOf[i] = make([]uint64, words) diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 55eb0b11d..f39989271 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -392,6 +392,25 @@ func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) { } } +// RouteRanges returns sorted, disjoint ranges covering valid route prefixes. +func RouteRanges(prefixes []netip.Prefix) []netipx.IPRange { + if len(prefixes) == 0 { + return nil + } + + var builder netipx.IPSetBuilder + + for _, prefix := range prefixes { + if prefix.IsValid() { + builder.AddPrefix(prefix) + } + } + + set, _ := builder.IPSet() + + return set.Ranges() +} + // CanAccess reports whether node may reach node2 under the given // matchers. A node owns two source identities for ACL purposes: // - its own IPs (regular peer membership) @@ -402,17 +421,19 @@ func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) { // matching node2's IPs, node2's approved subnet routes, or "the // internet" when node2 is an exit node — grants access. func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool { - return node.canAccess(matchers, node2, node.SubnetRoutes(), node2.SubnetRoutes(), node2.IsExitNode()) + return node.canAccess(matchers, node2, + RouteRanges(node.SubnetRoutes()), RouteRanges(node2.SubnetRoutes()), + node2.IsExitNode()) } // canAccess is [Node.CanAccess] with the snapshot-stable route data supplied by -// the caller. The peer-map build precomputes each node's SubnetRoutes and +// the caller. The peer-map build precomputes each node's route ranges and // exit-node status once and passes them here, so the O(n^2) pair scan does not // recompute them for every pair. func (node *Node) canAccess( matchers []matcher.Match, node2 *Node, - srcRoutes, dstRoutes []netip.Prefix, + srcRoutes, dstRoutes []netipx.IPRange, dstIsExit bool, ) bool { src := node.IPs() @@ -420,7 +441,7 @@ func (node *Node) canAccess( for _, m := range matchers { srcMatchesIP := m.SrcsContainsIPs(src...) - srcMatchesRoutes := len(srcRoutes) > 0 && m.SrcsOverlapsPrefixes(srcRoutes...) + srcMatchesRoutes := len(srcRoutes) > 0 && m.SrcsOverlapsRanges(srcRoutes) if !srcMatchesIP && !srcMatchesRoutes { continue @@ -430,7 +451,7 @@ func (node *Node) canAccess( return true } - if len(dstRoutes) > 0 && m.DestsOverlapsPrefixes(dstRoutes...) { + if len(dstRoutes) > 0 && m.DestsOverlapsRanges(dstRoutes) { return true } @@ -1026,12 +1047,13 @@ func (nv NodeView) CanAccess(matchers []matcher.Match, node2 NodeView) bool { return nv.ж.CanAccess(matchers, node2.ж) } -// CanAccessWithRoutes is [NodeView.CanAccess] with precomputed route data, used +// CanAccessWithRoutes is [NodeView.CanAccess] with precomputed route ranges, used // by the peer-map build to avoid recomputing each node's routes per pair. +// Callers must pass sorted, disjoint ranges, as returned by [RouteRanges]. func (nv NodeView) CanAccessWithRoutes( matchers []matcher.Match, node2 NodeView, - srcRoutes, dstRoutes []netip.Prefix, + srcRoutes, dstRoutes []netipx.IPRange, dstIsExit bool, ) bool { if !nv.Valid() || !node2.Valid() {