mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-07 21:47:46 +09:00
policy/v2: implement via route compilation for grants
Compile grants with "via" field into FilterRules that are placed only on nodes matching the via tag and actually advertising the destination subnets. Key behavior: - Filter rules go exclusively to via-nodes with matching approved routes - Destination subnets not advertised by the via node are silently dropped - App-only via grants (no ip field) produce no packet filter rules - Via grants are skipped in the global compileFilterRules since they are node-specific Reduces grant compat test skips from 41 to 30 (11 newly passing). Updates #2180
This commit is contained in:
@@ -130,6 +130,12 @@ func (pol *Policy) compileFilterRules(
|
||||
}
|
||||
|
||||
for _, grant := range grants {
|
||||
// Via grants are compiled per-node in compileViaGrant,
|
||||
// not in the global filter set.
|
||||
if len(grant.Via) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
srcIPs, err := grant.Sources.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("resolving source ips")
|
||||
@@ -287,19 +293,151 @@ func (pol *Policy) compileFilterRulesForNode(
|
||||
return mergeFilterRules(rules), nil
|
||||
}
|
||||
|
||||
// compileViaGrant compiles a grant with a "via" field. Via grants
|
||||
// produce filter rules ONLY on nodes matching a via tag that actually
|
||||
// advertise (and have approved) the destination subnets. All other
|
||||
// nodes receive no rules. App-only via grants (no ip field) produce
|
||||
// no packet filter rules.
|
||||
func (pol *Policy) compileViaGrant(
|
||||
grant Grant,
|
||||
users types.Users,
|
||||
node types.NodeView,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) ([]tailcfg.FilterRule, error) {
|
||||
// Check if the current node matches any of the via tags.
|
||||
matchesVia := false
|
||||
|
||||
for _, viaTag := range grant.Via {
|
||||
if node.HasTag(string(viaTag)) {
|
||||
matchesVia = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matchesVia {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// App-only via grants produce no packet filter rules.
|
||||
if len(grant.InternetProtocols) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Find which grant destination subnets this node actually advertises.
|
||||
nodeRoutes := node.SubnetRoutes()
|
||||
if len(nodeRoutes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Collect destination prefixes that match the node's approved routes.
|
||||
var viaDstPrefixes []netip.Prefix
|
||||
|
||||
for _, dst := range grant.Destinations {
|
||||
p, ok := dst.(*Prefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
dstPrefix := netip.Prefix(*p)
|
||||
if slices.Contains(nodeRoutes, dstPrefix) {
|
||||
viaDstPrefixes = append(viaDstPrefixes, dstPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
if len(viaDstPrefixes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Resolve source IPs.
|
||||
var resolvedSrcs []ResolvedAddresses
|
||||
|
||||
for _, src := range grant.Sources {
|
||||
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||||
return nil, errSelfInSources
|
||||
}
|
||||
|
||||
ips, err := src.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("resolving source ips")
|
||||
}
|
||||
|
||||
if ips != nil {
|
||||
resolvedSrcs = append(resolvedSrcs, ips)
|
||||
}
|
||||
}
|
||||
|
||||
if len(resolvedSrcs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build merged SrcIPs from all sources.
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcs {
|
||||
for _, pref := range ips.Prefixes() {
|
||||
srcIPs.AddPrefix(pref)
|
||||
}
|
||||
}
|
||||
|
||||
srcResolved, err := newResolved(&srcIPs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if srcResolved.Empty() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
hasWildcard := sourcesHaveWildcard(grant.Sources)
|
||||
srcIPStrs := srcIPsWithRoutes(srcResolved, hasWildcard, nodes)
|
||||
|
||||
// Build DstPorts from the matching via prefixes.
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
for _, ipp := range grant.InternetProtocols {
|
||||
var destPorts []tailcfg.NetPortRange
|
||||
|
||||
for _, prefix := range viaDstPrefixes {
|
||||
for _, port := range ipp.Ports {
|
||||
destPorts = append(destPorts, tailcfg.NetPortRange{
|
||||
IP: prefix.String(),
|
||||
Ports: port,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(destPorts) > 0 {
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcIPStrs,
|
||||
DstPorts: destPorts,
|
||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// compileGrantWithAutogroupSelf compiles a single Grant rule, handling
|
||||
// autogroup:self per-node while supporting all other alias types normally.
|
||||
// It returns a slice of filter rules because when an Grant has both autogroup:self
|
||||
// and other destinations, they need to be split into separate rules with different
|
||||
// source filtering logic.
|
||||
//
|
||||
//nolint:gocyclo // complex ACL compilation logic
|
||||
//nolint:gocyclo,cyclop // complex ACL compilation logic
|
||||
func (pol *Policy) compileGrantWithAutogroupSelf(
|
||||
grant Grant,
|
||||
users types.Users,
|
||||
node types.NodeView,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) ([]tailcfg.FilterRule, error) {
|
||||
// Handle via route grants — filter rules only go to the node
|
||||
// matching the via tag that actually advertises the destination subnets.
|
||||
if len(grant.Via) > 0 {
|
||||
return pol.compileViaGrant(grant, users, node, nodes)
|
||||
}
|
||||
|
||||
var (
|
||||
autogroupSelfDests []Alias
|
||||
otherDests []Alias
|
||||
|
||||
@@ -215,13 +215,11 @@ func loadGrantTestFile(t *testing.T, path string) grantTestFile {
|
||||
// Impact summary (highest first):
|
||||
//
|
||||
// ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules
|
||||
// VIA_COMPILATION_AND_SRCIPS_FORMAT - 7 tests: Via route compilation + SrcIPs format
|
||||
// VIA_COMPILATION - 4 tests: Via route compilation
|
||||
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
||||
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
|
||||
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
||||
//
|
||||
// Total: 41 tests skipped, ~196 tests expected to pass.
|
||||
// Total: 30 tests skipped, ~207 tests expected to pass.
|
||||
var grantSkipReasons = map[string]string{
|
||||
// ========================================================================
|
||||
// USER_PASSKEY_WILDCARD (2 tests)
|
||||
@@ -240,39 +238,10 @@ var grantSkipReasons = map[string]string{
|
||||
"GRANT-K20": "USER_PASSKEY_WILDCARD: src=user:*@passkey, dst=tag:server — source can't be resolved, no rules produced",
|
||||
"GRANT-K21": "USER_PASSKEY_WILDCARD: src=*, dst=user:*@passkey — destination can't be resolved, no rules produced",
|
||||
|
||||
// ========================================================================
|
||||
// VIA_COMPILATION (3 tests)
|
||||
//
|
||||
// TODO: Implement via route compilation in filter rules.
|
||||
//
|
||||
// Via routes with specific (non-wildcard) sources produce DstPorts rules
|
||||
// with correctly restricted SrcIPs. These tests have no SrcIPs format
|
||||
// issue because they use specific src identities (tags, groups, members).
|
||||
// ========================================================================
|
||||
"GRANT-K12": "VIA_COMPILATION: via tag:router + src:* + dst:10.33.0.0/16 + app — via route with CapGrant",
|
||||
"GRANT-V11": "VIA_COMPILATION: via tag:router + src:tag:client — SrcIPs = client IPs only",
|
||||
"GRANT-V12": "VIA_COMPILATION: via tag:router + src:autogroup:member — SrcIPs = member IPs",
|
||||
"GRANT-V13": "VIA_COMPILATION: via tag:router + src:group:developers + tcp:80,443 — group SrcIPs + specific ports",
|
||||
// (VIA_COMPILATION tests removed — via route compilation now implemented)
|
||||
|
||||
// ========================================================================
|
||||
// VIA_COMPILATION_AND_SRCIPS_FORMAT (7 tests)
|
||||
//
|
||||
// TODO: Implement via route compilation in filter rules.
|
||||
//
|
||||
// Via routes ("via" field in grants) specify that traffic to a destination
|
||||
// CIDR should be routed through a specific tagged subnet router. The via
|
||||
// field is currently parsed and validated but NOT compiled into FilterRules.
|
||||
//
|
||||
// These tests also have SrcIPs format differences (wildcard src expands
|
||||
// to split CGNAT ranges).
|
||||
// ========================================================================
|
||||
"GRANT-I1": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
|
||||
"GRANT-I2": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
|
||||
"GRANT-I3": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
|
||||
"GRANT-K13": "VIA_COMPILATION_AND_SRCIPS_FORMAT",
|
||||
"GRANT-V17": "VIA_COMPILATION_AND_SRCIPS_FORMAT: via tag:router + multi-dst — unadvertised subnets silently dropped",
|
||||
"GRANT-V21": "VIA_COMPILATION_AND_SRCIPS_FORMAT: via [tag:router, tag:exit] — only advertising nodes get rules",
|
||||
"GRANT-V23": "VIA_COMPILATION_AND_SRCIPS_FORMAT: via tag:router + tcp:22,80,443 — via + multiple ports",
|
||||
// (VIA_COMPILATION_AND_SRCIPS_FORMAT tests removed — via route compilation
|
||||
// and SrcIPs format are both now implemented),
|
||||
|
||||
// ========================================================================
|
||||
// AUTOGROUP_DANGER_ALL (3 tests)
|
||||
@@ -381,13 +350,11 @@ var grantSkipReasons = map[string]string{
|
||||
// Skip category impact summary (highest first):
|
||||
//
|
||||
// ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules
|
||||
// VIA_COMPILATION_AND_SRCIPS_FORMAT - 7 tests: Via route compilation + SrcIPs format
|
||||
// VIA_COMPILATION - 4 tests: Via route compilation
|
||||
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
||||
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
|
||||
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
||||
//
|
||||
// Total: 41 tests skipped, ~196 tests expected to pass.
|
||||
// Total: 30 tests skipped, ~207 tests expected to pass.
|
||||
func TestGrantsCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user