From 84210c03bbfb24c3f414af34e3d8001c18aea5ca Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 18 Mar 2026 21:15:58 +0000 Subject: [PATCH] policy/v2: accept empty grant sources and destinations Tailscale SaaS accepts grants with empty src=[] or dst=[] arrays, producing no filter rules for any node. Headscale previously rejected these with validation errors. Remove the empty source/destination validation checks and add an early return in compileGrantWithAutogroupSelf when the grant has literally empty Sources or Destinations arrays. This is distinct from sources that resolve to empty (e.g., group:empty) where Tailscale still produces CapGrant rules with empty SrcIPs. Updates #2180 --- hscontrol/policy/v2/filter.go | 8 ++++++ .../policy/v2/tailscale_grants_compat_test.go | 13 ++------- hscontrol/policy/v2/types.go | 12 ++------ hscontrol/policy/v2/types_test.go | 28 ++++++++++++++++--- 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go index 3f5dcc28..52cd7267 100644 --- a/hscontrol/policy/v2/filter.go +++ b/hscontrol/policy/v2/filter.go @@ -478,6 +478,14 @@ func (pol *Policy) compileGrantWithAutogroupSelf( } } + // When the grant has literally empty src=[] or dst=[], produce no rules + // at all — Tailscale returns null for these. This is distinct from sources + // that resolve to empty (e.g., group:empty) where Tailscale still produces + // CapGrant rules with empty SrcIPs. + if len(grant.Sources) == 0 || len(grant.Destinations) == 0 { + return rules, nil + } + if len(resolvedSrcs) == 0 && grant.App == nil { return rules, nil } diff --git a/hscontrol/policy/v2/tailscale_grants_compat_test.go b/hscontrol/policy/v2/tailscale_grants_compat_test.go index 024753ff..9aff6713 100644 --- a/hscontrol/policy/v2/tailscale_grants_compat_test.go +++ b/hscontrol/policy/v2/tailscale_grants_compat_test.go @@ -217,9 +217,8 @@ func loadGrantTestFile(t *testing.T, path string) grantTestFile { // ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules // 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: 30 tests skipped, ~207 tests expected to pass. +// Total: 28 tests skipped, ~209 tests expected to pass. var grantSkipReasons = map[string]string{ // ======================================================================== // USER_PASSKEY_WILDCARD (2 tests) @@ -313,12 +312,7 @@ var grantSkipReasons = map[string]string{ "GRANT-V16": "ERROR_VALIDATION_GAP: dst [0.0.0.0/0, ::/0] with via — both rejected", "GRANT-V18": "ERROR_VALIDATION_GAP: dst 0.0.0.0/0 with via + app — rejected regardless of via or app", - // Empty src/dst validation difference: - // Tailscale ACCEPTS empty src/dst arrays (producing no filter rules), - // but headscale rejects them with "grant sources/destinations cannot be empty". - // headscale is stricter here — should match Tailscale and accept empty arrays. - "GRANT-H4": "VALIDATION_STRICTNESS: headscale rejects empty src=[] but Tailscale accepts it (producing no rules)", - "GRANT-H5": "VALIDATION_STRICTNESS: headscale rejects empty dst=[] but Tailscale accepts it (producing no rules)", + // (VALIDATION_STRICTNESS tests H4/H5 removed — empty src/dst now accepted) // ======================================================================== // NIL_VS_EMPTY_RULES (varies) @@ -352,9 +346,8 @@ var grantSkipReasons = map[string]string{ // ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules // 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: 30 tests skipped, ~207 tests expected to pass. +// Total: 28 tests skipped, ~209 tests expected to pass. func TestGrantsCompat(t *testing.T) { t.Parallel() diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index fdfbf3e1..cc01d77c 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -2386,11 +2386,7 @@ func (p *Policy) validate() error { errs = append(errs, ErrGrantMissingIPOrApp) } - // Validate sources - if len(grant.Sources) == 0 { - errs = append(errs, ErrGrantEmptySources) - } - + // Validate sources (empty arrays are allowed — they produce no rules) for _, src := range grant.Sources { switch src := src.(type) { case *Host: @@ -2429,11 +2425,7 @@ func (p *Policy) validate() error { } } - // Validate destinations - if len(grant.Destinations) == 0 { - errs = append(errs, ErrGrantEmptyDestinations) - } - + // Validate destinations (empty arrays are allowed — they produce no rules) for _, dst := range grant.Destinations { switch h := dst.(type) { case *Host: diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index bb5e597a..1e31c7f2 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -4632,7 +4632,7 @@ func TestUnmarshalGrants(t *testing.T) { wantErr: "grants must specify either 'ip' or 'app' field", }, { - name: "invalid-grant-empty-sources", + name: "valid-grant-empty-sources", input: ` { "grants": [ @@ -4644,10 +4644,20 @@ func TestUnmarshalGrants(t *testing.T) { ] } `, - wantErr: "grant sources cannot be empty", + want: &Policy{ + Grants: []Grant{ + { + Sources: Aliases{}, + Destinations: Aliases{Wildcard}, + InternetProtocols: []ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + }, + }, + }, }, { - name: "invalid-grant-empty-destinations", + name: "valid-grant-empty-destinations", input: ` { "grants": [ @@ -4659,7 +4669,17 @@ func TestUnmarshalGrants(t *testing.T) { ] } `, - wantErr: "grant destinations cannot be empty", + want: &Policy{ + Grants: []Grant{ + { + Sources: Aliases{Wildcard}, + Destinations: Aliases{}, + InternetProtocols: []ProtocolPort{ + {Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + }, + }, + }, }, { name: "invalid-grant-undefined-via-tag",