diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index 1941ecf7..4d33397c 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -197,6 +197,10 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node log.Warn().Err(testErr).Msg("policy tests failed at boot; server starting anyway, fix the policy and reload") } + if testErr := pm.RunSSHTests(); testErr != nil { //nolint:noinlineerr // boot path: warn-and-continue, not return + log.Warn().Err(testErr).Msg("policy sshTests failed at boot; server starting anyway, fix the policy and reload") + } + return &pm, nil } @@ -461,9 +465,16 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) { // sandbox compiled from the new policy + current users/nodes; if // they fail, return without mutating the live PolicyManager so the // failed write does not knock the running config offline. - err = evaluateTests(pol, pm.users, pm.nodes) - if err != nil { - return false, err + // + // Aggregate ACL and SSH test failures via multierr so operators + // see both classes in a single response instead of having to + // fix-and-retry to discover the second one. + testErr := multierr.New( + evaluateTests(pol, pm.users, pm.nodes), + evaluateSSHTests(pol, pm.users, pm.nodes), + ) + if testErr != nil { + return false, testErr } // Log policy metadata for debugging diff --git a/hscontrol/policy/v2/sshtest.go b/hscontrol/policy/v2/sshtest.go new file mode 100644 index 00000000..4af62101 --- /dev/null +++ b/hscontrol/policy/v2/sshtest.go @@ -0,0 +1,714 @@ +package v2 + +import ( + "fmt" + "net/netip" + "slices" + "strings" + + "github.com/juanfont/headscale/hscontrol/types" + "go4.org/netipx" + "tailscale.com/tailcfg" + "tailscale.com/types/views" +) + +// The sshTests block is Tailscale's parallel of the ACL `tests` block for +// SSH-shaped rules: each entry asserts that a source identity reaching out +// to one or more destination hosts can SSH in as the named login users — +// or, conversely, must be refused. The block runs at the same user-write +// boundary as `tests` (SetPolicy, `headscale policy check`, file-mode +// reload after a change). Boot-time reload skips evaluation so a stored +// policy referencing a now-deleted entity does not block startup. +// +// Three assertion kinds: +// +// - accept[user]: from src, every dst must be reachable as user via an +// action:accept OR action:check rule. Check counts as reachable for +// the accept assertion because both actions resolve to "the user is +// allowed to start a session" at the wire layer. +// - deny[user]: from src, no dst is reachable as user. A test passes +// when no rule allows the user at all (or every matching rule's +// SSHUsers map blocks the user). +// - check[user]: from src, every dst must be reachable as user via a +// rule whose action is specifically check (HoldAndDelegate is the +// wire signal — see filter.go sshCheck). An accept-only match +// fails the check assertion: SaaS keeps the distinction so policy +// authors can pin sensitive logins to check rules. + +// SSHPolicyTestResult is the outcome of a single SSHPolicyTest. +// +// Each map is keyed by login user and records the per-dst breakdown so +// the rendered error tells the operator which (src, user, dst) triple +// went the wrong way. +type SSHPolicyTestResult struct { + Src string `json:"src"` + Passed bool `json:"passed"` + Errors []string `json:"errors,omitempty"` + + AcceptOK map[string][]string `json:"accept_ok,omitempty"` + AcceptFail map[string][]string `json:"accept_fail,omitempty"` + DenyOK map[string][]string `json:"deny_ok,omitempty"` + DenyFail map[string][]string `json:"deny_fail,omitempty"` + CheckOK map[string][]string `json:"check_ok,omitempty"` + CheckFail map[string][]string `json:"check_fail,omitempty"` +} + +// SSHPolicyTestResults aggregates one evaluation run. +type SSHPolicyTestResults struct { + AllPassed bool `json:"all_passed"` + Results []SSHPolicyTestResult `json:"results"` +} + +// Errors renders the per-test failure breakdown joined by newlines. +// +// Tailscale SaaS returns only the literal "test(s) failed" body for +// either assertion class. We keep the per-test detail because operators +// invoking SetPolicy from the CLI or file-mode reload have no separate +// audit endpoint, so the rendered body is the only signal they get. +func (r SSHPolicyTestResults) Errors() string { + if r.AllPassed { + return "" + } + + var lines []string + + for _, res := range r.Results { + if res.Passed { + continue + } + + for _, e := range res.Errors { + lines = append(lines, fmt.Sprintf("%s: %s", res.Src, e)) + } + + for _, user := range sortedUsers(res.AcceptFail) { + for _, dst := range res.AcceptFail[user] { + lines = append(lines, fmt.Sprintf( + "%s/%s -> %s: expected ALLOWED, got DENIED", + res.Src, displayUser(user), dst, + )) + } + } + + for _, user := range sortedUsers(res.DenyFail) { + for _, dst := range res.DenyFail[user] { + lines = append(lines, fmt.Sprintf( + "%s/%s -> %s: expected DENIED, got ALLOWED", + res.Src, displayUser(user), dst, + )) + } + } + + for _, user := range sortedUsers(res.CheckFail) { + for _, dst := range res.CheckFail[user] { + lines = append(lines, fmt.Sprintf( + "%s/%s -> %s: expected ALLOWED via check, got %s", + res.Src, displayUser(user), dst, + checkFailReason(res, user, dst), + )) + } + } + } + + return strings.Join(lines, "\n") +} + +// sortedUsers returns the keys of m sorted by user name so error +// rendering is deterministic across runs. +func sortedUsers(m map[string][]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + slices.Sort(keys) + + return keys +} + +// displayUser formats a login user for the rendered error. An empty +// string is shown as `""` so the operator can see that the assertion +// referenced an empty username (which is itself a failure case). +func displayUser(u string) string { + if u == "" { + return `""` + } + + return u +} + +// checkFailReason annotates a check-fail line with whether the user +// reached the dst via an accept rule (so the operator knows to flip the +// rule to action:check) or did not reach the dst at all. +func checkFailReason(res SSHPolicyTestResult, user, dst string) string { + if slices.Contains(res.AcceptOK[user], dst) { + return "ALLOWED via accept" + } + + return "DENIED" +} + +// RunSSHTests evaluates the policy's sshTests block against the live +// users and nodes and returns a wrapped error when any assertion fails. +// Callers that need the per-test breakdown can call runSSHPolicyTests +// directly with their own compile cache. +func (pm *PolicyManager) RunSSHTests() error { + if pm == nil || pm.pol == nil || len(pm.pol.SSHTests) == 0 { + return nil + } + + pm.mu.Lock() + defer pm.mu.Unlock() + + cache := make(map[types.NodeID]*tailcfg.SSHPolicy) + results := runSSHPolicyTests(pm.pol, pm.users, pm.nodes, cache) + + if results.AllPassed { + return nil + } + + return fmt.Errorf("%w:\n%s", errSSHPolicyTestsFailed, results.Errors()) +} + +// evaluateSSHTests is the user-write sandbox: run sshTests against pol +// + current users/nodes without mutating any live state. It mirrors +// evaluateTests for the ACL block. +func evaluateSSHTests( + pol *Policy, + users []types.User, + nodes views.Slice[types.NodeView], +) error { + if pol == nil || len(pol.SSHTests) == 0 { + return nil + } + + cache := make(map[types.NodeID]*tailcfg.SSHPolicy) + results := runSSHPolicyTests(pol, users, nodes, cache) + + if results.AllPassed { + return nil + } + + return fmt.Errorf("%w:\n%s", errSSHPolicyTestsFailed, results.Errors()) +} + +// runSSHPolicyTests evaluates every sshTests entry against pol. The +// cache is keyed by destination node ID and reused across entries so a +// 10-entry block hitting 4 dst nodes pays 4 compiles, not 40. +func runSSHPolicyTests( + pol *Policy, + users []types.User, + nodes views.Slice[types.NodeView], + cache map[types.NodeID]*tailcfg.SSHPolicy, +) SSHPolicyTestResults { + results := SSHPolicyTestResults{ + AllPassed: true, + Results: make([]SSHPolicyTestResult, 0, len(pol.SSHTests)), + } + + for _, test := range pol.SSHTests { + res := runSSHPolicyTest(test, pol, users, nodes, cache) + if !res.Passed { + results.AllPassed = false + } + + results.Results = append(results.Results, res) + } + + return results +} + +// runSSHPolicyTest evaluates one SSHPolicyTest entry against pol. +// +// Order of operations: resolve src → resolve dst nodes → reject empty +// assertion blocks → walk accept/deny/check arrays, asking the per-dst +// compiled SSH policy whether the user can reach the dst. +func runSSHPolicyTest( + test SSHPolicyTest, + pol *Policy, + users []types.User, + nodes views.Slice[types.NodeView], + cache map[types.NodeID]*tailcfg.SSHPolicy, +) SSHPolicyTestResult { + res := SSHPolicyTestResult{ + Src: test.Src, + Passed: true, + } + + srcAddrs, srcUserID, err := resolveSSHTestSource(test.Src, pol, users, nodes) + if err != nil { + res.Passed = false + res.Errors = append(res.Errors, + fmt.Sprintf("failed to resolve source %q: %v", test.Src, err)) + + return res + } + + if len(srcAddrs) == 0 { + res.Passed = false + res.Errors = append(res.Errors, + fmt.Sprintf("source %q resolved to no IP addresses", test.Src)) + + return res + } + + // Tailscale SaaS treats an entry with no accept/deny/check arrays + // as "nothing to assert", which is reported as a failure. Catching + // it here keeps the engine output mirroring SaaS for parse-accepted + // inputs. + if len(test.Accept) == 0 && len(test.Deny) == 0 && len(test.Check) == 0 { + res.Passed = false + res.Errors = append(res.Errors, + "no accept, deny, or check assertions specified") + + return res + } + + dstNodes, err := resolveSSHTestDestNodes(test.Dst, pol, users, nodes, srcUserID) + if err != nil { + res.Passed = false + res.Errors = append(res.Errors, + fmt.Sprintf("failed to resolve destinations: %v", err)) + + return res + } + + for _, user := range test.Accept { + evaluateAssertion( + pol, users, nodes, cache, + srcAddrs, dstNodes, user, + assertAccept, &res, + ) + } + + for _, user := range test.Deny { + evaluateAssertion( + pol, users, nodes, cache, + srcAddrs, dstNodes, user, + assertDeny, &res, + ) + } + + for _, user := range test.Check { + evaluateAssertion( + pol, users, nodes, cache, + srcAddrs, dstNodes, user, + assertCheck, &res, + ) + } + + return res +} + +// sshAssertion is the kind of assertion being evaluated for a single +// (src, dst, user) triple. +type sshAssertion int + +const ( + assertAccept sshAssertion = iota + assertDeny + assertCheck +) + +// evaluateAssertion walks every (srcAddr, dstNode) pair for one user and +// records the outcome in res. The semantics: +// +// - accept passes iff every (srcAddr, dstNode) reaches the dst via at +// least one rule whose action is accept or check. +// - deny passes iff no (srcAddr, dstNode) is reachable as user. +// - check passes iff every (srcAddr, dstNode) reaches the dst via at +// least one check rule (HoldAndDelegate set). An accept-only match +// fails the check assertion — SaaS keeps the two categories +// distinct. +// +// Empty username is parse-accepted but reports as a failure here: SSH +// login users cannot be empty, so the assertion can never be satisfied. +func evaluateAssertion( + pol *Policy, + users []types.User, + nodes views.Slice[types.NodeView], + cache map[types.NodeID]*tailcfg.SSHPolicy, + srcAddrs []netip.Addr, + dstNodes []types.NodeView, + user string, + kind sshAssertion, + res *SSHPolicyTestResult, +) { + for _, dst := range dstNodes { + dstPol, err := compiledSSHPolicy(pol, users, nodes, cache, dst) + if err != nil { + res.Passed = false + res.Errors = append(res.Errors, + fmt.Sprintf("compiling SSH policy for %s: %v", + dst.Hostname(), err)) + + continue + } + + dstLabel := dst.Hostname() + + // reachableAccept covers "any matching accept-or-check rule"; + // reachableCheck restricts to check-action matches only. + acceptHit := false + checkHit := false + + for _, srcAddr := range srcAddrs { + a, c := reachability(dstPol, srcAddr, user) + if a { + acceptHit = true + } + + if c { + checkHit = true + } + + // accept and deny require ALL src IPs to reach (or all + // to be blocked). A single counter-example fails the + // assertion. + switch kind { + case assertAccept: + if !a { + res.Passed = false + res.AcceptFail = appendUserDst(res.AcceptFail, user, dstLabel) + + goto nextDst + } + case assertDeny: + if a { + res.Passed = false + res.DenyFail = appendUserDst(res.DenyFail, user, dstLabel) + + goto nextDst + } + case assertCheck: + if !c { + res.Passed = false + res.CheckFail = appendUserDst(res.CheckFail, user, dstLabel) + + // Record whether the accept side passed so + // the rendered error can say "ALLOWED via + // accept" instead of "DENIED". + if a { + res.AcceptOK = appendUserDst(res.AcceptOK, user, dstLabel) + } + + goto nextDst + } + } + } + + switch kind { + case assertAccept: + if acceptHit { + res.AcceptOK = appendUserDst(res.AcceptOK, user, dstLabel) + } + case assertDeny: + res.DenyOK = appendUserDst(res.DenyOK, user, dstLabel) + case assertCheck: + if checkHit { + res.CheckOK = appendUserDst(res.CheckOK, user, dstLabel) + } + } + + nextDst: + } +} + +// appendUserDst appends dst to m[user], lazily allocating m. +func appendUserDst(m map[string][]string, user, dst string) map[string][]string { + if m == nil { + m = make(map[string][]string) + } + + m[user] = append(m[user], dst) + + return m +} + +// resolveSSHTestSource resolves the src alias into a list of +// netip.Addr (one per principal address the SSH compiler would emit +// for the same source). For user-shaped sources, srcUserID returns the +// resolved user's ID so autogroup:self destinations can scope to the +// same user. Returns ID 0 when the source is a tag, host, or IP. +func resolveSSHTestSource( + src string, + pol *Policy, + users []types.User, + nodes views.Slice[types.NodeView], +) ([]netip.Addr, uint, error) { + alias, err := parseAlias(src) + if err != nil { + return nil, 0, fmt.Errorf("invalid alias: %w", err) + } + + addrs, err := alias.Resolve(pol, users, nodes) + if err != nil { + return nil, 0, fmt.Errorf("resolving: %w", err) + } + + if addrs == nil || addrs.Empty() { + return nil, 0, nil + } + + out := make([]netip.Addr, 0) + for a := range addrs.Iter() { + out = append(out, a) + } + + var userID uint + + u, ok := alias.(*Username) + if ok { + resolved, rErr := u.resolveUser(users) + if rErr == nil { + userID = resolved.ID + } + } + + return out, userID, nil +} + +// resolveSSHTestDestNodes resolves every dst alias in the test entry +// to its destination NodeViews. autogroup:self requires special +// handling because it cannot resolve in the general (non-per-node) +// context — see AutoGroup.resolve in types.go. +// +// For non-self aliases, the resolved IPSet is matched against each +// node's IPs via InIPSet (the same primitive the SSH compiler uses to +// decide whether a node is a destination of a given rule). +func resolveSSHTestDestNodes( + dsts []string, + pol *Policy, + users []types.User, + nodes views.Slice[types.NodeView], + srcUserID uint, +) ([]types.NodeView, error) { + seen := make(map[types.NodeID]struct{}) + + var out []types.NodeView + + for _, dst := range dsts { + alias, err := parseAlias(dst) + if err != nil { + return nil, fmt.Errorf("invalid destination %q: %w", dst, err) + } + + if ag, ok := alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { + // autogroup:self → destinations are the non-tagged + // nodes owned by the same user as src. A tagged or + // IP-only src has no user identity, so the dst set + // is empty (matches SaaS, which treats this as a + // no-op assertion that the engine then reports as a + // failure via the empty-dst-nodes branch below). + if srcUserID == 0 { + continue + } + + for _, n := range nodes.All() { + if n.IsTagged() { + continue + } + + if !n.User().Valid() { + continue + } + + if n.User().ID() != srcUserID { + continue + } + + if _, dup := seen[n.ID()]; dup { + continue + } + + seen[n.ID()] = struct{}{} + out = append(out, n) + } + + continue + } + + ips, err := alias.Resolve(pol, users, nodes) + if err != nil { + return nil, fmt.Errorf("resolving destination %q: %w", dst, err) + } + + if ips == nil || ips.Empty() { + continue + } + + // Compile to an IPSet for the InIPSet primitive. ResolvedAddresses + // already wraps one; expose it via the IPSet builder by walking + // the resolved prefixes. + set, err := prefixesToIPSet(ips.Prefixes()) + if err != nil { + return nil, fmt.Errorf("building IPSet for %q: %w", dst, err) + } + + for _, n := range nodes.All() { + if !n.InIPSet(set) { + continue + } + + if _, dup := seen[n.ID()]; dup { + continue + } + + seen[n.ID()] = struct{}{} + out = append(out, n) + } + } + + return out, nil +} + +// prefixesToIPSet builds a netipx.IPSet from a slice of prefixes. The +// SSH compiler does the same dance via netipx.IPSetBuilder; we mirror +// the shape so InIPSet (the node-side primitive) behaves identically +// for test evaluation and live compilation. +func prefixesToIPSet(prefixes []netip.Prefix) (*netipx.IPSet, error) { + var b netipx.IPSetBuilder + + for _, p := range prefixes { + b.AddPrefix(p) + } + + return b.IPSet() +} + +// compiledSSHPolicy returns the per-node compiled SSH policy, populating +// cache on miss. baseURL is empty because the engine only needs the +// "is this rule a check rule" signal (HoldAndDelegate non-empty), not +// the actual URL contents. +func compiledSSHPolicy( + pol *Policy, + users []types.User, + nodes views.Slice[types.NodeView], + cache map[types.NodeID]*tailcfg.SSHPolicy, + node types.NodeView, +) (*tailcfg.SSHPolicy, error) { + if sshPol, ok := cache[node.ID()]; ok { + return sshPol, nil + } + + sshPol, err := pol.compileSSHPolicy("", users, node, nodes) + if err != nil { + return nil, err + } + + cache[node.ID()] = sshPol + + return sshPol, nil +} + +// reachability walks dstPolicy.Rules and reports whether srcAddr is +// allowed to log in as user via: +// +// - any rule (first return) — satisfies accept assertions +// - a check rule specifically (second return) — satisfies check assertions +// +// A nil policy is treated as "no rule matches", which is the right +// answer for both accept (DENIED) and check (DENIED) and for deny +// (PASS, because the deny assertion inverts). +func reachability( + dstPolicy *tailcfg.SSHPolicy, + srcAddr netip.Addr, + user string, +) (bool, bool) { + if dstPolicy == nil { + return false, false + } + + var acceptHit, checkHit bool + + for _, rule := range dstPolicy.Rules { + if !principalContainsAddr(rule.Principals, srcAddr) { + continue + } + + if !sshUserMapAllows(rule.SSHUsers, user) { + continue + } + + if rule.Action == nil { + continue + } + + acceptHit = true + + if rule.Action.HoldAndDelegate != "" { + checkHit = true + } + + // Early-out only when both bits are set; a rule that + // satisfies one assertion may not satisfy the other. + if acceptHit && checkHit { + return acceptHit, checkHit + } + } + + return acceptHit, checkHit +} + +// principalContainsAddr reports whether any principal has a NodeIP +// matching srcAddr. The SSH compiler emits one principal per source +// IP, so an exact-match comparison is correct. +func principalContainsAddr( + principals []*tailcfg.SSHPrincipal, + srcAddr netip.Addr, +) bool { + for _, p := range principals { + if p == nil { + continue + } + + if p.NodeIP == "" { + continue + } + + addr, err := netip.ParseAddr(p.NodeIP) + if err != nil { + continue + } + + if addr == srcAddr { + return true + } + } + + return false +} + +// sshUserMapAllows reports whether SSHUsers permits user. The wire +// shape (see filter.go compileSSHPolicy): +// +// - SSHUsers["root"] == "root" when the rule's users list contains +// "root", and == "" otherwise (the empty mapping means "root NOT +// allowed", per Tailscale's SSH evaluator). +// - SSHUsers["*"] == "=" when the rule's users list contains +// autogroup:nonroot — wildcard fallback for any non-root user. +// - SSHUsers[] == for every named SSH user in +// the rule. +// +// An empty user input (which the parse layer accepts but treats as +// a failure case) cannot match any map entry. +func sshUserMapAllows(m map[string]string, user string) bool { + if user == "" { + return false + } + + if v, ok := m[user]; ok { + return v != "" + } + + if user == "root" { + return false + } + + // Wildcard fallback for non-root users. + if v, ok := m["*"]; ok { + return v != "" + } + + return false +} diff --git a/hscontrol/policy/v2/sshtest_test.go b/hscontrol/policy/v2/sshtest_test.go new file mode 100644 index 00000000..b76e54ef --- /dev/null +++ b/hscontrol/policy/v2/sshtest_test.go @@ -0,0 +1,990 @@ +package v2 + +import ( + "strings" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "tailscale.com/tailcfg" +) + +// 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{ + {Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"}, + {Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@headscale.net"}, + {Model: gorm.Model{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"}, + }, + } +} + +// commonHosts contains every host alias the table below references +// as an sshTests dst. Embedded in the JSON policy under "hosts". +const commonHosts = `"hosts": { "alice-laptop": "100.64.0.1", "bob-laptop": "100.64.0.2" }` + +// 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: "tag-as-dst-single-node", + 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: "host-alias-as-dst", + policy: `{ + ` + commonHosts + `, + "ssh": [{ + "action": "accept", + "src": ["bob@headscale.net"], + "dst": ["alice-laptop"], + "users": ["root"] + }], + "sshTests": [{ + "src": "bob@headscale.net", + "dst": ["alice-laptop"], + "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"}, + }, + { + name: "acl-denies-tcp22-ssh-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"], + "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"}, + }, + } + + 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. + aliceView := nodes.ViewSlice().At(0) + + beforePol, err := pm.SSHPolicy("", aliceView) + 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) + require.Equal(t, sshRuleCount(beforePol), sshRuleCount(afterPol), + "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") +} + +// 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") +} + +// sshRuleCount returns the number of rules in p, treating nil as 0. +// Used by TestSetPolicyRejectsFailingSSHTests to verify a rejected +// SetPolicy leaves the live SSH policy untouched. +func sshRuleCount(p *tailcfg.SSHPolicy) int { + if p == nil { + return 0 + } + + return len(p.Rules) +}