ssh/tailssh: display more useful error messages when authentication fails

Also add a trailing newline to error banners so that SSH client messages don't print on the same line.

Updates tailscale/corp#29138

Signed-off-by: Percy Wegmann <percy@tailscale.com>
This commit is contained in:
Percy Wegmann 2025-05-29 09:11:31 -05:00 committed by Percy Wegmann
parent 5f0e139012
commit 1635ccca27
2 changed files with 73 additions and 30 deletions

View File

@ -281,7 +281,7 @@ func (c *conn) errBanner(message string, err error) error {
if err != nil { if err != nil {
c.logf("%s: %s", message, err) c.logf("%s: %s", message, err)
} }
if err := c.spac.SendAuthBanner("tailscale: " + message); err != nil { if err := c.spac.SendAuthBanner("tailscale: " + message + "\n"); err != nil {
c.logf("failed to send auth banner: %s", err) c.logf("failed to send auth banner: %s", err)
} }
return errTerminal return errTerminal
@ -324,9 +324,16 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, retE
return nil, c.errBanner("failed to get connection info", err) return nil, c.errBanner("failed to get connection info", err)
} }
action, localUser, acceptEnv, err := c.evaluatePolicy() action, localUser, acceptEnv, result := c.evaluatePolicy()
if err != nil { switch result {
return nil, c.errBanner("failed to evaluate SSH policy", err) case accepted:
// do nothing
case rejectedUser:
return nil, c.errBanner(fmt.Sprintf("tailnet policy does not permit you to SSH as user %q", c.info.sshUser), nil)
case rejected, noPolicy:
return nil, c.errBanner("tailnet policy does not permit you to SSH to this node", fmt.Errorf("failed to evaluate policy, result: %s", result))
default:
return nil, c.errBanner("failed to evaluate tailnet policy", fmt.Errorf("failed to evaluate policy, result: %s", result))
} }
c.action0 = action c.action0 = action
@ -597,18 +604,23 @@ func (c *conn) setInfo(cm gossh.ConnMetadata) error {
return nil return nil
} }
type evalResult string
const (
noPolicy evalResult = "no policy"
rejected evalResult = "rejected"
rejectedUser evalResult = "rejected user"
accepted evalResult = "accept"
)
// evaluatePolicy returns the SSHAction and localUser after evaluating // evaluatePolicy returns the SSHAction and localUser after evaluating
// the SSHPolicy for this conn. // the SSHPolicy for this conn.
func (c *conn) evaluatePolicy() (_ *tailcfg.SSHAction, localUser string, acceptEnv []string, _ error) { func (c *conn) evaluatePolicy() (_ *tailcfg.SSHAction, localUser string, acceptEnv []string, result evalResult) {
pol, ok := c.sshPolicy() pol, ok := c.sshPolicy()
if !ok { if !ok {
return nil, "", nil, fmt.Errorf("tailssh: rejecting connection; no SSH policy") return nil, "", nil, noPolicy
} }
a, localUser, acceptEnv, ok := c.evalSSHPolicy(pol) return c.evalSSHPolicy(pol)
if !ok {
return nil, "", nil, fmt.Errorf("tailssh: rejecting connection; no matching policy")
}
return a, localUser, acceptEnv, nil
} }
// handleSessionPostSSHAuth runs an SSH session after the SSH-level authentication, // handleSessionPostSSHAuth runs an SSH session after the SSH-level authentication,
@ -706,9 +718,9 @@ func (c *conn) newSSHSession(s ssh.Session) *sshSession {
// isStillValid reports whether the conn is still valid. // isStillValid reports whether the conn is still valid.
func (c *conn) isStillValid() bool { func (c *conn) isStillValid() bool {
a, localUser, _, err := c.evaluatePolicy() a, localUser, _, result := c.evaluatePolicy()
c.vlogf("stillValid: %+v %v %v", a, localUser, err) c.vlogf("stillValid: %+v %v %v", a, localUser, result)
if err != nil { if result != accepted {
return false return false
} }
if !a.Accept && a.HoldAndDelegate == "" { if !a.Accept && a.HoldAndDelegate == "" {
@ -1089,13 +1101,20 @@ func (c *conn) ruleExpired(r *tailcfg.SSHRule) bool {
return r.RuleExpires.Before(c.srv.now()) return r.RuleExpires.Before(c.srv.now())
} }
func (c *conn) evalSSHPolicy(pol *tailcfg.SSHPolicy) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, ok bool) { func (c *conn) evalSSHPolicy(pol *tailcfg.SSHPolicy) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, result evalResult) {
failedOnUser := false
for _, r := range pol.Rules { for _, r := range pol.Rules {
if a, localUser, acceptEnv, err := c.matchRule(r); err == nil { if a, localUser, acceptEnv, err := c.matchRule(r); err == nil {
return a, localUser, acceptEnv, true return a, localUser, acceptEnv, accepted
} else if errors.Is(err, errUserMatch) {
failedOnUser = true
} }
} }
return nil, "", nil, false result = rejected
if failedOnUser {
result = rejectedUser
}
return nil, "", nil, result
} }
// internal errors for testing; they don't escape to callers or logs. // internal errors for testing; they don't escape to callers or logs.
@ -1129,6 +1148,9 @@ func (c *conn) matchRule(r *tailcfg.SSHRule) (a *tailcfg.SSHAction, localUser st
if c.ruleExpired(r) { if c.ruleExpired(r) {
return nil, "", nil, errRuleExpired return nil, "", nil, errRuleExpired
} }
if !c.anyPrincipalMatches(r.Principals) {
return nil, "", nil, errPrincipalMatch
}
if !r.Action.Reject { if !r.Action.Reject {
// For all but Reject rules, SSHUsers is required. // For all but Reject rules, SSHUsers is required.
// If SSHUsers is nil or empty, mapLocalUser will return an // If SSHUsers is nil or empty, mapLocalUser will return an
@ -1138,9 +1160,6 @@ func (c *conn) matchRule(r *tailcfg.SSHRule) (a *tailcfg.SSHAction, localUser st
return nil, "", nil, errUserMatch return nil, "", nil, errUserMatch
} }
} }
if !c.anyPrincipalMatches(r.Principals) {
return nil, "", nil, errPrincipalMatch
}
return r.Action, localUser, r.AcceptEnv, nil return r.Action, localUser, r.AcceptEnv, nil
} }

