Просмотр исходного кода

ssh/tailssh: accept passwords and public keys

Some clients don't request 'none' authentication. Instead, they immediately supply
a password or public key. This change allows them to do so, but ignores the supplied
credentials and authenticates using Tailscale instead.

Updates #14922

Signed-off-by: Percy Wegmann <[email protected]>
Percy Wegmann 1 год назад
Родитель
Сommit
db231107a2

+ 72 - 26
ssh/tailssh/tailssh.go

@@ -51,6 +51,11 @@ var (
 	sshDisableSFTP       = envknob.RegisterBool("TS_SSH_DISABLE_SFTP")
 	sshDisableForwarding = envknob.RegisterBool("TS_SSH_DISABLE_FORWARDING")
 	sshDisablePTY        = envknob.RegisterBool("TS_SSH_DISABLE_PTY")
+
+	// errTerminal is an empty gossh.PartialSuccessError (with no 'Next'
+	// authentication methods that may proceed), which results in the SSH
+	// server immediately disconnecting the client.
+	errTerminal = &gossh.PartialSuccessError{}
 )
 
 const (
@@ -230,8 +235,8 @@ type conn struct {
 	finalAction *tailcfg.SSHAction // set by clientAuth
 
 	info         *sshConnInfo // set by setInfo
-	localUser    *userMeta    // set by doPolicyAuth
-	userGroupIDs []string     // set by doPolicyAuth
+	localUser    *userMeta    // set by clientAuth
+	userGroupIDs []string     // set by clientAuth
 	acceptEnv    []string
 
 	// mu protects the following fields.
@@ -255,46 +260,73 @@ func (c *conn) vlogf(format string, args ...any) {
 }
 
 // errDenied is returned by auth callbacks when a connection is denied by the
-// policy. It returns a gossh.BannerError to make sure the message gets
-// displayed as an auth banner.
-func errDenied(message string) error {
+// policy. It writes the message to an auth banner and then returns an empty
+// gossh.PartialSuccessError in order to stop processing authentication
+// attempts and immediately disconnect the client.
+func (c *conn) errDenied(message string) error {
 	if message == "" {
 		message = "tailscale: access denied"
 	}
-	return &gossh.BannerError{
-		Message: message,
+	if err := c.spac.SendAuthBanner(message); err != nil {
+		c.logf("failed to send auth banner: %s", err)
 	}
+	return errTerminal
 }
 
-// bannerError creates a gossh.BannerError that will result in the given
-// message being displayed to the client. If err != nil, this also logs
-// message:error. The contents of err is not leaked to clients in the banner.
-func (c *conn) bannerError(message string, err error) error {
+// errBanner writes the given message to an auth banner and then returns an
+// empty gossh.PartialSuccessError in order to stop processing authentication
+// attempts and immediately disconnect the client. The contents of err is not
+// leaked in the auth banner, but it is logged to the server's log.
+func (c *conn) errBanner(message string, err error) error {
 	if err != nil {
 		c.logf("%s: %s", message, err)
 	}
-	return &gossh.BannerError{
-		Err:     err,
-		Message: fmt.Sprintf("tailscale: %s", message),
+	if err := c.spac.SendAuthBanner("tailscale: " + message); err != nil {
+		c.logf("failed to send auth banner: %s", err)
 	}
+	return errTerminal
+}
+
+// errUnexpected is returned by auth callbacks that encounter an unexpected
+// error, such as being unable to send an auth banner. It sends an empty
+// gossh.PartialSuccessError to tell gossh.Server to stop processing
+// authentication attempts and instead disconnect immediately.
+func (c *conn) errUnexpected(err error) error {
+	c.logf("terminal error: %s", err)
+	return errTerminal
 }
 
 // clientAuth is responsible for performing client authentication.
 //
 // If policy evaluation fails, it returns an error.
-// If access is denied, it returns an error.
-func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
+// If access is denied, it returns an error. This must always be an empty
+// gossh.PartialSuccessError to prevent further authentication methods from
+// being tried.
+func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, retErr error) {
+	defer func() {
+		if pse, ok := retErr.(*gossh.PartialSuccessError); ok {
+			if pse.Next.GSSAPIWithMICConfig != nil ||
+				pse.Next.KeyboardInteractiveCallback != nil ||
+				pse.Next.PasswordCallback != nil ||
+				pse.Next.PublicKeyCallback != nil {
+				panic("clientAuth attempted to return a non-empty PartialSuccessError")
+			}
+		} else if retErr != nil {
+			panic(fmt.Sprintf("clientAuth attempted to return a non-PartialSuccessError error of type: %t", retErr))
+		}
+	}()
+
 	if c.insecureSkipTailscaleAuth {
 		return &gossh.Permissions{}, nil
 	}
 
 	if err := c.setInfo(cm); err != nil {
-		return nil, c.bannerError("failed to get connection info", err)
+		return nil, c.errBanner("failed to get connection info", err)
 	}
 
 	action, localUser, acceptEnv, err := c.evaluatePolicy()
 	if err != nil {
-		return nil, c.bannerError("failed to evaluate SSH policy", err)
+		return nil, c.errBanner("failed to evaluate SSH policy", err)
 	}
 
 	c.action0 = action
@@ -304,11 +336,11 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
 		// hold and delegate URL (if necessary).
 		lu, err := userLookup(localUser)
 		if err != nil {
-			return nil, c.bannerError(fmt.Sprintf("failed to look up local user %q ", localUser), err)
+			return nil, c.errBanner(fmt.Sprintf("failed to look up local user %q ", localUser), err)
 		}
 		gids, err := lu.GroupIds()
 		if err != nil {
-			return nil, c.bannerError("failed to look up local user's group IDs", err)
+			return nil, c.errBanner("failed to look up local user's group IDs", err)
 		}
 		c.userGroupIDs = gids
 		c.localUser = lu
@@ -321,7 +353,7 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
 			metricTerminalAccept.Add(1)
 			if action.Message != "" {
 				if err := c.spac.SendAuthBanner(action.Message); err != nil {
-					return nil, fmt.Errorf("error sending auth welcome message: %w", err)
+					return nil, c.errUnexpected(fmt.Errorf("error sending auth welcome message: %w", err))
 				}
 			}
 			c.finalAction = action
@@ -329,11 +361,11 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
 		case action.Reject:
 			metricTerminalReject.Add(1)
 			c.finalAction = action
-			return nil, errDenied(action.Message)
+			return nil, c.errDenied(action.Message)
 		case action.HoldAndDelegate != "":
 			if action.Message != "" {
 				if err := c.spac.SendAuthBanner(action.Message); err != nil {
-					return nil, fmt.Errorf("error sending hold and delegate message: %w", err)
+					return nil, c.errUnexpected(fmt.Errorf("error sending hold and delegate message: %w", err))
 				}
 			}
 
@@ -349,11 +381,11 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
 			action, err = c.fetchSSHAction(ctx, url)
 			if err != nil {
 				metricTerminalFetchError.Add(1)
-				return nil, c.bannerError("failed to fetch next SSH action", fmt.Errorf("fetch failed from %s: %w", url, err))
+				return nil, c.errBanner("failed to fetch next SSH action", fmt.Errorf("fetch failed from %s: %w", url, err))
 			}
 		default:
 			metricTerminalMalformed.Add(1)
-			return nil, c.bannerError("reached Action that had neither Accept, Reject, nor HoldAndDelegate", nil)
+			return nil, c.errBanner("reached Action that had neither Accept, Reject, nor HoldAndDelegate", nil)
 		}
 	}
 }
@@ -390,6 +422,20 @@ func (c *conn) ServerConfig(ctx ssh.Context) *gossh.ServerConfig {
 
 			return perms, nil
 		},
+		PasswordCallback: func(cm gossh.ConnMetadata, pword []byte) (*gossh.Permissions, error) {
+			// Some clients don't request 'none' authentication. Instead, they
+			// immediately supply a password. We humor them by accepting the
+			// password, but authenticate as usual, ignoring the actual value of
+			// the password.
+			return c.clientAuth(cm)
+		},
+		PublicKeyCallback: func(cm gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) {
+			// Some clients don't request 'none' authentication. Instead, they
+			// immediately supply a public key. We humor them by accepting the
+			// key, but authenticate as usual, ignoring the actual content of
+			// the key.
+			return c.clientAuth(cm)
+		},
 	}
 }
 
@@ -400,7 +446,7 @@ func (srv *server) newConn() (*conn, error) {
 		// Stop accepting new connections.
 		// Connections in the auth phase are handled in handleConnPostSSHAuth.
 		// Existing sessions are terminated by Shutdown.
-		return nil, errDenied("tailscale: server is shutting down")
+		return nil, errors.New("server is shutting down")
 	}
 	srv.mu.Unlock()
 	c := &conn{srv: srv}

+ 42 - 1
ssh/tailssh/tailssh_integration_test.go

@@ -2,7 +2,6 @@
 // SPDX-License-Identifier: BSD-3-Clause
 
 //go:build integrationtest
-// +build integrationtest
 
 package tailssh
 
@@ -410,6 +409,48 @@ func TestSSHAgentForwarding(t *testing.T) {
 	}
 }
 
