mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-19 09:24:38 +00:00
Compare commits
8 commits
main
...
sshtests-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10a51cfe70 | ||
|
|
2b4485ee32 | ||
|
|
1768268e84 | ||
|
|
e265a46c9e | ||
|
|
81246bde16 | ||
|
|
2f7f90529a | ||
|
|
295716bcca | ||
|
|
e4a1171ba4 |
110 changed files with 1975224 additions and 387 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
|
||||
|
|
|
|||
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -44,6 +44,30 @@ 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. Each entry names a source, one or
|
||||
more destination hosts, and three optional user lists: `accept` asserts the listed login users
|
||||
reach every destination via an accept- or check-action SSH rule, `deny` asserts none of them
|
||||
reach any destination, and `check` requires reachability specifically through a check-action
|
||||
rule. Tests 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.
|
||||
|
||||
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.
|
||||
|
||||
### SSH rule validation
|
||||
|
||||
SSH rule parsing now trims surrounding whitespace on `action`, `users`, `src`, and `dst`,
|
||||
rejects empty or wildcard entries in `users`, rejects empty `acceptEnv`, and rejects negative
|
||||
`checkPeriod`. `hosts:` aliases are rejected as SSH destinations, non-ASCII tag names are
|
||||
rejected at parse time, and the wording for group-nesting cycles matches Tailscale SaaS.
|
||||
|
||||
### 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 resolve user 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",
|
||||
|
|
|
|||
|
|
@ -208,6 +208,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
|
||||
}
|
||||
|
||||
|
|
@ -482,9 +486,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
|
||||
|
|
@ -1467,27 +1478,13 @@ 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 resolves nested tag-owner references. Cycles
|
||||
// (tag:a -> tag:b -> tag:a, or tag:a -> tag:a) drop the cycle-causing
|
||||
// edge and contribute 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, " -> "))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
visiting[tag] = true
|
||||
|
|
|
|||
664
hscontrol/policy/v2/sshtest.go
Normal file
664
hscontrol/policy/v2/sshtest.go
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// sshTests assertions evaluate on user-initiated writes; boot reload
|
||||
// skips them so a stale reference does not block startup. Each entry
|
||||
// names a src and one or more dst, and uses:
|
||||
//
|
||||
// - accept: every listed user reaches every dst via an accept- or
|
||||
// check-action rule.
|
||||
// - deny: no listed user reaches any dst.
|
||||
// - check: every listed user reaches every dst via a check-action
|
||||
// rule specifically (accept-only matches fail the assertion).
|
||||
|
||||
// SSHPolicyTestResult is the outcome of a single SSHPolicyTest.
|
||||
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.
|
||||
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")
|
||||
}
|
||||
|
||||
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 shows an empty username as `""` rather than blank.
|
||||
func displayUser(u string) string {
|
||||
if u == "" {
|
||||
return `""`
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
return "DENIED"
|
||||
}
|
||||
|
||||
// RunSSHTests evaluates the live policy's sshTests block and wraps any
|
||||
// failure in errSSHPolicyTestsFailed.
|
||||
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 runs the block against pol without mutating live state.
|
||||
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. The cache is keyed
|
||||
// by dst NodeID so repeat destinations only compile once per pass.
|
||||
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 entry: resolve src → resolve dst →
|
||||
// walk accept/deny/check arrays against each dst's compiled SSH policy.
|
||||
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
|
||||
}
|
||||
|
||||
// An entry with no assertion arrays would silently pass.
|
||||
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
|
||||
}
|
||||
|
||||
// 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 _, 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
|
||||
}
|
||||
|
||||
type sshAssertion int
|
||||
|
||||
const (
|
||||
assertAccept sshAssertion = iota
|
||||
assertDeny
|
||||
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.
|
||||
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 := false
|
||||
checkHit := false
|
||||
|
||||
for _, srcAddr := range srcAddrs {
|
||||
a, c := reachability(dstPol, srcAddr, user)
|
||||
if a {
|
||||
acceptHit = true
|
||||
}
|
||||
|
||||
if c {
|
||||
checkHit = true
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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], allocating m on first use.
|
||||
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 returns the src's principal addresses and, for
|
||||
// user-shaped sources, the user ID (so autogroup:self can scope to it).
|
||||
// Tag, host, and IP sources return userID 0.
|
||||
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 maps each dst alias to its destination
|
||||
// NodeViews. 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 IPSet and match via InIPSet.
|
||||
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 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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 the IPSet that InIPSet expects on the node
|
||||
// side.
|
||||
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, caching
|
||||
// on miss. baseURL is empty because reachability only checks for the
|
||||
// presence of HoldAndDelegate, not its value.
|
||||
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 reports whether srcAddr can log in as user via:
|
||||
//
|
||||
// - any matching rule (acceptHit, satisfies accept assertions)
|
||||
// - a check-action rule (checkHit, satisfies check assertions)
|
||||
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 satisfying
|
||||
// accept does not always satisfy check.
|
||||
if acceptHit && checkHit {
|
||||
return acceptHit, checkHit
|
||||
}
|
||||
}
|
||||
|
||||
return acceptHit, checkHit
|
||||
}
|
||||
|
||||
// principalContainsAddr reports whether any principal's NodeIP matches
|
||||
// srcAddr exactly (the SSH compiler emits one principal per source IP).
|
||||
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 SSHUsers
|
||||
// wire shape (see filter.go compileSSHPolicy):
|
||||
//
|
||||
// - SSHUsers["root"] == "root" allows root; == "" disallows it.
|
||||
// - SSHUsers["*"] == "=" is the wildcard fallback for non-root users
|
||||
// (set when the rule lists autogroup:nonroot).
|
||||
// - SSHUsers[<literal>] == <literal> for every named user.
|
||||
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
88
hscontrol/policy/v2/sshtester_compat_test.go
Normal file
88
hscontrol/policy/v2/sshtester_compat_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Replay golden HuJSON captures under testdata/sshtest_results/*.hujson:
|
||||
// the 200 path requires headscale's evaluateSSHTests to pass; the
|
||||
// non-200 path requires headscale to reject the same input with the
|
||||
// captured error body as a substring. Divergences are listed in
|
||||
// knownSSHTesterDivergences with the engine gap each represents.
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// knownSSHTesterDivergences names the engine gap for each capture where
|
||||
// headscale and upstream disagree.
|
||||
var knownSSHTesterDivergences = map[string]string{
|
||||
"sshtest-malformed-dst-bare-ipv6": "bare-IPv6 sshTests dst: upstream parse-accepts then engine-rejects; 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)
|
||||
}
|
||||
|
||||
// Each capture pins its own topology IPs; build nodes
|
||||
// from the capture so host-alias dsts resolve.
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,9 @@
|
|||
// This file implements a data-driven test runner for SSH compatibility tests.
|
||||
// It loads HuJSON golden files from testdata/ssh_results/ssh-*.hujson, captured
|
||||
// from a Tailscale-hosted control plane, 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)
|
||||
//
|
||||
// Tests known to fail due to unimplemented features or known differences are
|
||||
// skipped with a TODO comment explaining the root cause.
|
||||
//
|
||||
// Test data source: testdata/ssh_results/ssh-*.hujson
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
// Replay golden HuJSON captures under testdata/ssh_results/ssh-*.hujson:
|
||||
// the 200 path compares headscale's compileSSHPolicy output node-by-node
|
||||
// against the captured SSHRules; the non-200 path requires headscale to
|
||||
// reject the same input with the captured error body as a substring.
|
||||
// Divergences are listed in sshSkipReasons (200) and sshRejectSkipReasons
|
||||
// (non-200) with the engine gap each represents.
|
||||
|
||||
package v2
|
||||
|
||||
|
|
@ -31,16 +22,10 @@ import (
|
|||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// setupSSHDataCompatUsers returns the 3 test users for SSH data-driven
|
||||
// compatibility tests. Users get norse-god names; nodes get original-151
|
||||
// pokémon names — matching the anonymized identifiers the capture
|
||||
// tool writes into the capture files.
|
||||
//
|
||||
// odin and freya live on @example.com; thor lives on @example.org so
|
||||
// that "localpart:*@example.com" resolves to exactly two users
|
||||
// (matching SaaS output) and the "user on a different email domain"
|
||||
// case stays covered by scenarios like ssh-d1 that use
|
||||
// "localpart:*@example.org".
|
||||
// setupSSHDataCompatUsers returns three users straddling two email
|
||||
// domains so that "localpart:*@example.com" resolves to exactly two
|
||||
// users (odin, freya) and the cross-domain case stays covered through
|
||||
// thor on @example.org.
|
||||
func setupSSHDataCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
{
|
||||
|
|
@ -61,61 +46,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,39 +56,29 @@ 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 captures the upstream control plane accepts
|
||||
// but headscale cannot yet represent. Each entry names the feature gap.
|
||||
var sshSkipReasons = map[string]string{
|
||||
// USER_PASSKEY_WILDCARD (2 tests)
|
||||
//
|
||||
// headscale does not support passkey authentication and has no
|
||||
// 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)
|
||||
//
|
||||
// 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.
|
||||
"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-b5": "headscale has no passkey authentication; user:*@passkey wildcard unsupported",
|
||||
"ssh-d10": "headscale has no passkey authentication; user:*@passkey wildcard unsupported",
|
||||
}
|
||||
|
||||
// TestSSHDataCompat is a data-driven test that loads all ssh-*.hujson test
|
||||
// files captured from Tailscale SaaS and compares headscale's SSH policy
|
||||
// compilation against the real Tailscale behavior.
|
||||
//
|
||||
// Each capture file contains:
|
||||
// - The full policy that was POSTed to the SaaS API (Input.FullPolicy)
|
||||
// - Expected SSH rules per node (Captures[name].SSHRules)
|
||||
//
|
||||
// The test converts Tailscale user email formats to headscale format and runs
|
||||
// the captured policy through unmarshalPolicy and compileSSHPolicy.
|
||||
// sshRejectSkipReasons documents captures the upstream control plane
|
||||
// rejects for reasons headscale cannot apply. Each entry names the
|
||||
// feature gap.
|
||||
var sshRejectSkipReasons = map[string]string{
|
||||
"ssh-b4": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
|
||||
"ssh-d1": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
|
||||
"ssh-e1": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
|
||||
"ssh-e2": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
|
||||
"ssh-malformed-user-localpart-multi-glob": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
|
||||
}
|
||||
|
||||
// TestSSHDataCompat loads every ssh-*.hujson capture, parses the policy
|
||||
// it pinned, and compiles the same per-node SSH rules to compare against
|
||||
// the captured shape. Non-200 captures replay the rejection path: the
|
||||
// recorded error body must appear as a substring of headscale's
|
||||
// rejection.
|
||||
func TestSSHDataCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -192,39 +112,61 @@ func TestSSHDataCompat(t *testing.T) {
|
|||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Check if this test is in the skip list
|
||||
if reason, ok := sshSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf(
|
||||
"TODO: %s — see sshSkipReasons comments for details",
|
||||
reason,
|
||||
)
|
||||
|
||||
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.
|
||||
// the capture tool uses clean-slate mode, so each scenario has
|
||||
// different node IPs.
|
||||
// Each capture pins its own topology IPs, so nodes are
|
||||
// rebuilt from the capture rather than a shared fixture.
|
||||
nodes := buildGrantsNodesFromCapture(users, tf)
|
||||
|
||||
// Use the captured full policy as is. Anonymization in
|
||||
// captures already rewrite SaaS emails to @example.com.
|
||||
policyJSON := tf.Input.FullPolicy
|
||||
policyJSON := []byte(tf.Input.FullPolicy)
|
||||
|
||||
pol, err := unmarshalPolicy([]byte(policyJSON))
|
||||
if tf.Input.APIResponseCode != 200 {
|
||||
if reason, ok := sshRejectSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf("skipping: %s", 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
|
||||
}
|
||||
|
||||
if reason, ok := sshSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf("skipping: %s", reason)
|
||||
return
|
||||
}
|
||||
|
||||
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 +251,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"
|
||||
|
|
@ -24,14 +25,13 @@ import (
|
|||
// The tests evaluate against the compiled global filter rules, which fold in
|
||||
// both `acls` and `grants`, so the `tests` block validates the whole policy.
|
||||
|
||||
// errPolicyTestsFailed wraps the rendered failure body so callers can
|
||||
// type-assert when they need to react differently to test failures vs. parse
|
||||
// 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/.
|
||||
// errPolicyTestsFailed and errSSHPolicyTestsFailed share the
|
||||
// "test(s) failed" prefix but stay distinct so callers can use
|
||||
// errors.Is to tell ACL-test and SSH-test failures apart.
|
||||
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 +53,87 @@ type PolicyTest struct {
|
|||
Deny []string `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// SSHPolicyTest is one entry in the policy's `sshTests` block. 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).
|
||||
Src Alias `json:"src"`
|
||||
|
||||
// Dst lists destinations the test exercises (tag, host, or SSH-
|
||||
// compatible autogroup). Ports, CIDRs, and autogroup:internet are
|
||||
// rejected at parse time.
|
||||
Dst SSHTestDestinations `json:"dst"`
|
||||
|
||||
// Accept lists users that must reach every Dst via an accept- or
|
||||
// check-action rule.
|
||||
Accept []SSHUser `json:"accept,omitempty"`
|
||||
|
||||
// Deny lists users that must NOT reach any Dst.
|
||||
Deny []SSHUser `json:"deny,omitempty"`
|
||||
|
||||
// Check lists users that must reach every Dst via a check-action
|
||||
// rule specifically; an accept-action rule does not satisfy this.
|
||||
Check []SSHUser `json:"check,omitempty"`
|
||||
}
|
||||
|
||||
// SSHTestDestinations is the typed list of destination aliases an
|
||||
// sshTests entry targets. validateSSHTestDestination enforces the
|
||||
// SSH-specific shape rules (no :port, no CIDR, no autogroup:internet,
|
||||
// known tag).
|
||||
type SSHTestDestinations []Alias
|
||||
|
||||
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 parses each typed field. An empty src lands as a nil
|
||||
// Alias so validation surfaces ErrSSHTestEmptySrc rather than a 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
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
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
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
13556
hscontrol/policy/v2/testdata/sshtest_results/sshtest-mixed-acls-tcp22-deny-ssh-rule-allow.hujson
vendored
Normal file
13556
hscontrol/policy/v2/testdata/sshtest_results/sshtest-mixed-acls-tcp22-deny-ssh-rule-allow.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
13472
hscontrol/policy/v2/testdata/sshtest_results/sshtest-mixed-grants-app-cap-with-ssh.hujson
vendored
Normal file
13472
hscontrol/policy/v2/testdata/sshtest_results/sshtest-mixed-grants-app-cap-with-ssh.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
18749
hscontrol/policy/v2/testdata/sshtest_results/sshtest-tag-as-dst.hujson
vendored
Normal file
18749
hscontrol/policy/v2/testdata/sshtest_results/sshtest-tag-as-dst.hujson
vendored
Normal file
File diff suppressed because it is too large
Load diff
18749
hscontrol/policy/v2/testdata/sshtest_results/sshtest-tag-as-src.hujson
vendored
Normal file
18749
hscontrol/policy/v2/testdata/sshtest_results/sshtest-tag-as-src.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