From 34d67636173e930de7b43a8016cf70f572433685 Mon Sep 17 00:00:00 2001 From: Dmitry Reznitsky Date: Wed, 25 Feb 2026 09:44:28 +0100 Subject: [PATCH] Add fail2ban-friendly auth failure logging and regression test --- Changelog.md | 4 + auth_logging_test.go | 164 ++++++++++++++++++++ vendor/github.com/armon/go-socks5/auth.go | 110 +++++++++++-- vendor/github.com/armon/go-socks5/socks5.go | 13 +- 4 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 auth_logging_test.go diff --git a/Changelog.md b/Changelog.md index 49667779..b8ffd0d1 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,7 +3,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased - available on :latest tag for docker image] ### Changed +- Added fail2ban-friendly authentication failure logging with source `remote_ip`, + `username`, auth `method`, and structured `reason` values. ### Added +- Added a regression test that verifies invalid username/password attempts produce + the expected `auth_failed` log line fields. ## [v0.0.4] - 2025-10-07 diff --git a/auth_logging_test.go b/auth_logging_test.go new file mode 100644 index 00000000..6c8cd39c --- /dev/null +++ b/auth_logging_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "bytes" + "errors" + "io" + "log" + "net" + "strings" + "sync" + "testing" + "time" + + "github.com/armon/go-socks5" +) + +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestAuthFailureLogsIncludeFail2banFields(t *testing.T) { + logSink := &lockedBuffer{} + + creds := socks5.StaticCredentials{ + "testuser": "testpass", + } + auth := socks5.UserPassAuthenticator{Credentials: creds} + + server, err := socks5.New(&socks5.Config{ + Logger: log.New(logSink, "", 0), + AuthMethods: []socks5.Authenticator{auth}, + }) + if err != nil { + t.Fatalf("failed to create socks5 server: %v", err) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to create listener: %v", err) + } + + serveErr := make(chan error, 1) + done := make(chan struct{}) + go func() { + defer close(done) + err := server.Serve(listener) + if err != nil && !isClosedListenerError(err) { + serveErr <- err + } + }() + + t.Cleanup(func() { + _ = listener.Close() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for socks5 server to stop") + } + + select { + case err := <-serveErr: + t.Fatalf("server returned unexpected error: %v", err) + default: + } + }) + + sendInvalidAuthHandshake(t, listener.Addr().String(), "testuser", "wrongpass") + + waitForLogParts(t, logSink, []string{ + "auth_failed", + "remote_ip=127.0.0.1", + `username="testuser"`, + "method=2", + "reason=invalid_credentials", + }) +} + +func sendInvalidAuthHandshake(t *testing.T, addr, username, password string) { + t.Helper() + + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Fatalf("failed to connect to socks5 server: %v", err) + } + defer conn.Close() + + if err := conn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("failed to set connection deadline: %v", err) + } + + // Client greeting: SOCKS5 with username/password auth support. + if _, err := conn.Write([]byte{0x05, 0x01, 0x02}); err != nil { + t.Fatalf("failed to write client greeting: %v", err) + } + + resp := make([]byte, 2) + if _, err := io.ReadFull(conn, resp); err != nil { + t.Fatalf("failed to read auth method selection: %v", err) + } + if resp[0] != 0x05 || resp[1] != 0x02 { + t.Fatalf("unexpected method selection response: %#v", resp) + } + + if len(username) > 255 || len(password) > 255 { + t.Fatal("test username/password exceed RFC1929 limits") + } + + authReq := append([]byte{0x01, byte(len(username))}, []byte(username)...) + authReq = append(authReq, byte(len(password))) + authReq = append(authReq, []byte(password)...) + if _, err := conn.Write(authReq); err != nil { + t.Fatalf("failed to write auth request: %v", err) + } + + if _, err := io.ReadFull(conn, resp); err != nil { + t.Fatalf("failed to read auth response: %v", err) + } + if resp[0] != 0x01 || resp[1] != 0x01 { + t.Fatalf("expected auth failure response [0x01 0x01], got %#v", resp) + } +} + +func waitForLogParts(t *testing.T, sink *lockedBuffer, parts []string) { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + for { + logs := sink.String() + if containsAll(logs, parts) { + return + } + if time.Now().After(deadline) { + t.Fatalf("expected log to contain all parts %q, logs:\n%s", parts, logs) + } + time.Sleep(10 * time.Millisecond) + } +} + +func containsAll(s string, parts []string) bool { + for _, part := range parts { + if !strings.Contains(s, part) { + return false + } + } + return true +} + +func isClosedListenerError(err error) bool { + return errors.Is(err, net.ErrClosed) || strings.Contains(err.Error(), "use of closed network connection") +} diff --git a/vendor/github.com/armon/go-socks5/auth.go b/vendor/github.com/armon/go-socks5/auth.go index 7811e2aa..6c9bb570 100644 --- a/vendor/github.com/armon/go-socks5/auth.go +++ b/vendor/github.com/armon/go-socks5/auth.go @@ -3,6 +3,7 @@ package socks5 import ( "fmt" "io" + "net" ) const ( @@ -19,6 +20,78 @@ var ( NoSupportedAuth = fmt.Errorf("No supported authentication mechanism") ) +// authFailureError captures structured authentication failure details so callers +// can log consistent entries (for example, fail2ban-friendly log lines). +type authFailureError struct { + Method uint8 + Username string + Reason string + RemoteIP string + RemoteAddr string + Err error +} + +func (e *authFailureError) Error() string { + return fmt.Sprintf( + "authentication failed: method=%d username=%q remote_ip=%s remote_addr=%q reason=%s: %v", + e.Method, + e.Username, + e.RemoteIP, + e.RemoteAddr, + e.Reason, + e.Err, + ) +} + +func (e *authFailureError) Unwrap() error { + return e.Err +} + +func newAuthFailureError(writer io.Writer, method uint8, username, reason string, err error) error { + remoteAddr, remoteIP := getRemoteAddressAndIP(writer) + return &authFailureError{ + Method: method, + Username: username, + Reason: reason, + RemoteIP: remoteIP, + RemoteAddr: remoteAddr, + Err: err, + } +} + +func getRemoteAddressAndIP(writer io.Writer) (string, string) { + remoteAddr := "unknown" + remoteIP := "unknown" + + addrProvider, ok := writer.(interface{ RemoteAddr() net.Addr }) + if !ok { + return remoteAddr, remoteIP + } + + addr := addrProvider.RemoteAddr() + if addr == nil { + return remoteAddr, remoteIP + } + + remoteAddr = addr.String() + + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + if parsed := net.ParseIP(remoteAddr); parsed != nil { + return remoteAddr, parsed.String() + } + return remoteAddr, remoteIP + } + + if parsed := net.ParseIP(host); parsed != nil { + remoteIP = parsed.String() + } else { + remoteIP = host + } + + return remoteAddr, remoteIP +} + // A Request encapsulates authentication state provided // during negotiation type AuthContext struct { @@ -60,49 +133,55 @@ func (a UserPassAuthenticator) GetCode() uint8 { func (a UserPassAuthenticator) Authenticate(reader io.Reader, writer io.Writer) (*AuthContext, error) { // Tell the client to use user/pass auth if _, err := writer.Write([]byte{socks5Version, UserPassAuth}); err != nil { - return nil, err + return nil, newAuthFailureError(writer, UserPassAuth, "", "write_auth_method_failed", err) } // Get the version and username length header := []byte{0, 0} if _, err := io.ReadAtLeast(reader, header, 2); err != nil { - return nil, err + return nil, newAuthFailureError(writer, UserPassAuth, "", "read_auth_header_failed", err) } // Ensure we are compatible if header[0] != userAuthVersion { - return nil, fmt.Errorf("Unsupported auth version: %v", header[0]) + return nil, newAuthFailureError( + writer, + UserPassAuth, + "", + "unsupported_auth_version", + fmt.Errorf("Unsupported auth version: %v", header[0]), + ) } // Get the user name userLen := int(header[1]) user := make([]byte, userLen) if _, err := io.ReadAtLeast(reader, user, userLen); err != nil { - return nil, err + return nil, newAuthFailureError(writer, UserPassAuth, "", "read_username_failed", err) } // Get the password length if _, err := reader.Read(header[:1]); err != nil { - return nil, err + return nil, newAuthFailureError(writer, UserPassAuth, string(user), "read_password_length_failed", err) } // Get the password passLen := int(header[0]) pass := make([]byte, passLen) if _, err := io.ReadAtLeast(reader, pass, passLen); err != nil { - return nil, err + return nil, newAuthFailureError(writer, UserPassAuth, string(user), "read_password_failed", err) } // Verify the password if a.Credentials.Valid(string(user), string(pass)) { if _, err := writer.Write([]byte{userAuthVersion, authSuccess}); err != nil { - return nil, err + return nil, newAuthFailureError(writer, UserPassAuth, string(user), "write_auth_success_failed", err) } } else { if _, err := writer.Write([]byte{userAuthVersion, authFailure}); err != nil { - return nil, err + return nil, newAuthFailureError(writer, UserPassAuth, string(user), "write_auth_failure_failed", err) } - return nil, UserAuthFailed + return nil, newAuthFailureError(writer, UserPassAuth, string(user), "invalid_credentials", UserAuthFailed) } // Done @@ -114,19 +193,26 @@ func (s *Server) authenticate(conn io.Writer, bufConn io.Reader) (*AuthContext, // Get the methods methods, err := readMethods(bufConn) if err != nil { - return nil, fmt.Errorf("Failed to get auth methods: %v", err) + return nil, newAuthFailureError(conn, noAcceptable, "", "read_auth_methods_failed", fmt.Errorf("Failed to get auth methods: %v", err)) } // Select a usable method for _, method := range methods { cator, found := s.authMethods[method] if found { - return cator.Authenticate(bufConn, conn) + ctx, err := cator.Authenticate(bufConn, conn) + if err != nil { + if _, ok := err.(*authFailureError); ok { + return nil, err + } + return nil, newAuthFailureError(conn, method, "", "auth_method_failed", err) + } + return ctx, nil } } // No usable method found - return nil, noAcceptableAuth(conn) + return nil, newAuthFailureError(conn, noAcceptable, "", "no_supported_auth_method", noAcceptableAuth(conn)) } // noAcceptableAuth is used to handle when we have no eligible diff --git a/vendor/github.com/armon/go-socks5/socks5.go b/vendor/github.com/armon/go-socks5/socks5.go index 6d589029..35425c06 100644 --- a/vendor/github.com/armon/go-socks5/socks5.go +++ b/vendor/github.com/armon/go-socks5/socks5.go @@ -153,7 +153,7 @@ func (s *Server) ServeConn(conn net.Conn) error { s.config.Logger.Printf("[WARN] socks: Connection from not allowed IP address: %s", clientIP) return fmt.Errorf("connection from not allowed IP address") } - + // Read the version byte version := []byte{0} if _, err := bufConn.Read(version); err != nil { @@ -171,6 +171,17 @@ func (s *Server) ServeConn(conn net.Conn) error { // Authenticate the connection authContext, err := s.authenticate(conn, bufConn) if err != nil { + if authErr, ok := err.(*authFailureError); ok { + s.config.Logger.Printf( + "[WARN] socks: auth_failed remote_ip=%s remote_addr=%q username=%q method=%d reason=%s", + authErr.RemoteIP, + authErr.RemoteAddr, + authErr.Username, + authErr.Method, + authErr.Reason, + ) + return err + } err = fmt.Errorf("Failed to authenticate: %v", err) s.config.Logger.Printf("[ERR] socks: %v", err) return err