mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
policy/v2: branch tailscale_ssh_data_compat_test on APIResponseCode
Rejection captures (APIResponseCode != 200) now route through NewPolicyManager + SetPolicy mirroring sshtester_compat_test.go; the SaaS Message must be a substring of headscale's error. The accepted path (200) keeps the existing per-node SSHRules comparison. Adds 28 ssh-malformed-* captures and a parallel sshRejectSkipReasons map for 4xx scenarios where headscale and SaaS legitimately disagree.
This commit is contained in:
@@ -3,13 +3,17 @@
|
||||
// from a Tailscale-hosted control plane, and compares headscale's SSH policy
|
||||
// compilation against the captured SSH rules.
|
||||
//
|
||||
// Each file is a testcapture.Capture containing:
|
||||
// - The full policy that was POSTed to Tailscale SaaS (we use tf.Input.FullPolicy
|
||||
// directly instead of reconstructing it from a sub-section)
|
||||
// - The expected SSH rules for each of the 8 test nodes (in tf.Captures[name].SSHRules)
|
||||
// Each capture is one of:
|
||||
// - APIResponseCode == 200 — SaaS accepted the policy; the captured
|
||||
// per-node SSH rules in tf.Captures[name].SSHRules are the source of
|
||||
// truth, and headscale's compileSSHPolicy must produce the same shape.
|
||||
// - APIResponseCode != 200 — SaaS rejected the policy at the API; the
|
||||
// captured Message is the body the user saw. headscale must reject
|
||||
// the same input with an error whose text contains that body as a
|
||||
// substring (mirroring sshtester_compat_test.go).
|
||||
//
|
||||
// Tests known to fail due to unimplemented features or known differences are
|
||||
// skipped with a TODO comment explaining the root cause.
|
||||
// Tests known to diverge are listed in sshSkipReasons (200 path) or
|
||||
// sshRejectSkipReasons (!= 200 path) with a TODO explaining the gap.
|
||||
//
|
||||
// Test data source: testdata/ssh_results/ssh-*.hujson
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
@@ -71,8 +75,11 @@ func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture {
|
||||
return c
|
||||
}
|
||||
|
||||
// sshSkipReasons documents why each skipped test fails and what needs to be
|
||||
// fixed. Tests are grouped by root cause to identify high-impact changes.
|
||||
// sshSkipReasons documents APIResponseCode == 200 captures where SaaS
|
||||
// accepted the policy but headscale either does not yet support the
|
||||
// shape or rejects it stricter than SaaS does. Each entry should
|
||||
// describe the gap a follow-up PR needs to close (or justify why
|
||||
// headscale is intentionally stricter).
|
||||
var sshSkipReasons = map[string]string{
|
||||
// USER_PASSKEY_WILDCARD (2 tests)
|
||||
//
|
||||
@@ -81,17 +88,87 @@ var sshSkipReasons = map[string]string{
|
||||
"ssh-b5": "user:*@passkey wildcard not supported in headscale",
|
||||
"ssh-d10": "user:*@passkey wildcard not supported in headscale",
|
||||
|
||||
// DOMAIN_NOT_ASSOCIATED (4 tests)
|
||||
// AUTOGROUP_AS_SSH_USER (4 tests)
|
||||
//
|
||||
// SaaS accepts `users: ["autogroup:X"]` on an `ssh` rule for
|
||||
// every autogroup name (member, self, tagged, internet); the
|
||||
// resolution then produces zero principals at compile time for
|
||||
// the ones that don't map to actual logins. headscale's
|
||||
// validate() restricts users-side autogroups to just
|
||||
// `autogroup:nonroot` via autogroupForSSHUser, so the policy
|
||||
// fails to parse. Either widen autogroupForSSHUser to mirror
|
||||
// SaaS (and let compilation drop unsupported entries) or
|
||||
// document that headscale is intentionally stricter.
|
||||
"ssh-malformed-user-autogroup-internet": "headscale rejects `users: [autogroup:internet]`; SaaS accepts and compiles to zero principals",
|
||||
"ssh-malformed-user-autogroup-member": "headscale rejects `users: [autogroup:member]`; SaaS accepts and compiles to zero principals",
|
||||
"ssh-malformed-user-autogroup-self": "headscale rejects `users: [autogroup:self]`; SaaS accepts and compiles to zero principals",
|
||||
"ssh-malformed-user-autogroup-tagged": "headscale rejects `users: [autogroup:tagged]`; SaaS accepts and compiles to zero principals",
|
||||
|
||||
// LOCALPART_SHAPE (4 tests)
|
||||
//
|
||||
// SaaS accepts every shape under the `localpart:` prefix
|
||||
// (`localpart:`, `localpart:foo`, `localpart:*@`, and
|
||||
// `localpart:foo@example.com`) and compiles each to zero
|
||||
// principals. headscale's SSHUser.ParseLocalpart enforces the
|
||||
// strict `localpart:*@<domain>` shape and returns errors for
|
||||
// every other form — see hscontrol/policy/v2/types.go
|
||||
// ParseLocalpart and the call site in validate(). The strict
|
||||
// path is arguably correct (these strings can never produce a
|
||||
// useful principal) but it surfaces as a parse error rather
|
||||
// than the SaaS behaviour of silently producing zero
|
||||
// principals.
|
||||
"ssh-malformed-user-localpart-empty": "headscale rejects `localpart:` (missing @); SaaS accepts and compiles to zero principals",
|
||||
"ssh-malformed-user-localpart-no-at": "headscale rejects `localpart:foo` (missing @); SaaS accepts and compiles to zero principals",
|
||||
"ssh-malformed-user-localpart-no-domain": "headscale rejects `localpart:*@` (empty domain); SaaS accepts and compiles to zero principals",
|
||||
"ssh-malformed-user-localpart-no-glob": "headscale rejects `localpart:foo@example.com` (local part not *); SaaS accepts and compiles to zero principals",
|
||||
|
||||
// CHECK_PERIOD_MIN (2 tests)
|
||||
//
|
||||
// SaaS allows `checkPeriod: "0s"` and any sub-minute value;
|
||||
// headscale's SSHCheckPeriodMin = 1 minute rejects both. The
|
||||
// minimum may be a deliberate hardening, but it diverges from
|
||||
// SaaS — needs an explicit decision.
|
||||
"ssh-malformed-checkperiod-zero": "headscale rejects checkPeriod `0s`; SaaS accepts",
|
||||
"ssh-malformed-checkperiod-too-short": "headscale rejects checkPeriod `30s`; SaaS accepts (no minimum)",
|
||||
}
|
||||
|
||||
// sshRejectSkipReasons documents APIResponseCode != 200 captures where
|
||||
// headscale and SaaS legitimately disagree on whether the policy should
|
||||
// be rejected (or where headscale rejects with different wording).
|
||||
var sshRejectSkipReasons = map[string]string{
|
||||
// WORDING_DIFFERS (4 tests)
|
||||
//
|
||||
// headscale rejects these inputs but with different error
|
||||
// text, so the SaaS message is not a substring of headscale's.
|
||||
"ssh-malformed-action-deny": `headscale rejects "deny" with 'invalid SSH action: "deny", must be one of: accept, check' vs SaaS '"deny" is not a valid action'`,
|
||||
"ssh-malformed-action-empty": `headscale rejects empty action with 'invalid SSH action: "", must be one of: accept, check' vs SaaS 'action must be specified'`,
|
||||
"ssh-malformed-checkperiod-malformed": `headscale rejects malformed duration with 'not a valid duration string: "abc"' vs SaaS 'time: invalid duration "abc"'`,
|
||||
"ssh-malformed-checkperiod-too-long": "headscale rejects 200h with 'checkPeriod above maximum of 168 hours (1 week)' vs SaaS 'checkPeriod 200h0m0s is above the max (168h)'",
|
||||
|
||||
// MISSING_VALIDATIONS (5 tests)
|
||||
//
|
||||
// SaaS rejects these inputs; headscale accepts them today.
|
||||
// Fixes belong in hscontrol/policy/v2/types.go validate().
|
||||
"ssh-malformed-action-missing": "headscale accepts missing `action`; SaaS rejects with `action must be specified`",
|
||||
"ssh-malformed-acceptenv-empty": "headscale accepts `acceptEnv: [\"\"]`; SaaS rejects with `acceptEnv values cannot be empty`",
|
||||
"ssh-malformed-users-empty-array": "headscale accepts empty `users: []`; SaaS rejects with `users must be specified`",
|
||||
"ssh-malformed-users-missing": "headscale accepts missing `users`; SaaS rejects with `users must be specified`",
|
||||
"ssh-malformed-user-empty": `headscale accepts empty username ""; SaaS rejects with 'user "" is not valid'`,
|
||||
"ssh-malformed-user-wildcard": `headscale accepts wildcard "*"; SaaS rejects with 'user "*" is not valid' (same gap as sshtester_compat_test.go's sshtest-user-wildcard)`,
|
||||
|
||||
// DOMAIN_NOT_ASSOCIATED (5 tests)
|
||||
//
|
||||
// SaaS validates that email domains in user:*@domain and
|
||||
// localpart:*@domain expressions are configured tailnet domains.
|
||||
// headscale has no concept of "associated tailnet domains" — it
|
||||
// only has users with email addresses. These policies are
|
||||
// legitimately rejected by SaaS but not by headscale.
|
||||
// localpart:*@domain expressions are configured tailnet
|
||||
// domains. headscale has no concept of "associated tailnet
|
||||
// domains" — it only has users with email addresses. These
|
||||
// policies are legitimately rejected by SaaS but not by
|
||||
// headscale.
|
||||
"ssh-b4": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
"ssh-d1": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
"ssh-e1": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
"ssh-e2": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
"ssh-malformed-user-localpart-multi-glob": "domain validation: headscale has no 'associated tailnet domains' concept (same gap as ssh-b4/d1/e1/e2)",
|
||||
}
|
||||
|
||||
// TestSSHDataCompat is a data-driven test that loads all ssh-*.hujson test
|
||||
@@ -137,7 +214,63 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Check if this test is in the skip list
|
||||
// Build nodes per-scenario from this file's topology.
|
||||
// tscap uses clean-slate mode, so each scenario has
|
||||
// different node IPs.
|
||||
nodes := buildGrantsNodesFromCapture(users, tf)
|
||||
|
||||
// Use the captured full policy as is. Anonymization in
|
||||
// tscap already rewrites SaaS emails to @example.com.
|
||||
policyJSON := []byte(tf.Input.FullPolicy)
|
||||
|
||||
// Branch on the SaaS response code. Captures with
|
||||
// APIResponseCode != 200 are policies SaaS rejected at
|
||||
// the API; headscale must reject the same input. The
|
||||
// 200 path falls through to the existing per-node SSH
|
||||
// rule comparison.
|
||||
if tf.Input.APIResponseCode != 200 {
|
||||
if reason, ok := sshRejectSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf(
|
||||
"TODO: %s — see sshRejectSkipReasons for details",
|
||||
reason,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pm, parseErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
|
||||
|
||||
var got error
|
||||
|
||||
switch {
|
||||
case parseErr != nil:
|
||||
got = parseErr
|
||||
default:
|
||||
_, setErr := pm.SetPolicy(policyJSON)
|
||||
got = setErr
|
||||
}
|
||||
|
||||
require.Error(t, got, "tailscale rejected; headscale must reject too")
|
||||
|
||||
if tf.Input.APIResponseBody == nil ||
|
||||
tf.Input.APIResponseBody.Message == "" {
|
||||
return
|
||||
}
|
||||
|
||||
want := tf.Input.APIResponseBody.Message
|
||||
if !strings.Contains(got.Error(), want) {
|
||||
t.Errorf(
|
||||
"error body mismatch\n tailscale wants: %q\n headscale got: %q",
|
||||
want,
|
||||
got.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// APIResponseCode == 200: SaaS accepted; headscale must
|
||||
// match the captured per-node SSH rules.
|
||||
if reason, ok := sshSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf(
|
||||
"TODO: %s — see sshSkipReasons comments for details",
|
||||
@@ -147,29 +280,13 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
// SaaS rejected this policy — verify headscale also rejects it.
|
||||
if tf.Error {
|
||||
testSSHError(t, tf)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Build nodes per-scenario from this file's topology.
|
||||
// the capture tool uses clean-slate mode, so each scenario has
|
||||
// different node IPs.
|
||||
nodes := buildGrantsNodesFromCapture(users, tf)
|
||||
|
||||
// Use the captured full policy as is. Anonymization in
|
||||
// captures already rewrite SaaS emails to @example.com.
|
||||
policyJSON := tf.Input.FullPolicy
|
||||
|
||||
pol, err := unmarshalPolicy([]byte(policyJSON))
|
||||
pol, err := unmarshalPolicy(policyJSON)
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
"%s: policy should parse successfully\nPolicy:\n%s",
|
||||
tf.TestID,
|
||||
policyJSON,
|
||||
tf.Input.FullPolicy,
|
||||
)
|
||||
|
||||
for nodeName, capture := range tf.Captures {
|
||||
@@ -254,97 +371,3 @@ func TestSSHDataCompat(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sshErrorMessageMap maps Tailscale SaaS error substrings to headscale
|
||||
// equivalents where the wording differs but the meaning is the same.
|
||||
var sshErrorMessageMap = map[string]string{}
|
||||
|
||||
// testSSHError verifies that an invalid policy produces the expected error.
|
||||
func testSSHError(t *testing.T, tf *testcapture.Capture) {
|
||||
t.Helper()
|
||||
|
||||
policyJSON := []byte(tf.Input.FullPolicy)
|
||||
|
||||
pol, err := unmarshalPolicy(policyJSON)
|
||||
if err != nil {
|
||||
// Parse-time error.
|
||||
if tf.Input.APIResponseBody != nil {
|
||||
wantMsg := tf.Input.APIResponseBody.Message
|
||||
if wantMsg != "" {
|
||||
assertSSHErrorContains(t, err, wantMsg, tf.TestID)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = pol.validate()
|
||||
if err != nil {
|
||||
if tf.Input.APIResponseBody != nil {
|
||||
wantMsg := tf.Input.APIResponseBody.Message
|
||||
if wantMsg != "" {
|
||||
assertSSHErrorContains(t, err, wantMsg, tf.TestID)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.Errorf(
|
||||
"%s: expected error but policy parsed and validated successfully",
|
||||
tf.TestID,
|
||||
)
|
||||
}
|
||||
|
||||
// assertSSHErrorContains checks that an error message matches the
|
||||
// expected Tailscale SaaS message, using progressive fallbacks:
|
||||
// 1. Direct substring match
|
||||
// 2. Mapped equivalent from sshErrorMessageMap
|
||||
// 3. Key-part extraction (tags, autogroups)
|
||||
// 4. t.Errorf on no match (strict)
|
||||
func assertSSHErrorContains(
|
||||
t *testing.T,
|
||||
err error,
|
||||
wantMsg string,
|
||||
testID string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
errStr := err.Error()
|
||||
|
||||
// 1. Direct substring match.
|
||||
if strings.Contains(errStr, wantMsg) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Mapped equivalent.
|
||||
for tsKey, hsKey := range sshErrorMessageMap {
|
||||
if strings.Contains(wantMsg, tsKey) &&
|
||||
strings.Contains(errStr, hsKey) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Key-part extraction.
|
||||
for _, part := range []string{
|
||||
"autogroup:",
|
||||
"tag:",
|
||||
"undefined",
|
||||
"not valid",
|
||||
} {
|
||||
if strings.Contains(wantMsg, part) &&
|
||||
strings.Contains(errStr, part) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. No match — strict failure.
|
||||
t.Errorf(
|
||||
"%s: error message mismatch\n"+
|
||||
" want (tailscale): %q\n"+
|
||||
" got (headscale): %q",
|
||||
testID,
|
||||
wantMsg,
|
||||
errStr,
|
||||
)
|
||||
}
|
||||
|
||||
+20105
File diff suppressed because it is too large
Load Diff
+20082
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+20081
File diff suppressed because it is too large
Load Diff
+20078
File diff suppressed because it is too large
Load Diff
+20082
File diff suppressed because it is too large
Load Diff
+20082
File diff suppressed because it is too large
Load Diff
+20099
File diff suppressed because it is too large
Load Diff
+20099
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+20112
File diff suppressed because it is too large
Load Diff
+20112
File diff suppressed because it is too large
Load Diff
+20104
File diff suppressed because it is too large
Load Diff
+20112
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+20105
File diff suppressed because it is too large
Load Diff
+20104
File diff suppressed because it is too large
Load Diff
+20083
File diff suppressed because it is too large
Load Diff
+20104
File diff suppressed because it is too large
Load Diff
+20104
File diff suppressed because it is too large
Load Diff
+20112
File diff suppressed because it is too large
Load Diff
+20105
File diff suppressed because it is too large
Load Diff
+20081
File diff suppressed because it is too large
Load Diff
+20081
File diff suppressed because it is too large
Load Diff
+20078
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user