mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-18 14:32:01 +09:00
integration: reject failing sshTests at headscale policy set
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
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/hsic"
|
||||||
"github.com/juanfont/headscale/integration/tsic"
|
"github.com/juanfont/headscale/integration/tsic"
|
||||||
"github.com/stretchr/testify/require"
|
"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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user