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