policy/v2: implement via route compilation for grants

Compile grants with "via" field into FilterRules that are placed only
on nodes matching the via tag and actually advertising the destination
subnets. Key behavior:

- Filter rules go exclusively to via-nodes with matching approved routes
- Destination subnets not advertised by the via node are silently dropped
- App-only via grants (no ip field) produce no packet filter rules
- Via grants are skipped in the global compileFilterRules since they
  are node-specific

Reduces grant compat test skips from 41 to 30 (11 newly passing).

Updates #2180
This commit is contained in:
Kristoffer Dalby
2026-03-18 20:30:29 +00:00
parent 0e3acdd8ec
commit 54db47badc
2 changed files with 144 additions and 39 deletions

View File

@@ -130,6 +130,12 @@ func (pol *Policy) compileFilterRules(
}
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")
@@ -287,19 +293,151 @@ func (pol *Policy) compileFilterRulesForNode(
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 this node actually advertises.
nodeRoutes := node.SubnetRoutes()
if len(nodeRoutes) == 0 {
return nil, nil
}
// Collect destination prefixes that match the node's approved routes.
var viaDstPrefixes []netip.Prefix
for _, dst := range grant.Destinations {
p, ok := dst.(*Prefix)
if !ok {
continue
}
dstPrefix := netip.Prefix(*p)
if slices.Contains(nodeRoutes, dstPrefix) {
viaDstPrefixes = append(viaDstPrefixes, dstPrefix)
}
}
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)
srcIPStrs := srcIPsWithRoutes(srcResolved, hasWildcard, 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 // complex ACL compilation 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

View File

@@ -215,13 +215,11 @@ func loadGrantTestFile(t *testing.T, path string) grantTestFile {
// Impact summary (highest first):
//
// ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules
// VIA_COMPILATION_AND_SRCIPS_FORMAT - 7 tests: Via route compilation + SrcIPs format
// VIA_COMPILATION - 4 tests: Via route compilation
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
//
// Total: 41 tests skipped, ~196 tests expected to pass.
// Total: 30 tests skipped, ~207 tests expected to pass.
var grantSkipReasons = map[string]string{
// ========================================================================
// USER_PASSKEY_WILDCARD (2 tests)
@@ -240,39 +238,10 @@ var grantSkipReasons = map[string]string{
"GRANT-K20": "USER_PASSKEY_WILDCARD: src=user:*@passkey, dst=tag:server — source can't be resolved, no rules produced",
"GRANT-K21": "USER_PASSKEY_WILDCARD: src=*, dst=user:*@passkey — destination can't be resolved, no rules produced",
// ========================================================================
// VIA_COMPILATION (3 tests)
//
// TODO: Implement via route compilation in filter rules.
//
// Via routes with specific (non-wildcard) sources produce DstPorts rules
// with correctly restricted SrcIPs. These tests have no SrcIPs format
// issue because they use specific src identities (tags, groups, members).
// ========================================================================
"GRANT-K12": "VIA_COMPILATION: via tag:router + src:* + dst:10.33.0.0/16 + app — via route with CapGrant",
"GRANT-V11": "VIA_COMPILATION: via tag:router + src:tag:client — SrcIPs = client IPs only",
"GRANT-V12": "VIA_COMPILATION: via tag:router + src:autogroup:member — SrcIPs = member IPs",
"GRANT-V13": "VIA_COMPILATION: via tag:router + src:group:developers + tcp:80,443 — group SrcIPs + specific ports",
// (VIA_COMPILATION tests removed — via route compilation now implemented)
// ========================================================================
// VIA_COMPILATION_AND_SRCIPS_FORMAT (7 tests)
//
// TODO: Implement via route compilation in filter rules.
//
// Via routes ("via" field in grants) specify that traffic to a destination
// CIDR should be routed through a specific tagged subnet router. The via
// field is currently parsed and validated but NOT compiled into FilterRules.
//
// These tests also have SrcIPs format differences (wildcard src expands
// to split CGNAT ranges).
// ========================================================================
"GRANT-I1": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
"GRANT-I2": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
"GRANT-I3": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
"GRANT-K13": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
"GRANT-V17": "VIA_COMPILATION_AND_SRCIPS_FORMAT: via tag:router + multi-dst — unadvertised subnets silently dropped",
"GRANT-V21": "VIA_COMPILATION_AND_SRCIPS_FORMAT: via [tag:router, tag:exit] — only advertising nodes get rules",
"GRANT-V23": "VIA_COMPILATION_AND_SRCIPS_FORMAT: via tag:router + tcp:22,80,443 — via + multiple ports",
// (VIA_COMPILATION_AND_SRCIPS_FORMAT tests removed — via route compilation
// and SrcIPs format are both now implemented),
// ========================================================================
// AUTOGROUP_DANGER_ALL (3 tests)
@@ -381,13 +350,11 @@ var grantSkipReasons = map[string]string{
// Skip category impact summary (highest first):
//
// ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules
// VIA_COMPILATION_AND_SRCIPS_FORMAT - 7 tests: Via route compilation + SrcIPs format
// VIA_COMPILATION - 4 tests: Via route compilation
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
//
// Total: 41 tests skipped, ~196 tests expected to pass.
// Total: 30 tests skipped, ~207 tests expected to pass.
func TestGrantsCompat(t *testing.T) {
t.Parallel()