integration: reject failing sshTests at headscale policy set

This commit is contained in:
Kristoffer Dalby
2026-05-13 12:50:32 +00:00
parent e265a46c9e
commit 1768268e84
2 changed files with 124 additions and 0 deletions
+1
View File
@@ -203,6 +203,7 @@ jobs:
- TestAuthWebFlowLogoutAndReloginSameUser
- TestAuthWebFlowLogoutAndReloginNewUser
- TestPolicyCheckCommand
- TestSSHTestsRejectFailingPolicy
- TestUserCommand
- TestPreAuthKeyCommand
- TestPreAuthKeyCommandWithoutExpiry
+123
View File
@@ -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,125 @@ 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 as dst means every
// scenario node is a member, so dst resolution finds real nodes without
// requiring a separately-tagged node.
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: usernamep(user1),
Dst: policyv2.SSHTestDestinations{new(policyv2.AutoGroupMember)},
Accept: []policyv2.SSHUser{policyv2.SSHUser("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: usernamep(user2),
Dst: policyv2.SSHTestDestinations{new(policyv2.AutoGroupMember)},
Accept: []policyv2.SSHUser{policyv2.SSHUser("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 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")
}