diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go index 9bc75f9b..3f5dcc28 100644 --- a/hscontrol/policy/v2/filter.go +++ b/hscontrol/policy/v2/filter.go @@ -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 diff --git a/hscontrol/policy/v2/tailscale_grants_compat_test.go b/hscontrol/policy/v2/tailscale_grants_compat_test.go index 63dbbe9b..024753ff 100644 --- a/hscontrol/policy/v2/tailscale_grants_compat_test.go +++ b/hscontrol/policy/v2/tailscale_grants_compat_test.go @@ -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()