+// TestIntegrationParamiko attempts to connect to Tailscale SSH using the
+// paramiko Python library. This library does not request 'none' auth. This
+// test ensures that Tailscale SSH can correctly handle clients that don't
+// request 'none' auth and instead immediately authenticate with a public key
+// or password.
+func TestIntegrationParamiko(t *testing.T) {
+	debugTest.Store(true)
+	t.Cleanup(func() {
+		debugTest.Store(false)
+	})
+
+	addr := testServer(t, "testuser", true, false)
+	host, port, err := net.SplitHostPort(addr)
+	if err != nil {
+		t.Fatalf("Failed to split addr %q: %s", addr, err)
+	}
+
+	out, err := exec.Command("python3", "-c", fmt.Sprintf(`
+import paramiko.client as pm
+from paramiko.ecdsakey import ECDSAKey
+client = pm.SSHClient()
+client.set_missing_host_key_policy(pm.AutoAddPolicy)
+client.connect('%s', port=%s, username='testuser', pkey=ECDSAKey.generate(), allow_agent=False, look_for_keys=False)
+client.exec_command('pwd')
+`, host, port)).CombinedOutput()
+	if err != nil {
+		t.Fatalf("failed to connect with Paramiko using public key auth: %s\n%q", err, string(out))
+	}
+
+	out, err = exec.Command("python3", "-c", fmt.Sprintf(`
+import paramiko.client as pm
+from paramiko.ecdsakey import ECDSAKey
+client = pm.SSHClient()
+client.set_missing_host_key_policy(pm.AutoAddPolicy)
+client.connect('%s', port=%s, username='testuser', password='doesntmatter', allow_agent=False, look_for_keys=False)
+client.exec_command('pwd')
+`, host, port)).CombinedOutput()
+	if err != nil {
+		t.Fatalf("failed to connect with Paramiko using password auth: %s\n%q", err, string(out))
+	}
+}
+
 func fallbackToSUAvailable() bool {
 	if runtime.GOOS != "linux" {
 		return false

+ 149 - 77
ssh/tailssh/tailssh_test.go

@@ -8,12 +8,15 @@ package tailssh
 import (
 	"bytes"
 	"context"
+	"crypto/ecdsa"
 	"crypto/ed25519"
+	"crypto/elliptic"
 	"crypto/rand"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
+	"log"
 	"net"
 	"net/http"
 	"net/http/httptest"
@@ -41,7 +44,7 @@ import (
 	"tailscale.com/sessionrecording"
 	"tailscale.com/tailcfg"
 	"tailscale.com/tempfork/gliderlabs/ssh"
-	sshtest "tailscale.com/tempfork/sshtest/ssh"
+	testssh "tailscale.com/tempfork/sshtest/ssh"
 	"tailscale.com/tsd"
 	"tailscale.com/tstest"
 	"tailscale.com/types/key"
@@ -56,8 +59,6 @@ import (
 	"tailscale.com/wgengine"
 )
 
-type _ = sshtest.Client // TODO(bradfitz,percy): sshtest; delete this line
-
 func TestMatchRule(t *testing.T) {
 	someAction := new(tailcfg.SSHAction)
 	tests := []struct {
@@ -510,9 +511,9 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
 	defer s.Shutdown()
 
 	const sshUser = "alice"
-	cfg := &gossh.ClientConfig{
+	cfg := &testssh.ClientConfig{
 		User:            sshUser,
-		HostKeyCallback: gossh.InsecureIgnoreHostKey(),
+		HostKeyCallback: testssh.InsecureIgnoreHostKey(),
 	}
 
 	tests := []struct {
@@ -559,12 +560,12 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
 			wg.Add(1)
 			go func() {
 				defer wg.Done()
-				c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
+				c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
 				if err != nil {
 					t.Errorf("client: %v", err)
 					return
 				}
-				client := gossh.NewClient(c, chans, reqs)
+				client := testssh.NewClient(c, chans, reqs)
 				defer client.Close()
 				session, err := client.NewSession()
 				if err != nil {
@@ -645,21 +646,21 @@ func TestMultipleRecorders(t *testing.T) {
 	sc, dc := memnet.NewTCPConn(src, dst, 1024)
 
 	const sshUser = "alice"
-	cfg := &gossh.ClientConfig{
+	cfg := &testssh.ClientConfig{
 		User:            sshUser,
-		HostKeyCallback: gossh.InsecureIgnoreHostKey(),
+		HostKeyCallback: testssh.InsecureIgnoreHostKey(),
 	}
 
 	var wg sync.WaitGroup
 	wg.Add(1)
 	go func() {
 		defer wg.Done()
-		c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
+		c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
 		if err != nil {
 			t.Errorf("client: %v", err)
 			return
 		}
-		client := gossh.NewClient(c, chans, reqs)
+		client := testssh.NewClient(c, chans, reqs)
 		defer client.Close()
 		session, err := client.NewSession()
 		if err != nil {
@@ -736,21 +737,21 @@ func TestSSHRecordingNonInteractive(t *testing.T) {
 	sc, dc := memnet.NewTCPConn(src, dst, 1024)
 
 	const sshUser = "alice"
-	cfg := &gossh.ClientConfig{
+	cfg := &testssh.ClientConfig{
 		User:            sshUser,
-		HostKeyCallback: gossh.InsecureIgnoreHostKey(),
+		HostKeyCallback: testssh.InsecureIgnoreHostKey(),
 	}
 
 	var wg sync.WaitGroup
 	wg.Add(1)
 	go func() {
 		defer wg.Done()
-		c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
+		c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
 		if err != nil {
 			t.Errorf("client: %v", err)
 			return
 		}
-		client := gossh.NewClient(c, chans, reqs)
+		client := testssh.NewClient(c, chans, reqs)
 		defer client.Close()
 		session, err := client.NewSession()
 		if err != nil {
@@ -886,80 +887,151 @@ func TestSSHAuthFlow(t *testing.T) {
 		},
 	}
 	s := &server{
-		logf: logger.Discard,
+		logf: log.Printf,
 	}
 	defer s.Shutdown()
 	src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22"))
 	for _, tc := range tests {
-		t.Run(tc.name, func(t *testing.T) {
-			sc, dc := memnet.NewTCPConn(src, dst, 1024)
-			s.lb = tc.state
-			sshUser := "alice"
-			if tc.sshUser != "" {
-				sshUser = tc.sshUser
-			}
-			var passwordUsed atomic.Bool
-			cfg := &gossh.ClientConfig{
-				User:            sshUser,
-				HostKeyCallback: gossh.InsecureIgnoreHostKey(),
-				Auth: []gossh.AuthMethod{
-					gossh.PasswordCallback(func() (secret string, err error) {
-						if !tc.usesPassword {
-							t.Error("unexpected use of PasswordCallback")
-							return "", errors.New("unexpected use of PasswordCallback")
-						}
+		for _, authMethods := range [][]string{nil, {"publickey", "password"}, {"password", "publickey"}} {
+			t.Run(fmt.Sprintf("%s-skip-none-auth-%v", tc.name, strings.Join(authMethods, "-then-")), func(t *testing.T) {
+				sc, dc := memnet.NewTCPConn(src, dst, 1024)
+				s.lb = tc.state
+				sshUser := "alice"
+				if tc.sshUser != "" {
+					sshUser = tc.sshUser
+				}
+
+				wantBanners := slices.Clone(tc.wantBanners)
+				noneAuthEnabled := len(authMethods) == 0
+
+				var publicKeyUsed atomic.Bool
+				var passwordUsed atomic.Bool
+				var methods []testssh.AuthMethod
+
+				for _, authMethod := range authMethods {
+					switch authMethod {
+					case "publickey":
+						methods = append(methods,
+							testssh.PublicKeysCallback(func() (signers []testssh.Signer, err error) {
+								publicKeyUsed.Store(true)
+								key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
+								if err != nil {
+									return nil, err
+								}
+								sig, err := testssh.NewSignerFromKey(key)
+								if err != nil {
+									return nil, err
+								}
+								return []testssh.Signer{sig}, nil
+							}))
+					case "password":
+						methods = append(methods, testssh.PasswordCallback(func() (secret string, err error) {
+							passwordUsed.Store(true)
+							return "any-pass", nil
+						}))
+					}
+				}
+
+				if noneAuthEnabled && tc.usesPassword {
+					methods = append(methods, testssh.PasswordCallback(func() (secret string, err error) {
 						passwordUsed.Store(true)
 						return "any-pass", nil
-					}),
-				},
-				BannerCallback: func(message string) error {
-					if len(tc.wantBanners) == 0 {
-						t.Errorf("unexpected banner: %q", message)
-					} else if message != tc.wantBanners[0] {
-						t.Errorf("banner = %q; want %q", message, tc.wantBanners[0])
-					} else {
-						t.Logf("banner = %q", message)
-						tc.wantBanners = tc.wantBanners[1:]
+					}))
+				}
+
+				cfg := &testssh.ClientConfig{
+					User:            sshUser,
+					HostKeyCallback: testssh.InsecureIgnoreHostKey(),
+					SkipNoneAuth:    !noneAuthEnabled,
+					Auth:            methods,
+					BannerCallback: func(message string) error {
+						if len(wantBanners) == 0 {
+							t.Errorf("unexpected banner: %q", message)
+						} else if message != wantBanners[0] {
+							t.Errorf("banner = %q; want %q", message, wantBanners[0])
+						} else {
+							t.Logf("banner = %q", message)
+							wantBanners = wantBanners[1:]
+						}
+						return nil
+					},
+				}
+
+				var wg sync.WaitGroup
+				wg.Add(1)
+				go func() {
+					defer wg.Done()
+					c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
+					if err != nil {
+						if !tc.authErr {
+							t.Errorf("client: %v", err)
+						}
+						return
+					} else if tc.authErr {
+						c.Close()
+						t.Errorf("client: expected error, got nil")
+						return
 					}
-					return nil
-				},
-			}
-			var wg sync.WaitGroup
-			wg.Add(1)
-			go func() {
-				defer wg.Done()
-				c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
-				if err != nil {
-					if !tc.authErr {
+					client := testssh.NewClient(c, chans, reqs)
+					defer client.Close()
+					session, err := client.NewSession()
+					if err != nil {
 						t.Errorf("client: %v", err)
+						return
 					}
-					return
-				} else if tc.authErr {
-					c.Close()
-					t.Errorf("client: expected error, got nil")
-					return
+					defer session.Close()
+					_, err = session.CombinedOutput("echo Ran echo!")
+					if err != nil {
+						t.Errorf("client: %v", err)
+					}
+				}()
+				if err := s.HandleSSHConn(dc); err != nil {
+					t.Errorf("unexpected error: %v", err)
 				}
-				client := gossh.NewClient(c, chans, reqs)
-				defer client.Close()
-				session, err := client.NewSession()
-				if err != nil {
-					t.Errorf("client: %v", err)
-					return
+				wg.Wait()
+				if len(wantBanners) > 0 {
+					t.Errorf("missing banners: %v", wantBanners)
 				}
-				defer session.Close()
-				_, err = session.CombinedOutput("echo Ran echo!")
-				if err != nil {
-					t.Errorf("client: %v", err)
+
+				// Check to see which callbacks were invoked.
+				//
+				// When `none` auth is enabled, the public key callback should
+				// never fire, and the password callback should only fire if
+				// authentication succeeded and the client was trying to force
+				// password authentication by connecting with the '-password'
+				// username suffix.
+				//
+				// When skipping `none` auth, the first callback should always
+				// fire, and the 2nd callback should fire only if
+				// authentication failed.
+				wantPublicKey := false
+				wantPassword := false
+				if noneAuthEnabled {
+					wantPassword = !tc.authErr && tc.usesPassword
+				} else {
+					for i, authMethod := range authMethods {
+						switch authMethod {
+						case "publickey":
+							wantPublicKey = i == 0 || tc.authErr
+						case "password":
+							wantPassword = i == 0 || tc.authErr
+						}
+					}
 				}
-			}()
-			if err := s.HandleSSHConn(dc); err != nil {
-				t.Errorf("unexpected error: %v", err)
-			}
-			wg.Wait()
-			if len(tc.wantBanners) > 0 {
-				t.Errorf("missing banners: %v", tc.wantBanners)
-			}
-		})
+
+				if wantPublicKey && !publicKeyUsed.Load() {
+					t.Error("public key should have been attempted")
+				} else if !wantPublicKey && publicKeyUsed.Load() {
+					t.Errorf("public key should not have been attempted")
+				}
+
+				if wantPassword && !passwordUsed.Load() {
+					t.Error("password should have been attempted")
+				} else if !wantPassword && passwordUsed.Load() {
+					t.Error("password should not have been attempted")
+				}
+			})
+		}
 	}
 }
 

+ 8 - 3
ssh/tailssh/testcontainers/Dockerfile

@@ -3,9 +3,12 @@ FROM ${BASE}
 
 ARG BASE
 
-RUN echo "Install openssh, needed for scp."
-RUN if echo "$BASE" | grep "ubuntu:"; then apt-get update -y && apt-get install -y openssh-client; fi
-RUN if echo "$BASE" | grep "alpine:"; then apk add openssh; fi
+RUN echo "Install openssh, needed for scp. Also install python3"
+RUN if echo "$BASE" | grep "ubuntu:"; then apt-get update -y && apt-get install -y openssh-client python3 python3-pip; fi
+RUN if echo "$BASE" | grep "alpine:"; then apk add openssh python3 py3-pip; fi
+
+RUN echo "Install paramiko"
+RUN pip3 install paramiko==3.5.1 || pip3 install --break-system-packages paramiko==3.5.1
 
 # Note - on Ubuntu, we do not create the user's home directory, pam_mkhomedir will do that
 # for us, and we want to test that PAM gets triggered by Tailscale SSH.
@@ -33,6 +36,8 @@ RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi
 RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSCP
 RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi
 RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSSH
+RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi
+RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationParamiko
 
 RUN echo "Then run tests as non-root user testuser and make sure tests still pass."
 RUN touch /tmp/tailscalessh.log

+ 8 - 0
tempfork/sshtest/ssh/client.go

@@ -239,6 +239,14 @@ type ClientConfig struct {
 	//
 	// A Timeout of zero means no timeout.
 	Timeout time.Duration
+
+	// SkipNoneAuth allows skipping the initial "none" auth request. This is unusual
+	// behavior, but it is allowed by [RFC4252 5.2](https://datatracker.ietf.org/doc/html/rfc4252#section-5.2),
+	// and some clients in the wild behave like this. One such client is the paramiko Python
+	// library, which is used in pgadmin4 via the sshtunnel library.
+	// When SkipNoneAuth is true, the client will attempt all configured
+	// [AuthMethod]s until one works, or it runs out.
+	SkipNoneAuth bool
 }
 
 // InsecureIgnoreHostKey returns a function that can be used for

+ 10 - 1
tempfork/sshtest/ssh/client_auth.go

@@ -68,7 +68,16 @@ func (c *connection) clientAuthenticate(config *ClientConfig) error {
 	var lastMethods []string
 
 	sessionID := c.transport.getSessionID()
-	for auth := AuthMethod(new(noneAuth)); auth != nil; {
+	var auth AuthMethod
+	if !config.SkipNoneAuth {
+		auth = AuthMethod(new(noneAuth))
+	} else if len(config.Auth) > 0 {
+		auth = config.Auth[0]
+		for _, a := range config.Auth {
+			lastMethods = append(lastMethods, a.method())
+		}
+	}
+	for auth != nil {
 		ok, methods, err := auth.auth(sessionID, config.User, c.transport, config.Rand, extensions)
 		if err != nil {
 			// On disconnect, return error immediately