policy/v2: evaluate sshTests at write boundary

accept and check count as reachable; check requires a check-action rule
specifically. SetPolicy rejects failing tests without mutating live
state; boot path warns.
This commit is contained in:
Kristoffer Dalby
2026-05-12 21:04:42 +00:00
parent 57f838e268
commit 65e4dbc4c4
3 changed files with 1718 additions and 3 deletions
+14 -3
View File
@@ -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
+714
View File
@@ -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[<literal>] == <literal> 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
}
+990
View File
@@ -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)
}