policy/v2: implement CapGrant compilation with companion capabilities

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
This commit is contained in:
Kristoffer Dalby
2026-03-18 19:55:15 +00:00
parent ebe0f4078d
commit 0e3acdd8ec
6 changed files with 431 additions and 161 deletions

View File

@@ -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,
}
}

View File

@@ -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 {

View File

@@ -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)
}),
}
}

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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",