mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-21 00:01:32 +02: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
|
package policyutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
"tailscale.com/net/tsaddr"
|
"tailscale.com/net/tsaddr"
|
||||||
@@ -17,6 +19,17 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
|
|||||||
ret := []tailcfg.FilterRule{}
|
ret := []tailcfg.FilterRule{}
|
||||||
|
|
||||||
for _, rule := range rules {
|
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.
|
// record if the rule is actually relevant for the given node.
|
||||||
var dests []tailcfg.NetPortRange
|
var dests []tailcfg.NetPortRange
|
||||||
|
|
||||||
@@ -78,3 +91,77 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
|
|||||||
|
|
||||||
return ret
|
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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,6 +22,68 @@ var (
|
|||||||
errSelfInSources = errors.New("autogroup:self cannot be used in sources")
|
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
|
// sourcesHaveWildcard returns true if any of the source aliases is
|
||||||
// a wildcard (*). Used to determine whether approved subnet routes
|
// a wildcard (*). Used to determine whether approved subnet routes
|
||||||
// should be appended to SrcIPs.
|
// should be appended to SrcIPs.
|
||||||
@@ -91,7 +154,10 @@ func (pol *Policy) compileFilterRules(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if grant.App != nil {
|
if grant.App != nil {
|
||||||
var capGrants []tailcfg.CapGrant
|
var (
|
||||||
|
capGrants []tailcfg.CapGrant
|
||||||
|
dstIPStrings []string
|
||||||
|
)
|
||||||
|
|
||||||
for _, dst := range grant.Destinations {
|
for _, dst := range grant.Destinations {
|
||||||
ips, err := dst.Resolve(pol, users, nodes)
|
ips, err := dst.Resolve(pol, users, nodes)
|
||||||
@@ -99,16 +165,34 @@ func (pol *Policy) compileFilterRules(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dstPrefixes := ips.Prefixes()
|
||||||
capGrants = append(capGrants, tailcfg.CapGrant{
|
capGrants = append(capGrants, tailcfg.CapGrant{
|
||||||
Dsts: ips.Prefixes(),
|
Dsts: dstPrefixes,
|
||||||
CapMap: grant.App,
|
CapMap: grant.App,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
dstIPStrings = append(dstIPStrings, ips.Strings()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
srcIPStrs := srcIPsWithRoutes(srcIPs, hasWildcard, nodes)
|
||||||
rules = append(rules, tailcfg.FilterRule{
|
rules = append(rules, tailcfg.FilterRule{
|
||||||
SrcIPs: srcIPsWithRoutes(srcIPs, hasWildcard, nodes),
|
SrcIPs: srcIPStrs,
|
||||||
CapGrant: capGrants,
|
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
|
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
|
return rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,6 +1051,8 @@ func filterRuleKey(rule tailcfg.FilterRule) string {
|
|||||||
|
|
||||||
// mergeFilterRules merges rules with identical SrcIPs and IPProto by combining
|
// mergeFilterRules merges rules with identical SrcIPs and IPProto by combining
|
||||||
// their DstPorts. DstPorts are NOT deduplicated to match Tailscale behavior.
|
// 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 {
|
func mergeFilterRules(rules []tailcfg.FilterRule) []tailcfg.FilterRule {
|
||||||
if len(rules) <= 1 {
|
if len(rules) <= 1 {
|
||||||
return rules
|
return rules
|
||||||
@@ -810,6 +1062,14 @@ func mergeFilterRules(rules []tailcfg.FilterRule) []tailcfg.FilterRule {
|
|||||||
result := make([]tailcfg.FilterRule, 0, len(rules))
|
result := make([]tailcfg.FilterRule, 0, len(rules))
|
||||||
|
|
||||||
for _, rule := range 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)
|
key := filterRuleKey(rule)
|
||||||
|
|
||||||
if idx, exists := keyToIdx[key]; exists {
|
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.
|
// It sorts SrcIPs and DstPorts to handle ordering differences.
|
||||||
func cmpOptions() []cmp.Option {
|
func cmpOptions() []cmp.Option {
|
||||||
return []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 string) bool { return a < b }),
|
||||||
cmpopts.SortSlices(func(a, b tailcfg.NetPortRange) bool {
|
cmpopts.SortSlices(func(a, b tailcfg.NetPortRange) bool {
|
||||||
if a.IP != b.IP {
|
if a.IP != b.IP {
|
||||||
@@ -131,6 +132,54 @@ func cmpOptions() []cmp.Option {
|
|||||||
return a.Ports.Last < b.Ports.Last
|
return a.Ports.Last < b.Ports.Last
|
||||||
}),
|
}),
|
||||||
cmpopts.SortSlices(func(a, b int) bool { return a < b }),
|
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):
|
// Impact summary (highest first):
|
||||||
//
|
//
|
||||||
// CAPGRANT_COMPILATION - 49 tests: Implement app->CapGrant FilterRule compilation
|
|
||||||
// ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules
|
// 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
|
// 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 - 4 tests: Via route compilation
|
||||||
// VIA_COMPILATION - 3 tests: Via route compilation
|
|
||||||
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
||||||
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
|
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
|
||||||
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
// 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{
|
var grantSkipReasons = map[string]string{
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// USER_PASSKEY_WILDCARD (2 tests)
|
// 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-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",
|
"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)
|
// VIA_COMPILATION (3 tests)
|
||||||
//
|
//
|
||||||
@@ -384,6 +249,7 @@ var grantSkipReasons = map[string]string{
|
|||||||
// with correctly restricted SrcIPs. These tests have no SrcIPs format
|
// with correctly restricted SrcIPs. These tests have no SrcIPs format
|
||||||
// issue because they use specific src identities (tags, groups, members).
|
// 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-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-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",
|
"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):
|
// 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
|
// 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_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
|
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
||||||
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
|
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
|
||||||
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
// 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) {
|
func TestGrantsCompat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -67,14 +67,12 @@ var (
|
|||||||
|
|
||||||
// Grant validation errors.
|
// Grant validation errors.
|
||||||
var (
|
var (
|
||||||
ErrGrantIPAndAppMutuallyExclusive = errors.New("grants cannot specify both 'ip' and 'app' fields")
|
ErrGrantMissingIPOrApp = errors.New("grants must specify either 'ip' or 'app' field")
|
||||||
ErrGrantMissingIPOrApp = errors.New("grants must specify either 'ip' or 'app' field")
|
ErrGrantInvalidViaTag = errors.New("grant 'via' tag is not defined in policy")
|
||||||
ErrGrantInvalidViaTag = errors.New("grant 'via' tag is not defined in policy")
|
ErrGrantViaNotSupported = errors.New("grant 'via' routing is not yet supported in headscale")
|
||||||
ErrGrantViaNotSupported = errors.New("grant 'via' routing is not yet supported in headscale")
|
ErrGrantEmptySources = errors.New("grant sources cannot be empty")
|
||||||
ErrGrantAppProtocolConflict = errors.New("grants with 'app' cannot specify 'ip' protocols")
|
ErrGrantEmptyDestinations = errors.New("grant destinations cannot be empty")
|
||||||
ErrGrantEmptySources = errors.New("grant sources cannot be empty")
|
ErrProtocolPortInvalidFormat = errors.New("expected only one colon in Internet protocol and port type")
|
||||||
ErrGrantEmptyDestinations = errors.New("grant destinations cannot be empty")
|
|
||||||
ErrProtocolPortInvalidFormat = errors.New("expected only one colon in Internet protocol and port type")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Policy validation errors.
|
// Policy validation errors.
|
||||||
@@ -2380,14 +2378,10 @@ func (p *Policy) validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, grant := range p.Grants {
|
for _, grant := range p.Grants {
|
||||||
// Validate ip/app mutual exclusivity
|
// Validate that grants have at least ip or app
|
||||||
hasIP := len(grant.InternetProtocols) > 0
|
hasIP := len(grant.InternetProtocols) > 0
|
||||||
hasApp := len(grant.App) > 0
|
hasApp := len(grant.App) > 0
|
||||||
|
|
||||||
if hasIP && hasApp {
|
|
||||||
errs = append(errs, ErrGrantIPAndAppMutuallyExclusive)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasIP && !hasApp {
|
if !hasIP && !hasApp {
|
||||||
errs = append(errs, ErrGrantMissingIPOrApp)
|
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: `
|
input: `
|
||||||
{
|
{
|
||||||
"grants": [
|
"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",
|
name: "invalid-grant-missing-ip-and-app",
|
||||||
|
|||||||
Reference in New Issue
Block a user