mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-10 19:17:25 +02:00
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user