From ccd284c0a58fbb51480309ab52d5edb639c31cfc Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 26 Mar 2026 06:03:28 +0000 Subject: [PATCH] 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 --- hscontrol/policy/v2/policy.go | 23 ++++++++++++++++++++--- hscontrol/policy/v2/types.go | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index 76f5aed5..e4b469b5 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -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 diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index e053277c..732d18c8 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -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 +}