mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 04:34:07 +09:00
types, state: reduce a node's routes against the matchers once
Reuse matcher membership and approved subnets across the route list. Preserve route order and HA co-router visibility.
This commit is contained in:
1 parent
9093e0108b
commit
5314508846
5 files changed
+255
-39
No files matched your search
@@ -16,15 +16,7 @@ func ReduceRoutes(
|
||||
routes []netip.Prefix,
|
||||
matchers []matcher.Match,
|
||||
) []netip.Prefix {
|
||||
var result []netip.Prefix
|
||||
|
||||
for _, route := range routes {
|
||||
if node.CanAccessRoute(matchers, route) {
|
||||
result = append(result, route)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return node.CanAccessRoutes(matchers, routes)
|
||||
}
|
||||
|
||||
// ApproveRoutesWithPolicy checks if the node can approve the announced routes
|
||||
|
||||
@@ -1619,3 +1619,76 @@ func TestReduceRoutes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceRoutesDerivesNodeRoutesOnce(t *testing.T) {
|
||||
routes := make([]netip.Prefix, 16)
|
||||
|
||||
ip := netip.MustParseAddr("10.0.0.0")
|
||||
for i := range routes {
|
||||
routes[i] = netip.PrefixFrom(ip, 32)
|
||||
ip = ip.Next()
|
||||
}
|
||||
|
||||
n := &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: routes},
|
||||
ApprovedRoutes: routes,
|
||||
}
|
||||
matchers := matcher.MatchesFromFilterRules([]tailcfg.FilterRule{{
|
||||
SrcIPs: []string{"*"},
|
||||
DstPorts: []tailcfg.NetPortRange{{
|
||||
IP: "192.0.2.0/24", Ports: tailcfg.PortRangeAny,
|
||||
}},
|
||||
}})
|
||||
|
||||
var reduced []netip.Prefix
|
||||
|
||||
single := testing.AllocsPerRun(3, func() {
|
||||
reduced = ReduceRoutes(n.View(), routes[:1], matchers)
|
||||
})
|
||||
|
||||
require.Nil(t, reduced)
|
||||
|
||||
bulk := testing.AllocsPerRun(3, func() {
|
||||
reduced = ReduceRoutes(n.View(), routes, matchers)
|
||||
})
|
||||
|
||||
require.Nil(t, reduced)
|
||||
require.LessOrEqual(t, bulk, single+1,
|
||||
"route derivation must not repeat for each candidate",
|
||||
)
|
||||
}
|
||||
|
||||
func BenchmarkReduceRoutesOwnPrimaries(b *testing.B) {
|
||||
routes := make([]netip.Prefix, 1000)
|
||||
|
||||
ip := netip.MustParseAddr("10.0.0.0")
|
||||
for i := range routes {
|
||||
routes[i] = netip.PrefixFrom(ip, 32)
|
||||
ip = ip.Next()
|
||||
}
|
||||
|
||||
n := &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: routes},
|
||||
ApprovedRoutes: routes,
|
||||
}
|
||||
matchers := matcher.MatchesFromFilterRules([]tailcfg.FilterRule{{
|
||||
SrcIPs: []string{"*"},
|
||||
DstPorts: []tailcfg.NetPortRange{{
|
||||
IP: "192.0.2.0/24", Ports: tailcfg.PortRangeAny,
|
||||
}},
|
||||
}})
|
||||
|
||||
var reduced []netip.Prefix
|
||||
|
||||
b.ReportAllocs()
|
||||
|
||||
for b.Loop() {
|
||||
reduced = ReduceRoutes(n.View(), routes, matchers)
|
||||
}
|
||||
|
||||
if len(reduced) != 0 {
|
||||
b.Fatal("unmatched primaries must not be returned")
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/util/truncate"
|
||||
)
|
||||
|
||||
@@ -1671,11 +1672,15 @@ func (s *State) RoutesForPeer(
|
||||
// that the peer is HA primary for, the viewer must see that route
|
||||
// regardless of matcher authorization. HA secondaries need this to
|
||||
// know which peer is primary for their shared prefix.
|
||||
viewerSubnets := viewer.SubnetRoutes()
|
||||
if len(viewerSubnets) > 0 {
|
||||
for _, p := range globalPrimaries {
|
||||
if slices.Contains(viewerSubnets, p) && !slices.Contains(reduced, p) {
|
||||
reduced = append(reduced, p)
|
||||
if len(globalPrimaries) > 0 {
|
||||
viewerSubnets := set.SetOf(viewer.SubnetRoutes())
|
||||
if len(viewerSubnets) > 0 {
|
||||
seen := set.SetOf(reduced)
|
||||
for _, p := range globalPrimaries {
|
||||
if viewerSubnets.Contains(p) && !seen.Contains(p) {
|
||||
reduced = append(reduced, p)
|
||||
seen.Add(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
-25
@@ -440,8 +440,13 @@ func (node *Node) canAccess(
|
||||
return false
|
||||
}
|
||||
|
||||
// CanAccessRoute determines whether a specific route prefix should be
|
||||
// visible to this node based on the given matchers.
|
||||
// CanAccessRoute reports whether [Node.CanAccessRoutes] keeps route.
|
||||
func (node *Node) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
|
||||
return len(node.CanAccessRoutes(matchers, []netip.Prefix{route})) > 0
|
||||
}
|
||||
|
||||
// CanAccessRoutes returns the authorized routes in input order, evaluating the
|
||||
// node's matcher membership once for the whole list.
|
||||
//
|
||||
// Unlike [Node.CanAccess], this function intentionally does NOT check
|
||||
// [matcher.Match.DestsIsTheInternet]. Exit routes (0.0.0.0/0, ::/0) are handled by
|
||||
@@ -456,40 +461,47 @@ func (node *Node) canAccess(
|
||||
// internet" from internet-targeted ACLs. Wildcard "*" dests produce
|
||||
// matchers where [matcher.Match.DestsOverlapsPrefixes](0.0.0.0/0) already returns
|
||||
// true, so the check would be redundant for that case.
|
||||
func (node *Node) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
|
||||
src := node.IPs()
|
||||
func (node *Node) CanAccessRoutes(
|
||||
matchers []matcher.Match,
|
||||
routes []netip.Prefix,
|
||||
) []netip.Prefix {
|
||||
if len(routes) == 0 || len(matchers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ips := node.IPs()
|
||||
subnetRoutes := node.SubnetRoutes()
|
||||
sides := make([]struct{ isSrc, isDst bool }, len(matchers))
|
||||
|
||||
for _, matcher := range matchers {
|
||||
if matcher.SrcsContainsIPs(src...) && matcher.DestsOverlapsPrefixes(route) {
|
||||
return true
|
||||
}
|
||||
|
||||
if matcher.SrcsOverlapsPrefixes(route) && matcher.DestsContainsIP(src...) {
|
||||
return true
|
||||
}
|
||||
|
||||
for i, m := range matchers {
|
||||
// A subnet router acts on behalf of its advertised subnets.
|
||||
// If the node's approved subnet routes overlap the source set
|
||||
// and the route overlaps the destination set, the router needs
|
||||
// this route to forward traffic from its local subnet.
|
||||
if len(subnetRoutes) > 0 {
|
||||
if matcher.SrcsOverlapsPrefixes(subnetRoutes...) &&
|
||||
matcher.DestsOverlapsPrefixes(route) {
|
||||
return true
|
||||
}
|
||||
sides[i].isSrc = m.SrcsContainsIPs(ips...) ||
|
||||
m.SrcsOverlapsPrefixes(subnetRoutes...)
|
||||
|
||||
// Reverse: traffic from the route's subnet is destined for
|
||||
// this node's subnets; the router needs the route for return
|
||||
// traffic.
|
||||
if matcher.SrcsOverlapsPrefixes(route) &&
|
||||
matcher.DestsOverlapsPrefixes(subnetRoutes...) {
|
||||
return true
|
||||
// Reverse: traffic from the route's subnet is destined for
|
||||
// this node's subnets; the router needs the route for return
|
||||
// traffic.
|
||||
sides[i].isDst = m.DestsContainsIP(ips...) ||
|
||||
m.DestsOverlapsPrefixes(subnetRoutes...)
|
||||
}
|
||||
|
||||
var result []netip.Prefix
|
||||
|
||||
for _, route := range routes {
|
||||
for i, m := range matchers {
|
||||
if (sides[i].isSrc && m.DestsOverlapsPrefixes(route)) ||
|
||||
(sides[i].isDst && m.SrcsOverlapsPrefixes(route)) {
|
||||
result = append(result, route)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return result
|
||||
}
|
||||
|
||||
func (nodes Nodes) FilterByIP(ip netip.Addr) Nodes {
|
||||
@@ -973,6 +985,18 @@ func (nv NodeView) CanAccessRoute(matchers []matcher.Match, route netip.Prefix)
|
||||
return nv.ж.CanAccessRoute(matchers, route)
|
||||
}
|
||||
|
||||
// CanAccessRoutes is [Node.CanAccessRoutes] for a node view.
|
||||
func (nv NodeView) CanAccessRoutes(
|
||||
matchers []matcher.Match,
|
||||
routes []netip.Prefix,
|
||||
) []netip.Prefix {
|
||||
if !nv.Valid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nv.ж.CanAccessRoutes(matchers, routes)
|
||||
}
|
||||
|
||||
func (nv NodeView) AnnouncedRoutes() []netip.Prefix {
|
||||
if !nv.Valid() {
|
||||
return nil
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/stretchr/testify/require"
|
||||
"pgregory.net/rapid"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
@@ -1224,6 +1225,127 @@ func TestIsExitNodeSubnetRouterAllocs(t *testing.T) {
|
||||
require.Zero(t, allocs)
|
||||
}
|
||||
|
||||
func TestCanAccessRoutesMatchesReference(t *testing.T) {
|
||||
prefixStrings := []string{
|
||||
"100.64.0.1/32", "100.64.0.2/32", "100.64.0.0/24",
|
||||
"10.0.0.0/24", "10.0.1.0/24", "10.0.0.0/8", "192.168.0.0/16",
|
||||
"fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128", "fd7a:115c:a1e0::/64",
|
||||
"2001:db8::/32", "2001:db8:1::/64",
|
||||
"fd7a:115c:a1e0:b1a:0:7:a00:0/120",
|
||||
"0.0.0.0/0", "::/0", "0.0.0.0/1", "::/1",
|
||||
}
|
||||
prefixes := util.MustStringsToPrefixes(prefixStrings)
|
||||
routePool := append(slices.Clone(prefixes), netip.Prefix{})
|
||||
matchPool := append([]string{"*"}, prefixStrings...)
|
||||
prefixGen := rapid.SampledFrom(prefixes)
|
||||
matchGen := rapid.SampledFrom(matchPool)
|
||||
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
n := Node{
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: rapid.SliceOfN(prefixGen, 0, 8).Draw(rt, "announced"),
|
||||
},
|
||||
ApprovedRoutes: rapid.SliceOfN(prefixGen, 0, 8).Draw(rt, "approved"),
|
||||
}
|
||||
if rapid.Bool().Draw(rt, "hasIPv4") {
|
||||
ip := rapid.SampledFrom([]string{
|
||||
"100.64.0.1", "100.64.0.2", "10.0.0.1", "192.168.1.1",
|
||||
}).Draw(rt, "ipv4")
|
||||
n.IPv4 = new(netip.MustParseAddr(ip))
|
||||
}
|
||||
|
||||
if rapid.Bool().Draw(rt, "hasIPv6") {
|
||||
ip := rapid.SampledFrom([]string{
|
||||
"fd7a:115c:a1e0::1", "fd7a:115c:a1e0::2", "2001:db8::1",
|
||||
}).Draw(rt, "ipv6")
|
||||
n.IPv6 = new(netip.MustParseAddr(ip))
|
||||
}
|
||||
|
||||
count := rapid.IntRange(0, 5).Draw(rt, "matchers")
|
||||
matchers := make([]matcher.Match, 0, count)
|
||||
|
||||
for i := range count {
|
||||
sources := rapid.SliceOfN(matchGen, 0, 4).Draw(
|
||||
rt, fmt.Sprintf("sources-%d", i),
|
||||
)
|
||||
destinations := rapid.SliceOfN(matchGen, 0, 4).Draw(
|
||||
rt, fmt.Sprintf("destinations-%d", i),
|
||||
)
|
||||
|
||||
rule := tailcfg.FilterRule{SrcIPs: sources}
|
||||
for _, dst := range destinations {
|
||||
rule.DstPorts = append(rule.DstPorts, tailcfg.NetPortRange{
|
||||
IP: dst, Ports: tailcfg.PortRangeAny,
|
||||
})
|
||||
}
|
||||
|
||||
matchers = append(matchers, matcher.MatchFromFilterRule(rule))
|
||||
}
|
||||
|
||||
routes := rapid.SliceOfN(rapid.SampledFrom(routePool), 0, 16).Draw(rt, "routes")
|
||||
|
||||
var want []netip.Prefix
|
||||
|
||||
for _, route := range routes {
|
||||
allowed := canAccessRouteReference(&n, matchers, route)
|
||||
require.Equal(rt, allowed, n.CanAccessRoute(matchers, route))
|
||||
|
||||
if allowed {
|
||||
want = append(want, route)
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(rt, want, n.CanAccessRoutes(matchers, routes))
|
||||
require.Equal(rt, want, n.View().CanAccessRoutes(matchers, routes))
|
||||
})
|
||||
|
||||
var invalid NodeView
|
||||
|
||||
require.Nil(t, invalid.CanAccessRoutes(
|
||||
matcher.MatchesFromFilterRules(tailcfg.FilterAllowAll), prefixes,
|
||||
))
|
||||
}
|
||||
|
||||
func canAccessRouteReference(
|
||||
node *Node,
|
||||
matchers []matcher.Match,
|
||||
route netip.Prefix,
|
||||
) bool {
|
||||
src := node.IPs()
|
||||
subnetRoutes := node.SubnetRoutes()
|
||||
|
||||
for _, matcher := range matchers {
|
||||
if matcher.SrcsContainsIPs(src...) && matcher.DestsOverlapsPrefixes(route) {
|
||||
return true
|
||||
}
|
||||
|
||||
if matcher.SrcsOverlapsPrefixes(route) && matcher.DestsContainsIP(src...) {
|
||||
return true
|
||||
}
|
||||
|
||||
// A subnet router acts on behalf of its advertised subnets.
|
||||
// If the node's approved subnet routes overlap the source set
|
||||
// and the route overlaps the destination set, the router needs
|
||||
// this route to forward traffic from its local subnet.
|
||||
if len(subnetRoutes) > 0 {
|
||||
if matcher.SrcsOverlapsPrefixes(subnetRoutes...) &&
|
||||
matcher.DestsOverlapsPrefixes(route) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reverse: traffic from the route's subnet is destined for
|
||||
// this node's subnets; the router needs the route for return
|
||||
// traffic.
|
||||
if matcher.SrcsOverlapsPrefixes(route) &&
|
||||
matcher.DestsOverlapsPrefixes(subnetRoutes...) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func TestValidateHostinfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in new issue
Block a user