mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-09 18:21:32 +09:00
2c76d5c5b7
Go 1.27 allows setting promoted fields of embedded structs directly in composite literals, so gorm.Model wrappers go away. Plus strings.Cut, errors.AsType, reflect.TypeAssert and one gofumpt nit.
253 lines
7.5 KiB
Go
253 lines
7.5 KiB
Go
// Replay golden HuJSON captures under testdata/ssh_results/ssh-*.hujson:
|
|
// the 200 path compares headscale's compileSSHPolicy output node-by-node
|
|
// against the captured SSHRules; the non-200 path requires headscale to
|
|
// reject the same input with the captured error body as a substring.
|
|
// Divergences are listed in sshSkipReasons (200) and sshRejectSkipReasons
|
|
// (non-200) with the engine gap each represents.
|
|
|
|
package v2
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"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"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
// setupSSHDataCompatUsers returns three users straddling two email
|
|
// domains so that "localpart:*@example.com" resolves to exactly two
|
|
// users (odin, freya) and the cross-domain case stays covered through
|
|
// thor on @example.org.
|
|
func setupSSHDataCompatUsers() types.Users {
|
|
return types.Users{
|
|
{
|
|
ID: 1,
|
|
Name: "odin",
|
|
Email: "odin@example.com",
|
|
},
|
|
{
|
|
ID: 2,
|
|
Name: "thor",
|
|
Email: "thor@example.org",
|
|
},
|
|
{
|
|
ID: 3,
|
|
Name: "freya",
|
|
Email: "freya@example.com",
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 captures the upstream control plane accepts
|
|
// but headscale cannot yet represent. Each entry names the feature gap.
|
|
var sshSkipReasons = map[string]string{
|
|
"ssh-b5": "headscale has no passkey authentication; user:*@passkey wildcard unsupported",
|
|
"ssh-d10": "headscale has no passkey authentication; user:*@passkey wildcard unsupported",
|
|
}
|
|
|
|
// sshRejectSkipReasons documents captures the upstream control plane
|
|
// rejects for reasons headscale cannot apply. Each entry names the
|
|
// feature gap.
|
|
var sshRejectSkipReasons = map[string]string{
|
|
"ssh-b4": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
|
|
"ssh-d1": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
|
|
"ssh-e1": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
|
|
"ssh-e2": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
|
|
"ssh-malformed-user-localpart-multi-glob": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
|
|
}
|
|
|
|
// TestSSHDataCompat loads every ssh-*.hujson capture, parses the policy
|
|
// it pinned, and compiles the same per-node SSH rules to compare against
|
|
// the captured shape. Non-200 captures replay the rejection path: the
|
|
// recorded error body must appear as a substring of headscale's
|
|
// rejection.
|
|
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/",
|
|
)
|
|
|
|
allHujson, err := filepath.Glob(
|
|
filepath.Join("testdata", "ssh_results", "*.hujson"),
|
|
)
|
|
require.NoError(t, err, "failed to glob all hujson files")
|
|
require.Lenf(t, files, len(allHujson),
|
|
"ssh_results/ contains hujson files not picked up by the ssh-*.hujson loader; "+
|
|
"loader sees %d, directory has %d. Stale fixtures should be deleted.",
|
|
len(files), len(allHujson),
|
|
)
|
|
|
|
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()
|
|
|
|
// Each capture pins its own topology IPs, so nodes are
|
|
// rebuilt from the capture rather than a shared fixture.
|
|
nodes := buildGrantsNodesFromCapture(users, tf)
|
|
|
|
policyJSON := []byte(tf.Input.FullPolicy)
|
|
|
|
if tf.Input.APIResponseCode != 200 {
|
|
if reason, ok := sshRejectSkipReasons[tf.TestID]; ok {
|
|
t.Skipf("skipping: %s", 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
|
|
}
|
|
|
|
if reason, ok := sshSkipReasons[tf.TestID]; ok {
|
|
t.Skipf("skipping: %s", reason)
|
|
return
|
|
}
|
|
|
|
pol, err := unmarshalPolicy(policyJSON)
|
|
require.NoError(
|
|
t,
|
|
err,
|
|
"%s: policy should parse successfully\nPolicy:\n%s",
|
|
tf.TestID,
|
|
tf.Input.FullPolicy,
|
|
)
|
|
|
|
for nodeName, capture := range tf.Captures {
|
|
t.Run(nodeName, func(t *testing.T) {
|
|
node := findNodeByGivenName(nodes, nodeName)
|
|
require.NotNilf(t, node,
|
|
"golden node %s not found in test setup", nodeName)
|
|
|
|
// Compile headscale SSH policy for this node
|
|
gotSSH, err := pol.compileSSHPolicy(
|
|
"https://unused",
|
|
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).
|
|
//
|
|
opts := cmp.Options{
|
|
cmpopts.SortSlices(func(a, b *tailcfg.SSHPrincipal) bool {
|
|
return a.NodeIP < b.NodeIP
|
|
}),
|
|
cmpopts.EquateEmpty(),
|
|
}
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|