mirror of
https://github.com/tailscale/tailscale.git
synced 2025-02-20 11:58:39 +00:00
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 <percy@tailscale.com>
This commit is contained in:
parent
f2f7fd12eb
commit
db231107a2
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
defer session.Close()
|
||||
_, err = session.CombinedOutput("echo Ran echo!")
|
||||
if err != nil {
|
||||
t.Errorf("client: %v", err)
|
||||
}
|
||||
return
|
||||
} else if tc.authErr {
|
||||
c.Close()
|
||||
t.Errorf("client: expected error, got nil")
|
||||
return
|
||||
}()
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user