diff --git a/driver-core/src/main/com/mongodb/MongoSocksProxyException.java b/driver-core/src/main/com/mongodb/MongoSocksProxyException.java new file mode 100644 index 0000000000..aafb7fad7c --- /dev/null +++ b/driver-core/src/main/com/mongodb/MongoSocksProxyException.java @@ -0,0 +1,178 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb; + +import com.mongodb.lang.Nullable; + +import static com.mongodb.assertions.Assertions.notNull; + +/** + * Thrown when an error occurs while establishing a connection to a SOCKS5 proxy. + * + *

The {@link #getHandshakePhase()} identifies which phase of the SOCKS5 handshake failed. + * {@link #getProxyReplyCode()} returns the RFC 1928 reply code sent by the proxy when a + * non-success CONNECT reply was successfully parsed; it returns {@code null} otherwise + * (including for {@link HandshakePhase#CONNECT_RELAY} failures caused by an I/O error or + * an unrecognized reply field). + * + *

RFC 1928 reply codes: 1=general failure, 2=connection not allowed by ruleset, + * 3=network unreachable, 4=host unreachable, 5=connection refused, 6=TTL expired, + * 7=command not supported, 8=address type not supported. + * + *

Constructor parameter ordering follows the parent class first (message, address, + * optional cause), then SOCKS-specific arguments (handshakePhase, optional proxyReplyCode). + * + * @since 5.8 + */ +public class MongoSocksProxyException extends MongoSocketOpenException { + private static final long serialVersionUID = 1L; + + /** + * The phase of the SOCKS5 handshake at which the failure occurred. + * + * @since 5.8 + */ + public enum HandshakePhase { + /** + * TCP connection to the proxy host itself failed before any SOCKS5 exchange. + * The proxy may be temporarily unreachable. + */ + PROXY_TCP_CONNECT, + + /** + * The SOCKS5 method-selection exchange failed. Causes include: incompatible + * proxy version, no common authentication method, an unrecognized method, or + * an I/O failure (EOF, timeout, broken pipe) while sending the method-selection + * request or reading its reply. + */ + NEGOTIATION, + + /** + * Username/password sub-negotiation with the proxy failed. Causes include: + * the proxy rejecting the credentials (typically wrong username/password), + * or an I/O failure (EOF, timeout, broken pipe) while sending credentials + * or reading the auth result. + */ + AUTHENTICATION, + + /** + * A failure occurred while sending the CONNECT request to the proxy or + * reading/parsing its reply. Causes include: a parsed non-success RFC 1928 + * reply (in which case {@link MongoSocksProxyException#getProxyReplyCode()} + * carries the code), an unrecognized reply field or address type, or an + * I/O failure (EOF, timeout, broken pipe) on the CONNECT exchange. + */ + CONNECT_RELAY + } + + private final HandshakePhase handshakePhase; + + @Nullable + private final Integer proxyReplyCode; + + /** + * Construct an instance with no RFC 1928 reply code and no cause. Suitable for any phase + * whose failure does not carry a parsed reply code: {@link HandshakePhase#PROXY_TCP_CONNECT}, + * {@link HandshakePhase#NEGOTIATION}, {@link HandshakePhase#AUTHENTICATION}, and the + * {@link HandshakePhase#CONNECT_RELAY} sub-cases driven by an I/O failure or an unrecognized + * reply field. + * + * @param message the message + * @param serverAddress the server address + * @param handshakePhase the phase at which the failure occurred + */ + public MongoSocksProxyException(final String message, final ServerAddress serverAddress, final HandshakePhase handshakePhase) { + this(message, serverAddress, notNull("handshakePhase", handshakePhase), null); + } + + /** + * Construct an instance with no RFC 1928 reply code. Suitable for any phase whose failure + * does not carry a parsed reply code: {@link HandshakePhase#PROXY_TCP_CONNECT}, + * {@link HandshakePhase#NEGOTIATION}, {@link HandshakePhase#AUTHENTICATION}, and the + * {@link HandshakePhase#CONNECT_RELAY} sub-cases driven by an I/O failure or an unrecognized + * reply field. + * + * @param message the message + * @param address the server address + * @param cause the cause + * @param handshakePhase the phase at which the failure occurred + */ + public MongoSocksProxyException(final String message, final ServerAddress address, + final Throwable cause, final HandshakePhase handshakePhase) { + this(message, address, cause, notNull("handshakePhase", handshakePhase), null); + } + + /** + * Construct an instance with an optional RFC 1928 reply code. A non-{@code null} + * {@code proxyReplyCode} should only accompany {@link HandshakePhase#CONNECT_RELAY} and + * indicates a successfully parsed non-success reply from the proxy. Use {@code null} in + * all other cases — including {@link HandshakePhase#CONNECT_RELAY} failures caused by an + * I/O error or an unrecognized reply field. + * + * @param message the message + * @param address the server address + * @param handshakePhase the phase at which the failure occurred + * @param proxyReplyCode the RFC 1928 reply code, or {@code null} + */ + public MongoSocksProxyException(final String message, final ServerAddress address, final HandshakePhase handshakePhase, + @Nullable final Integer proxyReplyCode) { + super(message, address); + this.handshakePhase = notNull("handshakePhase", handshakePhase); + this.proxyReplyCode = proxyReplyCode; + } + + /** + * Construct an instance with an optional RFC 1928 reply code. A non-{@code null} + * {@code proxyReplyCode} should only accompany {@link HandshakePhase#CONNECT_RELAY} and + * indicates a successfully parsed non-success reply from the proxy. Use {@code null} in + * all other cases — including {@link HandshakePhase#CONNECT_RELAY} failures caused by an + * I/O error or an unrecognized reply field. + * + * @param message the message + * @param address the server address + * @param cause the cause + * @param handshakePhase the phase at which the failure occurred + * @param proxyReplyCode the RFC 1928 reply code, or {@code null} + */ + public MongoSocksProxyException(final String message, final ServerAddress address, + final Throwable cause, final HandshakePhase handshakePhase, + @Nullable final Integer proxyReplyCode) { + super(message, address, cause); + this.handshakePhase = notNull("handshakePhase", handshakePhase); + this.proxyReplyCode = proxyReplyCode; + } + + /** + * Returns the phase of the SOCKS5 handshake at which the failure occurred. + * + * @return the handshake phase, never {@code null} + */ + public HandshakePhase getHandshakePhase() { + return handshakePhase; + } + + /** + * Returns the RFC 1928 reply code sent by the SOCKS5 proxy when a non-success CONNECT + * reply was successfully parsed, or {@code null} otherwise. + * + * @return the RFC 1928 proxy reply code, or {@code null} + */ + @Nullable + public Integer getProxyReplyCode() { + return proxyReplyCode; + } +} diff --git a/driver-core/src/main/com/mongodb/internal/connection/BackpressureErrorLabeler.java b/driver-core/src/main/com/mongodb/internal/connection/BackpressureErrorLabeler.java index b062620455..50372e532c 100644 --- a/driver-core/src/main/com/mongodb/internal/connection/BackpressureErrorLabeler.java +++ b/driver-core/src/main/com/mongodb/internal/connection/BackpressureErrorLabeler.java @@ -18,6 +18,7 @@ import com.mongodb.MongoException; import com.mongodb.MongoSocketException; +import com.mongodb.MongoSocksProxyException; import javax.net.ssl.SSLHandshakeException; import javax.net.ssl.SSLPeerUnverifiedException; @@ -76,19 +77,36 @@ static void applyLabelsIfEligible(final Throwable t) { return; } MongoSocketException socketException = (MongoSocketException) t; + if (isSocksFailure(socketException)) { + return; + } if (isDnsLookupFailure(socketException)) { return; } if (isTlsConfigurationError(socketException)) { return; } - // TODO-BACKPRESSURE Nabil - Add SOCKS5 check once JAVA-6194 is introduced - // async proxy error surfaces can be handled together — likely via a dedicated internal - // exception thrown from the proxy code path. socketException.addLabel(MongoException.SYSTEM_OVERLOADED_ERROR_LABEL); socketException.addLabel(MongoException.RETRYABLE_ERROR_LABEL); } + private static boolean isSocksFailure(final MongoSocketException t) { + if (!(t instanceof MongoSocksProxyException)) { + return false; + } + MongoSocksProxyException socksException = (MongoSocksProxyException) t; + if (socksException.getHandshakePhase() != MongoSocksProxyException.HandshakePhase.CONNECT_RELAY) { + return true; + } + Integer replyCode = socksException.getProxyReplyCode(); + if (replyCode == null) { + return true; + } + return replyCode != SocksSocket.ServerReply.NET_UNREACHABLE.getReplyNumber() + && replyCode != SocksSocket.ServerReply.HOST_UNREACHABLE.getReplyNumber() + && replyCode != SocksSocket.ServerReply.CONN_REFUSED.getReplyNumber(); + } + private static boolean isDnsLookupFailure(final MongoSocketException t) { Throwable cause = t.getCause(); while (cause != null) { diff --git a/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java b/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java index a1c3ed0d91..dc3956f82d 100644 --- a/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java +++ b/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java @@ -16,9 +16,11 @@ package com.mongodb.internal.connection; +import com.mongodb.MongoInterruptedException; import com.mongodb.MongoSocketException; import com.mongodb.MongoSocketOpenException; import com.mongodb.MongoSocketReadException; +import com.mongodb.MongoSocksProxyException; import com.mongodb.ServerAddress; import com.mongodb.connection.AsyncCompletionHandler; import com.mongodb.connection.ProxySettings; @@ -38,6 +40,7 @@ import java.net.SocketTimeoutException; import java.util.Iterator; import java.util.List; +import java.util.Optional; import static com.mongodb.assertions.Assertions.assertTrue; import static com.mongodb.assertions.Assertions.notNull; @@ -79,10 +82,16 @@ public void open(final OperationContext operationContext) { socket = initializeSocket(operationContext); outputStream = socket.getOutputStream(); inputStream = socket.getInputStream(); + } catch (MongoSocksProxyException e) { + close(); + throw e; } catch (IOException e) { close(); - throw translateInterruptedException(e, "Interrupted while connecting") - .orElseThrow(() -> new MongoSocketOpenException("Exception opening socket", getAddress(), e)); + Optional interrupted = translateInterruptedException(e, "Interrupted while connecting"); + if (interrupted.isPresent()) { + throw interrupted.get(); + } + throw new MongoSocketOpenException("Exception opening socket", getAddress(), e); } } @@ -119,15 +128,32 @@ private SSLSocket initializeSslSocketOverSocksProxy(final OperationContext opera final int serverPort = address.getPort(); SocksSocket socksProxy = new SocksSocket(settings.getProxySettings()); - configureSocket(socksProxy, operationContext, settings); - InetSocketAddress inetSocketAddress = toSocketAddress(serverHost, serverPort); - socksProxy.connect(inetSocketAddress, operationContext.getTimeoutContext().getConnectTimeoutMs()); - - SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(socksProxy, serverHost, serverPort, true); - //Even though Socks proxy connection is already established, TLS handshake has not been performed yet. - //So it is possible to set SSL parameters before handshake is done. - configureSslSocket(sslSocket, sslSettings, inetSocketAddress); - return sslSocket; + // Track the outermost socket layer to close on failure. Initially this is socksProxy; + // once we wrap it into an SSLSocket, that becomes the outermost layer and closing it + // tears down the underlying socksProxy as well. + Socket toClose = socksProxy; + try { + configureSocket(socksProxy, operationContext, settings); + InetSocketAddress inetSocketAddress = toSocketAddress(serverHost, serverPort); + try { + socksProxy.connect(inetSocketAddress, operationContext.getTimeoutContext().getConnectTimeoutMs()); + } catch (IOException e) { + throw wrapAsProxyTcpConnect(e); + } + SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(socksProxy, serverHost, serverPort, true); + toClose = sslSocket; + //Even though Socks proxy connection is already established, TLS handshake has not been performed yet. + //So it is possible to set SSL parameters before handshake is done. + configureSslSocket(sslSocket, sslSettings, inetSocketAddress); + return sslSocket; + } catch (IOException | RuntimeException e) { + try { + toClose.close(); + } catch (IOException closeException) { + e.addSuppressed(closeException); + } + throw e; + } } @@ -139,19 +165,41 @@ private static InetSocketAddress toSocketAddress(final String serverHost, final return InetSocketAddress.createUnresolved(serverHost, serverPort); } + private MongoSocksProxyException wrapAsProxyTcpConnect(final IOException cause) { + ProxySettings proxySettings = settings.getProxySettings(); + return new MongoSocksProxyException( + "Exception connecting to SOCKS5 proxy (" + proxySettings.getHost() + ":" + proxySettings.getPort() + ")", + getAddress(), cause, + MongoSocksProxyException.HandshakePhase.PROXY_TCP_CONNECT); + } + private Socket initializeSocketOverSocksProxy(final OperationContext operationContext) throws IOException { Socket createdSocket = socketFactory.createSocket(); - configureSocket(createdSocket, operationContext, settings); - /* - Wrap the configured socket with SocksSocket to add extra functionality. - Reason for separate steps: We can't directly extend Java 11 methods within 'SocksSocket' - to configure itself. - */ - SocksSocket socksProxy = new SocksSocket(createdSocket, settings.getProxySettings()); - - socksProxy.connect(toSocketAddress(address.getHost(), address.getPort()), - operationContext.getTimeoutContext().getConnectTimeoutMs()); - return socksProxy; + try { + configureSocket(createdSocket, operationContext, settings); + /* + Wrap the configured socket with SocksSocket to add extra functionality. + Reason for separate steps: We can't directly extend Java 11 methods within 'SocksSocket' + to configure itself. + */ + SocksSocket socksProxy = new SocksSocket(createdSocket, settings.getProxySettings()); + try { + socksProxy.connect(toSocketAddress(address.getHost(), address.getPort()), + operationContext.getTimeoutContext().getConnectTimeoutMs()); + } catch (IOException e) { + throw wrapAsProxyTcpConnect(e); + } + return socksProxy; + } catch (IOException | RuntimeException e) { + // SocksSocket.connect() closes itself on failure, but createdSocket may not yet + // be owned by a SocksSocket (e.g. configureSocket threw). Close defensively; + try { + createdSocket.close(); + } catch (IOException closeException) { + e.addSuppressed(closeException); + } + throw e; + } } @Override diff --git a/driver-core/src/main/com/mongodb/internal/connection/SocksSocket.java b/driver-core/src/main/com/mongodb/internal/connection/SocksSocket.java index 2619a3c2c1..89166f9bce 100644 --- a/driver-core/src/main/com/mongodb/internal/connection/SocksSocket.java +++ b/driver-core/src/main/com/mongodb/internal/connection/SocksSocket.java @@ -15,6 +15,9 @@ */ package com.mongodb.internal.connection; +import com.mongodb.MongoSocksProxyException; +import com.mongodb.MongoSocksProxyException.HandshakePhase; +import com.mongodb.ServerAddress; import com.mongodb.connection.ProxySettings; import com.mongodb.internal.time.Timeout; import com.mongodb.lang.Nullable; @@ -95,24 +98,56 @@ public void connect(final SocketAddress endpoint, final int connectTimeoutMs) th timeout.checkedRun(MILLISECONDS, () -> socketConnect(proxyAddress, 0), (ms) -> socketConnect(proxyAddress, Math.toIntExact(ms)), - () -> throwSocketConnectionTimeout()); - - SocksAuthenticationMethod authenticationMethod = performNegotiation(timeout); - authenticate(authenticationMethod, timeout); - sendConnect(timeout); - } catch (SocketException socketException) { - /* - * The 'close()' call here has two purposes: - * - * 1. Enforces self-closing under RFC 1928 if METHOD is X'FF'. - * 2. Handles all other errors during connection, distinct from external closures. - */ + SocksSocket::throwSocketConnectionTimeout); + + // Each call below is wrapped so any IOException raised inside that phase is converted to + // a MongoSocksProxyException with the actual phase. Otherwise IOExceptions (EOF, timeout, + // unknown reply codes) escape unwrapped and are mislabeled as PROXY_TCP_CONNECT upstream. + // A MongoSocksProxyException thrown directly by a phase method is a RuntimeException and + // propagates past the IOException catch to the outer block. + SocksAuthenticationMethod authenticationMethod; + try { + authenticationMethod = performNegotiation(timeout); + } catch (IOException e) { + throw new MongoSocksProxyException("SOCKS5 negotiation failed: " + e.getMessage(), + targetServerAddress(), e, HandshakePhase.NEGOTIATION); + } + + try { + authenticate(authenticationMethod, timeout); + } catch (IOException e) { + throw new MongoSocksProxyException("SOCKS5 authentication failed: " + e.getMessage(), + targetServerAddress(), e, HandshakePhase.AUTHENTICATION); + } + + try { + sendConnect(timeout); + } catch (IOException e) { + throw new MongoSocksProxyException("SOCKS5 CONNECT relay failed: " + e.getMessage(), + targetServerAddress(), e, HandshakePhase.CONNECT_RELAY); + } + } catch (MongoSocksProxyException e) { + // Reached for any SOCKS5 protocol failure (negotiation / authentication / CONNECT-relay, + // including RFC 1928 X'FF' "no acceptable method" self-close). The proxy TCP socket is + // already connected at this point. MongoSocksProxyException is a RuntimeException and is + // not caught below, so close the socket here to avoid leaking the FD. try { close(); } catch (Exception closeException) { - socketException.addSuppressed(closeException); + e.addSuppressed(closeException); } - throw socketException; + throw e; + } catch (IOException ioException) { + // Reached only when the initial proxy TCP connect fails + // before any SOCKS5 handshake byte goes on the wire. Inner-phase IOExceptions are + // converted to MongoSocksProxyException by the per-phase wrappers and caught above. + // Close the partially-initialised proxy socket so we don't leak a FD. + try { + close(); + } catch (Exception closeException) { + ioException.addSuppressed(closeException); + } + throw ioException; } } @@ -223,7 +258,9 @@ private void checkServerReply(final Timeout timeout) throws IOException { } return; } - throw new ConnectException(reply.getMessage()); + throw new MongoSocksProxyException( + "SOCKS5 CONNECT reply: " + reply.message + " (code " + reply.replyNumber + ")", + targetServerAddress(), HandshakePhase.CONNECT_RELAY, reply.replyNumber); } private void authenticate(final SocksAuthenticationMethod authenticationMethod, final Timeout timeout) throws IOException { @@ -249,7 +286,9 @@ private void authenticate(final SocksAuthenticationMethod authenticationMethod, byte authStatus = authResult[1]; if (authStatus != AUTHENTICATION_SUCCEEDED_STATUS) { - throw new ConnectException("Authentication failed. Proxy server returned status: " + authStatus); + throw new MongoSocksProxyException( + "Authentication failed. Proxy server returned status: " + authStatus, + targetServerAddress(), HandshakePhase.AUTHENTICATION); } } } @@ -273,13 +312,16 @@ private SocksAuthenticationMethod performNegotiation(final Timeout timeout) thro byte[] handshakeReply = readSocksReply(2, timeout); if (handshakeReply[0] != SOCKS_VERSION) { - throw new ConnectException("Remote server doesn't support socks version 5" - + " Received version: " + handshakeReply[0]); + throw new MongoSocksProxyException("Remote server doesn't support socks version 5" + + " Received version: " + handshakeReply[0], + targetServerAddress(), HandshakePhase.NEGOTIATION); } byte authMethodNumber = handshakeReply[1]; if (authMethodNumber == (byte) 0xFF) { - throw new ConnectException("None of the authentication methods listed are acceptable. Attempted methods: " - + Arrays.toString(authenticationMethods)); + throw new MongoSocksProxyException( + "None of the authentication methods listed are acceptable. Attempted methods: " + + Arrays.toString(authenticationMethods), + targetServerAddress(), HandshakePhase.NEGOTIATION); } if (authMethodNumber == SocksAuthenticationMethod.NO_AUTH.getMethodNumber()) { return SocksAuthenticationMethod.NO_AUTH; @@ -287,7 +329,12 @@ private SocksAuthenticationMethod performNegotiation(final Timeout timeout) thro return SocksAuthenticationMethod.USERNAME_PASSWORD; } - throw new ConnectException("Proxy returned unsupported authentication method: " + authMethodNumber); + throw new MongoSocksProxyException("Proxy returned unsupported authentication method: " + authMethodNumber, + targetServerAddress(), HandshakePhase.NEGOTIATION); + } + + private ServerAddress targetServerAddress() { + return new ServerAddress(remoteAddress.getHostString(), remoteAddress.getPort()); } private SocksAuthenticationMethod[] getSocksAuthenticationMethods() { @@ -435,6 +482,10 @@ static ServerReply of(final byte byteStatus) throws ConnectException { public String getMessage() { return message; } + + public int getReplyNumber() { + return replyNumber; + } } @Override diff --git a/driver-core/src/test/unit/com/mongodb/internal/connection/BackpressureErrorLabelerTest.java b/driver-core/src/test/unit/com/mongodb/internal/connection/BackpressureErrorLabelerTest.java index 37b67430d2..7851ba0ec4 100644 --- a/driver-core/src/test/unit/com/mongodb/internal/connection/BackpressureErrorLabelerTest.java +++ b/driver-core/src/test/unit/com/mongodb/internal/connection/BackpressureErrorLabelerTest.java @@ -22,6 +22,7 @@ import com.mongodb.MongoSocketException; import com.mongodb.MongoSocketOpenException; import com.mongodb.MongoSocketReadTimeoutException; +import com.mongodb.MongoSocksProxyException; import com.mongodb.ServerAddress; import net.bytebuddy.ByteBuddy; import org.junit.jupiter.api.Named; @@ -83,6 +84,71 @@ void dnsFailureShouldNotBeLabeled(final MongoSocketException e) { assertLacksBackpressureLabels(e); } + static Stream> socks5ProxyExceptionsShouldNotBeLabeled() { + return Stream.of( + // PROXY_TCP_CONNECT happens before any byte is exchanged with mongod — the proxy + // itself is unreachable. Not a mongod overload signal. + named(new MongoSocksProxyException("tcp connect to proxy failed", ADDRESS, + MongoSocksProxyException.HandshakePhase.PROXY_TCP_CONNECT)), + // NEGOTIATION + AUTHENTICATION are proxy-side protocol / credential errors. + named(new MongoSocksProxyException("negotiation failed", ADDRESS, + MongoSocksProxyException.HandshakePhase.NEGOTIATION)), + named(new MongoSocksProxyException("auth failed", ADDRESS, + MongoSocksProxyException.HandshakePhase.AUTHENTICATION)), + // CONNECT_RELAY with null replyCode = I/O failure or unrecognized reply field; + // no definitive mongod-side signal. + named(new MongoSocksProxyException("connect relay io failure", ADDRESS, + MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, null)), + // CONNECT_RELAY with proxy-side / ambiguous reply codes — not mongod-attributable: + // 0x01 GENERAL_FAILURE (too generic to attribute) + // 0x02 NOT_ALLOWED (proxy ACL) + // 0x06 TTL_EXPIRED (transient routing, ambiguous) + // 0x07 COMMAND_NOT_SUPPORTED (proxy capability) + // 0x08 ADDRESS_TYPE_NOT_SUPPORTED (proxy capability) + named(new MongoSocksProxyException("general failure", ADDRESS, + MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 1)), + named(new MongoSocksProxyException("not allowed", ADDRESS, + MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 2)), + named(new MongoSocksProxyException("ttl expired", ADDRESS, + MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 6)), + named(new MongoSocksProxyException("command not supported", ADDRESS, + MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 7)), + named(new MongoSocksProxyException("address type not supported", ADDRESS, + MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 8)) + ); + } + + @ParameterizedTest + @MethodSource + void socks5ProxyExceptionsShouldNotBeLabeled(final MongoSocketException e) { + BackpressureErrorLabeler.applyLabelsIfEligible(e); + assertLacksBackpressureLabels(e); + } + + static Stream> socks5ProxyExceptionsShouldBeLabeled() { + // CONNECT_RELAY with reply codes 3 / 4 / 5 means the proxy tried to reach mongod on our + // behalf and got a transport-level failure that mirrors a direct-connection socket-open + // outcome. + return Stream.of( + // 0x03 NET_UNREACHABLE — proxy → mongod network path is down (≈ NoRouteToHostException) + named(new MongoSocksProxyException("network unreachable", ADDRESS, + MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 3)), + // 0x04 HOST_UNREACHABLE — proxy can't reach mongod host (≈ NoRouteToHostException) + named(new MongoSocksProxyException("host unreachable", ADDRESS, + MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 4)), + // 0x05 CONN_REFUSED — mongod actively refused (≈ ConnectException) + named(new MongoSocksProxyException("connection refused", ADDRESS, + MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 5)) + ); + } + + @ParameterizedTest + @MethodSource + void socks5ProxyExceptionsShouldBeLabeled(final MongoSocketException e) { + BackpressureErrorLabeler.applyLabelsIfEligible(e); + assertHasBackpressureLabels(e); + } + static Stream> localTlsConfigErrorShouldNotBeLabeled() { return Stream.of( named(new CertificateException("bad cert")), diff --git a/driver-core/src/test/unit/com/mongodb/internal/connection/SocksSocketTest.java b/driver-core/src/test/unit/com/mongodb/internal/connection/SocksSocketTest.java new file mode 100644 index 0000000000..f57bbf942c --- /dev/null +++ b/driver-core/src/test/unit/com/mongodb/internal/connection/SocksSocketTest.java @@ -0,0 +1,274 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.internal.connection; + +import com.mongodb.MongoSocksProxyException; +import com.mongodb.MongoSocksProxyException.HandshakePhase; +import com.mongodb.connection.ProxySettings; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies that SocksSocket tags each SOCKS5 protocol failure with the correct HandshakePhase + * and, for CONNECT_RELAY failures, the correct RFC 1928 reply code. + * Uses a local mini-server; no real SOCKS5 proxy required. + */ +class SocksSocketTest { + + private static final InetSocketAddress TARGET = + InetSocketAddress.createUnresolved("mongo.example.com", 27017); + + private Exception connectWithMiniServer(final byte[] serverBytes, final boolean withCredentials) + throws Exception { + return connectWithMiniServer(serverBytes, withCredentials, false); + } + + private Exception connectWithMiniServer(final byte[] serverBytes, final boolean withCredentials, + final boolean eofAfterWrite) + throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + int port = server.getLocalPort(); + + Thread t = new Thread(() -> { + try (Socket client = server.accept()) { + OutputStream out = client.getOutputStream(); + out.write(serverBytes); + out.flush(); + if (eofAfterWrite) { + // Half-close: send TCP FIN so the client sees EOF (in.read() == -1) on its + // next read. + client.shutdownOutput(); + } + // Drain anything the client writes (negotiation/auth/CONNECT bytes) until the + // client closes its end. This blocks the server thread so it does not tear + // down the socket while the client is still reading the canned bytes. + // Bounded by the client's natural close in the SocksSocket finally block. + // Plain read-loop (no transferTo/nullOutputStream) for Java 8 source compatibility. + InputStream in = client.getInputStream(); + byte[] discard = new byte[1024]; + //noinspection StatementWithEmptyBody + while (in.read(discard) != -1) { + // discard + } + } catch (Exception ignored) { + } + }); + t.setDaemon(true); + t.start(); + + try (SocksSocket socksSocket = new SocksSocket(buildProxySettings("127.0.0.1", port, withCredentials))) { + try { + socksSocket.connect(TARGET, 5000); + return null; + } catch (MongoSocksProxyException | IOException e) { + return e; + } + } finally { + try { + t.join(5000); + } catch (InterruptedException ie) { + // Don't mask the primary exception (if any) with the join interruption; + // just preserve the thread's interrupt status and continue. + Thread.currentThread().interrupt(); + } + } + } + } + + private static ProxySettings buildProxySettings(final String host, final int port, final boolean withCredentials) { + ProxySettings.Builder b = ProxySettings.builder().host(host).port(port); + if (withCredentials) { + b.username("user").password("pass"); + } + return b.build(); + } + + private static MongoSocksProxyException assertProxy(final Exception ex) { + return assertInstanceOf(MongoSocksProxyException.class, ex, + "Expected MongoSocksProxyException but got: " + (ex == null ? "null" : ex.getClass().getName())); + } + + // ----------------------------------------------------------------------- + // CONNECT_RELAY — RFC 1928 server reply codes + // ----------------------------------------------------------------------- + + @Test + void hostUnreachablePhaseConnectRelayCode4() throws Exception { + byte[] bytes = { + 0x05, 0x00, // negotiation OK, no auth + 0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0 // HOST_UNREACHABLE + }; + MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false)); + Assertions.assertNotNull(ex); + assertEquals(HandshakePhase.CONNECT_RELAY, ex.getHandshakePhase()); + assertEquals(SocksSocket.ServerReply.HOST_UNREACHABLE.getReplyNumber(), ex.getProxyReplyCode()); + } + + @Test + void connRefusedPhaseConnectRelayCode5() throws Exception { + byte[] bytes = { + 0x05, 0x00, + 0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0 // CONN_REFUSED + }; + MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false)); + Assertions.assertNotNull(ex); + assertEquals(HandshakePhase.CONNECT_RELAY, ex.getHandshakePhase()); + assertEquals(SocksSocket.ServerReply.CONN_REFUSED.getReplyNumber(), ex.getProxyReplyCode()); + } + + @Test + void notAllowedPhaseConnectRelayCode2() throws Exception { + byte[] bytes = { + 0x05, 0x00, + 0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0 // NOT_ALLOWED + }; + MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false)); + Assertions.assertNotNull(ex); + assertEquals(HandshakePhase.CONNECT_RELAY, ex.getHandshakePhase()); + assertEquals(SocksSocket.ServerReply.NOT_ALLOWED.getReplyNumber(), ex.getProxyReplyCode()); + } + + // ----------------------------------------------------------------------- + // AUTHENTICATION + // ----------------------------------------------------------------------- + + @Test + void authRejectedPhaseAuthenticationNoReplyCode() throws Exception { + byte[] bytes = { + 0x05, 0x02, // negotiation OK, needs username/password + 0x01, 0x01 // auth rejected + }; + MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, true)); + Assertions.assertNotNull(ex); + assertEquals(HandshakePhase.AUTHENTICATION, ex.getHandshakePhase()); + assertNull(ex.getProxyReplyCode()); + } + + // ----------------------------------------------------------------------- + // NEGOTIATION + // ----------------------------------------------------------------------- + + @Test + void noAcceptableMethodPhaseNegotiationNoReplyCode() throws Exception { + byte[] bytes = {0x05, (byte) 0xFF}; + MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false)); + Assertions.assertNotNull(ex); + assertEquals(HandshakePhase.NEGOTIATION, ex.getHandshakePhase()); + assertNull(ex.getProxyReplyCode()); + } + + @Test + void wrongSocksVersionPhaseNegotiationNoReplyCode() throws Exception { + byte[] bytes = {0x04, 0x00}; + MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false)); + Assertions.assertNotNull(ex); + assertEquals(HandshakePhase.NEGOTIATION, ex.getHandshakePhase()); + assertNull(ex.getProxyReplyCode()); + } + + // ----------------------------------------------------------------------- + // IOException-during-handshake → tagged with the proper phase, not PROXY_TCP_CONNECT + // ----------------------------------------------------------------------- + + @Test + void ioFailureDuringNegotiationTaggedAsNegotiation() throws Exception { + // Mini-server half-closes immediately after writing zero bytes of method-selection reply. + // Client's readSocksReply sees EOF (in.read() == -1) and throws ConnectException("Malformed + // reply..."). That IOException must be wrapped as MongoSocksProxyException with + // phase=NEGOTIATION, not PROXY_TCP_CONNECT. + byte[] noReply = new byte[0]; + MongoSocksProxyException ex = assertProxy(connectWithMiniServer(noReply, false, true)); + Assertions.assertNotNull(ex); + Assertions.assertTrue(ex.getMessage().contains("Malformed reply from SOCKS proxy server")); + assertEquals(HandshakePhase.NEGOTIATION, ex.getHandshakePhase()); + assertNull(ex.getProxyReplyCode()); + } + + @Test + void unknownReplyCodeDuringConnectRelayTaggedAsConnectRelay() throws Exception { + // Reply code 0x09 is not a known RFC 1928 code. + byte[] bytes = { + 0x05, 0x00, // negotiation OK + 0x05, 0x09, 0x00, 0x01, 0, 0, 0, 0, 0, 0 // unknown reply code 0x09 + }; + MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false)); + Assertions.assertNotNull(ex); + assertEquals(HandshakePhase.CONNECT_RELAY, ex.getHandshakePhase()); + assertNull(ex.getProxyReplyCode()); + } + + @Test + void ioFailureDuringAuthenticationTaggedAsAuthentication() throws Exception { + // Negotiation succeeds picking USERNAME_PASSWORD; mini-server then half-closes immediately, + // so the client reads the 2 negotiation bytes successfully and then sees EOF on the + // subsequent auth-result read. readSocksReply throws ConnectException("Malformed reply...") + // from inside authenticate(). The wrapper must tag the IOException as AUTHENTICATION. + byte[] bytes = {0x05, 0x02}; // negotiation OK, picked username/password; then EOF + MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, true, true)); + Assertions.assertNotNull(ex); + Assertions.assertTrue(ex.getMessage().contains("Malformed reply from SOCKS proxy server")); + assertEquals(HandshakePhase.AUTHENTICATION, ex.getHandshakePhase()); + assertNull(ex.getProxyReplyCode()); + } + + // ----------------------------------------------------------------------- + // PROXY_TCP_CONNECT — inferred at SocketStream boundary, not tagged here + // ----------------------------------------------------------------------- + + @Test + void tcpConnectFailureNotMongoSocksProxyException() throws IOException { + // Bind an ephemeral port then release it, so we have a port that is reliably closed + // for the duration of this test. Using a hard-coded low port (e.g. 1) is unreliable + // because some systems have services listening there. + int closedPort; + try (ServerSocket probe = new ServerSocket(0)) { + closedPort = probe.getLocalPort(); + } + try (SocksSocket s = new SocksSocket(buildProxySettings("127.0.0.1", closedPort, false))) { + // Expecting a plain IOException TCP connect failures + // are NOT tagged as MongoSocksProxyException at the SocksSocket layer. + assertThrows(IOException.class, () -> s.connect(TARGET, 5000)); + } + } + + @SuppressWarnings({"ThrowableNotThrown", "DataFlowIssue"}) + @Test + void constructorRejectsNullHandshakePhase() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new MongoSocksProxyException("m", new com.mongodb.ServerAddress(), null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new MongoSocksProxyException("m", new com.mongodb.ServerAddress(), + new RuntimeException("c"), null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new MongoSocksProxyException("m", new com.mongodb.ServerAddress(), null, 5)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new MongoSocksProxyException("m", new com.mongodb.ServerAddress(), + new RuntimeException("c"), null, 5)); + } +} diff --git a/driver-scala/src/main/scala/org/mongodb/scala/package.scala b/driver-scala/src/main/scala/org/mongodb/scala/package.scala index 6443157936..27f206d5f0 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/package.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/package.scala @@ -389,6 +389,13 @@ package object scala extends ClientSessionImplicits with ObservableImplicits wit */ type MongoSocketWriteException = com.mongodb.MongoSocketWriteException + /** + * This exception is thrown when an error occurs while establishing a connection to a SOCKS5 proxy. + * + * @since 5.8 + */ + type MongoSocksProxyException = com.mongodb.MongoSocksProxyException + /** * An exception indicating that the driver has timed out waiting for either a server or a connection to become available. */ diff --git a/driver-sync/src/test/functional/com/mongodb/client/Socks5ProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/Socks5ProseTest.java index 20e3a35534..d09617aef3 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/Socks5ProseTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/Socks5ProseTest.java @@ -17,7 +17,7 @@ import com.mongodb.ConnectionString; import com.mongodb.MongoClientSettings; -import com.mongodb.MongoSocketOpenException; +import com.mongodb.MongoSocksProxyException; import com.mongodb.MongoTimeoutException; import com.mongodb.connection.ClusterDescription; import com.mongodb.connection.ServerDescription; @@ -151,7 +151,7 @@ private static void assertSocksAuthenticationIssue(final ClusterListener cluster .filter(Objects::nonNull) .collect(Collectors.toList()); assumeFalse(errors.isEmpty()); - errors.forEach(throwable -> Assertions.assertEquals(MongoSocketOpenException.class, throwable.getClass())); + errors.forEach(throwable -> Assertions.assertInstanceOf(MongoSocksProxyException.class, throwable)); } private static void runHelloCommand(final MongoClient mongoClient) {