From 8bcdd39e53cf6bcfaf33b641899dcd6552270f42 Mon Sep 17 00:00:00 2001 From: Allan Roger Reid Date: Fri, 6 Nov 2026 05:26:35 -0800 Subject: [PATCH 1/2] feat: pass the client address and refused user to SFTP handshake errors Logger.Error with SSHKeyExchangeError gets a *HandshakeError that names the remote address, the user and whether an authentication callback refused the last credential. The error text is unchanged and Unwrap returns the handshake error. A server cannot tell a refused login from a dropped connection, or which client it came from, without this. The callbacks of one connection run one after another, so the per-connection state needs no lock. The package had no test file, so sftp_test.go is new. --- sftp/sftp.go | 74 ++++++++++++++- sftp/sftp_test.go | 236 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 sftp/sftp_test.go diff --git a/sftp/sftp.go b/sftp/sftp.go index 7cf7fb2..f62c11c 100644 --- a/sftp/sftp.go +++ b/sftp/sftp.go @@ -207,9 +207,15 @@ func (s *Server) Listen() (err error) { func (s *Server) handleConnection(conn net.Conn) { // Before use, a handshake must be performed on the incoming net.Conn. conn.SetDeadline(time.Now().Add(s.sshHandshakeDeadline)) - sconn, chans, reqs, err := ssh.NewServerConn(conn, &s.sshConfig) + cfg, auth := s.connConfig() + sconn, chans, reqs, err := ssh.NewServerConn(conn, cfg) if err != nil { - s.logger.Error(SSHKeyExchangeError, err) + s.logger.Error(SSHKeyExchangeError, &HandshakeError{ + Addr: conn.RemoteAddr(), + User: auth.user, + Refused: auth.refused, + Err: err, + }) return } @@ -263,3 +269,67 @@ func (s *Server) handleConnection(conn net.Conn) { }(requests) } } + +// HandshakeError is the error passed to Logger.Error with SSHKeyExchangeError. +type HandshakeError struct { + // Addr is the remote address of the client. + Addr net.Addr + // User is the name under which the client offered its last credential. + // The client chooses it and it is not sanitized. + User string + // Refused is true when an authentication callback rejected the last + // credential the client offered. + Refused bool + // Err is the error returned by the SSH handshake. + Err error +} + +// Error returns the text of the underlying handshake error. +func (e *HandshakeError) Error() string { + if e.Err == nil { + return "ssh handshake failed" + } + return e.Err.Error() +} + +// Unwrap returns the underlying handshake error. +func (e *HandshakeError) Unwrap() error { return e.Err } + +type authState struct { + user string + refused bool +} + +// connConfig returns a copy of the SSH config whose authentication callbacks +// record, for one connection only, whether the last credential was refused. +// The callbacks of a connection run one after another on its handshake +// goroutine, so the state needs no lock. +func (s *Server) connConfig() (*ssh.ServerConfig, *authState) { + st := new(authState) + cfg := s.sshConfig + note := func(user string, err error) { + st.user, st.refused = user, err != nil + } + if cb := cfg.PasswordCallback; cb != nil { + cfg.PasswordCallback = func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + perms, err := cb(c, pass) + note(c.User(), err) + return perms, err + } + } + if cb := cfg.PublicKeyCallback; cb != nil { + cfg.PublicKeyCallback = func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + perms, err := cb(c, key) + note(c.User(), err) + return perms, err + } + } + if cb := cfg.KeyboardInteractiveCallback; cb != nil { + cfg.KeyboardInteractiveCallback = func(c ssh.ConnMetadata, client ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { + perms, err := cb(c, client) + note(c.User(), err) + return perms, err + } + } + return &cfg, st +} diff --git a/sftp/sftp_test.go b/sftp/sftp_test.go new file mode 100644 index 0000000..4ed5a7c --- /dev/null +++ b/sftp/sftp_test.go @@ -0,0 +1,236 @@ +// Copyright (c) 2026 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package sftp + +import ( + "crypto/ed25519" + "crypto/rand" + "errors" + "net" + "strconv" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +type handshakeLogger struct { + errs chan *HandshakeError +} + +func (l *handshakeLogger) Info(LogType, string) {} + +func (l *handshakeLogger) Error(tag LogType, err error) { + var he *HandshakeError + if tag == SSHKeyExchangeError && errors.As(err, &he) { + l.errs <- he + } +} + +func (l *handshakeLogger) next(t *testing.T) *HandshakeError { + t.Helper() + select { + case he := <-l.errs: + return he + case <-time.After(10 * time.Second): + t.Fatal("no handshake error was logged") + return nil + } +} + +func newSigner(t *testing.T) ssh.Signer { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatal(err) + } + return signer +} + +// startHandshakeServer serves cfg on a loopback port and returns its address. +func startHandshakeServer(t *testing.T, cfg *ssh.ServerConfig) (string, *handshakeLogger) { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + + cfg.AddHostKey(newSigner(t)) + logger := &handshakeLogger{errs: make(chan *HandshakeError, 16)} + srv, err := NewServer(&Options{ + Port: port, + PublicIP: "127.0.0.1", + Logger: logger, + SSHConfig: cfg, + SSHHandshakeDeadline: 10 * time.Second, + HandleSFTPSession: func(ssh.Channel, *ssh.ServerConn) {}, + }) + if err != nil { + t.Fatal(err) + } + go srv.Listen() + t.Cleanup(func() { srv.ShutDown() }) + return net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), logger +} + +func dial(addr, user string, methods ...ssh.AuthMethod) (*ssh.Client, error) { + return ssh.Dial("tcp", addr, &ssh.ClientConfig{ + User: user, + Auth: methods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + }) +} + +func TestHandshakeError(t *testing.T) { + const goodPass = "good-pass" + goodKey := newSigner(t) + cfg := &ssh.ServerConfig{ + PasswordCallback: func(_ ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + if string(pass) != goodPass { + return nil, errors.New("bad password") + } + return nil, nil + }, + PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if string(key.Marshal()) != string(goodKey.PublicKey().Marshal()) { + return nil, errors.New("bad key") + } + return nil, nil + }, + KeyboardInteractiveCallback: func(ssh.ConnMetadata, ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { + return nil, errors.New("keyboard-interactive refused") + }, + } + addr, logger := startHandshakeServer(t, cfg) + + t.Run("refused-password", func(t *testing.T) { + if _, err := dial(addr, "alice", ssh.Password("wrong")); err == nil { + t.Fatal("login unexpectedly succeeded") + } + he := logger.next(t) + if !he.Refused || he.User != "alice" { + t.Fatalf("got Refused=%v User=%q, want true and alice", he.Refused, he.User) + } + if he.Addr == nil || he.Addr.(*net.TCPAddr).IP.String() != "127.0.0.1" { + t.Fatalf("got Addr=%v, want a loopback address", he.Addr) + } + if he.Error() != he.Err.Error() || errors.Unwrap(he) != he.Err { + t.Fatalf("Error and Unwrap do not match Err: %v", he) + } + }) + + t.Run("refused-key", func(t *testing.T) { + if _, err := dial(addr, "bob", ssh.PublicKeys(newSigner(t))); err == nil { + t.Fatal("login unexpectedly succeeded") + } + if he := logger.next(t); !he.Refused || he.User != "bob" { + t.Fatalf("got Refused=%v User=%q, want true and bob", he.Refused, he.User) + } + }) + + t.Run("refused-keyboard-interactive", func(t *testing.T) { + answer := ssh.KeyboardInteractive(func(string, string, []string, []bool) ([]string, error) { + return nil, nil + }) + if _, err := dial(addr, "carol", answer); err == nil { + t.Fatal("login unexpectedly succeeded") + } + if he := logger.next(t); !he.Refused || he.User != "carol" { + t.Fatalf("got Refused=%v User=%q, want true and carol", he.Refused, he.User) + } + }) + + t.Run("two-refused-keys-are-one-error", func(t *testing.T) { + if _, err := dial(addr, "dave", ssh.PublicKeys(newSigner(t), newSigner(t))); err == nil { + t.Fatal("login unexpectedly succeeded") + } + if he := logger.next(t); !he.Refused || he.User != "dave" { + t.Fatalf("got Refused=%v User=%q, want true and dave", he.Refused, he.User) + } + if _, err := dial(addr, "erin", ssh.Password("wrong")); err == nil { + t.Fatal("login unexpectedly succeeded") + } + if he := logger.next(t); he.User != "erin" { + t.Fatalf("a second error was logged for dave: got User=%q, want erin", he.User) + } + }) + + t.Run("refused-then-valid-logs-nothing", func(t *testing.T) { + tries := 0 + pass := ssh.RetryableAuthMethod(ssh.PasswordCallback(func() (string, error) { + tries++ + if tries == 1 { + return "wrong", nil + } + return goodPass, nil + }), 3) + client, err := dial(addr, "frank", pass) + if err != nil { + t.Fatalf("valid retry failed: %v", err) + } + client.Close() + if tries != 2 { + t.Fatalf("got %d password tries, want 2", tries) + } + if _, err := dial(addr, "grace", ssh.Password("wrong")); err == nil { + t.Fatal("login unexpectedly succeeded") + } + if he := logger.next(t); he.User != "grace" { + t.Fatalf("the valid login logged an error: got User=%q, want grace", he.User) + } + }) + + t.Run("connect-and-close", func(t *testing.T) { + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + conn.Close() + if he := logger.next(t); he.Refused || he.User != "" { + t.Fatalf("got Refused=%v User=%q, want false and empty", he.Refused, he.User) + } + }) + + t.Run("protocol-failure", func(t *testing.T) { + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, err := conn.Write([]byte("SSH-2.0-test\r\n" + strings.Repeat("\xff", 64))); err != nil { + t.Fatal(err) + } + if he := logger.next(t); he.Refused || he.User != "" { + t.Fatalf("got Refused=%v User=%q, want false and empty", he.Refused, he.User) + } + }) +} + +func TestHandshakeErrorNilErr(t *testing.T) { + if got := (&HandshakeError{}).Error(); got == "" { + t.Fatal("Error returned an empty string for a nil Err") + } +} From f987468f5dc1274c1fa0dc947cea74c7893239fc Mon Sep 17 00:00:00 2001 From: Allan Roger Reid Date: Sat, 7 Nov 2026 17:47:06 +0000 Subject: [PATCH 2/2] fix: take the SFTP handshake verdict from AuthLogCallback HandshakeError.Refused was set by wrapping the password, public key and keyboard-interactive callbacks. x/crypto decides a login after those callbacks in places they cannot see: the source-address check on the permissions a callback accepted, VerifiedPublicKeyCallback, the key algorithm list, a NoClientAuthCallback, and the callbacks a PartialSuccessError installs. Each of those refusals was reported as Refused=false, and a partial success was reported as a refusal. Record the user and verdict from ServerConfig.AuthLogCallback, which x/crypto calls with the final result of every attempt, and still call an AuthLogCallback the caller set. Skip a "none" attempt only when it fails with ErrNoAuth, since it offers no credential. Treat only an unwrapped PartialSuccessError as a partial success, as x/crypto does. A signature that fails verification ends the handshake before AuthLogCallback runs, so User and Refused then describe the last attempt the server decided before it; the field doc says so. The new subtests go into the existing sftp/sftp_test.go. --- sftp/sftp.go | 58 ++++++++++---------- sftp/sftp_test.go | 135 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 159 insertions(+), 34 deletions(-) diff --git a/sftp/sftp.go b/sftp/sftp.go index f62c11c..7a602c1 100644 --- a/sftp/sftp.go +++ b/sftp/sftp.go @@ -272,13 +272,21 @@ func (s *Server) handleConnection(conn net.Conn) { // HandshakeError is the error passed to Logger.Error with SSHKeyExchangeError. type HandshakeError struct { - // Addr is the remote address of the client. + // Addr is the remote address of the TCP connection. Behind a TCP proxy + // it is the proxy's address. Addr net.Addr - // User is the name under which the client offered its last credential. - // The client chooses it and it is not sanitized. + // User is the name the client sent with the last authentication attempt + // the server decided. A "none" attempt that fails with ssh.ErrNoAuth + // offers no credential and does not count. The client chooses the name + // and it is not sanitized. User string - // Refused is true when an authentication callback rejected the last - // credential the client offered. + // Refused is true when the server refused that attempt, including when + // its callback failed for another reason, such as an unreachable + // backend or a client that hung up at a prompt. A partial success is + // not a refusal. A public key signature that fails verification ends + // the handshake before the server decides, so User and Refused then + // describe the last attempt the server decided before it, and Err holds + // the verification error. Refused bool // Err is the error returned by the SSH handshake. Err error @@ -300,35 +308,25 @@ type authState struct { refused bool } -// connConfig returns a copy of the SSH config whose authentication callbacks -// record, for one connection only, whether the last credential was refused. -// The callbacks of a connection run one after another on its handshake -// goroutine, so the state needs no lock. +// connConfig returns a copy of the SSH config whose AuthLogCallback records, +// for one connection only, the server's verdict on the last attempt. The +// credential callbacks are not wrapped for that because checks run after them +// and a partial success replaces them. The callbacks of a connection run one +// after another on its handshake goroutine, so the state needs no lock. func (s *Server) connConfig() (*ssh.ServerConfig, *authState) { st := new(authState) cfg := s.sshConfig - note := func(user string, err error) { - st.user, st.refused = user, err != nil - } - if cb := cfg.PasswordCallback; cb != nil { - cfg.PasswordCallback = func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { - perms, err := cb(c, pass) - note(c.User(), err) - return perms, err - } - } - if cb := cfg.PublicKeyCallback; cb != nil { - cfg.PublicKeyCallback = func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - perms, err := cb(c, key) - note(c.User(), err) - return perms, err + logAuth := cfg.AuthLogCallback + cfg.AuthLogCallback = func(c ssh.ConnMetadata, method string, err error) { + // A "none" attempt that fails with ErrNoAuth offers no credential. + if method != "none" || !errors.Is(err, ssh.ErrNoAuth) { + // x/crypto treats only an unwrapped PartialSuccessError as a + // partial success. + _, partial := err.(*ssh.PartialSuccessError) + st.user, st.refused = c.User(), err != nil && !partial } - } - if cb := cfg.KeyboardInteractiveCallback; cb != nil { - cfg.KeyboardInteractiveCallback = func(c ssh.ConnMetadata, client ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { - perms, err := cb(c, client) - note(c.User(), err) - return perms, err + if logAuth != nil { + logAuth(c, method, err) } } return &cfg, st diff --git a/sftp/sftp_test.go b/sftp/sftp_test.go index 4ed5a7c..a0cba5e 100644 --- a/sftp/sftp_test.go +++ b/sftp/sftp_test.go @@ -18,9 +18,13 @@ package sftp import ( + "crypto/ecdsa" "crypto/ed25519" + "crypto/elliptic" "crypto/rand" "errors" + "fmt" + "io" "net" "strconv" "strings" @@ -67,6 +71,18 @@ func newSigner(t *testing.T) ssh.Signer { return signer } +// forgedSigner offers pub but signs with another key. +type forgedSigner struct { + pub ssh.PublicKey + other ssh.Signer +} + +func (f forgedSigner) PublicKey() ssh.PublicKey { return f.pub } + +func (f forgedSigner) Sign(r io.Reader, data []byte) (*ssh.Signature, error) { + return f.other.Sign(r, data) +} + // startHandshakeServer serves cfg on a loopback port and returns its address. func startHandshakeServer(t *testing.T, cfg *ssh.ServerConfig) (string, *handshakeLogger) { t.Helper() @@ -107,11 +123,26 @@ func dial(addr, user string, methods ...ssh.AuthMethod) (*ssh.Client, error) { func TestHandshakeError(t *testing.T) { const goodPass = "good-pass" goodKey := newSigner(t) + ecdsaPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ecdsaKey, err := ssh.NewSignerFromKey(ecdsaPriv) + if err != nil { + t.Fatal(err) + } + authLogs := make(chan string, 8) cfg := &ssh.ServerConfig{ - PasswordCallback: func(_ ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { if string(pass) != goodPass { return nil, errors.New("bad password") } + if c.User() == "wrapped-partial" { + return nil, fmt.Errorf("wrapped: %w", &ssh.PartialSuccessError{}) + } + if c.User() == "source-address" { + return &ssh.Permissions{CriticalOptions: map[string]string{"source-address": "192.0.2.1/32"}}, nil + } return nil, nil }, PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { @@ -120,9 +151,28 @@ func TestHandshakeError(t *testing.T) { } return nil, nil }, - KeyboardInteractiveCallback: func(ssh.ConnMetadata, ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { + VerifiedPublicKeyCallback: func(c ssh.ConnMetadata, _ ssh.PublicKey, _ *ssh.Permissions, _ string) (*ssh.Permissions, error) { + if c.User() == "verified-refused" { + return nil, errors.New("verified key refused") + } + return nil, &ssh.PartialSuccessError{Next: ssh.ServerAuthCallbacks{ + PasswordCallback: func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) { + return nil, errors.New("second factor refused") + }, + }} + }, + KeyboardInteractiveCallback: func(_ ssh.ConnMetadata, client ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { + if _, err := client("", "", []string{"code: "}, []bool{false}); err != nil { + return nil, err + } return nil, errors.New("keyboard-interactive refused") }, + AuthLogCallback: func(c ssh.ConnMetadata, method string, err error) { + if c.User() == "auth-log" { + authLogs <- fmt.Sprintf("%s:%v", method, err) + } + }, + PublicKeyAuthAlgorithms: []string{ssh.KeyAlgoED25519}, } addr, logger := startHandshakeServer(t, cfg) @@ -153,7 +203,7 @@ func TestHandshakeError(t *testing.T) { t.Run("refused-keyboard-interactive", func(t *testing.T) { answer := ssh.KeyboardInteractive(func(string, string, []string, []bool) ([]string, error) { - return nil, nil + return []string{"123456"}, nil }) if _, err := dial(addr, "carol", answer); err == nil { t.Fatal("login unexpectedly succeeded") @@ -203,15 +253,92 @@ func TestHandshakeError(t *testing.T) { } }) + for _, tc := range []struct { + name, user string + refused bool + methods []ssh.AuthMethod + }{ + {"refused-after-the-callback", "source-address", true, []ssh.AuthMethod{ssh.Password(goodPass)}}, + {"refused-by-verified-key-callback", "verified-refused", true, []ssh.AuthMethod{ssh.PublicKeys(goodKey)}}, + {"refused-second-factor", "mfa", true, []ssh.AuthMethod{ssh.PublicKeys(goodKey), ssh.Password("wrong")}}, + {"partial-success-is-not-refused", "mfa-abandoned", false, []ssh.AuthMethod{ssh.Password("wrong"), ssh.PublicKeys(goodKey)}}, + {"key-algorithm-refused", "ecdsa", true, []ssh.AuthMethod{ssh.PublicKeys(ecdsaKey)}}, + {"wrapped-partial-success-is-refused", "wrapped-partial", true, []ssh.AuthMethod{ssh.Password(goodPass)}}, + {"hang-up-at-a-prompt-is-refused", "hangup", true, []ssh.AuthMethod{ssh.KeyboardInteractive(func(string, string, []string, []bool) ([]string, error) { + return nil, errors.New("client gives up") + })}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := dial(addr, tc.user, tc.methods...); err == nil { + t.Fatal("login unexpectedly succeeded") + } + if he := logger.next(t); he.Refused != tc.refused || he.User != tc.user { + t.Fatalf("got Refused=%v User=%q, want %v and %s", he.Refused, he.User, tc.refused, tc.user) + } + }) + } + + t.Run("forged-signature-keeps-the-previous-verdict", func(t *testing.T) { + if _, err := dial(addr, "victim", ssh.Password("wrong"), ssh.PublicKeys(forgedSigner{goodKey.PublicKey(), newSigner(t)})); err == nil { + t.Fatal("login unexpectedly succeeded") + } + if he := logger.next(t); !he.Refused || he.User != "victim" || !strings.Contains(he.Error(), "signature") { + t.Fatalf("got Refused=%v User=%q Err=%v, want true, victim and a signature error", he.Refused, he.User, he.Err) + } + }) + + t.Run("no-credential-is-not-refused", func(t *testing.T) { + if _, err := dial(addr, "nobody"); err == nil { + t.Fatal("login unexpectedly succeeded") + } + if he := logger.next(t); he.Refused || he.User != "" { + t.Fatalf("got Refused=%v User=%q, want false and empty", he.Refused, he.User) + } + }) + + t.Run("caller-auth-log-sees-every-attempt", func(t *testing.T) { + if _, err := dial(addr, "auth-log", ssh.Password("wrong")); err == nil { + t.Fatal("login unexpectedly succeeded") + } + logger.next(t) + var got []string + for len(authLogs) > 0 { + got = append(got, <-authLogs) + } + want := []string{"none:" + ssh.ErrNoAuth.Error(), "password:bad password"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("the caller's AuthLogCallback got %q, want %q", got, want) + } + }) + + t.Run("refused-none-attempt", func(t *testing.T) { + anonAddr, anonLogger := startHandshakeServer(t, &ssh.ServerConfig{ + NoClientAuth: true, + NoClientAuthCallback: func(ssh.ConnMetadata) (*ssh.Permissions, error) { + return nil, errors.New("anonymous login refused") + }, + }) + if _, err := dial(anonAddr, "anon"); err == nil { + t.Fatal("login unexpectedly succeeded") + } + if he := anonLogger.next(t); !he.Refused || he.User != "anon" { + t.Fatalf("got Refused=%v User=%q, want true and anon", he.Refused, he.User) + } + }) + t.Run("connect-and-close", func(t *testing.T) { conn, err := net.Dial("tcp", addr) if err != nil { t.Fatal(err) } conn.Close() - if he := logger.next(t); he.Refused || he.User != "" { + he := logger.next(t) + if he.Refused || he.User != "" { t.Fatalf("got Refused=%v User=%q, want false and empty", he.Refused, he.User) } + if he.Addr == nil || he.Addr.String() != conn.LocalAddr().String() { + t.Fatalf("got Addr=%v, want the client address %v", he.Addr, conn.LocalAddr()) + } }) t.Run("protocol-failure", func(t *testing.T) {