mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-17 13:30:49 +09:00
policy: precompute node routes in the peer-map build
CanAccess recomputed each node's routes per pair, making the scan O(n^2)-heavy.
This commit is contained in:
committed by
Kristoffer Dalby
parent
0e7b154617
commit
2c9164b1c4
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+28
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user