policy/v2: wire PolicyManager through compiledGrant

Updates #3157
This commit is contained in:
Kristoffer Dalby
2026-04-04 13:18:15 +00:00
parent 1ea3b676f2
commit f55d6c3cb0
6 changed files with 819 additions and 759 deletions
+725
View File
@@ -0,0 +1,725 @@
package v2
import (
"net/netip"
"slices"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
"go4.org/netipx"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
)
// grantCategory classifies a grant by what per-node work it needs.
type grantCategory int
const (
// grantCategoryRegular requires no per-node work. The pre-compiled
// rules are complete and only need ReduceFilterRules.
grantCategoryRegular grantCategory = iota
// grantCategorySelf has autogroup:self destinations that must be
// expanded per-node to same-user untagged device IPs.
grantCategorySelf
// grantCategoryVia has Via tags that route rules to specific
// nodes based on their tags and advertised routes.
grantCategoryVia
)
// compiledGrant is a grant with its sources already resolved to IP
// addresses. The expensive work (alias → IP resolution) is done once
// here. Extracting rules for a specific node reads from pre-resolved
// data without re-resolving.
type compiledGrant struct {
category grantCategory
// srcIPStrings is the final SrcIPs for non-self rules, with
// nonWildcardSrcs appended to match Tailscale SaaS behavior.
srcIPStrings []string
hasWildcard bool
hasDangerAll bool
// rules are the pre-compiled filter rules for non-self, non-via
// destinations. For regular grants this is the complete output.
// For self grants with mixed destinations (self + other), this
// is the non-self portion only.
rules []tailcfg.FilterRule
// self is non-nil when the grant has autogroup:self destinations.
self *selfGrantData
// via is non-nil when the grant has Via tags.
via *viaGrantData
}
// selfGrantData holds data needed for per-node autogroup:self
// compilation. Sources are already resolved.
type selfGrantData struct {
resolvedSrcs []ResolvedAddresses
internetProtocols []ProtocolPort
app tailcfg.PeerCapMap
}
// viaGrantData holds data needed for per-node via-grant compilation.
// Sources are already resolved into srcIPStrings.
type viaGrantData struct {
viaTags []Tag
destinations Aliases
internetProtocols []ProtocolPort
srcIPStrings []string
}
// userNodeIndex maps user IDs to their untagged nodes. Precomputed
// once per node-set change, used by compileAutogroupSelf to avoid
// O(N) scans per self-grant per node.
type userNodeIndex map[uint][]types.NodeView
func buildUserNodeIndex(
nodes views.Slice[types.NodeView],
) userNodeIndex {
idx := make(userNodeIndex)
for _, n := range nodes.All() {
if !n.IsTagged() && n.User().Valid() {
uid := n.User().ID()
idx[uid] = append(idx[uid], n)
}
}
return idx
}
// compileGrants resolves all policy grants into compiledGrant structs.
// Source resolution and non-self destination resolution happens once
// here. This is the single resolution path that replaces the
// duplicated work in compileFilterRules and compileGrantWithAutogroupSelf.
func (pol *Policy) compileGrants(
users types.Users,
nodes views.Slice[types.NodeView],
) []compiledGrant {
if pol == nil || (pol.ACLs == nil && len(pol.Grants) == 0) {
return nil
}
grants := pol.Grants
for _, acl := range pol.ACLs {
grants = append(grants, aclToGrants(acl)...)
}
compiled := make([]compiledGrant, 0, len(grants))
for _, grant := range grants {
cg, err := pol.compileOneGrant(grant, users, nodes)
if err != nil {
log.Trace().Err(err).Msg("compiling grant")
continue
}
if cg != nil {
compiled = append(compiled, *cg)
}
}
return compiled
}
// compileOneGrant resolves a single grant into a compiledGrant.
// All source resolution happens here. Non-self, non-via destination
// resolution also happens here. Per-node data (self dests, via
// matching) is stored for deferred compilation.
//
//nolint:gocyclo,cyclop
func (pol *Policy) compileOneGrant(
grant Grant,
users types.Users,
nodes views.Slice[types.NodeView],
) (*compiledGrant, error) {
// Via grants: resolve sources, store deferred data.
if len(grant.Via) > 0 {
return pol.compileOneViaGrant(grant, users, nodes)
}
// Split destinations into self vs other.
var autogroupSelfDests, otherDests []Alias
for _, dest := range grant.Destinations {
if ag, ok := dest.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
autogroupSelfDests = append(autogroupSelfDests, dest)
} else {
otherDests = append(otherDests, dest)
}
}
// Resolve sources per-alias, tracking non-wildcard sources
// separately so we can preserve their IPs alongside the
// wildcard CGNAT ranges (matching Tailscale SaaS behavior).
resolvedSrcs, nonWildcardSrcs, err := resolveSources(
pol, grant.Sources, users, nodes,
)
if err != nil {
return nil, err
}
// Literally empty src=[] or dst=[] produces no rules.
if len(grant.Sources) == 0 || len(grant.Destinations) == 0 {
return nil, nil //nolint:nilnil
}
if len(resolvedSrcs) == 0 && grant.App == nil {
return nil, nil //nolint:nilnil
}
hasWildcard := sourcesHaveWildcard(grant.Sources)
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
srcIPStrings := buildSrcIPStrings(
resolvedSrcs, nonWildcardSrcs,
hasWildcard, hasDangerAll, nodes,
)
cg := &compiledGrant{
srcIPStrings: srcIPStrings,
hasWildcard: hasWildcard,
hasDangerAll: hasDangerAll,
}
// Compile non-self destination rules (done once, shared).
if len(otherDests) > 0 {
cg.rules = pol.compileOtherDests(
users, nodes, grant, otherDests,
resolvedSrcs, srcIPStrings,
)
}
// Classify and store deferred self data.
switch {
case len(autogroupSelfDests) > 0:
cg.category = grantCategorySelf
cg.self = &selfGrantData{
resolvedSrcs: resolvedSrcs,
internetProtocols: grant.InternetProtocols,
app: grant.App,
}
default:
cg.category = grantCategoryRegular
}
return cg, nil
}
// compileOneViaGrant resolves sources for a via grant and stores the
// deferred per-node data. The actual via-node matching and route
// intersection happens in compileViaForNode.
func (pol *Policy) compileOneViaGrant(
grant Grant,
users types.Users,
nodes views.Slice[types.NodeView],
) (*compiledGrant, error) {
if len(grant.InternetProtocols) == 0 {
return nil, nil //nolint:nilnil
}
resolvedSrcs, _, err := resolveSources(
pol, grant.Sources, users, nodes,
)
if err != nil {
return nil, err
}
if len(resolvedSrcs) == 0 {
return nil, nil //nolint:nilnil
}
// Build merged SrcIPs.
var srcIPs netipx.IPSetBuilder
for _, ips := range resolvedSrcs {
for _, pref := range ips.Prefixes() {
srcIPs.AddPrefix(pref)
}
}
srcResolved, err := newResolved(&srcIPs)
if err != nil {
return nil, err
}
if srcResolved.Empty() {
return nil, nil //nolint:nilnil
}
hasWildcard := sourcesHaveWildcard(grant.Sources)
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
return &compiledGrant{
category: grantCategoryVia,
via: &viaGrantData{
viaTags: grant.Via,
destinations: grant.Destinations,
internetProtocols: grant.InternetProtocols,
srcIPStrings: srcIPsWithRoutes(
srcResolved, hasWildcard, hasDangerAll, nodes,
),
},
}, nil
}
// resolveSources resolves grant sources per-alias, returning the
// resolved addresses and a separate slice of non-wildcard sources.
// This is the shared source resolution used by all grant categories.
func resolveSources(
pol *Policy,
sources Aliases,
users types.Users,
nodes views.Slice[types.NodeView],
) ([]ResolvedAddresses, []ResolvedAddresses, error) {
var all, nonWild []ResolvedAddresses
for i, src := range sources {
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
return nil, nil, errSelfInSources
}
ips, err := src.Resolve(pol, users, nodes)
if err != nil {
log.Trace().Caller().Err(err).
Msg("resolving source ips")
}
if ips != nil {
all = append(all, ips)
if _, isWildcard := sources[i].(Asterix); !isWildcard {
nonWild = append(nonWild, ips)
}
}
}
return all, nonWild, nil
}
// buildSrcIPStrings builds the final SrcIPs string slice from
// resolved sources, preserving non-wildcard IPs alongside wildcard
// CGNAT ranges to match Tailscale SaaS behavior.
func buildSrcIPStrings(
resolvedSrcs, nonWildcardSrcs []ResolvedAddresses,
hasWildcard, hasDangerAll bool,
nodes views.Slice[types.NodeView],
) []string {
var merged netipx.IPSetBuilder
for _, ips := range resolvedSrcs {
for _, pref := range ips.Prefixes() {
merged.AddPrefix(pref)
}
}
srcResolved, err := newResolved(&merged)
if err != nil {
return nil
}
if srcResolved.Empty() {
return nil
}
srcIPStrs := srcIPsWithRoutes(
srcResolved, hasWildcard, hasDangerAll, nodes,
)
// When sources include a wildcard (*) alongside explicit
// sources (tags, groups, etc.), Tailscale preserves the
// individual IPs from non-wildcard sources alongside the
// merged CGNAT ranges rather than absorbing them.
if hasWildcard && len(nonWildcardSrcs) > 0 {
seen := make(map[string]bool, len(srcIPStrs))
for _, s := range srcIPStrs {
seen[s] = true
}
for _, ips := range nonWildcardSrcs {
for _, s := range ips.Strings() {
if !seen[s] {
seen[s] = true
srcIPStrs = append(srcIPStrs, s)
}
}
}
}
return srcIPStrs
}
// compileOtherDests compiles filter rules for non-self, non-via
// destinations. This produces both DstPorts rules (from
// InternetProtocols) and CapGrant rules (from App).
func (pol *Policy) compileOtherDests(
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
}
// hasPerNodeGrants reports whether any compiled grant requires
// per-node filter compilation (via grants or autogroup:self).
func hasPerNodeGrants(grants []compiledGrant) bool {
for i := range grants {
if grants[i].category != grantCategoryRegular {
return true
}
}
return false
}
// globalFilterRules extracts global filter rules from compiled
// grants. Only includes pre-compiled rules from non-via grants.
// Via grants produce no global rules (they are per-node only).
func globalFilterRules(grants []compiledGrant) []tailcfg.FilterRule {
var rules []tailcfg.FilterRule
for i := range grants {
if grants[i].category == grantCategoryVia {
continue
}
rules = append(rules, grants[i].rules...)
}
return mergeFilterRules(rules)
}
// filterRulesForNode produces unreduced filter rules for a specific
// node by combining pre-compiled global rules with per-node self and
// via rules. Regular grants emit their pre-compiled rules as-is.
// Self grants add autogroup:self expansion. Via grants add
// tag-matched, route-intersected rules.
func filterRulesForNode(
grants []compiledGrant,
node types.NodeView,
userIdx userNodeIndex,
) []tailcfg.FilterRule {
var rules []tailcfg.FilterRule
for i := range grants {
cg := &grants[i]
// Pre-compiled rules apply to all grant categories
// (empty for via-only grants).
rules = append(rules, cg.rules...)
switch cg.category {
case grantCategoryRegular:
// Nothing more to do.
case grantCategorySelf:
rules = append(
rules,
compileAutogroupSelf(cg, node, userIdx)...,
)
case grantCategoryVia:
rules = append(
rules,
compileViaForNode(cg, node)...,
)
}
}
return mergeFilterRules(rules)
}
// compileAutogroupSelf produces filter rules for autogroup:self
// destinations for a specific node. Only called for grants with
// self destinations and only produces rules for untagged nodes.
func compileAutogroupSelf(
cg *compiledGrant,
node types.NodeView,
userIdx userNodeIndex,
) []tailcfg.FilterRule {
if node.IsTagged() || cg.self == nil {
return nil
}
if !node.User().Valid() {
return nil
}
sameUserNodes := userIdx[node.User().ID()]
if len(sameUserNodes) == 0 {
return nil
}
var rules []tailcfg.FilterRule
// Filter sources to only same-user untagged devices.
srcResolved := filterSourcesToSameUser(
cg.self.resolvedSrcs, sameUserNodes,
)
if srcResolved == nil || srcResolved.Empty() {
return nil
}
// DstPorts rules from InternetProtocols.
for _, ipp := range cg.self.internetProtocols {
var destPorts []tailcfg.NetPortRange
for _, n := range sameUserNodes {
for _, port := range ipp.Ports {
for _, ip := range n.IPs() {
destPorts = append(
destPorts,
tailcfg.NetPortRange{
IP: ip.String(),
Ports: port,
},
)
}
}
}
if len(destPorts) > 0 {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcResolved.Strings(),
DstPorts: destPorts,
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})
}
}
// CapGrant rules from App.
if cg.self.app != nil {
var (
capGrants []tailcfg.CapGrant
dstIPStrings []string
)
for _, n := range sameUserNodes {
var dsts []netip.Prefix
for _, ip := range n.IPs() {
dsts = append(
dsts,
netip.PrefixFrom(ip, ip.BitLen()),
)
dstIPStrings = append(
dstIPStrings, ip.String(),
)
}
capGrants = append(capGrants, tailcfg.CapGrant{
Dsts: dsts,
CapMap: cg.self.app,
})
}
if len(capGrants) > 0 {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcResolved.Strings(),
CapGrant: capGrants,
})
rules = append(
rules,
companionCapGrantRules(
dstIPStrings,
srcResolved.Prefixes(),
cg.self.app,
)...,
)
}
}
return rules
}
// filterSourcesToSameUser intersects resolved source addresses with
// same-user untagged device IPs, returning only the addresses that
// belong to those devices.
func filterSourcesToSameUser(
resolvedSrcs []ResolvedAddresses,
sameUserNodes []types.NodeView,
) ResolvedAddresses {
var srcIPs netipx.IPSetBuilder
for _, ips := range resolvedSrcs {
for _, n := range sameUserNodes {
if slices.ContainsFunc(n.IPs(), ips.Contains) {
n.AppendToIPSet(&srcIPs)
}
}
}
srcResolved, err := newResolved(&srcIPs)
if err != nil {
return nil
}
return srcResolved
}
// compileViaForNode produces via-grant filter rules for a specific
// node. Only produces rules when the node matches one of the via
// tags and advertises routes that match the grant destinations.
func compileViaForNode(
cg *compiledGrant,
node types.NodeView,
) []tailcfg.FilterRule {
if cg.via == nil {
return nil
}
// Check if node matches any via tag.
matchesVia := false
for _, viaTag := range cg.via.viaTags {
if node.HasTag(string(viaTag)) {
matchesVia = true
break
}
}
if !matchesVia {
return nil
}
// Find matching destination prefixes.
nodeSubnetRoutes := node.SubnetRoutes()
if len(nodeSubnetRoutes) == 0 {
return nil
}
var viaDstPrefixes []netip.Prefix
for _, dst := range cg.via.destinations {
switch d := dst.(type) {
case *Prefix:
dstPrefix := netip.Prefix(*d)
if slices.Contains(nodeSubnetRoutes, dstPrefix) {
viaDstPrefixes = append(
viaDstPrefixes, dstPrefix,
)
}
case *AutoGroup:
// autogroup:internet via grants do not produce
// PacketFilter rules on exit nodes.
}
}
if len(viaDstPrefixes) == 0 {
return nil
}
// Build rules using pre-resolved srcIPStrings.
var rules []tailcfg.FilterRule
for _, ipp := range cg.via.internetProtocols {
var destPorts []tailcfg.NetPortRange
for _, prefix := range viaDstPrefixes {
for _, port := range ipp.Ports {
destPorts = append(
destPorts,
tailcfg.NetPortRange{
IP: prefix.String(),
Ports: port,
},
)
}
}
if len(destPorts) > 0 {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: cg.via.srcIPStrings,
DstPorts: destPorts,
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})
}
}
return rules
}
+4 -584
View File
@@ -141,88 +141,7 @@ func (pol *Policy) compileFilterRules(
return tailcfg.FilterAllowAll, nil
}
var rules []tailcfg.FilterRule
grants := pol.Grants
for _, acl := range pol.ACLs {
grants = append(grants, aclToGrants(acl)...)
}
for _, grant := range grants {
// Via grants are compiled per-node in compileViaGrant,
// not in the global filter set.
if len(grant.Via) > 0 {
continue
}
srcIPs, err := grant.Sources.Resolve(pol, users, nodes)
if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving source ips")
}
if srcIPs.Empty() {
continue
}
hasWildcard := sourcesHaveWildcard(grant.Sources)
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
for _, ipp := range grant.InternetProtocols {
destPorts := pol.destinationsToNetPortRange(users, nodes, grant.Destinations, ipp.Ports)
if len(destPorts) > 0 {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcIPsWithRoutes(srcIPs, hasWildcard, hasDangerAll, nodes),
DstPorts: destPorts,
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})
}
}
if grant.App != nil {
var (
capGrants []tailcfg.CapGrant
dstIPStrings []string
)
for _, dst := range grant.Destinations {
ips, err := dst.Resolve(pol, users, nodes)
if err != nil {
continue
}
dstPrefixes := ips.Prefixes()
capGrants = append(capGrants, tailcfg.CapGrant{
Dsts: dstPrefixes,
CapMap: grant.App,
})
dstIPStrings = append(dstIPStrings, ips.Strings()...)
}
srcIPStrs := srcIPsWithRoutes(srcIPs, hasWildcard, hasDangerAll, nodes)
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcIPStrs,
CapGrant: capGrants,
})
// Companion rules use reversed direction: SrcIPs are
// destination IPs and CapGrant Dsts are source IPs.
// When destinations include a wildcard, add subnet
// routes to companion SrcIPs (same as main rule).
dstsHaveWildcard := sourcesHaveWildcard(grant.Destinations)
if dstsHaveWildcard {
dstIPStrings = append(dstIPStrings, approvedSubnetRoutes(nodes)...)
}
rules = append(
rules,
companionCapGrantRules(dstIPStrings, srcIPs.Prefixes(), grant.App)...,
)
}
}
return mergeFilterRules(rules), nil
return globalFilterRules(pol.compileGrants(users, nodes)), nil
}
func (pol *Policy) destinationsToNetPortRange(
@@ -293,509 +212,10 @@ func (pol *Policy) compileFilterRulesForNode(
return tailcfg.FilterAllowAll, nil
}
var rules []tailcfg.FilterRule
grants := pol.compileGrants(users, nodes)
userIdx := buildUserNodeIndex(nodes)
grants := pol.Grants
for _, acl := range pol.ACLs {
grants = append(grants, aclToGrants(acl)...)
}
for _, grant := range grants {
res, err := pol.compileGrantWithAutogroupSelf(grant, users, node, nodes)
if err != nil {
log.Trace().Err(err).Msgf("compiling ACL")
continue
}
rules = append(rules, res...)
}
return mergeFilterRules(rules), nil
}
// compileViaGrant compiles a grant with a "via" field. Via grants
// produce filter rules ONLY on nodes matching a via tag that actually
// advertise (and have approved) the destination subnets. All other
// nodes receive no rules. App-only via grants (no ip field) produce
// no packet filter rules.
func (pol *Policy) compileViaGrant(
grant Grant,
users types.Users,
node types.NodeView,
nodes views.Slice[types.NodeView],
) ([]tailcfg.FilterRule, error) {
// Check if the current node matches any of the via tags.
matchesVia := false
for _, viaTag := range grant.Via {
if node.HasTag(string(viaTag)) {
matchesVia = true
break
}
}
if !matchesVia {
return nil, nil
}
// App-only via grants produce no packet filter rules.
if len(grant.InternetProtocols) == 0 {
return nil, nil
}
// Find which grant destination subnets/exit routes this node actually advertises.
nodeSubnetRoutes := node.SubnetRoutes()
nodeExitRoutes := node.ExitRoutes()
if len(nodeSubnetRoutes) == 0 && len(nodeExitRoutes) == 0 {
return nil, nil
}
// Collect destination prefixes that match the node's approved routes.
var viaDstPrefixes []netip.Prefix
for _, dst := range grant.Destinations {
switch d := dst.(type) {
case *Prefix:
dstPrefix := netip.Prefix(*d)
if slices.Contains(nodeSubnetRoutes, dstPrefix) {
viaDstPrefixes = append(viaDstPrefixes, dstPrefix)
}
case *AutoGroup:
// autogroup:internet via grants do NOT produce PacketFilter rules
// on the exit node. Tailscale SaaS handles exit traffic forwarding
// through the client's exit node selection mechanism (AllowedIPs +
// ExitNodeOption), not through PacketFilter rules. Verified by
// golden captures GRANT-V14 through GRANT-V36.
}
}
if len(viaDstPrefixes) == 0 {
return nil, nil
}
// Resolve source IPs.
var resolvedSrcs []ResolvedAddresses
for _, src := range grant.Sources {
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
return nil, errSelfInSources
}
ips, err := src.Resolve(pol, users, nodes)
if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving source ips")
}
if ips != nil {
resolvedSrcs = append(resolvedSrcs, ips)
}
}
if len(resolvedSrcs) == 0 {
return nil, nil
}
// Build merged SrcIPs from all sources.
var srcIPs netipx.IPSetBuilder
for _, ips := range resolvedSrcs {
for _, pref := range ips.Prefixes() {
srcIPs.AddPrefix(pref)
}
}
srcResolved, err := newResolved(&srcIPs)
if err != nil {
return nil, err
}
if srcResolved.Empty() {
return nil, nil
}
hasWildcard := sourcesHaveWildcard(grant.Sources)
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
srcIPStrs := srcIPsWithRoutes(srcResolved, hasWildcard, hasDangerAll, nodes)
// Build DstPorts from the matching via prefixes.
var rules []tailcfg.FilterRule
for _, ipp := range grant.InternetProtocols {
var destPorts []tailcfg.NetPortRange
for _, prefix := range viaDstPrefixes {
for _, port := range ipp.Ports {
destPorts = append(destPorts, tailcfg.NetPortRange{
IP: prefix.String(),
Ports: port,
})
}
}
if len(destPorts) > 0 {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcIPStrs,
DstPorts: destPorts,
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})
}
}
return rules, nil
}
// compileGrantWithAutogroupSelf compiles a single Grant rule, handling
// autogroup:self per-node while supporting all other alias types normally.
// It returns a slice of filter rules because when an Grant has both autogroup:self
// and other destinations, they need to be split into separate rules with different
// source filtering logic.
//
//nolint:gocyclo,cyclop // complex ACL compilation logic
func (pol *Policy) compileGrantWithAutogroupSelf(
grant Grant,
users types.Users,
node types.NodeView,
nodes views.Slice[types.NodeView],
) ([]tailcfg.FilterRule, error) {
// Handle via route grants — filter rules only go to the node
// matching the via tag that actually advertises the destination subnets.
if len(grant.Via) > 0 {
return pol.compileViaGrant(grant, users, node, nodes)
}
var (
autogroupSelfDests []Alias
otherDests []Alias
)
for _, dest := range grant.Destinations {
if ag, ok := dest.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
autogroupSelfDests = append(autogroupSelfDests, dest)
} else {
otherDests = append(otherDests, dest)
}
}
var rules []tailcfg.FilterRule
var resolvedSrcs []ResolvedAddresses
// Track non-wildcard source IPs separately. When the grant has a
// wildcard (*) source plus explicit sources (tags, groups, etc.),
// Tailscale preserves the explicit IPs alongside the wildcard
// CGNAT ranges rather than merging them into the IPSet.
var nonWildcardSrcs []ResolvedAddresses
for i, src := range grant.Sources {
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
return nil, errSelfInSources
}
ips, err := src.Resolve(pol, users, nodes)
if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving source ips")
}
if ips != nil {
resolvedSrcs = append(resolvedSrcs, ips)
if _, isWildcard := grant.Sources[i].(Asterix); !isWildcard {
nonWildcardSrcs = append(nonWildcardSrcs, ips)
}
}
}
// When the grant has literally empty src=[] or dst=[], produce no rules
// at all — Tailscale returns null for these. This is distinct from sources
// that resolve to empty (e.g., group:empty) where Tailscale still produces
// CapGrant rules with empty SrcIPs.
if len(grant.Sources) == 0 || len(grant.Destinations) == 0 {
return rules, nil
}
if len(resolvedSrcs) == 0 && grant.App == nil {
return rules, nil
}
hasWildcard := sourcesHaveWildcard(grant.Sources)
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
for _, ipp := range grant.InternetProtocols {
// Handle non-self destinations first to match Tailscale's
// rule ordering in the FilterRule wire format.
if len(otherDests) > 0 {
var srcIPs netipx.IPSetBuilder
for _, ips := range resolvedSrcs {
for _, pref := range ips.Prefixes() {
srcIPs.AddPrefix(pref)
}
}
srcResolved, err := newResolved(&srcIPs)
if err != nil {
return nil, err
}
if !srcResolved.Empty() {
destPorts := pol.destinationsToNetPortRange(users, nodes, otherDests, ipp.Ports)
if len(destPorts) > 0 {
srcIPStrs := srcIPsWithRoutes(srcResolved, hasWildcard, hasDangerAll, nodes)
// When sources include a wildcard (*) alongside
// explicit sources (tags, groups, etc.), Tailscale
// preserves the individual IPs from non-wildcard
// sources alongside the merged wildcard CGNAT
// ranges rather than absorbing them.
if hasWildcard && len(nonWildcardSrcs) > 0 {
seen := make(map[string]bool, len(srcIPStrs))
for _, s := range srcIPStrs {
seen[s] = true
}
for _, ips := range nonWildcardSrcs {
for _, s := range ips.Strings() {
if !seen[s] {
seen[s] = true
srcIPStrs = append(srcIPStrs, s)
}
}
}
}
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcIPStrs,
DstPorts: destPorts,
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})
}
}
}
// Handle autogroup:self destinations (if any)
// Tagged nodes don't participate in autogroup:self (identity is tag-based, not user-based)
if len(autogroupSelfDests) > 0 && !node.IsTagged() {
// Pre-filter to same-user untagged devices once - reuse for both sources and destinations
sameUserNodes := make([]types.NodeView, 0)
for _, n := range nodes.All() {
if !n.IsTagged() && n.User().ID() == node.User().ID() {
sameUserNodes = append(sameUserNodes, n)
}
}
if len(sameUserNodes) > 0 {
// Filter sources to only same-user untagged devices
var srcIPs netipx.IPSetBuilder
for _, ips := range resolvedSrcs {
for _, n := range sameUserNodes {
// Check if any of this node's IPs are in the source set
if slices.ContainsFunc(n.IPs(), ips.Contains) {
n.AppendToIPSet(&srcIPs)
}
}
}
srcResolved, err := newResolved(&srcIPs)
if err != nil {
return nil, err
}
if !srcResolved.Empty() {
var destPorts []tailcfg.NetPortRange
for _, n := range sameUserNodes {
for _, port := range ipp.Ports {
for _, ip := range n.IPs() {
destPorts = append(destPorts, tailcfg.NetPortRange{
IP: ip.String(),
Ports: port,
})
}
}
}
if len(destPorts) > 0 {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcResolved.Strings(),
DstPorts: destPorts,
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})
}
}
}
}
}
// Handle app grants (CapGrant rules) — these are separate from
// InternetProtocols and produce FilterRules with CapGrant instead
// of DstPorts. A grant with both ip and app fields produces rules
// for each independently.
if grant.App != nil {
// Handle non-self destinations for CapGrant
if len(otherDests) > 0 {
var srcIPStrs []string
if len(resolvedSrcs) > 0 {
var srcIPs netipx.IPSetBuilder
for _, ips := range resolvedSrcs {
for _, pref := range ips.Prefixes() {
srcIPs.AddPrefix(pref)
}
}
srcResolved, err := newResolved(&srcIPs)
if err != nil {
return nil, err
}
if !srcResolved.Empty() {
srcIPStrs = srcIPsWithRoutes(srcResolved, hasWildcard, hasDangerAll, nodes)
if hasWildcard && len(nonWildcardSrcs) > 0 {
seen := make(map[string]bool, len(srcIPStrs))
for _, s := range srcIPStrs {
seen[s] = true
}
for _, ips := range nonWildcardSrcs {
for _, s := range ips.Strings() {
if !seen[s] {
seen[s] = true
srcIPStrs = append(srcIPStrs, s)
}
}
}
}
}
}
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 {
// When sources resolved to empty (e.g. empty group),
// Tailscale still produces the CapGrant rule with
// empty SrcIPs.
if srcIPStrs == nil {
srcIPStrs = []string{}
}
// Collect source prefixes for reversed companion rules.
var srcPrefixes []netip.Prefix
for _, ips := range resolvedSrcs {
srcPrefixes = append(srcPrefixes, ips.Prefixes()...)
}
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcIPStrs,
CapGrant: capGrants,
})
// Companion rules use reversed direction: companion
// SrcIPs are the destination IPs. When destinations
// include a wildcard, add subnet routes to companion
// SrcIPs to match main rule behavior.
dstsHaveWildcard := sourcesHaveWildcard(otherDests)
if dstsHaveWildcard {
dstIPStrings = append(dstIPStrings, approvedSubnetRoutes(nodes)...)
}
rules = append(
rules,
companionCapGrantRules(dstIPStrings, srcPrefixes, grant.App)...,
)
}
}
// Handle autogroup:self destinations for CapGrant
if len(autogroupSelfDests) > 0 && !node.IsTagged() {
sameUserNodes := make([]types.NodeView, 0)
for _, n := range nodes.All() {
if !n.IsTagged() && n.User().ID() == node.User().ID() {
sameUserNodes = append(sameUserNodes, n)
}
}
if len(sameUserNodes) > 0 {
var srcIPs netipx.IPSetBuilder
for _, ips := range resolvedSrcs {
for _, n := range sameUserNodes {
if slices.ContainsFunc(n.IPs(), ips.Contains) {
n.AppendToIPSet(&srcIPs)
}
}
}
srcResolved, err := newResolved(&srcIPs)
if err != nil {
return nil, err
}
if !srcResolved.Empty() {
var (
capGrants []tailcfg.CapGrant
dstIPStrings []string
)
for _, n := range sameUserNodes {
var dsts []netip.Prefix
for _, ip := range n.IPs() {
dsts = append(
dsts,
netip.PrefixFrom(ip, ip.BitLen()),
)
dstIPStrings = append(dstIPStrings, ip.String())
}
capGrants = append(capGrants, tailcfg.CapGrant{
Dsts: dsts,
CapMap: grant.App,
})
}
if len(capGrants) > 0 {
srcIPStrs := srcResolved.Strings()
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcIPStrs,
CapGrant: capGrants,
})
rules = append(
rules,
companionCapGrantRules(
dstIPStrings,
srcResolved.Prefixes(),
grant.App,
)...,
)
}
}
}
}
}
return rules, nil
return filterRulesForNode(grants, node, userIdx), nil
}
var sshAccept = tailcfg.SSHAction{
+16 -3
View File
@@ -3832,7 +3832,7 @@ func TestCompileViaGrant(t *testing.T) {
nodeView := tt.node.View()
nodesSlice := tt.nodes.ViewSlice()
got, err := tt.pol.compileViaGrant(tt.grant, users, nodeView, nodesSlice)
cg, err := tt.pol.compileOneGrant(tt.grant, users, nodesSlice)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
@@ -3842,6 +3842,11 @@ func TestCompileViaGrant(t *testing.T) {
require.NoError(t, err)
var got []tailcfg.FilterRule
if cg != nil {
got = compileViaForNode(cg, nodeView)
}
if tt.name == "wildcard sources include subnet routes in SrcIPs" {
// Wildcard resolves to CGNAT ranges; just check the route is appended.
require.Len(t, got, 1)
@@ -3997,9 +4002,10 @@ func TestCompileGrantWithAutogroupSelf_GrantPaths(t *testing.T) {
nodeView := tt.node.View()
nodesSlice := allNodes.ViewSlice()
userIdx := buildUserNodeIndex(nodesSlice)
got, err := tt.pol.compileGrantWithAutogroupSelf(
tt.grant, users, nodeView, nodesSlice,
cg, err := tt.pol.compileOneGrant(
tt.grant, users, nodesSlice,
)
if tt.wantErr != nil {
@@ -4010,6 +4016,13 @@ func TestCompileGrantWithAutogroupSelf_GrantPaths(t *testing.T) {
require.NoError(t, err)
var got []tailcfg.FilterRule
if cg != nil {
got = append(got, cg.rules...)
got = append(got, compileAutogroupSelf(cg, nodeView, userIdx)...)
got = mergeFilterRules(got)
}
switch tt.name {
case "autogroup:self destination for untagged node produces same-user devices":
// Should produce rules; sources and destinations should only
+70 -112
View File
@@ -46,16 +46,17 @@ type PolicyManager struct {
// Lazy map of SSH policies
sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy
// Lazy map of per-node compiled filter rules (unreduced, for autogroup:self)
compiledFilterRulesMap map[types.NodeID][]tailcfg.FilterRule
// Lazy map of per-node filter rules (reduced, for packet filters)
filterRulesMap map[types.NodeID][]tailcfg.FilterRule
usesAutogroupSelf bool
// compiledGrants are the grants with sources pre-resolved.
// The single source of truth for filter compilation. Both
// global and per-node filter rules are derived from these.
compiledGrants []compiledGrant
userNodeIdx userNodeIndex
// needsPerNodeFilter is true when filter rules must be compiled
// per-node rather than globally. This is required when the policy
// uses autogroup:self (node-relative destinations) or via grants
// (per-router filter rules for steered traffic).
// Lazy map of per-node filter rules (reduced, for packet filters)
filterRulesMap map[types.NodeID][]tailcfg.FilterRule
// needsPerNodeFilter is true when any compiled grant requires
// per-node work (autogroup:self or via grants).
needsPerNodeFilter bool
}
@@ -77,14 +78,11 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
}
pm := PolicyManager{
pol: policy,
users: users,
nodes: nodes,
sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()),
compiledFilterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
usesAutogroupSelf: policy.usesAutogroupSelf(),
needsPerNodeFilter: policy.usesAutogroupSelf() || policy.hasViaGrants(),
pol: policy,
users: users,
nodes: nodes,
sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()),
filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
}
_, err = pm.updateLocked()
@@ -98,18 +96,17 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
// updateLocked updates the filter rules based on the current policy and nodes.
// It must be called with the lock held.
func (pm *PolicyManager) updateLocked() (bool, error) {
// Check if policy uses autogroup:self or via grants
pm.usesAutogroupSelf = pm.pol.usesAutogroupSelf()
pm.needsPerNodeFilter = pm.usesAutogroupSelf || pm.pol.hasViaGrants()
// Compile all grants once. Both global and per-node filter
// rules are derived from these compiled grants.
pm.compiledGrants = pm.pol.compileGrants(pm.users, pm.nodes)
pm.userNodeIdx = buildUserNodeIndex(pm.nodes)
pm.needsPerNodeFilter = hasPerNodeGrants(pm.compiledGrants)
var filter []tailcfg.FilterRule
var err error
// Standard compilation for all policies
filter, err = pm.pol.compileFilterRules(pm.users, pm.nodes)
if err != nil {
return false, fmt.Errorf("compiling filter rules: %w", err)
if pm.pol == nil || (pm.pol.ACLs == nil && len(pm.pol.Grants) == 0) {
filter = tailcfg.FilterAllowAll
} else {
filter = globalFilterRules(pm.compiledGrants)
}
// Hash both the compiled filter AND the policy content together.
@@ -209,7 +206,6 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
// policies for nodes that have changed. Particularly if the only difference is
// that nodes has been added or removed.
clear(pm.sshPolicyMap)
clear(pm.compiledFilterRulesMap)
clear(pm.filterRulesMap)
}
@@ -414,16 +410,8 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
// but peer relationships require the full bidirectional access rules.
nodeMatchers := make(map[types.NodeID][]matcher.Match, nodes.Len())
for _, node := range nodes.All() {
filter, err := pm.compileFilterRulesForNodeLocked(node)
if err != nil {
continue
}
// Include all nodes in nodeMatchers, even those with empty filters.
// Empty filters result in empty matchers where CanAccess() returns false,
// but the node still needs to be in the map so hasFilterX is true.
// This ensures symmetric visibility works correctly: if node A can access
// node B, both should see each other regardless of B's filter rules.
nodeMatchers[node.ID()] = matcher.MatchesFromFilterRules(filter)
unreduced := pm.filterRulesForNodeLocked(node)
nodeMatchers[node.ID()] = matcher.MatchesFromFilterRules(unreduced)
}
// Check each node pair for peer relationships.
@@ -464,75 +452,50 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
return ret
}
// compileFilterRulesForNodeLocked returns the unreduced compiled filter rules for a node
// when using autogroup:self. This is used by BuildPeerMap to determine peer relationships.
// For packet filters sent to nodes, use filterForNodeLocked which returns reduced rules.
func (pm *PolicyManager) compileFilterRulesForNodeLocked(node types.NodeView) ([]tailcfg.FilterRule, error) {
if pm == nil {
return nil, nil
}
// Check if we have cached compiled rules
if rules, ok := pm.compiledFilterRulesMap[node.ID()]; ok {
return rules, nil
}
// Compile per-node rules with autogroup:self expanded
rules, err := pm.pol.compileFilterRulesForNode(pm.users, node, pm.nodes)
if err != nil {
return nil, fmt.Errorf("compiling filter rules for node: %w", err)
}
// Cache the unreduced compiled rules
pm.compiledFilterRulesMap[node.ID()] = rules
return rules, nil
// filterRulesForNodeLocked returns the unreduced compiled filter rules
// for a node, combining pre-compiled global rules with per-node self
// and via rules from the stored compiled grants.
func (pm *PolicyManager) filterRulesForNodeLocked(
node types.NodeView,
) []tailcfg.FilterRule {
return filterRulesForNode(
pm.compiledGrants, node, pm.userNodeIdx,
)
}
// filterForNodeLocked returns the filter rules for a specific node, already reduced
// to only include rules relevant to that node.
// This is a lock-free version of FilterForNode for internal use when the lock is already held.
// BuildPeerMap already holds the lock, so we need a version that doesn't re-acquire it.
func (pm *PolicyManager) filterForNodeLocked(node types.NodeView) ([]tailcfg.FilterRule, error) {
// filterForNodeLocked returns the filter rules for a specific node,
// already reduced to only include rules relevant to that node.
//
// Fast path (!needsPerNodeFilter): reduces global filter per-node.
// Slow path (needsPerNodeFilter): combines global + self + via rules
// from the stored compiled grants, then reduces.
//
// Both paths derive from the same compiledGrants, ensuring there is
// no divergence between global and per-node filter output.
//
// Lock-free version for internal use when the lock is already held.
func (pm *PolicyManager) filterForNodeLocked(
node types.NodeView,
) []tailcfg.FilterRule {
if pm == nil {
return nil, nil
return nil
}
if !pm.needsPerNodeFilter {
// For global filters, reduce to only rules relevant to this node.
// Cache the reduced filter per node for efficiency.
if rules, ok := pm.filterRulesMap[node.ID()]; ok {
return rules, nil
}
// Use policyutil.ReduceFilterRules for global filter reduction.
reducedFilter := policyutil.ReduceFilterRules(node, pm.filter)
pm.filterRulesMap[node.ID()] = reducedFilter
return reducedFilter, nil
}
// Per-node compilation is needed when the policy uses autogroup:self
// (node-relative destinations) or via grants (per-router filter rules).
// Check if we have cached reduced rules for this node.
if rules, ok := pm.filterRulesMap[node.ID()]; ok {
return rules, nil
return rules
}
// Get unreduced compiled rules
compiledRules, err := pm.compileFilterRulesForNodeLocked(node)
if err != nil {
return nil, err
var unreduced []tailcfg.FilterRule
if !pm.needsPerNodeFilter {
unreduced = pm.filter
} else {
unreduced = pm.filterRulesForNodeLocked(node)
}
// Reduce the compiled rules to only destinations relevant to this node
reducedFilter := policyutil.ReduceFilterRules(node, compiledRules)
reduced := policyutil.ReduceFilterRules(node, unreduced)
pm.filterRulesMap[node.ID()] = reduced
// Cache the reduced filter
pm.filterRulesMap[node.ID()] = reducedFilter
return reducedFilter, nil
return reduced
}
// FilterForNode returns the filter rules for a specific node, already reduced
@@ -547,7 +510,7 @@ func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRul
pm.mu.Lock()
defer pm.mu.Unlock()
return pm.filterForNodeLocked(node)
return pm.filterForNodeLocked(node), nil
}
// MatchersForNode returns the matchers for peer relationship determination for a specific node.
@@ -571,14 +534,11 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match,
return pm.matchers, nil
}
// For autogroup:self or via grants, get unreduced compiled rules and create matchers
compiledRules, err := pm.compileFilterRulesForNodeLocked(node)
if err != nil {
return nil, err
}
// For autogroup:self or via grants, derive matchers from
// the stored compiled grants for this specific node.
unreduced := pm.filterRulesForNodeLocked(node)
// Create matchers from unreduced rules for peer relationship determination
return matcher.MatchesFromFilterRules(compiledRules), nil
return matcher.MatchesFromFilterRules(unreduced), nil
}
// SetUsers updates the users in the policy manager and updates the filter rules.
@@ -649,7 +609,6 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro
if !needsUpdate {
// This ensures fresh filter rules are generated for all nodes
clear(pm.sshPolicyMap)
clear(pm.compiledFilterRulesMap)
clear(pm.filterRulesMap)
}
// Always return true when nodes changed, even if filter hash didn't change
@@ -1151,12 +1110,10 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
// If we found the user and they're affected, clear this cache entry
if found {
if _, affected := affectedUsers[nodeUserID]; affected {
delete(pm.compiledFilterRulesMap, nodeID)
delete(pm.filterRulesMap, nodeID)
}
} else {
// Node not found in either old or new list, clear it
delete(pm.compiledFilterRulesMap, nodeID)
delete(pm.filterRulesMap, nodeID)
}
}
@@ -1171,13 +1128,14 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
// invalidateNodeCache invalidates cache entries based on what changed.
func (pm *PolicyManager) invalidateNodeCache(newNodes views.Slice[types.NodeView]) {
if pm.usesAutogroupSelf {
// For autogroup:self, a node's filter depends on its peers (same user).
// When any node in a user changes, all nodes for that user need invalidation.
if pm.needsPerNodeFilter {
// For autogroup:self or via grants, a node's filter depends
// on its peers. When any node changes, invalidate affected
// users' caches.
pm.invalidateAutogroupSelfCache(pm.nodes, newNodes)
} else {
// For global policies, a node's filter depends only on its own properties.
// Only invalidate nodes whose properties actually changed.
// For global policies, a node's filter depends only on its
// own properties. Only invalidate changed nodes.
pm.invalidateGlobalPolicyCache(newNodes)
}
}
+4 -5
View File
@@ -355,9 +355,8 @@ func TestInvalidateGlobalPolicyCache(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pm := &PolicyManager{
nodes: tt.oldNodes.ViewSlice(),
filterRulesMap: tt.initialCache,
usesAutogroupSelf: false,
nodes: tt.oldNodes.ViewSlice(),
filterRulesMap: tt.initialCache,
}
pm.invalidateGlobalPolicyCache(tt.newNodes.ViewSlice())
@@ -399,7 +398,7 @@ func TestAutogroupSelfReducedVsUnreducedRules(t *testing.T) {
pm, err := NewPolicyManager([]byte(policyStr), users, nodes.ViewSlice())
require.NoError(t, err)
require.True(t, pm.usesAutogroupSelf, "policy should use autogroup:self")
require.True(t, pm.needsPerNodeFilter, "policy should need per-node filter")
// Test FilterForNode returns reduced rules
// For node1: should have rules where node1 is in destinations (its own IP)
@@ -572,7 +571,7 @@ func TestAutogroupSelfPolicyUpdateTriggersMapResponse(t *testing.T) {
pm, err := NewPolicyManager([]byte(initialPolicy), users, nodes.ViewSlice())
require.NoError(t, err)
require.True(t, pm.usesAutogroupSelf, "policy should use autogroup:self")
require.True(t, pm.needsPerNodeFilter, "policy should need per-node filter")
// Get initial filter rules for test-1 (should be cached)
rules1, err := pm.FilterForNode(test1Node.View())
-55
View File
@@ -3040,58 +3040,3 @@ func validateProtocolPortCompatibility(protocol Protocol, destinations []AliasWi
return nil
}
// usesAutogroupSelf checks if the policy uses autogroup:self in any ACL or SSH rules.
func (p *Policy) usesAutogroupSelf() bool {
if p == nil {
return false
}
// Check ACL rules
for _, acl := range p.ACLs {
for _, src := range acl.Sources {
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
return true
}
}
for _, dest := range acl.Destinations {
if ag, ok := dest.Alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
return true
}
}
}
// Check SSH rules
for _, ssh := range p.SSHs {
for _, src := range ssh.Sources {
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
return true
}
}
for _, dest := range ssh.Destinations {
if ag, ok := dest.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
return true
}
}
}
return false
}
// hasViaGrants returns true if any grant in the policy has a
// non-empty Via field, requiring per-node filter compilation.
func (p *Policy) hasViaGrants() bool {
if p == nil {
return false
}
for _, grant := range p.Grants {
if len(grant.Via) > 0 {
return true
}
}
return false
}