mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-07 21:47:46 +09:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user