ssh/tailssh: remove unused public key authentication logic

Updates #8593

Signed-off-by: Percy Wegmann <percy@tailscale.com>
This commit is contained in:
Percy Wegmann 2024-06-05 12:12:31 -05:00
parent 51f7cb0903
commit f78e8f6ca6
No known key found for this signature in database
GPG Key ID: 29D8CDEB4C13D48B

View File

@ -30,7 +30,7 @@
"syscall" "syscall"
"time" "time"
gossh "github.com/tailscale/golang-x-crypto/ssh" gossh "golang.org/x/crypto/ssh"
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnlocal"
"tailscale.com/logtail/backoff" "tailscale.com/logtail/backoff"
@ -200,7 +200,9 @@ func (srv *server) OnPolicyChange() {
// - ServerConfigCallback // - ServerConfigCallback
// //
// Do the user auth // Do the user auth
// - NoClientAuthHandler // - welcomeBanner
// - noClientAuthCallback
// - passwordCallback (if username ends in +password)
// //
// Once auth is done, the conn can be multiplexed with multiple sessions and // Once auth is done, the conn can be multiplexed with multiple sessions and
// channels concurrently. At which point any of the following can be called // channels concurrently. At which point any of the following can be called
@ -222,17 +224,16 @@ type conn struct {
// anyPasswordIsOkay is whether the client is authorized but has requested // anyPasswordIsOkay is whether the client is authorized but has requested
// password-based auth to work around their buggy SSH client. When set, we // password-based auth to work around their buggy SSH client. When set, we
// accept any password in the PasswordHandler. // accept any password in the passwordCallback.
anyPasswordIsOkay bool // set by NoClientAuthCallback anyPasswordIsOkay bool // set by NoClientAuthCallback
action0 *tailcfg.SSHAction // set by doPolicyAuth; first matching action action0 *tailcfg.SSHAction // the first action from authentication
currentAction *tailcfg.SSHAction // set by doPolicyAuth, updated by resolveNextAction action0Error error // the first error from authentication
finalAction *tailcfg.SSHAction // set by doPolicyAuth or resolveNextAction finalAction *tailcfg.SSHAction // the final action from authentication
finalActionErr error // set by doPolicyAuth or resolveNextAction
info *sshConnInfo // set by setInfo info *sshConnInfo // set by setInfo
localUser *userMeta // set by doPolicyAuth localUser *userMeta // set by authenticate
userGroupIDs []string // set by doPolicyAuth userGroupIDs []string // set by authenticate
// mu protects the following fields. // mu protects the following fields.
// //
@ -254,139 +255,185 @@ func (c *conn) vlogf(format string, args ...any) {
} }
} }
// isAuthorized walks through the action chain and returns nil if the connection // clientAuthCallback is a callback that handles the authentication of
// is authorized. If the connection is not authorized, it returns // clients, irrespective of whether they're authenticating with none, password
// errDenied. If the action chain resolution fails, it returns the // or public key. It picks up where welcomeBanner() left off.
// resolution error. func (c *conn) clientAuthCallback() (*gossh.Permissions, error) {
func (c *conn) isAuthorized(ctx ssh.Context) error { if c.action0Error != nil {
action := c.currentAction metricTerminalReject.Add(1)
for { return nil, c.action0Error
if action.Accept { }
return nil
if c.finalAction != nil {
switch {
case c.finalAction.Reject:
// This should never happen, as c.action0Error should have already been set
panic("finalAction unexpectedly Reject")
case c.finalAction.Accept:
// Already authenticated.
metricTerminalAccept.Add(1)
return &gossh.Permissions{}, nil
} }
if action.Reject || action.HoldAndDelegate == "" { }
return errDenied
} // Further steps are required
var err error url := c.action0.HoldAndDelegate
action, err = c.resolveNextAction(ctx) if url == "" {
metricTerminalMalformed.Add(1)
return nil, errors.New("reached Action that lacked Accept, Reject, and HoldAndDelegate")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
metricHolds.Add(1)
url = c.expandDelegateURLLocked(url)
var err error
c.finalAction, err = c.fetchSSHAction(ctx, url)
if err != nil {
metricTerminalFetchError.Add(1)
return nil, fmt.Errorf("fetching SSHAction from %s: %w", url, err)
}
switch {
case c.finalAction.Accept:
metricTerminalReject.Add(1)
return &gossh.Permissions{}, nil
case c.finalAction.Reject:
metricTerminalAccept.Add(1)
return nil, errDenied(c.finalAction.Message)
default:
metricTerminalMalformed.Add(1)
return nil, errors.New("final action was neither accept nor reject")
}
}
// authenticate authenticates the connection, returning the next (possibly
// final) tailcfg.SSHAction and, if it encounters a final error, the error.
func (c *conn) authenticate() (*tailcfg.SSHAction, error) {
a, localUser, err := c.evaluatePolicy()
if err != nil {
return nil, fmt.Errorf("%w: %v", errDenied(""), err)
}
switch {
case a.Reject:
return nil, errDenied(a.Message)
case a.Accept || a.HoldAndDelegate != "":
lu, err := userLookup(localUser)
if err != nil { if err != nil {
return err c.logf("failed to look up %v: %v", localUser, err)
return nil, bannerError(fmt.Sprintf("failed to look up %v\r\n", localUser), err)
} }
if action.Message != "" { gids, err := lu.GroupIds()
if err := ctx.SendAuthBanner(action.Message); err != nil { if err != nil {
return err c.logf("failed to look up local user's group IDs: %v", err)
} return nil, bannerError("failed to look up local user's group IDs\r\n", err)
} }
c.userGroupIDs = gids
c.localUser = lu
return a, nil
default:
// Shouldn't get here, but:
return nil, errDenied("")
} }
} }
// errDenied is returned by auth callbacks when a connection is denied by the // errDenied is returned by auth callbacks when a connection is denied by the
// policy. // policy. If message is non-empty, it returns a gossh.BannerError to make sure
var errDenied = errors.New("ssh: access denied") // the message gets displayed as an auth banner.
func errDenied(message string) error {
err := errors.New("ssh: access denied")
if message == "" {
return err
}
return bannerError(message, err)
}
// NoClientAuthCallback implements gossh.NoClientAuthCallback and is called by func bannerError(message string, err error) error {
return &gossh.BannerError{
Err: err,
Message: message,
}
}
// noClientAuthCallback implements gossh.noClientAuthCallback and is called by
// the ssh.Server when the client first connects with the "none" // the ssh.Server when the client first connects with the "none"
// authentication method. // authentication method.
// //
// It is responsible for continuing policy evaluation from BannerCallback (or // It is responsible for continuing policy evaluation from BannerCallback (or
// starting it afresh). It returns an error if the policy evaluation fails, or // starting it afresh). It returns an error if the policy evaluation fails, or
// if the decision is "reject" // if the decision is "reject".
// //
// It either returns nil (accept) or errDenied (reject). The errors may be // It either returns nil (accept) or errDenied (reject). The errors may be
// wrapped. // wrapped.
func (c *conn) NoClientAuthCallback(ctx ssh.Context) error { func (c *conn) noClientAuthCallback(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
if c.insecureSkipTailscaleAuth { if c.insecureSkipTailscaleAuth {
return nil return &gossh.Permissions{}, nil
} }
if err := c.doPolicyAuth(ctx); err != nil { perms, err := c.clientAuthCallback()
return err if err != nil {
return nil, err
} }
if err := c.isAuthorized(ctx); err != nil {
return err
}
// Let users specify a username ending in +password to force password auth. // Let users specify a username ending in +password to force password auth.
// This exists for buggy SSH clients that get confused by success from // This exists for buggy SSH clients that get confused by success from
// "none" auth. // "none" auth.
if strings.HasSuffix(ctx.User(), forcePasswordSuffix) { if strings.HasSuffix(cm.User(), forcePasswordSuffix) {
c.anyPasswordIsOkay = true c.anyPasswordIsOkay = true
return errors.New("any password please") // not shown to users return nil, &gossh.PartialSuccessError{
Next: gossh.ServerAuthCallbacks{
PasswordCallback: c.passwordCallback,
},
}
} }
return nil return perms, nil
} }
func (c *conn) nextAuthMethodCallback(cm gossh.ConnMetadata, prevErrors []error) (nextMethod []string) { // passwordCallback is our implementation of the PasswordCallback hook that
if c.anyPasswordIsOkay {
nextMethod = append(nextMethod, "password")
}
// The fake "tailscale" method is always appended to next so OpenSSH renders
// that in parens as the final failure. (It also shows up in "ssh -v", etc)
nextMethod = append(nextMethod, "tailscale")
return
}
// fakePasswordHandler is our implementation of the PasswordHandler hook that
// checks whether the user's password is correct. But we don't actually use // checks whether the user's password is correct. But we don't actually use
// passwords. This exists only for when the user's username ends in "+password" // passwords. This exists only for when the user's username ends in "+password"
// to signal that their SSH client is buggy and gets confused by auth type // to signal that their SSH client is buggy and gets confused by auth type
// "none" succeeding and they want our SSH server to require a dummy password // "none" succeeding and they want our SSH server to require a dummy password
// prompt instead. We then accept any password since we've already authenticated // prompt instead. We then accept any password since we've already authenticated
// & authorized them. // & authorized them.
func (c *conn) fakePasswordHandler(ctx ssh.Context, password string) bool { func (c *conn) passwordCallback(_ gossh.ConnMetadata, password []byte) (*gossh.Permissions, error) {
return c.anyPasswordIsOkay return &gossh.Permissions{}, nil
} }
// doPolicyAuth verifies that conn can proceed. It returns nil if the matching // welcomeBanner looks for a welcome banner to display prior to authentication.
// policy action is Accept or HoldAndDelegate. Otherwise, it returns errDenied. // For example, if SSH session recording is enabled, control will give us an
func (c *conn) doPolicyAuth(ctx ssh.Context) error { // action message informing the user of this.
if err := c.setInfo(ctx); err != nil { // This function actually begins authentication in order to make sure it knows
// if there's a banner to send.
func (c *conn) welcomeBanner(cm gossh.ConnMetadata) (banner string) {
if c.insecureSkipTailscaleAuth {
return ""
}
if err := c.setInfo(cm); err != nil {
c.logf("failed to get conninfo: %v", err) c.logf("failed to get conninfo: %v", err)
return errDenied c.action0Error = errDenied("")
return ""
} }
a, localUser, err := c.evaluatePolicy()
if err != nil { c.action0, c.action0Error = c.authenticate()
return fmt.Errorf("%w: %v", errDenied, err) if c.action0Error == nil {
} if c.action0.Accept || c.action0.Reject {
c.action0 = a c.finalAction = c.action0
c.currentAction = a
if a.Message != "" {
if err := ctx.SendAuthBanner(a.Message); err != nil {
return fmt.Errorf("SendBanner: %w", err)
} }
return c.action0.Message
} }
if a.Accept || a.HoldAndDelegate != "" {
if a.Accept { return ""
c.finalAction = a
}
lu, err := userLookup(localUser)
if err != nil {
c.logf("failed to look up %v: %v", localUser, err)
ctx.SendAuthBanner(fmt.Sprintf("failed to look up %v\r\n", localUser))
return err
}
gids, err := lu.GroupIds()
if err != nil {
c.logf("failed to look up local user's group IDs: %v", err)
return err
}
c.userGroupIDs = gids
c.localUser = lu
return nil
}
if a.Reject {
c.finalAction = a
return errDenied
}
// Shouldn't get here, but:
return errDenied
} }
// ServerConfig implements ssh.ServerConfigCallback. // ServerConfig implements ssh.ServerConfigCallback.
func (c *conn) ServerConfig(ctx ssh.Context) *gossh.ServerConfig { func (c *conn) ServerConfig(ctx ssh.Context) *gossh.ServerConfig {
return &gossh.ServerConfig{ return &gossh.ServerConfig{
NoClientAuth: true, // required for the NoClientAuthCallback to run BannerCallback: c.welcomeBanner,
NextAuthMethodCallback: c.nextAuthMethodCallback, NoClientAuth: true, // required for the NoClientAuthCallback to run
NoClientAuthCallback: c.noClientAuthCallback,
} }
} }
@ -397,7 +444,7 @@ func (srv *server) newConn() (*conn, error) {
// Stop accepting new connections. // Stop accepting new connections.
// Connections in the auth phase are handled in handleConnPostSSHAuth. // Connections in the auth phase are handled in handleConnPostSSHAuth.
// Existing sessions are terminated by Shutdown. // Existing sessions are terminated by Shutdown.
return nil, errDenied return nil, errDenied("")
} }
srv.mu.Unlock() srv.mu.Unlock()
c := &conn{srv: srv} c := &conn{srv: srv}
@ -408,9 +455,6 @@ func (srv *server) newConn() (*conn, error) {
Version: "Tailscale", Version: "Tailscale",
ServerConfigCallback: c.ServerConfig, ServerConfigCallback: c.ServerConfig,
NoClientAuthHandler: c.NoClientAuthCallback,
PasswordHandler: c.fakePasswordHandler,
Handler: c.handleSessionPostSSHAuth, Handler: c.handleSessionPostSSHAuth,
LocalPortForwardingCallback: c.mayForwardLocalPortTo, LocalPortForwardingCallback: c.mayForwardLocalPortTo,
ReversePortForwardingCallback: c.mayReversePortForwardTo, ReversePortForwardingCallback: c.mayReversePortForwardTo,
@ -523,14 +567,14 @@ func toIPPort(a net.Addr) (ipp netip.AddrPort) {
// connInfo returns a populated sshConnInfo from the provided arguments, // connInfo returns a populated sshConnInfo from the provided arguments,
// validating only that they represent a known Tailscale identity. // validating only that they represent a known Tailscale identity.
func (c *conn) setInfo(ctx ssh.Context) error { func (c *conn) setInfo(cm gossh.ConnMetadata) error {
if c.info != nil { if c.info != nil {
return nil return nil
} }
ci := &sshConnInfo{ ci := &sshConnInfo{
sshUser: strings.TrimSuffix(ctx.User(), forcePasswordSuffix), sshUser: strings.TrimSuffix(cm.User(), forcePasswordSuffix),
src: toIPPort(ctx.RemoteAddr()), src: toIPPort(cm.RemoteAddr()),
dst: toIPPort(ctx.LocalAddr()), dst: toIPPort(cm.LocalAddr()),
} }
if !tsaddr.IsTailscaleIP(ci.dst.Addr()) { if !tsaddr.IsTailscaleIP(ci.dst.Addr()) {
return fmt.Errorf("tailssh: rejecting non-Tailscale local address %v", ci.dst) return fmt.Errorf("tailssh: rejecting non-Tailscale local address %v", ci.dst)
@ -545,7 +589,7 @@ func (c *conn) setInfo(ctx ssh.Context) error {
ci.node = node ci.node = node
ci.uprof = uprof ci.uprof = uprof
c.idH = ctx.SessionID() c.idH = string(cm.SessionID())
c.info = ci c.info = ci
c.logf("handling conn: %v", ci.String()) c.logf("handling conn: %v", ci.String())
return nil return nil
@ -592,62 +636,6 @@ func (c *conn) handleSessionPostSSHAuth(s ssh.Session) {
ss.run() ss.run()
} }
// resolveNextAction starts at c.currentAction and makes it way through the
// action chain one step at a time. An action without a HoldAndDelegate is
// considered the final action. Once a final action is reached, this function
// will keep returning that action. It updates c.currentAction to the next
// action in the chain. When the final action is reached, it also sets
// c.finalAction to the final action.
func (c *conn) resolveNextAction(sctx ssh.Context) (action *tailcfg.SSHAction, err error) {
if c.finalAction != nil || c.finalActionErr != nil {
return c.finalAction, c.finalActionErr
}
defer func() {
if action != nil {
c.currentAction = action
if action.Accept || action.Reject {
c.finalAction = action
}
}
if err != nil {
c.finalActionErr = err
}
}()
ctx, cancel := context.WithCancel(sctx)
defer cancel()
// Loop processing/fetching Actions until one reaches a
// terminal state (Accept, Reject, or invalid Action), or
// until fetchSSHAction times out due to the context being
// done (client disconnect) or its 30 minute timeout passes.
// (Which is a long time for somebody to see login
// instructions and go to a URL to do something.)
action = c.currentAction
if action.Accept || action.Reject {
if action.Reject {
metricTerminalReject.Add(1)
} else {
metricTerminalAccept.Add(1)
}
return action, nil
}
url := action.HoldAndDelegate
if url == "" {
metricTerminalMalformed.Add(1)
return nil, errors.New("reached Action that lacked Accept, Reject, and HoldAndDelegate")
}
metricHolds.Add(1)
url = c.expandDelegateURLLocked(url)
nextAction, err := c.fetchSSHAction(ctx, url)
if err != nil {
metricTerminalFetchError.Add(1)
return nil, fmt.Errorf("fetching SSHAction from %s: %w", url, err)
}
return nextAction, nil
}
func (c *conn) expandDelegateURLLocked(actionURL string) string { func (c *conn) expandDelegateURLLocked(actionURL string) string {
nm := c.srv.lb.NetMap() nm := c.srv.lb.NetMap()
ci := c.info ci := c.info