From 5bed3b29b9f78e29d765376131376e517bb46720 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 13 May 2026 08:16:28 +0000 Subject: [PATCH] integration: reject failing sshTests at headscale policy set --- integration/cli_policy_test.go | 125 +++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/integration/cli_policy_test.go b/integration/cli_policy_test.go index 2f8a0c11..eae0cac5 100644 --- a/integration/cli_policy_test.go +++ b/integration/cli_policy_test.go @@ -5,6 +5,7 @@ import ( "testing" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" + "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/tsic" "github.com/stretchr/testify/require" @@ -164,3 +165,127 @@ func TestPolicyCheckCommand(t *testing.T) { }) } } + +// TestSSHTestsRejectFailingPolicy asserts that `headscale policy set` +// rejects a policy whose sshTests evaluate to a failure, surfaces the +// engine's "test(s) failed" sentinel on stderr, and leaves the previously +// stored policy untouched. The good policy admits user1@ → autogroup:member +// as root; the bad policy reuses the same SSH rule but asserts user2@ can +// SSH, which the rule denies. autogroup:member is used as the dst so the +// test does not need a separately-tagged node: every scenario node is a +// member, so dst resolution finds real nodes to evaluate against. SaaS +// returns the same literal "test(s) failed" body for both ACL tests and +// SSH tests; headscale matches that surface. +func TestSSHTestsRejectFailingPolicy(t *testing.T) { + IntegrationSkip(t) + + const ( + user1 = "user1@" + user2 = "user2@" + ) + + // Good policy: SSH rule and sshTests agree — user1@ may SSH as root + // to any autogroup:member node, and the sshTests entry asserts exactly + // that. + goodPolicy := policyv2.Policy{ + SSHs: []policyv2.SSH{ + { + Action: policyv2.SSHActionAccept, + Sources: policyv2.SSHSrcAliases{usernamep(user1)}, + Destinations: policyv2.SSHDstAliases{ + new(policyv2.AutoGroupMember), + }, + Users: []policyv2.SSHUser{policyv2.SSHUser("root")}, + }, + }, + SSHTests: []policyv2.SSHPolicyTest{ + { + Src: user1, + Dst: []string{"autogroup:member"}, + Accept: []string{"root"}, + }, + }, + } + + // Bad policy: same SSH rule, but the sshTests block asserts that + // user2@ can SSH as root to autogroup:member. The rule only admits + // user1@, so the assertion must fail and the write must be rejected. + // SSHTests is a slice (reference type), so reassigning the field + // rather than mutating in place preserves goodPolicy.SSHTests. + badPolicy := goodPolicy + badPolicy.SSHTests = []policyv2.SSHPolicyTest{ + { + Src: user2, + Dst: []string{"autogroup:member"}, + Accept: []string{"root"}, + }, + } + + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{"user1", "user2"}, + } + + scenario, err := NewScenario(spec) + require.NoError(t, err) + + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("cli-policyset-sshtests"), + hsic.WithConfigEnv(map[string]string{ + "HEADSCALE_POLICY_MODE": types.PolicyModeDB, + }), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + goodBytes, err := json.Marshal(goodPolicy) + require.NoError(t, err) + + badBytes, err := json.Marshal(badPolicy) + require.NoError(t, err) + + const ( + goodPath = "/etc/headscale/policy-good.json" + badPath = "/etc/headscale/policy-bad.json" + ) + + require.NoError(t, headscale.WriteFile(goodPath, goodBytes)) + require.NoError(t, headscale.WriteFile(badPath, badBytes)) + + // Establish the good policy as the live policy. + _, err = headscale.Execute([]string{ + "headscale", "policy", "set", "-f", goodPath, + }) + require.NoError(t, err, "setting the good policy must succeed") + + // Confirm the server returns the good policy. + stdoutBefore, err := headscale.Execute([]string{ + "headscale", "policy", "get", + }) + require.NoError(t, err) + require.JSONEq(t, string(goodBytes), stdoutBefore, + "server should report the good policy after the initial set") + + // Attempt to overwrite with a policy whose sshTests fail. The CLI + // must surface the engine's "test(s) failed" sentinel (SaaS uses the + // same body for ACL tests and SSH tests) and exit non-zero. + _, err = headscale.Execute([]string{ + "headscale", "policy", "set", "-f", badPath, + }) + require.Error(t, err, "setting a policy with failing sshTests must fail") + require.ErrorContains(t, err, "test(s) failed", + "CLI error must surface the engine's test failure sentinel") + + // The rejected write must not have mutated the stored policy. + stdoutAfter, err := headscale.Execute([]string{ + "headscale", "policy", "get", + }) + require.NoError(t, err) + require.JSONEq(t, string(goodBytes), stdoutAfter, + "stored policy must be unchanged after a rejected set") +}