Files
headscale/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go
T
2026-04-12 19:28:28 +00:00

319 lines
9.9 KiB
Go

// This file implements a data-driven test runner for SSH compatibility tests.
// It loads HuJSON golden files from testdata/ssh_results/ssh-*.hujson, captured
// from Tailscale SaaS by tscap, 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)
//
// Tests known to fail due to unimplemented features or known differences are
// skipped with a TODO comment explaining the root cause.
//
// Test data source: testdata/ssh_results/ssh-*.hujson
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package v2
import (
"path/filepath"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
// setupSSHDataCompatUsers returns the 3 test users for SSH data-driven
// compatibility tests. Users get norse-god names; nodes get original-151
// pokémon names — matching the anonymized identifiers tscap writes into
// the capture files (see github.com/kradalby/tscap/anonymize).
//
// odin and freya live on @example.com; thor lives on @example.org so
// that "localpart:*@example.com" resolves to exactly two users
// (matching SaaS output) and the "user on a different email domain"
// case stays covered by scenarios like ssh-d1 that use
// "localpart:*@example.org".
func setupSSHDataCompatUsers() types.Users {
return types.Users{
{
Model: gorm.Model{ID: 1},
Name: "odin",
Email: "odin@example.com",
},
{
Model: gorm.Model{ID: 2},
Name: "thor",
Email: "thor@example.org",
},
{
Model: gorm.Model{ID: 3},
Name: "freya",
Email: "freya@example.com",
},
}
}
// setupSSHDataCompatNodes returns the test nodes for SSH data-driven
// compatibility tests. Node GivenNames match the anonymized pokémon names:
// - bulbasaur (owned by odin)
// - ivysaur (owned by thor)
// - venusaur (owned by freya)
// - beedrill (tag:server)
// - kakuna (tag:prod)
func setupSSHDataCompatNodes(users types.Users) types.Nodes {
return types.Nodes{
&types.Node{
ID: 1,
GivenName: "bulbasaur",
User: &users[0],
UserID: &users[0].ID,
IPv4: ptrAddr("100.90.199.68"),
IPv6: ptrAddr("fd7a:115c:a1e0::2d01:c747"),
Hostinfo: &tailcfg.Hostinfo{},
},
&types.Node{
ID: 2,
GivenName: "ivysaur",
User: &users[1],
UserID: &users[1].ID,
IPv4: ptrAddr("100.110.121.96"),
IPv6: ptrAddr("fd7a:115c:a1e0::1737:7960"),
Hostinfo: &tailcfg.Hostinfo{},
},
&types.Node{
ID: 3,
GivenName: "venusaur",
User: &users[2],
UserID: &users[2].ID,
IPv4: ptrAddr("100.103.90.82"),
IPv6: ptrAddr("fd7a:115c:a1e0::9e37:5a52"),
Hostinfo: &tailcfg.Hostinfo{},
},
&types.Node{
ID: 4,
GivenName: "beedrill",
IPv4: ptrAddr("100.108.74.26"),
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
Tags: []string{"tag:server"},
Hostinfo: &tailcfg.Hostinfo{},
},
&types.Node{
ID: 5,
GivenName: "kakuna",
IPv4: ptrAddr("100.103.8.15"),
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
Tags: []string{"tag:prod"},
Hostinfo: &tailcfg.Hostinfo{},
},
}
}
// loadSSHTestFile loads and parses a single SSH capture HuJSON file.
func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture {
t.Helper()
c, err := testcapture.Read(path)
require.NoError(t, err, "failed to read test file %s", path)
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.
var sshSkipReasons = map[string]string{
// user:*@passkey wildcard pattern not supported in headscale.
// headscale does not support passkey authentication and has no
// equivalent for this wildcard pattern.
"ssh-b5": "user:*@passkey wildcard not supported in headscale",
"ssh-d10": "user:*@passkey wildcard not supported in headscale",
}
// TestSSHDataCompat is a data-driven test that loads all ssh-*.hujson test
// files captured from Tailscale SaaS and compares headscale's SSH policy
// compilation against the real Tailscale behavior.
//
// Each capture file contains:
// - The full policy that was POSTed to the SaaS API (Input.FullPolicy)
// - Expected SSH rules per node (Captures[name].SSHRules)
//
// The test converts Tailscale user email formats to headscale format and runs
// the captured policy through unmarshalPolicy and compileSSHPolicy.
func TestSSHDataCompat(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(
filepath.Join("testdata", "ssh_results", "ssh-*.hujson"),
)
require.NoError(t, err, "failed to glob test files")
require.NotEmpty(
t,
files,
"no ssh-*.hujson test files found in testdata/ssh_results/",
)
t.Logf("Loaded %d SSH test files", len(files))
users := setupSSHDataCompatUsers()
for _, file := range files {
tf := loadSSHTestFile(t, file)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
// Check if this test is in the skip list
if reason, ok := sshSkipReasons[tf.TestID]; ok {
t.Skipf(
"TODO: %s — see sshSkipReasons comments for details",
reason,
)
return
}
// Skip captures the SaaS rejected — no expected SSH rules to compare against.
if tf.Error {
t.Skipf("%s: SaaS rejected the policy (api_response_code=%d); no expected SSH rules captured", tf.TestID, tf.Input.APIResponseCode)
return
}
// 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 verbatim. Anonymization in
// tscap already rewrites SaaS emails to @example.com.
policyJSON := tf.Input.FullPolicy
pol, err := unmarshalPolicy([]byte(policyJSON))
require.NoError(
t,
err,
"%s: policy should parse successfully\nPolicy:\n%s",
tf.TestID,
policyJSON,
)
for nodeName, capture := range tf.Captures {
t.Run(nodeName, func(t *testing.T) {
node := findNodeByGivenName(nodes, nodeName)
if node == nil {
t.Skipf("node %s not in this test's node set", nodeName)
return
}
// Compile headscale SSH policy for this node
gotSSH, err := pol.compileSSHPolicy(
"unused-server-url",
users,
node.View(),
nodes.ViewSlice(),
)
require.NoError(
t,
err,
"%s/%s: failed to compile SSH policy",
tf.TestID,
nodeName,
)
// Build expected SSHPolicy from the typed rules.
var wantSSH *tailcfg.SSHPolicy
if len(capture.SSHRules) > 0 {
wantSSH = &tailcfg.SSHPolicy{Rules: capture.SSHRules}
}
// Normalize: treat empty-rules SSHPolicy as nil
if gotSSH != nil && len(gotSSH.Rules) == 0 {
gotSSH = nil
}
// Compare headscale output against Tailscale expected.
// EquateEmpty treats nil and empty slices as equal.
// Sort principals within rules (order doesn't matter).
// Do NOT sort rules — order matters (first-match-wins).
//
// Ignore SSHAction fields that are known to differ
// between headscale and Tailscale SaaS for "check"
// actions:
//
// - SessionDuration: headscale defaults to a
// 12-hour check window; SaaS emits 0s when the
// scenario policy does not specify a checkPeriod.
//
// - AllowAgentForwarding / AllowLocalPortForwarding
// / AllowRemotePortForwarding: headscale hardcodes
// these to true for both accept and check actions
// (filter.go sshAccept / sshCheck); SaaS emits
// false for check actions.
//
// - HoldAndDelegate: headscale uses a URL template
// containing "/from/…?ssh_user=$SSH_USER&local_user=…"
// so its own SSH check handler can identify the
// requested SSH user; SaaS uses "…?local_user=…"
// without the ssh_user query parameter. Comparing
// the literal template would flag every check
// action — we still assert presence via a
// separate check below.
opts := cmp.Options{
cmpopts.SortSlices(func(a, b *tailcfg.SSHPrincipal) bool {
return a.NodeIP < b.NodeIP
}),
cmpopts.EquateEmpty(),
cmpopts.IgnoreFields(tailcfg.SSHAction{},
"SessionDuration",
"AllowAgentForwarding",
"AllowLocalPortForwarding",
"AllowRemotePortForwarding",
"HoldAndDelegate",
),
}
if diff := cmp.Diff(wantSSH, gotSSH, opts...); diff != "" {
t.Errorf(
"%s/%s: SSH policy mismatch (-tailscale +headscale):\n%s",
tf.TestID,
nodeName,
diff,
)
}
// Separate presence check: the fields ignored by
// the diff above must still be populated on matching
// rules. This catches regressions where headscale
// would silently drop the HoldAndDelegate URL or
// flip Accept to false while we are not looking.
if wantSSH != nil && gotSSH != nil {
for i, wantRule := range wantSSH.Rules {
if i >= len(gotSSH.Rules) {
break
}
gotRule := gotSSH.Rules[i]
if wantRule.Action == nil || gotRule.Action == nil {
continue
}
wantIsCheck := wantRule.Action.HoldAndDelegate != ""
gotIsCheck := gotRule.Action.HoldAndDelegate != ""
assert.Equalf(t, wantIsCheck, gotIsCheck,
"%s/%s rule %d: HoldAndDelegate presence mismatch",
tf.TestID, nodeName, i,
)
}
}
})
}
})
}
}