diff --git a/hscontrol/policy/v2/canaccess_equiv_test.go b/hscontrol/policy/v2/canaccess_equiv_test.go new file mode 100644 index 00000000..198ec41a --- /dev/null +++ b/hscontrol/policy/v2/canaccess_equiv_test.go @@ -0,0 +1,50 @@ +package v2 + +import ( + "net/netip" + "testing" + + "github.com/juanfont/headscale/hscontrol/policy/matcher" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "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"} + + 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")} + + 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")} + + plain := node("plain", "100.64.0.3", "fd7a:115c:a1e0::3", user) + + matchers := matcher.MatchesFromFilterRules([]tailcfg.FilterRule{ + { + SrcIPs: []string{"*"}, + DstPorts: []tailcfg.NetPortRange{{IP: "*", Ports: tailcfg.PortRangeAny}}, + }, + }) + + views := []types.NodeView{subnetRouter.View(), exitNode.View(), plain.View()} + + for _, a := range views { + for _, b := range views { + if a.ID() == b.ID() { + continue + } + + 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()) + } + } +} diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index d1c570ad..19a295c4 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -600,6 +600,18 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ pm.mu.Lock() defer pm.mu.Unlock() + // Precompute each node's subnet routes 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 + 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()} + } + // If we have a global filter, use it for all nodes (normal case). // Via grants require the per-node path because the global filter // skips via grants (compileFilterRules: if len(grant.Via) > 0 { continue }). @@ -613,7 +625,9 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ continue } - if nodes.At(i).CanAccess(pm.matchers, nodes.At(j)) || nodes.At(j).CanAccess(pm.matchers, nodes.At(i)) { + ri, rj := routeInfo[nodes.At(i).ID()], routeInfo[nodes.At(j).ID()] + if nodes.At(i).CanAccessWithRoutes(pm.matchers, nodes.At(j), ri.subnet, rj.subnet, rj.isExit) || + nodes.At(j).CanAccessWithRoutes(pm.matchers, nodes.At(i), rj.subnet, ri.subnet, ri.isExit) { ret[nodes.At(i).ID()] = append(ret[nodes.At(i).ID()], nodes.At(j)) ret[nodes.At(j).ID()] = append(ret[nodes.At(j).ID()], nodes.At(i)) } @@ -645,10 +659,12 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ for i := range nodes.Len() { nodeI := nodes.At(i) matchersI, hasFilterI := nodeMatchers[nodeI.ID()] + riI := routeInfo[nodeI.ID()] for j := i + 1; j < nodes.Len(); j++ { nodeJ := nodes.At(j) matchersJ, hasFilterJ := nodeMatchers[nodeJ.ID()] + riJ := routeInfo[nodeJ.ID()] // Check all access directions for symmetric peer visibility. // For via grants, filter rules exist on the via-designated node @@ -659,10 +675,10 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ // using nodeI's matchers? (reverse direction: the matchers // on the via node accept traffic FROM the source) // Same for matchersJ in both directions. - canIAccessJ := hasFilterI && nodeI.CanAccess(matchersI, nodeJ) - canJAccessI := hasFilterJ && nodeJ.CanAccess(matchersJ, nodeI) - canJReachI := hasFilterI && nodeJ.CanAccess(matchersI, nodeI) - canIReachJ := hasFilterJ && nodeI.CanAccess(matchersJ, nodeJ) + canIAccessJ := hasFilterI && nodeI.CanAccessWithRoutes(matchersI, nodeJ, riI.subnet, riJ.subnet, riJ.isExit) + canJAccessI := hasFilterJ && nodeJ.CanAccessWithRoutes(matchersJ, nodeI, riJ.subnet, riI.subnet, riI.isExit) + canJReachI := hasFilterI && nodeJ.CanAccessWithRoutes(matchersI, nodeI, riJ.subnet, riI.subnet, riI.isExit) + canIReachJ := hasFilterJ && nodeI.CanAccessWithRoutes(matchersJ, nodeJ, riI.subnet, riJ.subnet, riJ.isExit) if canIAccessJ || canJAccessI || canJReachI || canIReachJ { ret[nodeI.ID()] = append(ret[nodeI.ID()], nodeJ) diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index fe120a62..5060dde2 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -363,11 +363,21 @@ 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()) +} + +// canAccess is [Node.CanAccess] with the snapshot-stable route data supplied by +// the caller. The peer-map build precomputes each node's SubnetRoutes 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, + dstIsExit bool, +) bool { src := node.IPs() allowedIPs := node2.IPs() - srcRoutes := node.SubnetRoutes() - dstRoutes := node2.SubnetRoutes() - dstIsExit := node2.IsExitNode() for _, m := range matchers { srcMatchesIP := m.SrcsContainsIPs(src...) @@ -864,6 +874,21 @@ func (nv NodeView) CanAccess(matchers []matcher.Match, node2 NodeView) bool { return nv.ж.CanAccess(matchers, node2.ж) } +// CanAccessWithRoutes is [NodeView.CanAccess] with precomputed route data, used +// by the peer-map build to avoid recomputing each node's routes per pair. +func (nv NodeView) CanAccessWithRoutes( + matchers []matcher.Match, + node2 NodeView, + srcRoutes, dstRoutes []netip.Prefix, + dstIsExit bool, +) bool { + if !nv.Valid() || !node2.Valid() { + return false + } + + return nv.ж.canAccess(matchers, node2.ж, srcRoutes, dstRoutes, dstIsExit) +} + func (nv NodeView) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool { if !nv.Valid() { return false