policy/v2: label sshTests failures with the dst alias

The report goes back to the policy author: name src and dst as written,
one line per dst occurrence, and a dst with no node as not reached.
This commit is contained in:
Kristoffer Dalby committed 2026-10-28 00:51:39 +00:00
1 parent 649985954f
commit bda2cb5076
6 files changed
+253 -245

No files matched your search

+2 -5
View File
@@ -80,10 +80,8 @@ var apiv2CompatSkipReasons = map[string]string{
"oauth/K2/0/outcome": unknownKeyType,
"oauth/K5/0/status": unknownKeyType,
"oauth/J3/1/hostnames": sshTestHostnames,
"oauth/J4/0/hostnames": sshTestHostnames,
"oauth/J3/1/detail": refusalDetail,
"oauth/J4/0/detail": refusalDetail,
"oauth/J3/1/detail": refusalDetail,
"oauth/J4/0/detail": refusalDetail,
"oauth/E2/0/status": taggedAllClientAnyTag,
"oauth/E2/0/outcome": taggedAllClientAnyTag,
@@ -229,7 +227,6 @@ const (
bodyBeforeScope = "BODY_BEFORE_SCOPE: headscale validates a createKey body before the token's scope and answers 422; SaaS answers 404 for the missing scope"
taggedAllClientAnyTag = "TAGGED_ALL_CLIENT_ANY_TAG: SaaS lets a tagged all-scope client's token ask only for the client's tags; headscale lets it ask for any tag"
retagRemovesUnownedTag = "RETAG_REMOVES_UNOWNED_TAG: SaaS refuses a token's retag of a device holding a tag outside the token's grant with 400 cannot remove current tags; headscale replaces the tags"
sshTestHostnames = "SSH_TEST_HOSTNAMES: headscale names the nodes behind a failing sshTests destination; SaaS names the destination as written"
)
// Differences no planned change removes yet.
@@ -53,8 +53,6 @@ var knownPolicyTesterDivergences = map[string]string{ //nolint:gosec // strings
"policytest-src-wildcard-deny/verdict": testSrcShape,
"policytest-dst-multinode-partial-accept/verdict": "TEST_DST_EVERY_NODE: SaaS accepts only when every node of the dst alias is reached; headscale when one is",
"policytest-report-both-blocks/label": sshTestNodeLabel,
}
const (
@@ -179,8 +177,7 @@ var (
// with the ones headscale's evaluators report, as sorted lines of
// "src -> dst: want" (tests), "src/user: want @ dst" (sshTests) and
// "src: error" for an entry that could not be evaluated. Repeats are kept.
// An sshTests dst is the entry's alias in headscale's form: SaaS names the
// alias, headscale the node, which the label check compares.
// Both name an sshTests src and dst as written.
func requireFailureBreakdown(
t *testing.T,
skips map[string]string,
@@ -203,14 +200,7 @@ func requireFailureBreakdown(
}
if m := saasSSHTestFailRE.FindStringSubmatch(line); m != nil {
dst := m[1]
alias, err := parseAlias(dst)
if err == nil {
dst = alias.String()
}
want = append(want, fmt.Sprintf("%s/%s: %s @ %s", d.User, m[2], m[3], dst))
want = append(want, fmt.Sprintf("%s/%s: %s @ %s", d.User, m[2], m[3], m[1]))
continue
}
@@ -306,14 +296,11 @@ func testerBreakdown(pm *PolicyManager) []string {
cache := make(map[types.NodeID]*tailcfg.SSHPolicy)
// One result per entry, in policy order.
for i, res := range runSSHPolicyTests(pm.pol, pm.users, pm.nodes, cache).Results {
for _, res := range runSSHPolicyTests(pm.pol, pm.users, pm.nodes, cache).Results {
for range res.Errors {
out = append(out, res.Src+": error")
}
aliases := sshTestDstAliases(pm, pm.pol.SSHTests[i])
for want, fails := range map[string]map[string][]string{
"accept": res.AcceptFail,
"deny": res.DenyFail,
@@ -321,7 +308,7 @@ func testerBreakdown(pm *PolicyManager) []string {
} {
for user, dsts := range fails {
for _, dst := range dsts {
out = append(out, fmt.Sprintf("%s/%s: %s @ %s", res.Src, user, want, aliases[dst]))
out = append(out, fmt.Sprintf("%s/%s: %s @ %s", res.Src, user, want, dst))
}
}
}
@@ -332,30 +319,6 @@ func testerBreakdown(pm *PolicyManager) []string {
return out
}
// sshTestDstAliases maps each node hostname an sshTests entry reaches to the
// first of its dst aliases that selects it, the one the evaluator checks the
// node under.
func sshTestDstAliases(pm *PolicyManager, test SSHPolicyTest) map[string]string {
_, srcUserID, _ := resolveSSHTestSource(test.Src, pm.pol, pm.users, pm.nodes)
out := make(map[string]string)
for _, alias := range test.Dst {
nodes, _, err := resolveSSHTestDestNodes(SSHTestDestinations{alias}, pm.pol, pm.users, pm.nodes, srcUserID)
if err != nil {
continue
}
for _, n := range nodes {
if _, ok := out[n.Hostname()]; !ok {
out[n.Hostname()] = alias.String()
}
}
}
return out
}
// nodeLabels lists the capture's hostnames that text names while the policy
// does not, so they are there only because the report labels nodes.
func nodeLabels(c *testcapture.Capture, text string) []string {
+177 -153
View File
@@ -34,6 +34,11 @@ type SSHPolicyTestResult struct {
DenyFail map[string][]string `json:"deny_fail,omitempty"`
CheckOK map[string][]string `json:"check_ok,omitempty"`
CheckFail map[string][]string `json:"check_fail,omitempty"`
// CheckDenied holds the check failures where a node was not reached at
// all. A dst alias can cover nodes that fail in different ways, and one
// node not reached makes the alias DENIED.
CheckDenied map[string][]string `json:"check_denied,omitempty"`
}
// SSHPolicyTestResults aggregates one evaluation run.
@@ -82,7 +87,7 @@ func (r SSHPolicyTestResults) Errors() string {
for _, user := range sortedUsers(res.CheckFail) {
for _, dst := range res.CheckFail[user] {
// The reason scans AcceptOK; skip it for lines that
// The reason scans CheckDenied; skip it for lines that
// are only counted.
var reason string
if !failures.full() {
@@ -117,11 +122,11 @@ func displayUser(u string) string {
// checkFailReason annotates a check-fail with whether the user reached
// the dst via an accept rule or did not reach at all.
func checkFailReason(res SSHPolicyTestResult, user, dst string) string {
if slices.Contains(res.AcceptOK[user], dst) {
return "ALLOWED via accept"
if slices.Contains(res.CheckDenied[user], dst) {
return "DENIED"
}
return "DENIED"
return "ALLOWED via accept"
}
// RunSSHTests evaluates the live policy's sshTests block and wraps any
@@ -193,7 +198,7 @@ func runSSHPolicyTest(
) SSHPolicyTestResult {
srcLabel := ""
if test.Src != nil {
srcLabel = test.Src.String()
srcLabel = sshTestLabel(test.Src)
}
res := SSHPolicyTestResult{
@@ -227,7 +232,7 @@ func runSSHPolicyTest(
return res
}
dstNodes, emptyDsts, err := resolveSSHTestDestNodes(test.Dst, pol, users, nodes, srcUserID)
dsts, err := resolveSSHTestDests(test.Dst, pol, users, nodes, srcUserID)
if err != nil {
res.Passed = false
res.Errors = append(res.Errors,
@@ -236,17 +241,6 @@ func runSSHPolicyTest(
return res
}
// A dst resolving to zero nodes would silently pass.
for _, dst := range emptyDsts {
res.Passed = false
res.Errors = append(res.Errors,
fmt.Sprintf("dst alias %q resolved to no nodes", dst))
}
if len(dstNodes) == 0 {
return res
}
for _, g := range []struct {
users []SSHUser
kind sshAssertion
@@ -258,7 +252,7 @@ func runSSHPolicyTest(
for _, user := range g.users {
evaluateAssertion(
pol, users, nodes, cache,
srcAddrs, dstNodes, user.String(),
srcAddrs, dsts, user.String(),
g.kind, &res,
)
}
@@ -275,10 +269,77 @@ const (
assertCheck
)
// evaluateAssertion walks every (srcAddr, dstNode) pair for one user
// and records the outcome. Empty username fails — SSH login users
// cannot be empty even when parse accepted it.
// evaluateAssertion records one user's outcome for each dst alias of an
// entry, under the alias as written: the report goes back to the policy
// author, who may not be allowed to list nodes. An alias passes when every
// node it selects does, for every src address; one selecting no node is not
// reached. Each distinct alias is evaluated once and reported at every
// occurrence, as Tailscale SaaS reports them.
func evaluateAssertion(
pol *Policy,
users []types.User,
nodes views.Slice[types.NodeView],
cache map[types.NodeID]*tailcfg.SSHPolicy,
srcAddrs []netip.Addr,
dsts []sshTestDst,
user string,
kind sshAssertion,
res *SSHPolicyTestResult,
) {
type outcome struct{ passed, notReached, failed bool }
seen := make(map[string]outcome, len(dsts))
for _, dst := range dsts {
o, ok := seen[dst.label]
if !ok {
passed, notReached, err := sshTestAliasOutcome(
pol, users, nodes, cache, srcAddrs, dst.nodes, user, kind,
)
if err != nil {
res.Passed = false
res.Errors = append(res.Errors,
fmt.Sprintf("compiling SSH policy for %s: %v", dst.label, err))
}
o = outcome{passed, notReached, err != nil}
seen[dst.label] = o
}
if o.failed {
continue
}
if !o.passed {
res.Passed = false
}
switch {
case kind == assertAccept && o.passed:
res.AcceptOK = appendUserDst(res.AcceptOK, user, dst.label)
case kind == assertAccept:
res.AcceptFail = appendUserDst(res.AcceptFail, user, dst.label)
case kind == assertDeny && o.passed:
res.DenyOK = appendUserDst(res.DenyOK, user, dst.label)
case kind == assertDeny:
res.DenyFail = appendUserDst(res.DenyFail, user, dst.label)
case o.passed:
res.CheckOK = appendUserDst(res.CheckOK, user, dst.label)
default:
res.CheckFail = appendUserDst(res.CheckFail, user, dst.label)
if o.notReached {
res.CheckDenied = appendUserDst(res.CheckDenied, user, dst.label)
}
}
}
}
// sshTestAliasOutcome reports whether user's assertion holds on every node
// of one dst alias from every src address, and whether a failing node was
// not reached at all. All src addresses must agree: the first
// counter-example fails the node.
func sshTestAliasOutcome(
pol *Policy,
users []types.User,
nodes views.Slice[types.NodeView],
@@ -287,82 +348,43 @@ func evaluateAssertion(
dstNodes []types.NodeView,
user string,
kind sshAssertion,
res *SSHPolicyTestResult,
) {
dstLoop:
) (bool, bool, error) {
if len(dstNodes) == 0 {
return kind == assertDeny, true, nil
}
passed, notReached := true, false
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
return false, false, err
}
dstLabel := dst.Hostname()
acceptHit := false
checkHit := false
for _, srcAddr := range srcAddrs {
a, c := reachability(dstPol, srcAddr, user)
if a {
acceptHit = true
}
if c {
checkHit = true
}
var holds bool
// All src IPs must agree; one counter-example fails
// the whole (user, dst) pair.
switch kind {
case assertAccept:
if !a {
res.Passed = false
res.AcceptFail = appendUserDst(res.AcceptFail, user, dstLabel)
continue dstLoop
}
holds = a
case assertDeny:
if a {
res.Passed = false
res.DenyFail = appendUserDst(res.DenyFail, user, dstLabel)
continue dstLoop
}
holds = !a
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)
}
continue dstLoop
}
holds = c
}
}
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)
if !holds {
passed = false
notReached = notReached || !a
break
}
}
}
return passed, notReached, nil
}
// appendUserDst appends dst to m[user], allocating m on first use.
@@ -456,105 +478,107 @@ func resolveSSHTestSource(
return out, userID, nil
}
// resolveSSHTestDestNodes maps each dst alias to its destination
// [types.NodeView]s. autogroup:self needs special handling: it cannot
// resolve without per-node context, so it walks the node set keyed on
// src's owning user. Other aliases resolve to an [netipx.IPSet] and match
// via [types.NodeView.InIPSet].
func resolveSSHTestDestNodes(
// sshTestLabel names an sshTests alias as its author writes it: a single
// address bare, not as a /32 or /128 prefix.
func sshTestLabel(alias Alias) string {
if p, ok := alias.(*Prefix); ok {
return filterIP(netip.Prefix(*p))
}
return alias.String()
}
// sshTestDst is one dst alias of an sshTests entry and the nodes it
// selects.
type sshTestDst struct {
label string
nodes []types.NodeView
}
// resolveSSHTestDests resolves each dst alias of an entry, in order and
// once per occurrence, so the report can name every alias as written.
// Each distinct alias resolves once.
func resolveSSHTestDests(
dsts SSHTestDestinations,
pol *Policy,
users []types.User,
nodes views.Slice[types.NodeView],
srcUserID uint,
) ([]types.NodeView, []string, error) {
seen := make(map[types.NodeID]struct{})
var (
out []types.NodeView
emptyDsts []string
)
) ([]sshTestDst, error) {
selected := make(map[string][]types.NodeView, len(dsts))
out := make([]sshTestDst, 0, len(dsts))
for _, alias := range dsts {
dstLabel := alias.String()
matched := false
label := sshTestLabel(alias)
if ag, ok := alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
// autogroup:self resolves to non-tagged nodes owned by
// the same user as src; tagged/IP sources have no user.
if srcUserID == 0 {
emptyDsts = append(emptyDsts, dstLabel)
dstNodes, ok := selected[label]
if !ok {
var err error
continue
dstNodes, err = sshTestDstNodes(alias, pol, users, nodes, srcUserID)
if err != nil {
return nil, err
}
for _, n := range nodes.All() {
if n.IsTagged() {
continue
}
if !n.User().Valid() {
continue
}
if n.User().ID() != srcUserID {
continue
}
matched = true
if _, dup := seen[n.ID()]; dup {
continue
}
seen[n.ID()] = struct{}{}
out = append(out, n)
}
if !matched {
emptyDsts = append(emptyDsts, dstLabel)
}
continue
selected[label] = dstNodes
}
ips, err := alias.Resolve(pol, users, nodes)
if err != nil {
return nil, nil, fmt.Errorf("resolving destination %q: %w", dstLabel, err)
}
out = append(out, sshTestDst{label: label, nodes: dstNodes})
}
if ips == nil || ips.Empty() {
emptyDsts = append(emptyDsts, dstLabel)
return out, nil
}
continue
}
// sshTestDstNodes returns the nodes one dst alias selects. autogroup:self
// cannot resolve without per-node context, so it walks the node set keyed
// on src's owning user. Other aliases resolve to an [netipx.IPSet] and
// match via [types.NodeView.InIPSet].
func sshTestDstNodes(
alias Alias,
pol *Policy,
users []types.User,
nodes views.Slice[types.NodeView],
srcUserID uint,
) ([]types.NodeView, error) {
var out []types.NodeView
set, err := prefixesToIPSet(ips.Prefixes())
if err != nil {
return nil, nil, fmt.Errorf("building IPSet for %q: %w", dstLabel, err)
if ag, ok := alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
// autogroup:self is the src user's untagged nodes; tagged and
// address sources have no user.
if srcUserID == 0 {
return nil, nil
}
for _, n := range nodes.All() {
if !n.InIPSet(set) {
continue
if !n.IsTagged() && n.User().Valid() && n.User().ID() == srcUserID {
out = append(out, n)
}
matched = true
if _, dup := seen[n.ID()]; dup {
continue
}
seen[n.ID()] = struct{}{}
out = append(out, n)
}
if !matched {
emptyDsts = append(emptyDsts, dstLabel)
return out, nil
}
ips, err := alias.Resolve(pol, users, nodes)
if err != nil {
return nil, fmt.Errorf("resolving destination %q: %w", sshTestLabel(alias), err)
}
if ips == nil || ips.Empty() {
return nil, nil
}
set, err := prefixesToIPSet(ips.Prefixes())
if err != nil {
return nil, fmt.Errorf("building IPSet for %q: %w", sshTestLabel(alias), err)
}
for _, n := range nodes.All() {
if n.InIPSet(set) {
out = append(out, n)
}
}
return out, emptyDsts, nil
return out, nil
}
// prefixesToIPSet builds the [netipx.IPSet] that [types.NodeView.InIPSet]
+68 -18
View File
@@ -715,11 +715,9 @@ func TestRunSSHTests(t *testing.T) {
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.
// tag:empty has an owner but no tagged nodes: an alias
// selecting no node is not reached, so the accept fails
// instead of passing over zero nodes.
name: "dst-tag-with-no-tagged-nodes-fails",
policy: `{
"tagOwners": { "tag:empty": ["alice@headscale.net"] },
@@ -730,12 +728,11 @@ func TestRunSSHTests(t *testing.T) {
}]
}`,
wantPass: false,
wantErrSub: []string{"tag:empty", "resolved to no nodes"},
wantErrSub: []string{"-> tag:empty: expected ALLOWED, got DENIED"},
},
{
// 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.
// scope to, so the dst alias selects no node either.
name: "dst-autogroup-self-from-tag-src-fails",
policy: `{
"tagOwners": { "tag:prod": ["alice@headscale.net"] },
@@ -746,7 +743,7 @@ func TestRunSSHTests(t *testing.T) {
}]
}`,
wantPass: false,
wantErrSub: []string{"autogroup:self", "resolved to no nodes"},
wantErrSub: []string{"-> autogroup:self: expected ALLOWED, got DENIED"},
},
{
name: "src-address-space-deny-pass",
@@ -1066,22 +1063,22 @@ func TestSSHPolicyTestResultsErrorsRendering(t *testing.T) {
{
Src: "alice@headscale.net",
AcceptFail: map[string][]string{
"root": {"server"},
"root": {"tag:server"},
},
},
{
Src: "bob@headscale.net",
DenyFail: map[string][]string{
"root": {"alice-laptop"},
"root": {"group:people"},
},
},
{
Src: "alice@headscale.net",
CheckFail: map[string][]string{
"ubuntu": {"server"},
"ubuntu": {"tag:server", "tag:prod"},
},
AcceptOK: map[string][]string{
"ubuntu": {"server"},
CheckDenied: map[string][]string{
"ubuntu": {"tag:prod"},
},
},
},
@@ -1089,13 +1086,66 @@ func TestSSHPolicyTestResultsErrorsRendering(t *testing.T) {
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",
"alice@headscale.net/root -> tag:server: expected ALLOWED, got DENIED",
"bob@headscale.net/root -> group:people: expected DENIED, got ALLOWED",
"alice@headscale.net/ubuntu -> tag:server: expected ALLOWED via check, got ALLOWED via accept",
"alice@headscale.net/ubuntu -> tag:prod: expected ALLOWED via check, got DENIED",
} {
assert.Contains(t, rendered, sub)
}
assert.Equal(t, 3, strings.Count(rendered, "\n")+1,
assert.Equal(t, 4, strings.Count(rendered, "\n")+1,
"expected one line per failing assertion")
}
// TestSSHTestsReportNamesAliasNotNode: the report goes back to the policy
// author, who may not be allowed to list nodes, so src and dst are named as
// written, one line per dst occurrence, as Tailscale SaaS reports them.
func TestSSHTestsReportNamesAliasNotNode(t *testing.T) {
users := sshTestUsers()
nodes := sshTestNodes(users)
pm, err := NewPolicyManager([]byte(`{}`), users, nodes.ViewSlice())
require.NoError(t, err)
// group:people covers alice's nodes, which her own rule reaches by
// accept, and bob's and thor's, not reached at all: it is DENIED.
const policy = `{
"groups": {"group:people": ["alice@headscale.net", "bob@headscale.net", "thor@example.org"]},
"tagOwners": {"tag:server": ["alice@headscale.net"], "tag:empty": ["alice@headscale.net"]},
"ssh": [{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["root"]
}],
"sshTests": [
{
"src": "10.0.0.1",
"dst": ["100.64.0.3", "tag:server", "tag:server", "tag:empty"],
"accept": ["root"]
},
{"src": "alice@headscale.net", "dst": ["autogroup:self"], "deny": ["root"]},
{"src": "alice@headscale.net", "dst": ["group:people"], "check": ["root"]}
]
}`
_, err = pm.SetPolicy([]byte(policy))
require.ErrorIs(t, err, errSSHPolicyTestsFailed)
for line, want := range map[string]int{
"10.0.0.1/root -> 100.64.0.3: expected ALLOWED, got DENIED": 1,
"10.0.0.1/root -> tag:server: expected ALLOWED, got DENIED": 2,
"10.0.0.1/root -> tag:empty: expected ALLOWED, got DENIED": 1,
"alice@headscale.net/root -> autogroup:self: expected DENIED, got ALLOWED": 1,
"alice@headscale.net/root -> group:people: expected ALLOWED via check, got DENIED": 1,
} {
assert.Equal(t, want, strings.Count(err.Error(), line), line)
}
for _, node := range nodes {
if !strings.Contains(policy, node.Hostname) {
assert.NotContains(t, err.Error(), node.Hostname)
}
}
}
+1 -27
View File
@@ -14,33 +14,7 @@ import (
// knownSSHTesterDivergences names the engine gap for each check where
// headscale and upstream disagree, keyed <test_id>/<check>.
var knownSSHTesterDivergences = map[string]string{
"sshtest-accept-fail-no-ssh-rule/label": sshTestNodeLabel,
"sshtest-accept-fail-wrong-login-user/label": sshTestNodeLabel,
"sshtest-both-tests-pass-sshTests-fail/label": sshTestNodeLabel,
"sshtest-deny-fail-policy-allows/label": sshTestNodeLabel,
"sshtest-malformed-empty-user/label": sshTestNodeLabel,
"sshtest-mixed-acls-tcp22-allow-no-ssh-rule/label": sshTestNodeLabel,
"sshtest-user-localpart-domain-mismatch/label": sshTestNodeLabel,
"sshtest-user-nonroot-blocks-root/label": sshTestNodeLabel,
"sshtest-fail-label-host-alias/label": sshTestNodeLabel,
"sshtest-fail-label-ip-dst/label": sshTestNodeLabel,
"sshtest-fail-label-same-node-twice/label": sshTestNodeLabel,
"sshtest-fail-label-user-dst/label": sshTestNodeLabel,
"sshtest-repeated-names/label": sshTestNodeLabel,
"sshtest-report-multi/label": sshTestNodeLabel,
"sshtest-src-unassigned-ip-accept/label": sshTestNodeLabel,
"sshtest-fail-label-same-node-twice/breakdown": "SSHTEST_PER_ALIAS: SaaS reports each dst alias, also two naming one node; headscale checks each node once",
"sshtest-fail-label-ip-no-device/breakdown": sshTestNoDevice,
"sshtest-report-multi/breakdown": "SSHTEST_PER_ALIAS+SSHTEST_NO_DEVICE: SaaS reports one line per dst alias and a deviceless alias as not reached; headscale one per node and an error",
"sshtest-src-unassigned-ip-accept/breakdown": "SSHTEST_SRC_LABEL: SaaS labels a single-address source as written, headscale as a /32 prefix",
}
const (
sshTestNodeLabel = "SSHTEST_NODE_LABEL: headscale names each failing dst node by hostname; SaaS names the dst as written"
sshTestNoDevice = "SSHTEST_NO_DEVICE: SaaS reports a dst that selects no device as not reached; headscale as an error"
)
var knownSSHTesterDivergences = map[string]string{}
func TestSSHTesterCompat(t *testing.T) {
t.Parallel()
+1 -1
View File
@@ -468,7 +468,7 @@ func TestFailureBreakdownIsBounded(t *testing.T) {
{
name: "sshTests dst without node",
block: `"sshTests": [{"src": "` + name + `", "dst": [` +
strings.Repeat(`"gone",`, failures) + `], "deny": ["root"]}]`,
strings.Repeat(`"gone",`, failures) + `], "accept": ["root"]}]`,
sentinel: errSSHPolicyTestsFailed,
},
{