From 0e3acdd8ecf99170b7bfeca2b690d23019c684c7 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 18 Mar 2026 19:55:15 +0000 Subject: [PATCH] policy/v2: implement CapGrant compilation with companion capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compile grant app fields into CapGrant FilterRules matching Tailscale SaaS behavior. Key changes: - Generate CapGrant rules in compileFilterRules and compileGrantWithAutogroupSelf, with node-specific /32 and /128 Dsts for autogroup:self grants - Add reversed companion rules for drive→drive-sharer and relay→relay-target capabilities, ordered by original cap name - Narrow broad CapGrant Dsts to node-specific prefixes in ReduceFilterRules via new reduceCapGrantRule helper - Skip merging CapGrant rules in mergeFilterRules to preserve per-capability structure - Remove ip+app mutual exclusivity validation (Tailscale accepts both) - Add semantic JSON comparison for RawMessage types and netip.Prefix comparators in test infrastructure Reduces grant compat test skips from 99 to 41 (58 newly passing). Updates #2180 --- hscontrol/policy/policyutil/reduce.go | 87 ++++++ hscontrol/policy/v2/filter.go | 268 +++++++++++++++++- .../v2/tailscale_acl_data_compat_test.go | 49 ++++ .../policy/v2/tailscale_grants_compat_test.go | 147 +--------- hscontrol/policy/v2/types.go | 20 +- hscontrol/policy/v2/types_test.go | 21 +- 6 files changed, 431 insertions(+), 161 deletions(-) diff --git a/hscontrol/policy/policyutil/reduce.go b/hscontrol/policy/policyutil/reduce.go index f3f1903c..c8b6a437 100644 --- a/hscontrol/policy/policyutil/reduce.go +++ b/hscontrol/policy/policyutil/reduce.go @@ -1,6 +1,8 @@ package policyutil import ( + "net/netip" + "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "tailscale.com/net/tsaddr" @@ -17,6 +19,17 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf ret := []tailcfg.FilterRule{} for _, rule := range rules { + // Handle CapGrant rules separately — they use CapGrant[].Dsts + // instead of DstPorts for destination matching. + if len(rule.CapGrant) > 0 { + reduced := reduceCapGrantRule(node, rule) + if reduced != nil { + ret = append(ret, *reduced) + } + + continue + } + // record if the rule is actually relevant for the given node. var dests []tailcfg.NetPortRange @@ -78,3 +91,77 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf return ret } + +// reduceCapGrantRule filters a CapGrant rule to only include CapGrant +// entries whose Dsts match the given node's IPs. When a broad prefix +// (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is +// narrowed to the node's specific /32 or /128 prefix. Returns nil if +// no CapGrant entries are relevant to this node. +func reduceCapGrantRule( + node types.NodeView, + rule tailcfg.FilterRule, +) *tailcfg.FilterRule { + var capGrants []tailcfg.CapGrant + + nodeIPs := node.IPs() + + for _, cg := range rule.CapGrant { + // Collect the node's IPs that fall within any of this + // CapGrant's Dsts. Broad prefixes are narrowed to specific + // /32 and /128 entries for the node. + var matchingDsts []netip.Prefix + + for _, dst := range cg.Dsts { + if dst.IsSingleIP() { + // Already a specific IP — keep it if it matches. + if dst.Addr() == nodeIPs[0] || (len(nodeIPs) > 1 && dst.Addr() == nodeIPs[1]) { + matchingDsts = append(matchingDsts, dst) + } + } else { + // Broad prefix — narrow to node's specific IPs. + for _, ip := range nodeIPs { + if dst.Contains(ip) { + matchingDsts = append(matchingDsts, netip.PrefixFrom(ip, ip.BitLen())) + } + } + } + } + + // Also check routable IPs (subnet routes) — nodes that + // advertise routes should receive CapGrant rules for + // destinations that overlap their routes. + if node.Hostinfo().Valid() { + routableIPs := node.Hostinfo().RoutableIPs() + if routableIPs.Len() > 0 { + for _, dst := range cg.Dsts { + for _, routableIP := range routableIPs.All() { + if tsaddr.IsExitRoute(routableIP) { + continue + } + + if dst.Overlaps(routableIP) { + // For route overlaps, keep the original prefix. + matchingDsts = append(matchingDsts, dst) + } + } + } + } + } + + if len(matchingDsts) > 0 { + capGrants = append(capGrants, tailcfg.CapGrant{ + Dsts: matchingDsts, + CapMap: cg.CapMap, + }) + } + } + + if len(capGrants) == 0 { + return nil + } + + return &tailcfg.FilterRule{ + SrcIPs: rule.SrcIPs, + CapGrant: capGrants, + } +} diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go index 7e1639c6..9bc75f9b 100644 --- a/hscontrol/policy/v2/filter.go +++ b/hscontrol/policy/v2/filter.go @@ -3,6 +3,7 @@ package v2 import ( "errors" "fmt" + "net/netip" "slices" "strconv" "strings" @@ -21,6 +22,68 @@ var ( errSelfInSources = errors.New("autogroup:self cannot be used in sources") ) +// companionCaps maps certain well-known Tailscale capabilities to +// their companion capability. When a grant includes one of these +// capabilities, Tailscale automatically generates an additional +// FilterRule with the companion capability and a nil CapMap value. +var companionCaps = map[tailcfg.PeerCapability]tailcfg.PeerCapability{ + tailcfg.PeerCapabilityTaildrive: tailcfg.PeerCapabilityTaildriveSharer, + tailcfg.PeerCapabilityRelay: tailcfg.PeerCapabilityRelayTarget, +} + +// companionCapGrantRules returns additional FilterRules for any +// well-known capabilities that have companion caps. Companion rules +// are **reversed**: SrcIPs come from the original destinations and +// CapGrant Dsts come from the original sources. This allows +// ReduceFilterRules to distribute companion rules to source nodes +// (e.g. drive-sharer goes to the member nodes, not the destination). +// Rules are ordered by the original capability name. +// +// dstIPStrings are the resolved destination IPs as strings (used as +// companion SrcIPs). srcPrefixes are the resolved source IPs as +// netip.Prefix (used as companion CapGrant Dsts). +func companionCapGrantRules( + dstIPStrings []string, + srcPrefixes []netip.Prefix, + capMap tailcfg.PeerCapMap, +) []tailcfg.FilterRule { + // Process in deterministic order by original capability name. + type pair struct { + original tailcfg.PeerCapability + companion tailcfg.PeerCapability + } + + var pairs []pair + + for cap, companion := range companionCaps { + if _, ok := capMap[cap]; ok { + pairs = append(pairs, pair{cap, companion}) + } + } + + slices.SortFunc(pairs, func(a, b pair) int { + return strings.Compare(string(a.original), string(b.original)) + }) + + companions := make([]tailcfg.FilterRule, 0, len(pairs)) + + for _, p := range pairs { + companions = append(companions, tailcfg.FilterRule{ + SrcIPs: dstIPStrings, + CapGrant: []tailcfg.CapGrant{ + { + Dsts: srcPrefixes, + CapMap: tailcfg.PeerCapMap{ + p.companion: nil, + }, + }, + }, + }) + } + + return companions +} + // sourcesHaveWildcard returns true if any of the source aliases is // a wildcard (*). Used to determine whether approved subnet routes // should be appended to SrcIPs. @@ -91,7 +154,10 @@ func (pol *Policy) compileFilterRules( } if grant.App != nil { - var capGrants []tailcfg.CapGrant + var ( + capGrants []tailcfg.CapGrant + dstIPStrings []string + ) for _, dst := range grant.Destinations { ips, err := dst.Resolve(pol, users, nodes) @@ -99,16 +165,34 @@ func (pol *Policy) compileFilterRules( continue } + dstPrefixes := ips.Prefixes() capGrants = append(capGrants, tailcfg.CapGrant{ - Dsts: ips.Prefixes(), + Dsts: dstPrefixes, CapMap: grant.App, }) + + dstIPStrings = append(dstIPStrings, ips.Strings()...) } + srcIPStrs := srcIPsWithRoutes(srcIPs, hasWildcard, nodes) rules = append(rules, tailcfg.FilterRule{ - SrcIPs: srcIPsWithRoutes(srcIPs, hasWildcard, nodes), + 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)..., + ) } } @@ -256,7 +340,7 @@ func (pol *Policy) compileGrantWithAutogroupSelf( } } - if len(resolvedSrcs) == 0 { + if len(resolvedSrcs) == 0 && grant.App == nil { return rules, nil } @@ -371,6 +455,172 @@ func (pol *Policy) compileGrantWithAutogroupSelf( } } + // 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, 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 } @@ -801,6 +1051,8 @@ func filterRuleKey(rule tailcfg.FilterRule) string { // mergeFilterRules merges rules with identical SrcIPs and IPProto by combining // their DstPorts. DstPorts are NOT deduplicated to match Tailscale behavior. +// CapGrant rules (which have no DstPorts) are passed through without merging +// since CapGrant and DstPorts are mutually exclusive in a FilterRule. func mergeFilterRules(rules []tailcfg.FilterRule) []tailcfg.FilterRule { if len(rules) <= 1 { return rules @@ -810,6 +1062,14 @@ func mergeFilterRules(rules []tailcfg.FilterRule) []tailcfg.FilterRule { result := make([]tailcfg.FilterRule, 0, len(rules)) for _, rule := range rules { + // CapGrant rules are not merged — they are structurally + // different from DstPorts rules and passed through as-is. + if len(rule.CapGrant) > 0 { + result = append(result, rule) + + continue + } + key := filterRuleKey(rule) if idx, exists := keyToIdx[key]; exists { diff --git a/hscontrol/policy/v2/tailscale_acl_data_compat_test.go b/hscontrol/policy/v2/tailscale_acl_data_compat_test.go index 7c371b36..5c665575 100644 --- a/hscontrol/policy/v2/tailscale_acl_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_acl_data_compat_test.go @@ -118,6 +118,7 @@ func findNodeByGivenName(nodes types.Nodes, name string) *types.Node { // It sorts SrcIPs and DstPorts to handle ordering differences. func cmpOptions() []cmp.Option { return []cmp.Option{ + cmpopts.EquateComparable(netip.Prefix{}, netip.Addr{}), cmpopts.SortSlices(func(a, b string) bool { return a < b }), cmpopts.SortSlices(func(a, b tailcfg.NetPortRange) bool { if a.IP != b.IP { @@ -131,6 +132,54 @@ func cmpOptions() []cmp.Option { return a.Ports.Last < b.Ports.Last }), cmpopts.SortSlices(func(a, b int) bool { return a < b }), + cmpopts.SortSlices(func(a, b netip.Prefix) bool { + if a.Addr() != b.Addr() { + return a.Addr().Less(b.Addr()) + } + + return a.Bits() < b.Bits() + }), + // Compare json.RawMessage semantically rather than by exact + // bytes to handle indentation differences between the policy + // source and the golden capture data. + cmp.Comparer(func(a, b json.RawMessage) bool { + var va, vb any + + err := json.Unmarshal(a, &va) + if err != nil { + return string(a) == string(b) + } + + err = json.Unmarshal(b, &vb) + if err != nil { + return string(a) == string(b) + } + + ja, _ := json.Marshal(va) + jb, _ := json.Marshal(vb) + + return string(ja) == string(jb) + }), + // Compare tailcfg.RawMessage semantically (it's a string type + // containing JSON) to handle indentation differences. + cmp.Comparer(func(a, b tailcfg.RawMessage) bool { + var va, vb any + + err := json.Unmarshal([]byte(a), &va) + if err != nil { + return a == b + } + + err = json.Unmarshal([]byte(b), &vb) + if err != nil { + return a == b + } + + ja, _ := json.Marshal(va) + jb, _ := json.Marshal(vb) + + return string(ja) == string(jb) + }), } } diff --git a/hscontrol/policy/v2/tailscale_grants_compat_test.go b/hscontrol/policy/v2/tailscale_grants_compat_test.go index cc7de8c4..63dbbe9b 100644 --- a/hscontrol/policy/v2/tailscale_grants_compat_test.go +++ b/hscontrol/policy/v2/tailscale_grants_compat_test.go @@ -214,20 +214,14 @@ func loadGrantTestFile(t *testing.T, path string) grantTestFile { // // Impact summary (highest first): // -// CAPGRANT_COMPILATION - 49 tests: Implement app->CapGrant FilterRule compilation // ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules -// MISSING_IPV6_ADDRS - 90 tests: Include IPv6 for identity-based alias resolution -// CAPGRANT_COMPILATION_AND_SRCIPS - 11 tests: Both CapGrant compilation + SrcIPs format // VIA_COMPILATION_AND_SRCIPS_FORMAT - 7 tests: Via route compilation + SrcIPs format -// AUTOGROUP_SELF_CIDR_FORMAT - 4 tests: DstPorts IPs get /32 or /128 suffix for autogroup:self -// VIA_COMPILATION - 3 tests: Via route compilation +// 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) -// RAW_IPV6_ADDR_EXPANSION - 2 tests: Raw fd7a: IPv6 src/dst expanded to include IPv4 -// SRCIPS_WILDCARD_NODE_DEDUP - 1 test: Wildcard+specific source node IP deduplication // -// Total: 197 tests skipped, 40 tests expected to pass. +// Total: 41 tests skipped, ~196 tests expected to pass. var grantSkipReasons = map[string]string{ // ======================================================================== // USER_PASSKEY_WILDCARD (2 tests) @@ -246,135 +240,6 @@ 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", - // ======================================================================== - // SRCIPS_WILDCARD_NODE_DEDUP (1 test) - // - // TODO: When src includes both * (wildcard) and specific identities, - // Tailscale unions individual node IPs with the wildcard CGNAT ranges. - // headscale only produces the wildcard ranges, omitting the individual - // node IPs that are technically covered by those ranges. - // - // Example (GRANT-P09_7A, src=[*, autogroup:member, tag:client, ...]): - // SrcIPs: tailscale=[individual IPs + CGNAT ranges + IPv6s] (20 entries) - // SrcIPs: headscale=[10.33.0.0/16, CGNAT ranges, fd7a::/48] (4 entries) - // ======================================================================== - "GRANT-P09_7A": "SRCIPS_WILDCARD_NODE_DEDUP: src=[*,...] — individual node IPs missing from SrcIPs", - - // ======================================================================== - // CAPGRANT_COMPILATION (49 tests) - // - // TODO: Implement app capability grant -> CapGrant FilterRule compilation. - // - // When a grant specifies an "app" field (application capabilities), it - // should produce a FilterRule with CapGrant entries instead of DstPorts. - // headscale currently does not compile app grants into CapGrant FilterRules, - // producing empty output where Tailscale produces CapGrant rules. - // - // Each CapGrant FilterRule contains: - // - SrcIPs: source IP ranges (same format as DstPorts rules) - // - CapGrant: []tailcfg.CapGrant with Dsts (destination IPs) and - // CapMap (capability name -> JSON values) - // - // Fixing CapGrant compilation would resolve all 41 tests in this category. - // ======================================================================== - - // A-series: Basic app capability grants - "GRANT-A1": "CAPGRANT_COMPILATION", - "GRANT-A3": "CAPGRANT_COMPILATION", - "GRANT-A4": "CAPGRANT_COMPILATION", - "GRANT-A6": "CAPGRANT_COMPILATION", - - // B-series: Specific capability types (kubernetes, drive, etc.) - "GRANT-B1": "CAPGRANT_COMPILATION", - "GRANT-B2": "CAPGRANT_COMPILATION", - "GRANT-B3": "CAPGRANT_COMPILATION", - "GRANT-B4": "CAPGRANT_COMPILATION", - "GRANT-B5": "CAPGRANT_COMPILATION", - - // C-series: Capability values and multiple caps - "GRANT-C1": "CAPGRANT_COMPILATION", - "GRANT-C2": "CAPGRANT_COMPILATION", - "GRANT-C3": "CAPGRANT_COMPILATION", - "GRANT-C4": "CAPGRANT_COMPILATION", - "GRANT-C5": "CAPGRANT_COMPILATION", - "GRANT-C6": "CAPGRANT_COMPILATION", - - // D-series: Source targeting with app caps - "GRANT-D1": "CAPGRANT_COMPILATION", - "GRANT-D2": "CAPGRANT_COMPILATION", - "GRANT-D3": "CAPGRANT_COMPILATION", - "GRANT-D4": "CAPGRANT_COMPILATION", - "GRANT-D5": "CAPGRANT_COMPILATION", - "GRANT-D6": "CAPGRANT_COMPILATION", - "GRANT-D7": "CAPGRANT_COMPILATION", - - // E-series: Destination targeting with app caps - "GRANT-E1": "CAPGRANT_COMPILATION", - "GRANT-E2": "CAPGRANT_COMPILATION", - "GRANT-E4": "CAPGRANT_COMPILATION", - "GRANT-E5": "CAPGRANT_COMPILATION", - "GRANT-E6": "CAPGRANT_COMPILATION", - "GRANT-E7": "CAPGRANT_COMPILATION", - "GRANT-E8": "CAPGRANT_COMPILATION", - - // G-series: Group-based source with app caps (pure capgrant) - "GRANT-G1": "CAPGRANT_COMPILATION", - "GRANT-G2": "CAPGRANT_COMPILATION", - "GRANT-G3": "CAPGRANT_COMPILATION", - "GRANT-G6": "CAPGRANT_COMPILATION", - - // H-series: Edge cases with app caps - "GRANT-H2": "CAPGRANT_COMPILATION", - "GRANT-H6": "CAPGRANT_COMPILATION", - - // K-series: Various app cap patterns - "GRANT-K11": "CAPGRANT_COMPILATION", - "GRANT-K18": "CAPGRANT_COMPILATION", - "GRANT-K19": "CAPGRANT_COMPILATION", - "GRANT-K24": "CAPGRANT_COMPILATION", - "GRANT-K25": "CAPGRANT_COMPILATION", - "GRANT-K27": "CAPGRANT_COMPILATION", - - // V-series: App caps on specific tags, drive cap, autogroup:self app - "GRANT-V02": "CAPGRANT_COMPILATION: app grant on tag:exit — CapGrant with exit-node IPs as Dsts not compiled", - "GRANT-V03": "CAPGRANT_COMPILATION: app grant on tag:router — CapGrant with router IPs as Dsts not compiled", - "GRANT-V06": "CAPGRANT_COMPILATION: multi-dst app grant on [tag:server, tag:exit] — per-node CapGrant not compiled", - "GRANT-V19": "CAPGRANT_COMPILATION: drive cap on tag:exit — drive CapGrant + reverse drive-sharer not compiled", - "GRANT-V20": "CAPGRANT_COMPILATION: kubernetes cap on tag:router — CapGrant not compiled", - "GRANT-V25": "CAPGRANT_COMPILATION: autogroup:self app grant — self-targeting CapGrant per member not compiled", - - // ======================================================================== - // CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT (11 tests) - // - // TODO: These tests have BOTH DstPorts and CapGrant FilterRules. - // They require both CapGrant compilation AND SrcIPs format fixes. - // Grants with both "ip" and "app" fields produce two separate FilterRules: - // one with DstPorts (from "ip") and one with CapGrant (from "app"). - // - // V09/V10: headscale currently rejects mixed ip+app grants with - // "grants cannot specify both 'ip' and 'app' fields", but Tailscale - // accepts them and produces two FilterRules per grant. - // ======================================================================== - - // F-series: Mixed ip+app grants - "GRANT-F1": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT", - "GRANT-F2": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT", - "GRANT-F3": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT", - "GRANT-F4": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT", - - // G-series: Group-based mixed grants - "GRANT-G4": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT", - "GRANT-G5": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT", - - // K-series: Mixed patterns - "GRANT-K3": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT", - "GRANT-K5": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT", - "GRANT-K28": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT", - - // V-series: Mixed ip+app on specific tags - "GRANT-V09": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT: mixed ip+app on tag:exit — headscale rejects, Tailscale produces DstPorts + CapGrant", - "GRANT-V10": "CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT: mixed ip+app on tag:router — headscale rejects, Tailscale produces DstPorts + CapGrant", - // ======================================================================== // VIA_COMPILATION (3 tests) // @@ -384,6 +249,7 @@ var grantSkipReasons = map[string]string{ // 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", @@ -514,17 +380,14 @@ var grantSkipReasons = map[string]string{ // // Skip category impact summary (highest first): // -// CAPGRANT_COMPILATION - 49 tests: Implement app->CapGrant FilterRule compilation // ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules -// CAPGRANT_COMPILATION_AND_SRCIPS - 11 tests: Both CapGrant compilation + SrcIPs format // VIA_COMPILATION_AND_SRCIPS_FORMAT - 7 tests: Via route compilation + SrcIPs format -// VIA_COMPILATION - 3 tests: Via route compilation +// 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) -// SRCIPS_WILDCARD_NODE_DEDUP - 1 test: Wildcard+specific source node IP deduplication // -// Total: 99 tests skipped, ~138 tests expected to pass. +// Total: 41 tests skipped, ~196 tests expected to pass. func TestGrantsCompat(t *testing.T) { t.Parallel() diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 29945099..fdfbf3e1 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -67,14 +67,12 @@ var ( // Grant validation errors. var ( - ErrGrantIPAndAppMutuallyExclusive = errors.New("grants cannot specify both 'ip' and 'app' fields") - ErrGrantMissingIPOrApp = errors.New("grants must specify either 'ip' or 'app' field") - ErrGrantInvalidViaTag = errors.New("grant 'via' tag is not defined in policy") - ErrGrantViaNotSupported = errors.New("grant 'via' routing is not yet supported in headscale") - ErrGrantAppProtocolConflict = errors.New("grants with 'app' cannot specify 'ip' protocols") - ErrGrantEmptySources = errors.New("grant sources cannot be empty") - ErrGrantEmptyDestinations = errors.New("grant destinations cannot be empty") - ErrProtocolPortInvalidFormat = errors.New("expected only one colon in Internet protocol and port type") + ErrGrantMissingIPOrApp = errors.New("grants must specify either 'ip' or 'app' field") + ErrGrantInvalidViaTag = errors.New("grant 'via' tag is not defined in policy") + ErrGrantViaNotSupported = errors.New("grant 'via' routing is not yet supported in headscale") + ErrGrantEmptySources = errors.New("grant sources cannot be empty") + ErrGrantEmptyDestinations = errors.New("grant destinations cannot be empty") + ErrProtocolPortInvalidFormat = errors.New("expected only one colon in Internet protocol and port type") ) // Policy validation errors. @@ -2380,14 +2378,10 @@ func (p *Policy) validate() error { } for _, grant := range p.Grants { - // Validate ip/app mutual exclusivity + // Validate that grants have at least ip or app hasIP := len(grant.InternetProtocols) > 0 hasApp := len(grant.App) > 0 - if hasIP && hasApp { - errs = append(errs, ErrGrantIPAndAppMutuallyExclusive) - } - if !hasIP && !hasApp { errs = append(errs, ErrGrantMissingIPOrApp) } diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index ee3d29f0..bb5e597a 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -4583,7 +4583,7 @@ func TestUnmarshalGrants(t *testing.T) { }, }, { - name: "invalid-grant-both-ip-and-app", + name: "valid-grant-both-ip-and-app", input: ` { "grants": [ @@ -4598,7 +4598,24 @@ func TestUnmarshalGrants(t *testing.T) { ] } `, - wantErr: "grants cannot specify both 'ip' and 'app' fields", + want: &Policy{ + Grants: []Grant{ + { + Sources: Aliases{ + Wildcard, + }, + Destinations: Aliases{ + Wildcard, + }, + InternetProtocols: []ProtocolPort{ + {Protocol: "tcp", Ports: []tailcfg.PortRange{{First: 443, Last: 443}}}, + }, + App: tailcfg.PeerCapMap{ + "tailscale.com/cap/relay": []tailcfg.RawMessage{}, + }, + }, + }, + }, }, { name: "invalid-grant-missing-ip-and-app",