mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-19 23:04:53 +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.
1000 lines
25 KiB
Go
1000 lines
25 KiB
Go
package v2
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// sshTestUsers/sshTestNodes are reused across the table below to keep
|
|
// each row focussed on the policy under exercise. Three users, six
|
|
// nodes:
|
|
//
|
|
// - alice (id 1) at headscale.net owns alice-laptop and alice-tablet
|
|
// - bob (id 2) at headscale.net owns bob-laptop
|
|
// - thor (id 3) at example.org owns thor-laptop
|
|
// - server (alice-created tagged node) → tag:server
|
|
// - prod (alice-created tagged node) → tag:prod
|
|
func sshTestUsers() types.Users {
|
|
return types.Users{
|
|
{ID: 1, Name: "alice", Email: "alice@headscale.net"},
|
|
{ID: 2, Name: "bob", Email: "bob@headscale.net"},
|
|
{ID: 3, Name: "thor", Email: "thor@example.org"},
|
|
}
|
|
}
|
|
|
|
func sshTestNodes(users types.Users) types.Nodes {
|
|
return types.Nodes{
|
|
{
|
|
ID: 1,
|
|
Hostname: "alice-laptop",
|
|
IPv4: ap("100.64.0.1"),
|
|
IPv6: ap("fd7a:115c:a1e0::1"),
|
|
User: &users[0],
|
|
UserID: &users[0].ID,
|
|
},
|
|
{
|
|
ID: 2,
|
|
Hostname: "bob-laptop",
|
|
IPv4: ap("100.64.0.2"),
|
|
IPv6: ap("fd7a:115c:a1e0::2"),
|
|
User: &users[1],
|
|
UserID: &users[1].ID,
|
|
},
|
|
{
|
|
ID: 3,
|
|
Hostname: "server",
|
|
IPv4: ap("100.64.0.3"),
|
|
IPv6: ap("fd7a:115c:a1e0::3"),
|
|
User: &users[0],
|
|
UserID: &users[0].ID,
|
|
Tags: []string{"tag:server"},
|
|
},
|
|
{
|
|
ID: 4,
|
|
Hostname: "alice-tablet",
|
|
IPv4: ap("100.64.0.4"),
|
|
IPv6: ap("fd7a:115c:a1e0::4"),
|
|
User: &users[0],
|
|
UserID: &users[0].ID,
|
|
},
|
|
{
|
|
ID: 5,
|
|
Hostname: "thor-laptop",
|
|
IPv4: ap("100.64.0.5"),
|
|
IPv6: ap("fd7a:115c:a1e0::5"),
|
|
User: &users[2],
|
|
UserID: &users[2].ID,
|
|
},
|
|
{
|
|
ID: 6,
|
|
Hostname: "prod",
|
|
IPv4: ap("100.64.0.6"),
|
|
IPv6: ap("fd7a:115c:a1e0::6"),
|
|
User: &users[0],
|
|
UserID: &users[0].ID,
|
|
Tags: []string{"tag:prod"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestRunSSHTests covers the engine's per-test outcome reporting. Each
|
|
// row constructs a PolicyManager (whose SetPolicy sandbox also exercises
|
|
// evaluateSSHTests) and asserts on the resulting RunSSHTests behaviour.
|
|
// SetPolicy gating is exercised separately in
|
|
// TestSetPolicyRejectsFailingSSHTests below.
|
|
func TestRunSSHTests(t *testing.T) {
|
|
users := sshTestUsers()
|
|
nodes := sshTestNodes(users)
|
|
|
|
tests := []struct {
|
|
name string
|
|
policy string
|
|
wantPass bool
|
|
wantErrSub []string
|
|
}{
|
|
{
|
|
name: "accept-pass-basic",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "accept-pass-multi-user-in-rule",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root", "ubuntu"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root", "ubuntu"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "accept-fail-no-rule",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"alice@headscale.net", "root", "expected ALLOWED"},
|
|
},
|
|
{
|
|
name: "accept-fail-user-not-allowed-by-rule",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["mallory"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"alice@headscale.net", "mallory", "expected ALLOWED"},
|
|
},
|
|
{
|
|
name: "accept-fail-different-src",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "bob@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"bob@headscale.net", "root", "expected ALLOWED"},
|
|
},
|
|
{
|
|
name: "deny-pass-no-rule",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"deny": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "deny-pass-rule-blocks-user",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["autogroup:nonroot"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"deny": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "deny-fail-rule-allows",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"deny": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"alice@headscale.net", "root", "expected DENIED"},
|
|
},
|
|
{
|
|
name: "check-pass-rule-is-check",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "check",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"check": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "check-fail-rule-is-accept",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"check": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{
|
|
"alice@headscale.net",
|
|
"root",
|
|
"via check",
|
|
"via accept",
|
|
},
|
|
},
|
|
{
|
|
name: "check-pass-and-accept-pass-coexist",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
},
|
|
{
|
|
"action": "check",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["ubuntu"]
|
|
}
|
|
],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"],
|
|
"check": ["ubuntu"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "accept-passes-on-check-rule",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "check",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "multi-dst-all-must-reach",
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:server": ["alice@headscale.net"],
|
|
"tag:prod": ["alice@headscale.net"]
|
|
},
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server", "tag:prod"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{
|
|
"alice@headscale.net",
|
|
"root",
|
|
"prod",
|
|
"expected ALLOWED",
|
|
},
|
|
},
|
|
{
|
|
name: "multi-user-mixed-accept-deny-check-in-one-entry",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
},
|
|
{
|
|
"action": "check",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["ubuntu"]
|
|
}
|
|
],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"],
|
|
"deny": ["mallory"],
|
|
"check": ["ubuntu"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "nonroot-allows-alice",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["autogroup:nonroot"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["alice"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "nonroot-denies-root",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["autogroup:nonroot"]
|
|
}],
|
|
"sshTests": [
|
|
{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
},
|
|
{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"deny": ["root"]
|
|
}
|
|
]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"alice@headscale.net", "root", "expected ALLOWED"},
|
|
},
|
|
{
|
|
name: "wildcard-user-allows-mallory",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["autogroup:nonroot"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["mallory"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "root-only-rule",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [
|
|
{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
},
|
|
{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["alice"]
|
|
}
|
|
]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"alice@headscale.net", "alice", "expected ALLOWED"},
|
|
},
|
|
{
|
|
name: "autogroup-self-same-user",
|
|
policy: `{
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["autogroup:member"],
|
|
"dst": ["autogroup:self"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["autogroup:self"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "autogroup-self-cross-user-fails",
|
|
policy: `{
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["autogroup:self"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "bob@headscale.net",
|
|
"dst": ["autogroup:self"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
// autogroup:self for bob resolves to bob-laptop; the only
|
|
// rule allows alice as src, so reachability fails.
|
|
wantErrSub: []string{"bob@headscale.net", "root"},
|
|
},
|
|
{
|
|
name: "localpart-domain-match",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["localpart:*@headscale.net"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["alice"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "localpart-domain-mismatch",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["thor@example.org"],
|
|
"dst": ["tag:server"],
|
|
"users": ["localpart:*@headscale.net"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "thor@example.org",
|
|
"dst": ["tag:server"],
|
|
"accept": ["thor"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"thor@example.org", "thor", "expected ALLOWED"},
|
|
},
|
|
{
|
|
name: "tag-as-src",
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:server": ["alice@headscale.net"],
|
|
"tag:prod": ["alice@headscale.net"]
|
|
},
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["tag:prod"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "tag:prod",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "acl-allows-tcp22-no-ssh-rule",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"alice@headscale.net", "root", "expected ALLOWED"},
|
|
},
|
|
{
|
|
// ACL grants only TCP:80 to alice; no rule grants TCP:22.
|
|
// SSH rule independently allows root@tag:server. The
|
|
// sshTests assertion must pass on the SSH layer alone,
|
|
// proving the engine does not require an ACL packet-
|
|
// filter rule for the SSH port.
|
|
name: "acl-denies-tcp22-ssh-rule-allows",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:80"]
|
|
}],
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "no-sshTests-block",
|
|
policy: `{
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["*"],
|
|
"dst": ["*:*"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "both-tests-and-sshTests-both-pass",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"tests": [{
|
|
"src": "alice@headscale.net",
|
|
"accept": ["tag:server:22"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: true,
|
|
},
|
|
{
|
|
name: "empty-accept-deny-check-in-entry",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"alice@headscale.net", "no accept, deny, or check"},
|
|
},
|
|
{
|
|
name: "empty-user-in-accept",
|
|
policy: `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": [""]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"alice@headscale.net", "expected ALLOWED"},
|
|
},
|
|
{
|
|
// tag:empty has an owner but no tagged nodes, so the dst
|
|
// alias resolves to no nodes. Without the empty-dst guard
|
|
// the per-assertion loop runs zero iterations and the
|
|
// test silently passes — exactly the regression the
|
|
// guard exists to catch.
|
|
name: "dst-tag-with-no-tagged-nodes-fails",
|
|
policy: `{
|
|
"tagOwners": { "tag:empty": ["alice@headscale.net"] },
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:empty"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"tag:empty", "resolved to no nodes"},
|
|
},
|
|
{
|
|
// autogroup:self from a tag src has no user identity to
|
|
// scope to, so the dst alias resolves to no nodes. Same
|
|
// empty-dst guard, distinct trigger path.
|
|
name: "dst-autogroup-self-from-tag-src-fails",
|
|
policy: `{
|
|
"tagOwners": { "tag:prod": ["alice@headscale.net"] },
|
|
"sshTests": [{
|
|
"src": "tag:prod",
|
|
"dst": ["autogroup:self"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`,
|
|
wantPass: false,
|
|
wantErrSub: []string{"autogroup:self", "resolved to no nodes"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pm, err := NewPolicyManager([]byte(tt.policy), users, nodes.ViewSlice())
|
|
require.NoError(t, err, "policy must parse and compile")
|
|
|
|
runErr := pm.RunSSHTests()
|
|
if tt.wantPass {
|
|
require.NoError(t, runErr, "sshTests should pass")
|
|
|
|
return
|
|
}
|
|
|
|
require.Error(t, runErr, "sshTests should fail")
|
|
require.ErrorIs(t, runErr, errSSHPolicyTestsFailed)
|
|
|
|
for _, sub := range tt.wantErrSub {
|
|
assert.Contains(t, runErr.Error(), sub,
|
|
"rendered error should mention %q", sub)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunSSHTestsBothTestsPassSSHTestsFail captures the distinction the
|
|
// caller cares about: a passing ACL `tests` block plus a failing
|
|
// `sshTests` block returns errSSHPolicyTestsFailed and NOT
|
|
// errPolicyTestsFailed. The two sentinels share a literal message but
|
|
// are distinct values.
|
|
func TestRunSSHTestsBothTestsPassSSHTestsFail(t *testing.T) {
|
|
users := sshTestUsers()
|
|
nodes := sshTestNodes(users)
|
|
|
|
policy := `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"tests": [{
|
|
"src": "alice@headscale.net",
|
|
"accept": ["tag:server:22"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
|
|
// ACL tests should pass.
|
|
require.NoError(t, pm.RunTests())
|
|
|
|
// SSH tests should fail because there's no SSH rule for root.
|
|
sshErr := pm.RunSSHTests()
|
|
require.Error(t, sshErr)
|
|
require.ErrorIs(t, sshErr, errSSHPolicyTestsFailed)
|
|
require.NotErrorIs(t, sshErr, errPolicyTestsFailed,
|
|
"ACL test sentinel must not appear on SSH-only failure")
|
|
}
|
|
|
|
// TestSetPolicyRejectsFailingSSHTests asserts SetPolicy is the user-write
|
|
// boundary: a policy whose sshTests fail is rejected without mutating
|
|
// the live PolicyManager. SSHPolicy() output must remain the prior
|
|
// rules.
|
|
func TestSetPolicyRejectsFailingSSHTests(t *testing.T) {
|
|
users := sshTestUsers()
|
|
nodes := sshTestNodes(users)
|
|
|
|
good := `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`
|
|
|
|
bad := `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "bob@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(good), users, nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
|
|
// Snapshot SSHPolicy output for alice-laptop before the rejected
|
|
// write — the live PolicyManager state must still describe the
|
|
// previous (good) rules afterwards. JSON-marshal the snapshot so
|
|
// the comparison sees rule content, not just object identity: a
|
|
// hypothetical mutation that preserves the slice length but
|
|
// rewrites principals or SSHUsers would slip past a count-only
|
|
// assertion.
|
|
aliceView := nodes.ViewSlice().At(0)
|
|
|
|
beforePol, err := pm.SSHPolicy("", aliceView)
|
|
require.NoError(t, err)
|
|
|
|
beforeJSON, err := json.Marshal(beforePol)
|
|
require.NoError(t, err)
|
|
|
|
changed, err := pm.SetPolicy([]byte(bad))
|
|
require.Error(t, err, "SetPolicy must reject a policy whose sshTests fail")
|
|
require.False(t, changed, "SetPolicy must report no change when rejected")
|
|
require.ErrorIs(t, err, errSSHPolicyTestsFailed)
|
|
require.Contains(t, err.Error(), "expected ALLOWED")
|
|
|
|
afterPol, err := pm.SSHPolicy("", aliceView)
|
|
require.NoError(t, err)
|
|
|
|
afterJSON, err := json.Marshal(afterPol)
|
|
require.NoError(t, err)
|
|
|
|
require.JSONEq(t, string(beforeJSON), string(afterJSON),
|
|
"live SSH policy must not change after a rejected SetPolicy")
|
|
}
|
|
|
|
// TestSetPolicyAggregatesACLAndSSHTestFailures exercises the multierr
|
|
// aggregation: when both layers fail, the returned error wraps both
|
|
// sentinels so operators see every failure in a single round trip.
|
|
func TestSetPolicyAggregatesACLAndSSHTestFailures(t *testing.T) {
|
|
users := sshTestUsers()
|
|
nodes := sshTestNodes(users)
|
|
|
|
good := `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"tests": [{
|
|
"src": "alice@headscale.net",
|
|
"accept": ["tag:server:22"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`
|
|
|
|
// Both blocks fail: acls only allow alice but tests assert bob;
|
|
// ssh only allows alice but sshTests assert bob.
|
|
bad := `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"acls": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server:22"]
|
|
}],
|
|
"ssh": [{
|
|
"action": "accept",
|
|
"src": ["alice@headscale.net"],
|
|
"dst": ["tag:server"],
|
|
"users": ["root"]
|
|
}],
|
|
"tests": [{
|
|
"src": "bob@headscale.net",
|
|
"accept": ["tag:server:22"]
|
|
}],
|
|
"sshTests": [{
|
|
"src": "bob@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(good), users, nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
|
|
_, err = pm.SetPolicy([]byte(bad))
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, errPolicyTestsFailed,
|
|
"aggregated error must wrap the ACL test sentinel")
|
|
require.ErrorIs(t, err, errSSHPolicyTestsFailed,
|
|
"aggregated error must wrap the SSH test sentinel")
|
|
|
|
body := err.Error()
|
|
assert.Contains(t, body, "tag:server:22",
|
|
"aggregated error must include the ACL failure message")
|
|
assert.Contains(t, body, "bob@headscale.net",
|
|
"aggregated error must include the bob src")
|
|
// The SSH renderer emits "src/user -> dst" form; the ACL renderer
|
|
// emits "src -> dst". Substring "/root -> " is unique to the SSH
|
|
// body, so finding it inside the aggregated error proves the SSH
|
|
// failure rendering was concatenated alongside the ACL body.
|
|
assert.Contains(t, body, "/root -> ",
|
|
"aggregated error must include the SSH-shape src/user -> dst rendering")
|
|
}
|
|
|
|
// TestNewPolicyManagerWarnsOnSSHTestsFailure asserts the boot path does
|
|
// not error on a failing sshTests block: warn-and-continue is the right
|
|
// behaviour for stale stored policy, mirroring the ACL tests handling.
|
|
func TestNewPolicyManagerWarnsOnSSHTestsFailure(t *testing.T) {
|
|
users := sshTestUsers()
|
|
nodes := sshTestNodes(users)
|
|
|
|
// sshTests reference a user that does exist but no rule allows
|
|
// them — the test should fail at user-write but not at boot.
|
|
stale := `{
|
|
"tagOwners": { "tag:server": ["alice@headscale.net"] },
|
|
"sshTests": [{
|
|
"src": "alice@headscale.net",
|
|
"dst": ["tag:server"],
|
|
"accept": ["root"]
|
|
}]
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(stale), users, nodes.ViewSlice())
|
|
require.NoError(t, err, "boot must not error on sshTests failure")
|
|
require.NotNil(t, pm)
|
|
|
|
// A subsequent SetPolicy of the same body must reject — that's
|
|
// the user-write path.
|
|
_, err = pm.SetPolicy([]byte(stale))
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, errSSHPolicyTestsFailed)
|
|
}
|
|
|
|
// TestSSHPolicyTestResultsErrorsRendering checks the multi-line render
|
|
// layout. Because the body is the user-facing error, the format needs
|
|
// to identify (src, user, dst) cleanly across accept, deny, and check.
|
|
func TestSSHPolicyTestResultsErrorsRendering(t *testing.T) {
|
|
results := SSHPolicyTestResults{
|
|
AllPassed: false,
|
|
Results: []SSHPolicyTestResult{
|
|
{
|
|
Src: "alice@headscale.net",
|
|
AcceptFail: map[string][]string{
|
|
"root": {"server"},
|
|
},
|
|
},
|
|
{
|
|
Src: "bob@headscale.net",
|
|
DenyFail: map[string][]string{
|
|
"root": {"alice-laptop"},
|
|
},
|
|
},
|
|
{
|
|
Src: "alice@headscale.net",
|
|
CheckFail: map[string][]string{
|
|
"ubuntu": {"server"},
|
|
},
|
|
AcceptOK: map[string][]string{
|
|
"ubuntu": {"server"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
rendered := results.Errors()
|
|
for _, sub := range []string{
|
|
"alice@headscale.net/root -> server: expected ALLOWED, got DENIED",
|
|
"bob@headscale.net/root -> alice-laptop: expected DENIED, got ALLOWED",
|
|
"alice@headscale.net/ubuntu -> server: expected ALLOWED via check, got ALLOWED via accept",
|
|
} {
|
|
assert.Contains(t, rendered, sub)
|
|
}
|
|
|
|
assert.Equal(t, 3, strings.Count(rendered, "\n")+1,
|
|
"expected one line per failing assertion")
|
|
}
|