mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-18 00:45:06 +00:00
Compare commits
23 commits
main
...
sshtests-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e22ab769 | ||
|
|
d32d91d9fb | ||
|
|
9b4f68257d | ||
|
|
63de6b2d38 | ||
|
|
2499c066a3 | ||
|
|
2ec7a71a56 | ||
|
|
2e1d11adbf | ||
|
|
51522195ef | ||
|
|
7e4be35cd2 | ||
|
|
c2d012cdf9 | ||
|
|
e772faf22f | ||
|
|
9df5a1513e | ||
|
|
3e83be2de1 | ||
|
|
9da4543c8d | ||
|
|
439964ae24 | ||
|
|
b2d891c9d1 | ||
|
|
ea52cbb288 | ||
|
|
eafc35106e | ||
|
|
5bed3b29b9 | ||
|
|
5b7e9bb9c8 | ||
|
|
90648a525d | ||
|
|
65e4dbc4c4 | ||
|
|
57f838e268 |
115 changed files with 2078602 additions and 321 deletions
1
.github/workflows/test-integration.yaml
vendored
1
.github/workflows/test-integration.yaml
vendored
|
|
@ -203,6 +203,7 @@ jobs:
|
|||
- TestAuthWebFlowLogoutAndReloginSameUser
|
||||
- TestAuthWebFlowLogoutAndReloginNewUser
|
||||
- TestPolicyCheckCommand
|
||||
- TestSSHTestsRejectFailingPolicy
|
||||
- TestUserCommand
|
||||
- TestPreAuthKeyCommand
|
||||
- TestPreAuthKeyCommandWithoutExpiry
|
||||
|
|
|
|||
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -44,6 +44,38 @@ This feature is **beta** while behavioural coverage against Tailscale SaaS broad
|
|||
|
||||
[#3229](https://github.com/juanfont/headscale/pull/3229)
|
||||
|
||||
### SSH policy tests (beta)
|
||||
|
||||
Headscale now evaluates the `sshTests` block in a policy file. Tests assert which SSH login users
|
||||
can connect from a named source to named destinations against the same SSH rules clients receive.
|
||||
They run on `headscale policy set`, on SIGHUP reload (`systemctl reload headscale` /
|
||||
`kill -HUP $(pidof headscale)`), and on `headscale policy check`. A failing test rejects the write
|
||||
before it is applied, with the same error message Tailscale SaaS would return for the same policy.
|
||||
|
||||
An entry has the shape:
|
||||
|
||||
```hujson
|
||||
"sshTests": [
|
||||
{
|
||||
"src": "alice@example.com",
|
||||
"dst": ["tag:server"],
|
||||
"accept": ["root"],
|
||||
"deny": ["alice"],
|
||||
"check": ["ubuntu"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`accept` asserts the listed login users reach every dst via an accept- or check-action SSH rule,
|
||||
`deny` asserts none of them reach any dst, and `check` requires reachability specifically via a
|
||||
check-action rule.
|
||||
|
||||
At boot a stored policy whose sshTests no longer pass — for example because a referenced user was
|
||||
deleted while the server was offline — logs a warning and the server keeps running. Fix the policy
|
||||
and reload.
|
||||
|
||||
This feature is **beta** while behavioural coverage against Tailscale SaaS broadens.
|
||||
|
||||
### Grants
|
||||
|
||||
We now support [Tailscale grants](https://tailscale.com/docs/features/access-control/grants)
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ func init() {
|
|||
policyCmd.AddCommand(setPolicy)
|
||||
|
||||
checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format")
|
||||
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to validate user@ token references and to evaluate the policy's tests block. Required when those checks are needed.")
|
||||
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to validate user@ token references and to evaluate the policy's tests and sshTests blocks. Required when those checks are needed.")
|
||||
mustMarkRequired(checkPolicy, "file")
|
||||
policyCmd.AddCommand(checkPolicy)
|
||||
}
|
||||
|
|
@ -173,8 +173,8 @@ var checkPolicy = &cobra.Command{
|
|||
Short: "Check the Policy file for errors",
|
||||
Long: `
|
||||
Check validates the policy against the server's live users and nodes,
|
||||
running any "tests" block. By default the command is a thin frontend
|
||||
for a gRPC call to a running headscale; pass --` + bypassFlag + ` to
|
||||
running any "tests" or "sshTests" block. By default the command is a
|
||||
thin frontend for a gRPC call to a running headscale; pass --` + bypassFlag + ` to
|
||||
open the database directly when headscale is not running.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
policyPath, _ := cmd.Flags().GetString("file")
|
||||
|
|
@ -208,7 +208,7 @@ var checkPolicy = &cobra.Command{
|
|||
// NewPolicyManager validates structure and user references
|
||||
// but intentionally skips test evaluation (boot path).
|
||||
// SetPolicy is the user-write boundary and is what runs the
|
||||
// tests block.
|
||||
// tests and sshTests blocks.
|
||||
pm, err := policy.NewPolicyManager(policyBytes, users, nodes.ViewSlice())
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing policy file: %w", err)
|
||||
|
|
|
|||
|
|
@ -1317,7 +1317,7 @@ func TestSSHPolicyRules(t *testing.T) {
|
|||
]
|
||||
}`,
|
||||
expectErr: true,
|
||||
errorMessage: `invalid SSH action: "invalid", must be one of: accept, check`,
|
||||
errorMessage: `"invalid" is not a valid action`,
|
||||
},
|
||||
{
|
||||
name: "invalid-check-period",
|
||||
|
|
@ -1341,10 +1341,15 @@ func TestSSHPolicyRules(t *testing.T) {
|
|||
]
|
||||
}`,
|
||||
expectErr: true,
|
||||
errorMessage: "not a valid duration string",
|
||||
errorMessage: `time: invalid duration "invalid"`,
|
||||
},
|
||||
// `autogroup:invalid` as an SSH user is no longer rejected:
|
||||
// SaaS treats every `autogroup:*` user-string as a literal
|
||||
// label and compiles it into the SSHUsers map. The compat
|
||||
// suite covers this via ssh-malformed-user-autogroup-* — no
|
||||
// dedicated case is needed here.
|
||||
{
|
||||
name: "unsupported-autogroup",
|
||||
name: "ssh-user-unknown-autogroup-as-literal",
|
||||
targetNode: taggedClient,
|
||||
peers: types.Nodes{&nodeUser2},
|
||||
policy: `{
|
||||
|
|
@ -1363,8 +1368,23 @@ func TestSSHPolicyRules(t *testing.T) {
|
|||
}
|
||||
]
|
||||
}`,
|
||||
expectErr: true,
|
||||
errorMessage: "autogroup not supported for SSH user",
|
||||
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
||||
{
|
||||
Principals: []*tailcfg.SSHPrincipal{
|
||||
{NodeIP: "100.64.0.2"},
|
||||
},
|
||||
SSHUsers: map[string]string{
|
||||
"autogroup:invalid": "autogroup:invalid",
|
||||
"root": "",
|
||||
},
|
||||
Action: &tailcfg.SSHAction{
|
||||
Accept: true,
|
||||
AllowAgentForwarding: true,
|
||||
AllowLocalPortForwarding: true,
|
||||
AllowRemotePortForwarding: true,
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "autogroup-nonroot-should-use-wildcard-with-root-excluded",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1445,27 +1456,18 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
|
|||
}
|
||||
}
|
||||
|
||||
// flattenTags flattens the TagOwners by resolving nested tags and detecting cycles.
|
||||
// It will return a Owners list where all the Tag types have been resolved to their underlying Owners.
|
||||
// flattenTags flattens the TagOwners by resolving nested tags. Cycles
|
||||
// in the ownership graph (tag:a -> tag:b -> tag:a, or tag:a -> tag:a)
|
||||
// are tolerated to match SaaS: the cycle-causing edge is dropped, the
|
||||
// remaining owners propagate, and the cycle itself contributes no
|
||||
// addresses. Non-cycle owners on the cycled tags still resolve.
|
||||
// Undefined-tag references remain a hard error.
|
||||
func flattenTags(tagOwners TagOwners, tag Tag, visiting map[Tag]bool, chain []Tag) (Owners, error) {
|
||||
if visiting[tag] {
|
||||
cycleStart := 0
|
||||
|
||||
for i, t := range chain {
|
||||
if t == tag {
|
||||
cycleStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cycleTags := make([]string, len(chain[cycleStart:]))
|
||||
for i, t := range chain[cycleStart:] {
|
||||
cycleTags[i] = string(t)
|
||||
}
|
||||
|
||||
slices.Sort(cycleTags)
|
||||
|
||||
return nil, fmt.Errorf("%w: %s", ErrCircularReference, strings.Join(cycleTags, " -> "))
|
||||
// Cycle: this tag is already on the resolution stack. SaaS
|
||||
// drops the edge instead of failing, so we return an empty
|
||||
// owner set and let the caller continue with any siblings.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
visiting[tag] = true
|
||||
|
|
|
|||
748
hscontrol/policy/v2/sshtest.go
Normal file
748
hscontrol/policy/v2/sshtest.go
Normal file
|
|
@ -0,0 +1,748 @@
|
|||
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 {
|
||||
srcLabel := ""
|
||||
if test.Src != nil {
|
||||
srcLabel = test.Src.String()
|
||||
}
|
||||
|
||||
res := SSHPolicyTestResult{
|
||||
Src: srcLabel,
|
||||
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", srcLabel, err))
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
if len(srcAddrs) == 0 {
|
||||
res.Passed = false
|
||||
res.Errors = append(res.Errors,
|
||||
fmt.Sprintf("source %q resolved to no IP addresses", srcLabel))
|
||||
|
||||
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, emptyDsts, 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
|
||||
}
|
||||
|
||||
// SaaS treats a dst alias that resolves to no nodes as a failure
|
||||
// when the entry has anything to assert. Without this branch, the
|
||||
// per-assertion loops below run zero iterations and the test
|
||||
// passes silently — wrong shape, missed regression.
|
||||
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 _, user := range test.Accept {
|
||||
evaluateAssertion(
|
||||
pol, users, nodes, cache,
|
||||
srcAddrs, dstNodes, user.String(),
|
||||
assertAccept, &res,
|
||||
)
|
||||
}
|
||||
|
||||
for _, user := range test.Deny {
|
||||
evaluateAssertion(
|
||||
pol, users, nodes, cache,
|
||||
srcAddrs, dstNodes, user.String(),
|
||||
assertDeny, &res,
|
||||
)
|
||||
}
|
||||
|
||||
for _, user := range test.Check {
|
||||
evaluateAssertion(
|
||||
pol, users, nodes, cache,
|
||||
srcAddrs, dstNodes, user.String(),
|
||||
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,
|
||||
) {
|
||||
dstLoop:
|
||||
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()
|
||||
|
||||
// acceptHit covers "any matching accept-or-check rule";
|
||||
// checkHit 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)
|
||||
|
||||
continue dstLoop
|
||||
}
|
||||
case assertDeny:
|
||||
if a {
|
||||
res.Passed = false
|
||||
res.DenyFail = appendUserDst(res.DenyFail, user, dstLabel)
|
||||
|
||||
continue dstLoop
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 typed 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 Alias,
|
||||
pol *Policy,
|
||||
users []types.User,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) ([]netip.Addr, uint, error) {
|
||||
if src == nil {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
addrs, err := src.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 := src.(*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 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
|
||||
)
|
||||
|
||||
for _, alias := range dsts {
|
||||
dstLabel := alias.String()
|
||||
matched := false
|
||||
|
||||
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 and the caller surfaces it as a failure
|
||||
// (matches SaaS, which treats a no-node dst as a
|
||||
// failing assertion).
|
||||
if srcUserID == 0 {
|
||||
emptyDsts = append(emptyDsts, dstLabel)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ips, err := alias.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolving destination %q: %w", dstLabel, err)
|
||||
}
|
||||
|
||||
if ips == nil || ips.Empty() {
|
||||
emptyDsts = append(emptyDsts, dstLabel)
|
||||
|
||||
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, nil, fmt.Errorf("building IPSet for %q: %w", dstLabel, err)
|
||||
}
|
||||
|
||||
for _, n := range nodes.All() {
|
||||
if !n.InIPSet(set) {
|
||||
continue
|
||||
}
|
||||
|
||||
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, emptyDsts, 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
|
||||
}
|
||||
1000
hscontrol/policy/v2/sshtest_test.go
Normal file
1000
hscontrol/policy/v2/sshtest_test.go
Normal file
File diff suppressed because it is too large
Load diff
115
hscontrol/policy/v2/sshtester_compat_test.go
Normal file
115
hscontrol/policy/v2/sshtester_compat_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Compatibility tests for the policy `sshTests` block, replaying captures
|
||||
// recorded against a real Tailscale SaaS tailnet. The runner mirrors the
|
||||
// pattern in policytester_compat_test.go: a single Glob over a testdata
|
||||
// directory, one t.Run per file. Each capture is one of:
|
||||
//
|
||||
// - APIResponseCode != 200 — the policy was rejected by SaaS, the
|
||||
// captured Message is the body the user saw, and headscale must
|
||||
// reject the same input with an error string that contains the same
|
||||
// body (substring match, allowing wrapping like "test(s) failed:\n…").
|
||||
// - APIResponseCode == 200 — SaaS accepted the policy (its sshTests
|
||||
// block passed); headscale's evaluateSSHTests must also pass.
|
||||
//
|
||||
// Captures live in testdata/sshtest_results/*.hujson. Scenarios in
|
||||
// knownSSHTesterDivergences are skipped with their tracking note —
|
||||
// these are real Tailscale ↔ headscale divergences that need engine-level
|
||||
// fixes in follow-up PRs.
|
||||
//
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// knownSSHTesterDivergences tracks scenarios where headscale and SaaS
|
||||
// disagree on whether a policy is accepted. Each entry should describe
|
||||
// the engine area a follow-up PR needs to touch.
|
||||
var knownSSHTesterDivergences = map[string]string{
|
||||
// SaaS parse-accepts a bare IPv6 sshTests dst but engine-rejects
|
||||
// the same input with "test(s) failed" while the matching IPv4
|
||||
// scenario engine-passes. The two captures share the same topology
|
||||
// and the same policy shape, so the asymmetry is in the SaaS
|
||||
// sshTests evaluator's IPv6 handling, not in any rule the user
|
||||
// wrote. Headscale's evaluator resolves both literals to the
|
||||
// tagged node that carries them and the assertion passes — a
|
||||
// follow-up needs to either reproduce the SaaS-side IPv6 quirk or
|
||||
// confirm this is a SaaS bug we will not match.
|
||||
"sshtest-malformed-dst-bare-ipv6": "engine: SaaS rejects bare IPv6 sshTests dst; headscale accepts (IPv4 mirror passes both sides)",
|
||||
}
|
||||
|
||||
func TestSSHTesterCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files, err := filepath.Glob(filepath.Join("testdata", "sshtest_results", "*.hujson"))
|
||||
require.NoError(t, err, "failed to glob test files")
|
||||
|
||||
if len(files) == 0 {
|
||||
t.Skip("no sshtest captures yet")
|
||||
}
|
||||
|
||||
users := setupSSHDataCompatUsers()
|
||||
|
||||
for _, file := range files {
|
||||
c, err := testcapture.Read(file)
|
||||
require.NoError(t, err, "reading %s", file)
|
||||
|
||||
t.Run(c.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if reason, skip := knownSSHTesterDivergences[c.TestID]; skip {
|
||||
t.Skip(reason)
|
||||
}
|
||||
|
||||
// Per-capture nodes mean the topology IPs (which a
|
||||
// policy `hosts` mapping references by literal IP)
|
||||
// resolve to real nodes in the test fixture. Without
|
||||
// this the static fixture's IPs do not overlap with
|
||||
// the captures and host-alias dsts resolve to no
|
||||
// nodes — that path is now a load-bearing failure.
|
||||
nodes := buildGrantsNodesFromCapture(users, c)
|
||||
|
||||
policyJSON := []byte(c.Input.FullPolicy)
|
||||
|
||||
pm, parseErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
|
||||
|
||||
if c.Input.APIResponseCode == 200 {
|
||||
require.NoError(t, parseErr,
|
||||
"tailscale accepted this policy; headscale must parse it")
|
||||
|
||||
_, setErr := pm.SetPolicy(policyJSON)
|
||||
require.NoError(t, setErr,
|
||||
"tailscale accepted this policy; headscale sshTests must pass")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var got error
|
||||
|
||||
switch {
|
||||
case parseErr != nil:
|
||||
got = parseErr
|
||||
default:
|
||||
_, setErr := pm.SetPolicy(policyJSON)
|
||||
got = setErr
|
||||
}
|
||||
|
||||
require.Error(t, got, "tailscale rejected; headscale must reject too")
|
||||
|
||||
if c.Input.APIResponseBody == nil || c.Input.APIResponseBody.Message == "" {
|
||||
return
|
||||
}
|
||||
|
||||
want := c.Input.APIResponseBody.Message
|
||||
if !strings.Contains(got.Error(), want) {
|
||||
t.Errorf("error body mismatch\n tailscale wants: %q\n headscale got: %q", want, got.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,17 @@
|
|||
// from Tailscale SaaS by tscap, and compares headscale's SSH policy compilation
|
||||
// against the captured SSH rules.
|
||||
//
|
||||
// Each file is a testcapture.Capture containing:
|
||||
// - The full policy that was POSTed to Tailscale SaaS (we use tf.Input.FullPolicy
|
||||
// directly instead of reconstructing it from a sub-section)
|
||||
// - The expected SSH rules for each of the 8 test nodes (in tf.Captures[name].SSHRules)
|
||||
// Each capture is one of:
|
||||
// - APIResponseCode == 200 — SaaS accepted the policy; the captured
|
||||
// per-node SSH rules in tf.Captures[name].SSHRules are the source of
|
||||
// truth, and headscale's compileSSHPolicy must produce the same shape.
|
||||
// - APIResponseCode != 200 — SaaS rejected the policy at the API; the
|
||||
// captured Message is the body the user saw. headscale must reject
|
||||
// the same input with an error whose text contains that body as a
|
||||
// substring (mirroring sshtester_compat_test.go).
|
||||
//
|
||||
// Tests known to fail due to unimplemented features or known differences are
|
||||
// skipped with a TODO comment explaining the root cause.
|
||||
// Tests known to diverge are listed in sshSkipReasons (200 path) or
|
||||
// sshRejectSkipReasons (!= 200 path) with a TODO explaining the gap.
|
||||
//
|
||||
// Test data source: testdata/ssh_results/ssh-*.hujson
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
|
|
@ -61,61 +65,6 @@ func setupSSHDataCompatUsers() types.Users {
|
|||
}
|
||||
}
|
||||
|
||||
// setupSSHDataCompatNodes returns the test nodes for SSH data-driven
|
||||
// compatibility tests. Node GivenNames match the anonymized pokémon names:
|
||||
// - bulbasaur (owned by odin)
|
||||
// - ivysaur (owned by thor)
|
||||
// - venusaur (owned by freya)
|
||||
// - beedrill (tag:server)
|
||||
// - kakuna (tag:prod)
|
||||
func setupSSHDataCompatNodes(users types.Users) types.Nodes {
|
||||
return types.Nodes{
|
||||
&types.Node{
|
||||
ID: 1,
|
||||
GivenName: "bulbasaur",
|
||||
User: &users[0],
|
||||
UserID: &users[0].ID,
|
||||
IPv4: ptrAddr("100.90.199.68"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::2d01:c747"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
&types.Node{
|
||||
ID: 2,
|
||||
GivenName: "ivysaur",
|
||||
User: &users[1],
|
||||
UserID: &users[1].ID,
|
||||
IPv4: ptrAddr("100.110.121.96"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::1737:7960"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
&types.Node{
|
||||
ID: 3,
|
||||
GivenName: "venusaur",
|
||||
User: &users[2],
|
||||
UserID: &users[2].ID,
|
||||
IPv4: ptrAddr("100.103.90.82"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::9e37:5a52"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
&types.Node{
|
||||
ID: 4,
|
||||
GivenName: "beedrill",
|
||||
IPv4: ptrAddr("100.108.74.26"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
|
||||
Tags: []string{"tag:server"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
&types.Node{
|
||||
ID: 5,
|
||||
GivenName: "kakuna",
|
||||
IPv4: ptrAddr("100.103.8.15"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
|
||||
Tags: []string{"tag:prod"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// loadSSHTestFile loads and parses a single SSH capture HuJSON file.
|
||||
func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture {
|
||||
t.Helper()
|
||||
|
|
@ -126,8 +75,11 @@ func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture {
|
|||
return c
|
||||
}
|
||||
|
||||
// sshSkipReasons documents why each skipped test fails and what needs to be
|
||||
// fixed. Tests are grouped by root cause to identify high-impact changes.
|
||||
// sshSkipReasons documents APIResponseCode == 200 captures where SaaS
|
||||
// accepted the policy but headscale either does not yet support the
|
||||
// shape or rejects it stricter than SaaS does. Each entry should
|
||||
// describe the gap a follow-up PR needs to close (or justify why
|
||||
// headscale is intentionally stricter).
|
||||
var sshSkipReasons = map[string]string{
|
||||
// USER_PASSKEY_WILDCARD (2 tests)
|
||||
//
|
||||
|
|
@ -135,18 +87,25 @@ var sshSkipReasons = map[string]string{
|
|||
// equivalent for the user:*@passkey wildcard pattern.
|
||||
"ssh-b5": "user:*@passkey wildcard not supported in headscale",
|
||||
"ssh-d10": "user:*@passkey wildcard not supported in headscale",
|
||||
}
|
||||
|
||||
// DOMAIN_NOT_ASSOCIATED (4 tests)
|
||||
// sshRejectSkipReasons documents APIResponseCode != 200 captures where
|
||||
// headscale and SaaS legitimately disagree on whether the policy should
|
||||
// be rejected (or where headscale rejects with different wording).
|
||||
var sshRejectSkipReasons = map[string]string{
|
||||
// DOMAIN_NOT_ASSOCIATED (5 tests)
|
||||
//
|
||||
// SaaS validates that email domains in user:*@domain and
|
||||
// localpart:*@domain expressions are configured tailnet domains.
|
||||
// headscale has no concept of "associated tailnet domains" — it
|
||||
// only has users with email addresses. These policies are
|
||||
// legitimately rejected by SaaS but not by headscale.
|
||||
// localpart:*@domain expressions are configured tailnet
|
||||
// domains. headscale has no concept of "associated tailnet
|
||||
// domains" — it only has users with email addresses. These
|
||||
// policies are legitimately rejected by SaaS but not by
|
||||
// headscale.
|
||||
"ssh-b4": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
"ssh-d1": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
"ssh-e1": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
"ssh-e2": "domain validation: headscale has no 'associated tailnet domains' concept",
|
||||
"ssh-malformed-user-localpart-multi-glob": "domain validation: headscale has no 'associated tailnet domains' concept (same gap as ssh-b4/d1/e1/e2)",
|
||||
}
|
||||
|
||||
// TestSSHDataCompat is a data-driven test that loads all ssh-*.hujson test
|
||||
|
|
@ -192,7 +151,63 @@ func TestSSHDataCompat(t *testing.T) {
|
|||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Check if this test is in the skip list
|
||||
// Build nodes per-scenario from this file's topology.
|
||||
// tscap uses clean-slate mode, so each scenario has
|
||||
// different node IPs.
|
||||
nodes := buildGrantsNodesFromCapture(users, tf)
|
||||
|
||||
// Use the captured full policy as is. Anonymization in
|
||||
// tscap already rewrites SaaS emails to @example.com.
|
||||
policyJSON := []byte(tf.Input.FullPolicy)
|
||||
|
||||
// Branch on the SaaS response code. Captures with
|
||||
// APIResponseCode != 200 are policies SaaS rejected at
|
||||
// the API; headscale must reject the same input. The
|
||||
// 200 path falls through to the existing per-node SSH
|
||||
// rule comparison.
|
||||
if tf.Input.APIResponseCode != 200 {
|
||||
if reason, ok := sshRejectSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf(
|
||||
"TODO: %s — see sshRejectSkipReasons for details",
|
||||
reason,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pm, parseErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
|
||||
|
||||
var got error
|
||||
|
||||
switch {
|
||||
case parseErr != nil:
|
||||
got = parseErr
|
||||
default:
|
||||
_, setErr := pm.SetPolicy(policyJSON)
|
||||
got = setErr
|
||||
}
|
||||
|
||||
require.Error(t, got, "tailscale rejected; headscale must reject too")
|
||||
|
||||
if tf.Input.APIResponseBody == nil ||
|
||||
tf.Input.APIResponseBody.Message == "" {
|
||||
return
|
||||
}
|
||||
|
||||
want := tf.Input.APIResponseBody.Message
|
||||
if !strings.Contains(got.Error(), want) {
|
||||
t.Errorf(
|
||||
"error body mismatch\n tailscale wants: %q\n headscale got: %q",
|
||||
want,
|
||||
got.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// APIResponseCode == 200: SaaS accepted; headscale must
|
||||
// match the captured per-node SSH rules.
|
||||
if reason, ok := sshSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf(
|
||||
"TODO: %s — see sshSkipReasons comments for details",
|
||||
|
|
@ -202,29 +217,13 @@ func TestSSHDataCompat(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
// SaaS rejected this policy — verify headscale also rejects it.
|
||||
if tf.Error {
|
||||
testSSHError(t, tf)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Build nodes per-scenario from this file's topology.
|
||||
// tscap uses clean-slate mode, so each scenario has
|
||||
// different node IPs.
|
||||
nodes := buildGrantsNodesFromCapture(users, tf)
|
||||
|
||||
// Use the captured full policy as is. Anonymization in
|
||||
// tscap already rewrites SaaS emails to @example.com.
|
||||
policyJSON := tf.Input.FullPolicy
|
||||
|
||||
pol, err := unmarshalPolicy([]byte(policyJSON))
|
||||
pol, err := unmarshalPolicy(policyJSON)
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
"%s: policy should parse successfully\nPolicy:\n%s",
|
||||
tf.TestID,
|
||||
policyJSON,
|
||||
tf.Input.FullPolicy,
|
||||
)
|
||||
|
||||
for nodeName, capture := range tf.Captures {
|
||||
|
|
@ -309,97 +308,3 @@ func TestSSHDataCompat(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sshErrorMessageMap maps Tailscale SaaS error substrings to headscale
|
||||
// equivalents where the wording differs but the meaning is the same.
|
||||
var sshErrorMessageMap = map[string]string{}
|
||||
|
||||
// testSSHError verifies that an invalid policy produces the expected error.
|
||||
func testSSHError(t *testing.T, tf *testcapture.Capture) {
|
||||
t.Helper()
|
||||
|
||||
policyJSON := []byte(tf.Input.FullPolicy)
|
||||
|
||||
pol, err := unmarshalPolicy(policyJSON)
|
||||
if err != nil {
|
||||
// Parse-time error.
|
||||
if tf.Input.APIResponseBody != nil {
|
||||
wantMsg := tf.Input.APIResponseBody.Message
|
||||
if wantMsg != "" {
|
||||
assertSSHErrorContains(t, err, wantMsg, tf.TestID)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = pol.validate()
|
||||
if err != nil {
|
||||
if tf.Input.APIResponseBody != nil {
|
||||
wantMsg := tf.Input.APIResponseBody.Message
|
||||
if wantMsg != "" {
|
||||
assertSSHErrorContains(t, err, wantMsg, tf.TestID)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.Errorf(
|
||||
"%s: expected error but policy parsed and validated successfully",
|
||||
tf.TestID,
|
||||
)
|
||||
}
|
||||
|
||||
// assertSSHErrorContains checks that an error message matches the
|
||||
// expected Tailscale SaaS message, using progressive fallbacks:
|
||||
// 1. Direct substring match
|
||||
// 2. Mapped equivalent from sshErrorMessageMap
|
||||
// 3. Key-part extraction (tags, autogroups)
|
||||
// 4. t.Errorf on no match (strict)
|
||||
func assertSSHErrorContains(
|
||||
t *testing.T,
|
||||
err error,
|
||||
wantMsg string,
|
||||
testID string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
errStr := err.Error()
|
||||
|
||||
// 1. Direct substring match.
|
||||
if strings.Contains(errStr, wantMsg) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Mapped equivalent.
|
||||
for tsKey, hsKey := range sshErrorMessageMap {
|
||||
if strings.Contains(wantMsg, tsKey) &&
|
||||
strings.Contains(errStr, hsKey) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Key-part extraction.
|
||||
for _, part := range []string{
|
||||
"autogroup:",
|
||||
"tag:",
|
||||
"undefined",
|
||||
"not valid",
|
||||
} {
|
||||
if strings.Contains(wantMsg, part) &&
|
||||
strings.Contains(errStr, part) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. No match — strict failure.
|
||||
t.Errorf(
|
||||
"%s: error message mismatch\n"+
|
||||
" want (tailscale): %q\n"+
|
||||
" got (headscale): %q",
|
||||
testID,
|
||||
wantMsg,
|
||||
errStr,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/go-json-experiment/json"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"tailscale.com/tailcfg"
|
||||
|
|
@ -29,9 +30,15 @@ import (
|
|||
// errors. The Error() prefix is "test(s) failed", the same string Tailscale
|
||||
// SaaS returns in the api_response_body.message — see
|
||||
// hscontrol/policy/v2/testdata/policytest_results/.
|
||||
//
|
||||
// errSSHPolicyTestsFailed wraps sshTests failures. Tailscale SaaS returns the
|
||||
// same literal "test(s) failed" body for both ACL tests and SSH tests, but
|
||||
// the two sentinels are kept as distinct values so callers can use errors.Is
|
||||
// to tell them apart while still matching the SaaS body byte-for-byte.
|
||||
var (
|
||||
errPolicyTestsFailed = errors.New("test(s) failed")
|
||||
errTestDestinationNoIP = errors.New("destination resolved to no IP addresses")
|
||||
errPolicyTestsFailed = errors.New("test(s) failed")
|
||||
errSSHPolicyTestsFailed = errors.New("test(s) failed")
|
||||
errTestDestinationNoIP = errors.New("destination resolved to no IP addresses")
|
||||
)
|
||||
|
||||
// PolicyTest is one entry in the policy's `tests` block.
|
||||
|
|
@ -53,6 +60,102 @@ type PolicyTest struct {
|
|||
Deny []string `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// SSHPolicyTest is one entry in the policy's `sshTests` block. Unlike the
|
||||
// ACL `tests` block, sshTests describe SSH login attempts: a source alias
|
||||
// connects to each destination host and tries each named login user. The
|
||||
// accept / deny / check arrays carry usernames, not destinations — every
|
||||
// listed user is asserted against every entry in dst.
|
||||
type SSHPolicyTest struct {
|
||||
// Src is a single source alias (user, group, tag, host, or IP). Same
|
||||
// shape as PolicyTest.Src — Tailscale only supports one src per entry.
|
||||
Src Alias `json:"src"`
|
||||
|
||||
// Dst lists destination host aliases the test exercises. Tags, hosts,
|
||||
// and the SSH-compatible autogroups are valid; ports, CIDR ranges, and
|
||||
// autogroup:internet are rejected at parse time.
|
||||
Dst SSHTestDestinations `json:"dst"`
|
||||
|
||||
// Accept lists SSH login users that must be allowed by an action:accept
|
||||
// or action:check rule when Src connects to each entry in Dst.
|
||||
Accept []SSHUser `json:"accept,omitempty"`
|
||||
|
||||
// Deny lists SSH login users that must NOT be allowed by any rule when
|
||||
// Src connects to each entry in Dst.
|
||||
Deny []SSHUser `json:"deny,omitempty"`
|
||||
|
||||
// Check lists SSH login users that must reach every dst via an
|
||||
// action:check rule specifically (the HoldAndDelegate signal on the
|
||||
// compiled SSH policy). An action:accept rule alone does not satisfy
|
||||
// a check assertion — SaaS keeps the two categories distinct so
|
||||
// policy authors can pin sensitive logins to check rules.
|
||||
Check []SSHUser `json:"check,omitempty"`
|
||||
}
|
||||
|
||||
// SSHTestDestinations is the list of destination aliases an sshTests entry
|
||||
// targets. Unmarshalling reuses the same alias parser the rest of the
|
||||
// policy engine drives so each element lands as a typed Alias; the parse-
|
||||
// time shape rules in validateSSHTestDestination continue to enforce the
|
||||
// SSH-specific restrictions (no :port, no CIDR, no autogroup:internet,
|
||||
// known tag).
|
||||
type SSHTestDestinations []Alias
|
||||
|
||||
// UnmarshalJSON walks the JSON array, dispatching each element through
|
||||
// AliasEnc so trimming and prefix detection match the rest of the parser.
|
||||
func (d *SSHTestDestinations) UnmarshalJSON(b []byte) error {
|
||||
var aliases []AliasEnc
|
||||
|
||||
err := json.Unmarshal(b, &aliases, policyJSONOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*d = make([]Alias, len(aliases))
|
||||
for i, a := range aliases {
|
||||
(*d)[i] = a.Alias
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON drives the typed shape of SSHPolicyTest. The wire format
|
||||
// is unchanged: src is a JSON string parsed through parseAlias; dst is an
|
||||
// array of strings handled by SSHTestDestinations; accept/deny/check are
|
||||
// arrays of strings handled per element by SSHUser.UnmarshalJSON. An
|
||||
// empty src string lands as a nil Alias so the empty-src case stays a
|
||||
// validation-time error with the SaaS-aligned ErrSSHTestEmptySrc body
|
||||
// rather than a raw parser failure.
|
||||
func (t *SSHPolicyTest) UnmarshalJSON(b []byte) error {
|
||||
var raw struct {
|
||||
Src string `json:"src"`
|
||||
Dst SSHTestDestinations `json:"dst"`
|
||||
Accept []SSHUser `json:"accept,omitempty"`
|
||||
Deny []SSHUser `json:"deny,omitempty"`
|
||||
Check []SSHUser `json:"check,omitempty"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(b, &raw, policyJSONOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trimmedSrc := strings.TrimSpace(raw.Src)
|
||||
if trimmedSrc != "" {
|
||||
alias, parseErr := parseAlias(trimmedSrc)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
|
||||
t.Src = alias
|
||||
}
|
||||
|
||||
t.Dst = raw.Dst
|
||||
t.Accept = raw.Accept
|
||||
t.Deny = raw.Deny
|
||||
t.Check = raw.Check
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PolicyTestResult is the outcome of a single PolicyTest.
|
||||
type PolicyTestResult struct {
|
||||
Src string `json:"src"`
|
||||
|
|
|
|||
22349
hscontrol/policy/v2/testdata/ssh_results/ssh-acceptenv-null.hujson
vendored
Normal file
22349
hscontrol/policy/v2/testdata/ssh_results/ssh-acceptenv-null.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22348
hscontrol/policy/v2/testdata/ssh_results/ssh-acceptenv-omitted.hujson
vendored
Normal file
22348
hscontrol/policy/v2/testdata/ssh_results/ssh-acceptenv-omitted.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22343
hscontrol/policy/v2/testdata/ssh_results/ssh-checkperiod-null.hujson
vendored
Normal file
22343
hscontrol/policy/v2/testdata/ssh_results/ssh-checkperiod-null.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22330
hscontrol/policy/v2/testdata/ssh_results/ssh-dst-empty-tag.hujson
vendored
Normal file
22330
hscontrol/policy/v2/testdata/ssh_results/ssh-dst-empty-tag.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22348
hscontrol/policy/v2/testdata/ssh_results/ssh-dst-trailing-whitespace.hujson
vendored
Normal file
22348
hscontrol/policy/v2/testdata/ssh_results/ssh-dst-trailing-whitespace.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22109
hscontrol/policy/v2/testdata/ssh_results/ssh-group-nested-cycle.hujson
vendored
Normal file
22109
hscontrol/policy/v2/testdata/ssh_results/ssh-group-nested-cycle.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22113
hscontrol/policy/v2/testdata/ssh_results/ssh-group-nested-three-deep.hujson
vendored
Normal file
22113
hscontrol/policy/v2/testdata/ssh_results/ssh-group-nested-three-deep.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22109
hscontrol/policy/v2/testdata/ssh_results/ssh-group-nested-two-deep.hujson
vendored
Normal file
22109
hscontrol/policy/v2/testdata/ssh_results/ssh-group-nested-two-deep.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22107
hscontrol/policy/v2/testdata/ssh_results/ssh-hosts-as-dst-multi-host-prefix.hujson
vendored
Normal file
22107
hscontrol/policy/v2/testdata/ssh_results/ssh-hosts-as-dst-multi-host-prefix.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22107
hscontrol/policy/v2/testdata/ssh_results/ssh-hosts-as-dst-single-ip.hujson
vendored
Normal file
22107
hscontrol/policy/v2/testdata/ssh_results/ssh-hosts-as-dst-single-ip.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20105
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-acceptenv-bad-glob.hujson
vendored
Normal file
20105
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-acceptenv-bad-glob.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20082
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-acceptenv-empty.hujson
vendored
Normal file
20082
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-acceptenv-empty.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20105
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-acceptenv-glob-shapes.hujson
vendored
Normal file
20105
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-acceptenv-glob-shapes.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-deny.hujson
vendored
Normal file
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-deny.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-empty.hujson
vendored
Normal file
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-empty.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20078
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-missing.hujson
vendored
Normal file
20078
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-missing.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-mixedcase.hujson
vendored
Normal file
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-mixedcase.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-uppercase.hujson
vendored
Normal file
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-uppercase.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-whitespace.hujson
vendored
Normal file
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-action-whitespace.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20099
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-exact-max.hujson
vendored
Normal file
20099
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-exact-max.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20082
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-malformed.hujson
vendored
Normal file
20082
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-malformed.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20082
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-negative.hujson
vendored
Normal file
20082
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-negative.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20082
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-over-max-by-1s.hujson
vendored
Normal file
20082
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-over-max-by-1s.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20082
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-too-long.hujson
vendored
Normal file
20082
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-too-long.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20099
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-too-short.hujson
vendored
Normal file
20099
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-too-short.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20099
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-zero.hujson
vendored
Normal file
20099
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-checkperiod-zero.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20079
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-dst-empty.hujson
vendored
Normal file
20079
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-dst-empty.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20074
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-dst-missing.hujson
vendored
Normal file
20074
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-dst-missing.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20076
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-src-empty.hujson
vendored
Normal file
20076
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-src-empty.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20074
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-src-missing.hujson
vendored
Normal file
20074
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-src-missing.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20112
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-autogroup-internet.hujson
vendored
Normal file
20112
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-autogroup-internet.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20112
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-autogroup-member.hujson
vendored
Normal file
20112
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-autogroup-member.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-autogroup-self.hujson
vendored
Normal file
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-autogroup-self.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20112
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-autogroup-tagged.hujson
vendored
Normal file
20112
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-autogroup-tagged.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-empty.hujson
vendored
Normal file
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-empty.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20105
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-group-prefix.hujson
vendored
Normal file
20105
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-group-prefix.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-localpart-empty.hujson
vendored
Normal file
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-localpart-empty.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20083
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-localpart-multi-glob.hujson
vendored
Normal file
20083
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-localpart-multi-glob.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-localpart-no-at.hujson
vendored
Normal file
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-localpart-no-at.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-localpart-no-domain.hujson
vendored
Normal file
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-localpart-no-domain.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20112
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-localpart-no-glob.hujson
vendored
Normal file
20112
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-localpart-no-glob.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20105
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-tag-prefix.hujson
vendored
Normal file
20105
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-tag-prefix.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-whitespace-leading.hujson
vendored
Normal file
20104
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-whitespace-leading.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-wildcard.hujson
vendored
Normal file
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-user-wildcard.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-users-empty-array.hujson
vendored
Normal file
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-users-empty-array.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20078
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-users-missing.hujson
vendored
Normal file
20078
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-users-missing.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-users-mixed-valid-empty.hujson
vendored
Normal file
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-users-mixed-valid-empty.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-users-mixed-valid-wildcard.hujson
vendored
Normal file
20081
hscontrol/policy/v2/testdata/ssh_results/ssh-malformed-users-mixed-valid-wildcard.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22143
hscontrol/policy/v2/testdata/ssh_results/ssh-many-tags-as-dst.hujson
vendored
Normal file
22143
hscontrol/policy/v2/testdata/ssh_results/ssh-many-tags-as-dst.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
21916
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-rule-accept-then-check.hujson
vendored
Normal file
21916
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-rule-accept-then-check.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
21916
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-rule-check-then-accept.hujson
vendored
Normal file
21916
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-rule-check-then-accept.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22146
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-rule-disjoint-srcs.hujson
vendored
Normal file
22146
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-rule-disjoint-srcs.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22152
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-rule-overlap-conflicting-acceptenv.hujson
vendored
Normal file
22152
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-rule-overlap-conflicting-acceptenv.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22146
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-rule-overlap-different-users.hujson
vendored
Normal file
22146
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-rule-overlap-different-users.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22146
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-src-multi-dst-multi-user.hujson
vendored
Normal file
22146
hscontrol/policy/v2/testdata/ssh_results/ssh-multi-src-multi-dst-multi-user.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
21944
hscontrol/policy/v2/testdata/ssh_results/ssh-src-autogroup-tagged-no-tagged-nodes.hujson
vendored
Normal file
21944
hscontrol/policy/v2/testdata/ssh_results/ssh-src-autogroup-tagged-no-tagged-nodes.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22105
hscontrol/policy/v2/testdata/ssh_results/ssh-src-empty-group.hujson
vendored
Normal file
22105
hscontrol/policy/v2/testdata/ssh_results/ssh-src-empty-group.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22123
hscontrol/policy/v2/testdata/ssh_results/ssh-src-leading-whitespace.hujson
vendored
Normal file
22123
hscontrol/policy/v2/testdata/ssh_results/ssh-src-leading-whitespace.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22123
hscontrol/policy/v2/testdata/ssh_results/ssh-tab-in-user.hujson
vendored
Normal file
22123
hscontrol/policy/v2/testdata/ssh_results/ssh-tab-in-user.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22101
hscontrol/policy/v2/testdata/ssh_results/ssh-tag-owner-cycle.hujson
vendored
Normal file
22101
hscontrol/policy/v2/testdata/ssh_results/ssh-tag-owner-cycle.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22101
hscontrol/policy/v2/testdata/ssh_results/ssh-tag-owner-self-reference.hujson
vendored
Normal file
22101
hscontrol/policy/v2/testdata/ssh_results/ssh-tag-owner-self-reference.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
21880
hscontrol/policy/v2/testdata/ssh_results/ssh-unicode-cyrillic-tag.hujson
vendored
Normal file
21880
hscontrol/policy/v2/testdata/ssh_results/ssh-unicode-cyrillic-tag.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
21898
hscontrol/policy/v2/testdata/ssh_results/ssh-unicode-emoji-user.hujson
vendored
Normal file
21898
hscontrol/policy/v2/testdata/ssh_results/ssh-unicode-emoji-user.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22123
hscontrol/policy/v2/testdata/ssh_results/ssh-unicode-rtl-user.hujson
vendored
Normal file
22123
hscontrol/policy/v2/testdata/ssh_results/ssh-unicode-rtl-user.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
22106
hscontrol/policy/v2/testdata/ssh_results/ssh-users-null.hujson
vendored
Normal file
22106
hscontrol/policy/v2/testdata/ssh_results/ssh-users-null.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
21887
hscontrol/policy/v2/testdata/sshtest_results/sshtest-accept-and-deny-same-user.hujson
vendored
Normal file
21887
hscontrol/policy/v2/testdata/sshtest_results/sshtest-accept-and-deny-same-user.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20077
hscontrol/policy/v2/testdata/sshtest_results/sshtest-accept-fail-no-ssh-rule.hujson
vendored
Normal file
20077
hscontrol/policy/v2/testdata/sshtest_results/sshtest-accept-fail-no-ssh-rule.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20084
hscontrol/policy/v2/testdata/sshtest_results/sshtest-accept-fail-wrong-login-user.hujson
vendored
Normal file
20084
hscontrol/policy/v2/testdata/sshtest_results/sshtest-accept-fail-wrong-login-user.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20102
hscontrol/policy/v2/testdata/sshtest_results/sshtest-acceptenv-no-effect.hujson
vendored
Normal file
20102
hscontrol/policy/v2/testdata/sshtest_results/sshtest-acceptenv-no-effect.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20094
hscontrol/policy/v2/testdata/sshtest_results/sshtest-action-check-treated-as-allowed.hujson
vendored
Normal file
20094
hscontrol/policy/v2/testdata/sshtest_results/sshtest-action-check-treated-as-allowed.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20036
hscontrol/policy/v2/testdata/sshtest_results/sshtest-allpass-autogroup-self-same-user.hujson
vendored
Normal file
20036
hscontrol/policy/v2/testdata/sshtest_results/sshtest-allpass-autogroup-self-same-user.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20099
hscontrol/policy/v2/testdata/sshtest_results/sshtest-allpass-basic-user-to-tag.hujson
vendored
Normal file
20099
hscontrol/policy/v2/testdata/sshtest_results/sshtest-allpass-basic-user-to-tag.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
13577
hscontrol/policy/v2/testdata/sshtest_results/sshtest-both-tests-and-sshTests-both-pass.hujson
vendored
Normal file
13577
hscontrol/policy/v2/testdata/sshtest_results/sshtest-both-tests-and-sshTests-both-pass.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20090
hscontrol/policy/v2/testdata/sshtest_results/sshtest-both-tests-pass-sshTests-fail.hujson
vendored
Normal file
20090
hscontrol/policy/v2/testdata/sshtest_results/sshtest-both-tests-pass-sshTests-fail.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20094
hscontrol/policy/v2/testdata/sshtest_results/sshtest-checkperiod-no-effect.hujson
vendored
Normal file
20094
hscontrol/policy/v2/testdata/sshtest_results/sshtest-checkperiod-no-effect.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20082
hscontrol/policy/v2/testdata/sshtest_results/sshtest-deny-fail-policy-allows.hujson
vendored
Normal file
20082
hscontrol/policy/v2/testdata/sshtest_results/sshtest-deny-fail-policy-allows.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20139
hscontrol/policy/v2/testdata/sshtest_results/sshtest-deny-pass-cross-user-blocked.hujson
vendored
Normal file
20139
hscontrol/policy/v2/testdata/sshtest_results/sshtest-deny-pass-cross-user-blocked.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20099
hscontrol/policy/v2/testdata/sshtest_results/sshtest-deny-pass-no-rule.hujson
vendored
Normal file
20099
hscontrol/policy/v2/testdata/sshtest_results/sshtest-deny-pass-no-rule.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
21903
hscontrol/policy/v2/testdata/sshtest_results/sshtest-dst-duplicate-tags.hujson
vendored
Normal file
21903
hscontrol/policy/v2/testdata/sshtest_results/sshtest-dst-duplicate-tags.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20105
hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-as-src.hujson
vendored
Normal file
20105
hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-as-src.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20100
hscontrol/policy/v2/testdata/sshtest_results/sshtest-host-alias-as-dst.hujson
vendored
Normal file
20100
hscontrol/policy/v2/testdata/sshtest_results/sshtest-host-alias-as-dst.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20099
hscontrol/policy/v2/testdata/sshtest_results/sshtest-ip-literal-src.hujson
vendored
Normal file
20099
hscontrol/policy/v2/testdata/sshtest_results/sshtest-ip-literal-src.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20088
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-dst-autogroup-internet.hujson
vendored
Normal file
20088
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-dst-autogroup-internet.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
18758
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-dst-bare-ipv4.hujson
vendored
Normal file
18758
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-dst-bare-ipv4.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
18737
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-dst-bare-ipv6.hujson
vendored
Normal file
18737
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-dst-bare-ipv6.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20084
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-dst-cidr.hujson
vendored
Normal file
20084
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-dst-cidr.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20084
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-dst-with-port.hujson
vendored
Normal file
20084
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-dst-with-port.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20082
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-empty-accept-and-deny.hujson
vendored
Normal file
20082
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-empty-accept-and-deny.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20082
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-empty-user.hujson
vendored
Normal file
20082
hscontrol/policy/v2/testdata/sshtest_results/sshtest-malformed-empty-user.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
20082
hscontrol/policy/v2/testdata/sshtest_results/sshtest-mixed-acls-tcp22-allow-no-ssh-rule.hujson
vendored
Normal file
20082
hscontrol/policy/v2/testdata/sshtest_results/sshtest-mixed-acls-tcp22-allow-no-ssh-rule.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue