policy/matcher, types: compare a node's routes to matchers as ranges

This commit is contained in:
Kristoffer Dalby committed 2026-10-30 14:55:36 +00:00
1 parent 926998d553
commit 05a5c11a20
5 files changed
+304 -41

No files matched your search

+20
View File
@@ -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
+243 -27
View File
@@ -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)
}
})
}
}
}
+5 -2
View File
@@ -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())
+7 -5
View File
@@ -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)
+29 -7
View File
@@ -390,6 +390,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)
@@ -400,17 +419,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()
@@ -418,7 +439,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
@@ -428,7 +449,7 @@ func (node *Node) canAccess(
return true
}
if len(dstRoutes) > 0 && m.DestsOverlapsPrefixes(dstRoutes...) {
if len(dstRoutes) > 0 && m.DestsOverlapsRanges(dstRoutes) {
return true
}
@@ -962,12 +983,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() {