policy/v2: reuse grant destination resolutions

Avoid resolving the same destination for each protocol and capability rule.
This commit is contained in:
Kristoffer Dalby committed 2026-10-22 22:17:36 +02:00
1 parent 893b7fd908
commit 65ac7af595
2 files changed
+589

No files matched your search

+37
View File
@@ -483,6 +483,21 @@ func buildSrcIPStrings(
return srcIPStrs
}
type resolvedDestination struct {
Alias
addresses ResolvedAddresses
err error
}
func (dst resolvedDestination) Resolve(
_ *Policy,
_ types.Users,
_ views.Slice[types.NodeView],
) (ResolvedAddresses, error) {
return dst.addresses, dst.err
}
// compileOtherDests compiles filter rules for non-self, non-via
// destinations. This produces both [tailcfg.FilterRule.DstPorts] rules
// (from [Grant.InternetProtocols]) and [tailcfg.CapGrant] rules (from
@@ -497,6 +512,28 @@ func (pol *Policy) compileOtherDests(
) []tailcfg.FilterRule {
var rules []tailcfg.FilterRule
// Reuse resolutions while keeping the port encoder's partial-error handling
// and logging. Wildcards must retain their special port and companion rules.
if len(grant.InternetProtocols) > 1 ||
(len(grant.InternetProtocols) > 0 && grant.App != nil) {
resolvedDests := make(Aliases, len(otherDests))
for i, dst := range otherDests {
resolvedDests[i] = dst
if _, wildcard := dst.(Asterix); wildcard {
continue
}
addresses, err := dst.Resolve(pol, users, nodes)
resolvedDests[i] = resolvedDestination{
Alias: dst,
addresses: addresses,
err: err,
}
}
otherDests = resolvedDests
}
// DstPorts rules from InternetProtocols.
for _, ipp := range grant.InternetProtocols {
destPorts := pol.destinationsToNetPortRange(
+552
View File
@@ -0,0 +1,552 @@
package v2
import (
"bytes"
"encoding/json"
"fmt"
"math/rand/v2"
"net/netip"
"slices"
"strconv"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/views"
)
// compileOtherDestsReference retains the original implementation for exact-output
// differential tests and whole-helper benchmarks. Keep its body unchanged.
func (pol *Policy) compileOtherDestsReference(
users types.Users,
nodes views.Slice[types.NodeView],
grant Grant,
otherDests Aliases,
resolvedSrcs []ResolvedAddresses,
srcIPStrings []string,
) []tailcfg.FilterRule {
var rules []tailcfg.FilterRule
// DstPorts rules from InternetProtocols.
for _, ipp := range grant.InternetProtocols {
destPorts := pol.destinationsToNetPortRange(
users, nodes, otherDests, ipp.Ports,
)
if len(destPorts) > 0 && len(srcIPStrings) > 0 {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcIPStrings,
DstPorts: destPorts,
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})
}
}
// CapGrant rules from App.
if grant.App != nil {
capSrcIPStrs := srcIPStrings
// When sources resolved to empty but App is set,
// Tailscale still produces the CapGrant rule with
// empty SrcIPs.
if capSrcIPStrs == nil {
capSrcIPStrs = []string{}
}
var (
capGrants []tailcfg.CapGrant
dstIPStrings []string
)
for _, dst := range otherDests {
ips, err := dst.Resolve(pol, users, nodes)
if err != nil {
continue
}
capGrants = append(capGrants, tailcfg.CapGrant{
Dsts: ips.Prefixes(),
CapMap: grant.App,
})
dstIPStrings = append(dstIPStrings, ips.Strings()...)
}
if len(capGrants) > 0 {
srcPrefixes := make([]netip.Prefix, 0, len(resolvedSrcs)*2)
for _, ips := range resolvedSrcs {
srcPrefixes = append(
srcPrefixes, ips.Prefixes()...,
)
}
rules = append(rules, tailcfg.FilterRule{
SrcIPs: capSrcIPStrs,
CapGrant: capGrants,
})
dstsHaveWildcard := sourcesHaveWildcard(otherDests)
if dstsHaveWildcard {
dstIPStrings = append(
dstIPStrings,
approvedSubnetRoutes(nodes)...,
)
}
rules = append(
rules,
companionCapGrantRules(
dstIPStrings, srcPrefixes, grant.App,
)...,
)
}
}
return rules
}
func compileOtherDestsFixture(
tb testing.TB,
userCount, nodeCount int,
) (*Policy, types.Users, types.Nodes) {
tb.Helper()
users := make(types.Users, userCount)
usernames := make(Usernames, userCount)
for i := range users {
users[i] = types.User{ID: uint(i + 1), Name: fmt.Sprintf("u%d", i)}
usernames[i] = Username(users[i].Name + "@")
}
pol := &Policy{
Groups: Groups{
"group:all": usernames,
"group:some": usernames[:(userCount+1)/2],
"group:empty": {},
"group:missing": {"missing@"},
},
Hosts: Hosts{
"host4": Prefix(netip.MustParsePrefix("100.64.0.0/24")),
"host6": Prefix(netip.MustParsePrefix("fd7a:115c:a1e0::1/128")),
},
TagOwners: TagOwners{
"tag:a": {up("u0@")},
"tag:b": {gp("group:some")},
},
Grants: []Grant{{
Sources: Aliases{Asterix(0), tp("tag:a")},
Destinations: Aliases{gp("group:all"), hp("host4")},
InternetProtocols: []ProtocolPort{
{Protocol: "tcp", Ports: []tailcfg.PortRange{{First: 22, Last: 22}}},
{Protocol: "udp", Ports: []tailcfg.PortRange{{First: 53, Last: 53}}},
{Protocol: "tcp", Ports: []tailcfg.PortRange{{First: 80, Last: 81}}},
},
App: tailcfg.PeerCapMap{
peercap.Taildrive: nil,
peercap.Relay: {},
"example.com/cap": {
tailcfg.RawMessage(`{"enabled":true,"ports":[22,80]}`),
},
},
}},
}
data, err := json.Marshal(pol)
require.NoError(tb, err)
pol, err = unmarshalPolicy(data)
require.NoError(tb, err)
nodes := make(types.Nodes, nodeCount)
for i := range nodes {
u := users[i%len(users)]
ip4 := netip.AddrFrom4(
[4]byte{100, 64, byte(i / 256), byte(i % 256)},
) //nolint:gosec
ip6 := netip.MustParseAddr(fmt.Sprintf("fd7a:115c:a1e0::%x", i+1))
n := &types.Node{
ID: types.NodeID(i + 1), //nolint:gosec
Hostname: fmt.Sprintf("n%d", i),
UserID: new(u.ID),
User: new(u),
IPv4: new(ip4),
IPv6: new(ip6),
}
if i%5 == 0 {
n.Tags = []string{"tag:a", "tag:b"}
}
if i%4 == 0 {
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.33.0.0/16"),
netip.MustParsePrefix("10.33.0.0/24"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
}}
n.ApprovedRoutes = slices.Clone(n.Hostinfo.RoutableIPs)
}
nodes[i] = n
}
return pol, users, nodes
}
func assertCompileOtherDestsReference(
t *testing.T,
pol *Policy,
users types.Users,
nodes types.Nodes,
grant Grant,
) {
t.Helper()
srcs, nonWild, err := resolveSources(pol, grant.Sources, users, nodes.ViewSlice())
require.NoError(t, err)
srcStrings := buildSrcIPStrings(srcs, nonWild,
sourcesHaveWildcard(grant.Sources), sourcesHaveDangerAll(grant.Sources),
nodes.ViewSlice())
if grant.Sources != nil && len(grant.Sources) == 0 {
srcStrings = []string{}
}
before, err := json.Marshal(grant)
require.NoError(t, err)
previousLogger, previousLevel := log.Logger, zerolog.GlobalLevel()
defer func() {
log.Logger = previousLogger
zerolog.SetGlobalLevel(previousLevel)
}()
zerolog.SetGlobalLevel(zerolog.TraceLevel)
var wantLog, gotLog bytes.Buffer
log.Logger = zerolog.New(&wantLog)
want := pol.compileOtherDestsReference(users, nodes.ViewSlice(), grant,
grant.Destinations, srcs, srcStrings)
log.Logger = zerolog.New(&gotLog)
got := pol.compileOtherDests(users, nodes.ViewSlice(), grant,
grant.Destinations, srcs, srcStrings)
require.Equal(t, want, got, "ordered rules, duplicates, and nil/empty must match")
require.Equal(t, wantLog.String(), gotLog.String(), "resolution logs must match")
wantJSON, err := json.Marshal(want)
require.NoError(t, err)
gotJSON, err := json.Marshal(got)
require.NoError(t, err)
require.JSONEq(
t,
string(wantJSON),
string(gotJSON),
"wire output must match exactly",
)
after, err := json.Marshal(grant)
require.NoError(t, err)
require.Equal(
t,
string(before),
string(after),
"compilation must not mutate the grant",
)
}
func TestCompileOtherDestsMatchesReference(t *testing.T) {
for _, name := range []string{
"mixed", "repeated", "wildcard", "nil-destinations", "empty-destinations",
"nil-protocols", "empty-protocols", "app-only", "single-protocol",
"single-protocol-app", "nil-sources", "empty-sources", "empty-app",
"empty-group", "missing-user", "missing-host", "partial-group",
} {
t.Run(name, func(t *testing.T) {
pol, users, nodes := compileOtherDestsFixture(t, 4, 24)
grant := pol.Grants[0]
switch name {
case "mixed":
grant.Destinations = Aliases{
tp("tag:a"),
hp("host6"),
gp("group:some"),
up("u1@"),
pp("10.33.0.0/16"),
}
case "repeated":
grant.Destinations = Aliases{
gp("group:all"),
gp("group:all"),
hp("host4"),
hp("host4"),
}
case "wildcard":
grant.Destinations = Aliases{Asterix(0), hp("host4"), Asterix(0)}
case "nil-destinations":
grant.Destinations = nil
case "empty-destinations":
grant.Destinations = Aliases{}
case "nil-protocols":
grant.InternetProtocols, grant.App = nil, nil
case "empty-protocols":
grant.InternetProtocols, grant.App = []ProtocolPort{}, tailcfg.PeerCapMap{}
case "app-only":
grant.InternetProtocols = nil
case "single-protocol":
grant.InternetProtocols, grant.App = grant.InternetProtocols[:1], nil
case "single-protocol-app":
grant.InternetProtocols = grant.InternetProtocols[:1]
case "nil-sources":
grant.Sources = nil
case "empty-sources":
grant.Sources = Aliases{}
case "empty-app":
grant.App = tailcfg.PeerCapMap{}
case "empty-group":
grant.Destinations = Aliases{gp("group:empty")}
case "missing-user":
grant.Destinations = Aliases{up("missing@"), gp("group:missing")}
case "missing-host":
grant.Destinations = Aliases{
hp("undefined"),
hp("host4"),
hp("undefined"),
}
case "partial-group":
users = append(users, types.User{ID: 100, Name: users[0].Name})
ips, err := grant.Destinations[0].Resolve(pol, users, nodes.ViewSlice())
require.Error(t, err)
require.NotNil(t, ips)
require.False(
t,
ips.Empty(),
"exercise a partial result alongside the error",
)
}
assertCompileOtherDestsReference(t, pol, users, nodes, grant)
})
}
}
//nolint:gosec // Seeded random data makes the differential test reproducible.
func TestCompileOtherDestsRandomizedReference(t *testing.T) {
rng := rand.New(rand.NewPCG(3556, 20261018))
aliases := []string{
"*", "autogroup:member", "autogroup:tagged", "group:all", "group:some",
"group:empty", "group:missing", "tag:a", "tag:b", "host4", "host6",
"u0@", "u1@", "missing@", "100.64.0.1", "10.33.0.0/16", "fd7a:115c:a1e0::1",
}
for iteration := range 200 {
t.Run(strconv.Itoa(iteration), func(t *testing.T) {
pol, users, nodes := compileOtherDestsFixture(t, 4, rng.IntN(32))
for _, n := range nodes {
if rng.IntN(4) == 0 {
n.IPv4 = nil
}
if rng.IntN(4) == 0 {
n.IPv6 = nil
}
if rng.IntN(5) == 0 {
n.User = nil
}
if n.Hostinfo != nil && rng.IntN(2) == 0 {
n.ApprovedRoutes = n.ApprovedRoutes[:1]
}
}
grant := pol.Grants[0]
grant.Destinations = make(Aliases, rng.IntN(12))
for i := range grant.Destinations {
name := aliases[rng.IntN(len(aliases))]
if i > 0 && rng.IntN(2) == 0 {
name = grant.Destinations[i-1].String()
}
alias, err := parseAlias(name)
require.NoError(t, err)
grant.Destinations[i] = alias
}
grant.InternetProtocols = make([]ProtocolPort, rng.IntN(5))
for i := range grant.InternetProtocols {
grant.InternetProtocols[i] = ProtocolPort{
Protocol: Protocol([]string{"tcp", "udp", "icmp", ""}[rng.IntN(4)]),
}
for range rng.IntN(4) {
port := uint16(rng.IntN(65536)) //nolint:gosec
grant.InternetProtocols[i].Ports = append(
grant.InternetProtocols[i].Ports,
tailcfg.PortRange{First: port, Last: port},
)
}
}
switch rng.IntN(4) {
case 0:
grant.App = nil
grant.Destinations = append(grant.Destinations, new(AutoGroupInternet))
case 1:
grant.App = tailcfg.PeerCapMap{}
}
switch rng.IntN(4) {
case 0:
grant.Sources = nil
case 1:
grant.Sources = Aliases{}
case 2:
grant.Sources = Aliases{up("missing@")}
}
if rng.IntN(3) == 0 {
users = append(users, types.User{ID: 100, Name: users[0].Name})
grant.Destinations = append(
grant.Destinations,
gp("group:all"),
hp("undefined"),
)
}
assertCompileOtherDestsReference(t, pol, users, nodes, grant)
})
}
}
type countedDestination struct {
Alias
calls int
}
func (dst *countedDestination) Resolve(
pol *Policy,
users types.Users,
nodes views.Slice[types.NodeView],
) (ResolvedAddresses, error) {
dst.calls++
return dst.Alias.Resolve(pol, users, nodes)
}
func TestCompileOtherDestsResolvesOnce(t *testing.T) {
pol, users, nodes := compileOtherDestsFixture(t, 4, 24)
grant := pol.Grants[0]
srcs, nonWild, err := resolveSources(pol, grant.Sources, users, nodes.ViewSlice())
require.NoError(t, err)
srcStrings := buildSrcIPStrings(srcs, nonWild, true, false, nodes.ViewSlice())
dst := &countedDestination{Alias: gp("group:all")}
grant.Destinations = Aliases{dst}
want := pol.compileOtherDestsReference(users, nodes.ViewSlice(), grant,
grant.Destinations, srcs, srcStrings)
require.Equal(t, len(grant.InternetProtocols)+1, dst.calls)
dst.calls = 0
got := pol.compileOtherDests(users, nodes.ViewSlice(), grant,
grant.Destinations, srcs, srcStrings)
require.Equal(t, want, got)
require.Equal(
t,
1,
dst.calls,
"share the real group resolution across protocols and app",
)
}
var compileOtherDestsBenchmarkResult []tailcfg.FilterRule
func BenchmarkCompileOtherDests(b *testing.B) {
for _, shape := range []struct {
name string
users int
nodes int
dsts []string
single bool
}{
{name: "single-protocol", users: 8, nodes: 128, dsts: []string{"group:all", "host4"}, single: true},
{name: "prefix-only", users: 8, nodes: 128, dsts: []string{"100.64.0.0/24", "fd7a:115c:a1e0::/64"}},
{name: "wildcard-only", users: 8, nodes: 128, dsts: []string{"*"}},
{name: "repeated", users: 8, nodes: 128, dsts: []string{"group:all", "host4", "group:all", "host4"}},
{name: "unique", users: 8, nodes: 128, dsts: []string{"group:all", "group:some", "tag:a", "tag:b", "u0@", "u1@", "host4", "host6"}},
{name: "large-group", users: 128, nodes: 1000, dsts: []string{"group:all", "group:some", "group:all", "group:some"}},
} {
b.Run(shape.name, func(b *testing.B) {
pol, users, nodes := compileOtherDestsFixture(b, shape.users, shape.nodes)
grant := pol.Grants[0]
grant.Destinations = nil
for _, name := range shape.dsts {
alias, err := parseAlias(name)
require.NoError(b, err)
grant.Destinations = append(grant.Destinations, alias)
}
if shape.single {
grant.InternetProtocols, grant.App = grant.InternetProtocols[:1], nil
}
srcs, nonWild, err := resolveSources(
pol,
grant.Sources,
users,
nodes.ViewSlice(),
)
require.NoError(b, err)
srcStrings := buildSrcIPStrings(
srcs,
nonWild,
true,
false,
nodes.ViewSlice(),
)
want := pol.compileOtherDestsReference(users, nodes.ViewSlice(), grant,
grant.Destinations, srcs, srcStrings)
require.NotEmpty(b, want)
require.Equal(
b,
want,
pol.compileOtherDests(users, nodes.ViewSlice(), grant,
grant.Destinations, srcs, srcStrings),
)
for _, impl := range []struct {
name string
fn func(types.Users, views.Slice[types.NodeView], Grant, Aliases, []ResolvedAddresses, []string) []tailcfg.FilterRule
}{
{name: "reference", fn: pol.compileOtherDestsReference},
{name: "optimized", fn: pol.compileOtherDests},
} {
b.Run(impl.name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
compileOtherDestsBenchmarkResult = impl.fn(
users,
nodes.ViewSlice(),
grant,
grant.Destinations,
srcs,
srcStrings,
)
}
})
}
})
}
}