View File

@ -253,7 +253,7 @@ func TestEvalSSHPolicy(t *testing.T) {
name string name string
policy *tailcfg.SSHPolicy policy *tailcfg.SSHPolicy
ci *sshConnInfo ci *sshConnInfo
wantMatch bool wantResult evalResult
wantUser string wantUser string
wantAcceptEnv []string wantAcceptEnv []string
}{ }{
@ -299,10 +299,20 @@ func TestEvalSSHPolicy(t *testing.T) {
ci: &sshConnInfo{sshUser: "alice"}, ci: &sshConnInfo{sshUser: "alice"},
wantUser: "thealice", wantUser: "thealice",
wantAcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"}, wantAcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"},
wantMatch: true, wantResult: accepted,
}, },
{ {
name: "no-matches-returns-failure", name: "no-matches-returns-rejected",
policy: &tailcfg.SSHPolicy{
Rules: []*tailcfg.SSHRule{},
},
ci: &sshConnInfo{sshUser: "alice"},
wantUser: "",
wantAcceptEnv: nil,
wantResult: rejected,
},
{
name: "no-user-matches-returns-rejected-user",
policy: &tailcfg.SSHPolicy{ policy: &tailcfg.SSHPolicy{
Rules: []*tailcfg.SSHRule{ Rules: []*tailcfg.SSHRule{
{ {
@ -340,7 +350,7 @@ func TestEvalSSHPolicy(t *testing.T) {
ci: &sshConnInfo{sshUser: "alice"}, ci: &sshConnInfo{sshUser: "alice"},
wantUser: "", wantUser: "",
wantAcceptEnv: nil, wantAcceptEnv: nil,
wantMatch: false, wantResult: rejectedUser,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
@ -349,14 +359,14 @@ func TestEvalSSHPolicy(t *testing.T) {
info: tt.ci, info: tt.ci,
srv: &server{logf: tstest.WhileTestRunningLogger(t)}, srv: &server{logf: tstest.WhileTestRunningLogger(t)},
} }
got, gotUser, gotAcceptEnv, match := c.evalSSHPolicy(tt.policy) got, gotUser, gotAcceptEnv, result := c.evalSSHPolicy(tt.policy)
if match != tt.wantMatch { if result != tt.wantResult {
t.Errorf("match = %v; want %v", match, tt.wantMatch) t.Errorf("result = %v; want %v", result, tt.wantResult)
} }
if gotUser != tt.wantUser { if gotUser != tt.wantUser {
t.Errorf("user = %q; want %q", gotUser, tt.wantUser) t.Errorf("user = %q; want %q", gotUser, tt.wantUser)
} }
if tt.wantMatch == true && got == nil { if tt.wantResult == accepted && got == nil {
t.Errorf("expected non-nil action on success") t.Errorf("expected non-nil action on success")
} }
if !slices.Equal(gotAcceptEnv, tt.wantAcceptEnv) { if !slices.Equal(gotAcceptEnv, tt.wantAcceptEnv) {
@ -467,7 +477,7 @@ func (ts *localState) NodeKey() key.NodePublic {
func newSSHRule(action *tailcfg.SSHAction) *tailcfg.SSHRule { func newSSHRule(action *tailcfg.SSHAction) *tailcfg.SSHRule {
return &tailcfg.SSHRule{ return &tailcfg.SSHRule{
SSHUsers: map[string]string{ SSHUsers: map[string]string{
"*": currentUser, "alice": currentUser,
}, },
Action: action, Action: action,
Principals: []*tailcfg.SSHPrincipal{ Principals: []*tailcfg.SSHPrincipal{
@ -789,6 +799,11 @@ func TestSSHAuthFlow(t *testing.T) {
Accept: true, Accept: true,
Message: "Welcome to Tailscale SSH!", Message: "Welcome to Tailscale SSH!",
}) })
bobRule := newSSHRule(&tailcfg.SSHAction{
Accept: true,
Message: "Welcome to Tailscale SSH!",
})
bobRule.SSHUsers = map[string]string{"bob": "bob"}
rejectRule := newSSHRule(&tailcfg.SSHAction{ rejectRule := newSSHRule(&tailcfg.SSHAction{
Reject: true, Reject: true,
Message: "Go Away!", Message: "Go Away!",
@ -808,7 +823,16 @@ func TestSSHAuthFlow(t *testing.T) {
sshEnabled: true, sshEnabled: true,
}, },
authErr: true, authErr: true,
wantBanners: []string{"tailscale: failed to evaluate SSH policy"}, wantBanners: []string{"tailscale: tailnet policy does not permit you to SSH to this node\n"},
},
{
name: "user-mismatch",
state: &localState{
sshEnabled: true,
matchingRule: bobRule,
},
authErr: true,
wantBanners: []string{`tailscale: tailnet policy does not permit you to SSH as user "alice"` + "\n"},
}, },
{ {
name: "accept", name: "accept",