policy/v2: use per-node filter compilation for via grants

Via grants compile filter rules that depend on the node's route state
(SubnetRoutes, ExitRoutes). Without per-node compilation, these rules
were only included in the global filter path which explicitly skips via
grants (compileFilterRules skips grants with non-empty Via fields).

Add a needsPerNodeFilter flag that is true when the policy uses either
autogroup:self or via grants. filterForNodeLocked now uses this flag
instead of usesAutogroupSelf alone, ensuring via grant rules are
compiled per-node through compileFilterRulesForNode/compileViaGrant.

The filter cache also needs to account for route-dependent compilation:

- nodesHavePolicyAffectingChanges now treats route changes as
  policy-affecting when needsPerNodeFilter is true, so SetNodes
  triggers updateLocked and clears caches through the normal flow.

- invalidateGlobalPolicyCache now clears compiledFilterRulesMap
  (the unreduced per-node cache) alongside filterRulesMap when
  needsPerNodeFilter is true and routes changed.

Updates #2180
This commit is contained in:
Kristoffer Dalby
2026-03-26 06:03:28 +00:00
parent 9db5fb6393
commit ccd284c0a5
2 changed files with 36 additions and 3 deletions

View File

@@ -51,6 +51,12 @@ type PolicyManager struct {
// Lazy map of per-node filter rules (reduced, for packet filters)
filterRulesMap map[types.NodeID][]tailcfg.FilterRule
usesAutogroupSelf bool
// 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).
needsPerNodeFilter bool
}
// filterAndPolicy combines the compiled filter rules with policy content for hashing.
@@ -78,6 +84,7 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
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(),
}
_, err = pm.updateLocked()
@@ -91,8 +98,9 @@ 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
// Check if policy uses autogroup:self or via grants
pm.usesAutogroupSelf = pm.pol.usesAutogroupSelf()
pm.needsPerNodeFilter = pm.usesAutogroupSelf || pm.pol.hasViaGrants()
var filter []tailcfg.FilterRule
@@ -482,7 +490,7 @@ func (pm *PolicyManager) filterForNodeLocked(node types.NodeView) ([]tailcfg.Fil
return nil, nil
}
if !pm.usesAutogroupSelf {
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 {
@@ -497,7 +505,8 @@ func (pm *PolicyManager) filterForNodeLocked(node types.NodeView) ([]tailcfg.Fil
return reducedFilter, nil
}
// For autogroup:self, compile per-node rules then reduce them.
// 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
@@ -660,6 +669,14 @@ func (pm *PolicyManager) nodesHavePolicyAffectingChanges(newNodes views.Slice[ty
if newNode.HasPolicyChange(oldNode) {
return true
}
// Via grants and autogroup:self compile filter rules per-node
// that depend on the node's route state (SubnetRoutes, ExitRoutes).
// Route changes are policy-affecting in this context because they
// alter which filter rules get generated for the via-designated node.
if pm.needsPerNodeFilter && newNode.HasNetworkChanges(oldNode) {
return true
}
}
return false

View File

@@ -3084,3 +3084,19 @@ func (p *Policy) usesAutogroupSelf() bool {
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
}