diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index f4538d3c7..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,17 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - -version: 2 -updates: - - package-ecosystem: "maven" - directories: - - "/" - schedule: - interval: "daily" - - package-ecosystem: "github-actions" - directories: - - "/" - schedule: - interval: "daily" diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 6a59bde6c..2586cf3c6 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -5,34 +5,50 @@ on: - cron: '0 12 * * *' jobs: + Verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Grant Permission + run: chmod +x ./mvnw + - uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '11' + - name: Verify + run: ./mvnw -B -ntp clean verify -DskipTests -Dgpg.skip=true + RunOnLinux: runs-on: ubuntu-latest + needs: Verify steps: - uses: actions/checkout@v4 - name: Grant Permission - run: sudo chmod +x ./mvnw + run: chmod +x ./mvnw - uses: actions/setup-java@v4 with: distribution: 'corretto' java-version: '11' - name: Run Tests - run: ./mvnw -B -ntp clean test + run: ./mvnw -B -ntp test RunOnMacOs: runs-on: macos-latest + needs: Verify steps: - uses: actions/checkout@v4 - name: Grant Permission - run: sudo chmod +x ./mvnw + run: chmod +x ./mvnw - uses: actions/setup-java@v4 with: distribution: 'corretto' java-version: '11' - name: Run Tests - run: ./mvnw -B -ntp clean test + run: ./mvnw -B -ntp test RunOnWindows: runs-on: windows-latest + needs: Verify steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 @@ -40,4 +56,4 @@ jobs: distribution: 'corretto' java-version: '11' - name: Run Tests - run: ./mvnw.cmd -B -ntp clean test + run: ./mvnw.cmd -B -ntp test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a462dc99..059d1640f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,13 +31,13 @@ jobs: with: servers: | [{ - "id": "ossrh", + "id": "central", "username": "${{ secrets.OSSRH_USERNAME }}", "password": "${{ secrets.OSSRH_PASSWORD }}" }] - name: Import GPG - uses: crazy-max/ghaction-import-gpg@v6.2.0 + uses: crazy-max/ghaction-import-gpg@v6.3.0 with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} diff --git a/.gitignore b/.gitignore index d424b2597..546e0e6fd 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ MANIFEST.MF work atlassian-ide-plugin.xml /bom/.flattened-pom.xml + +# Docker volumes and logs (but keep configuration) +docker/squid/logs/ +docker/nginx/logs/ diff --git a/README.md b/README.md index 4ae651b75..318d58da2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Maven: org.asynchttpclient async-http-client - 3.0.1 + 3.0.4 ``` @@ -28,7 +28,7 @@ Maven: Gradle: ```groovy dependencies { - implementation 'org.asynchttpclient:async-http-client:3.0.1' + implementation 'org.asynchttpclient:async-http-client:3.0.4' } ``` diff --git a/client/pom.xml b/client/pom.xml index 58dcd0aad..f3804ca3c 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -19,7 +19,7 @@ org.asynchttpclient async-http-client-project - 3.0.1 + 3.0.4 4.0.0 @@ -31,7 +31,7 @@ org.asynchttpclient.client 11.0.24 - 10.1.33 + 10.1.47 2.18.0 4.11.0 3.0 @@ -51,7 +51,7 @@ commons-fileupload commons-fileupload - 1.5 + 1.6.0 test @@ -188,5 +188,88 @@ 2.1.6 test + + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + org.testcontainers + testcontainers-junit-jupiter + ${testcontainers.version} + test + + + + + docker-tests + + + docker.tests + true + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + false + true + + + + + + + + testcontainers-auto + + true + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + + + + + no-docker-tests + + + no.docker.tests + true + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + disabled + + + + + + + diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java index 12dc93d7d..216dc4ed6 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java @@ -375,6 +375,16 @@ public interface AsyncHttpClientConfig { int getIoThreadsCount(); + /** + * Indicates whether the Authorization header should be stripped during redirects to a different domain. + * + * @return true if the Authorization header should be stripped, false otherwise. + */ + default boolean isStripAuthorizationOnRedirect() { + // By default, we throw, so that existing implementations don't break. + throw new UnsupportedOperationException("IsStripAuthorizationOnRedirect is not supported by " + getClass().getName()); + } + enum ResponseBodyPartFactory { EAGER { diff --git a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java index e72235c17..1c7dbf37f 100644 --- a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java @@ -127,6 +127,7 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig { private final boolean keepEncodingHeader; private final ProxyServerSelector proxyServerSelector; private final boolean validateResponseHeaders; + private final boolean stripAuthorizationOnRedirect; // websockets private final boolean aggregateWebSocketFrameFragments; @@ -219,6 +220,7 @@ private DefaultAsyncHttpClientConfig(// http boolean validateResponseHeaders, boolean aggregateWebSocketFrameFragments, boolean enablewebSocketCompression, + boolean stripAuthorizationOnRedirect, // timeouts Duration connectTimeout, @@ -307,6 +309,7 @@ private DefaultAsyncHttpClientConfig(// http this.keepEncodingHeader = keepEncodingHeader; this.proxyServerSelector = proxyServerSelector; this.validateResponseHeaders = validateResponseHeaders; + this.stripAuthorizationOnRedirect = stripAuthorizationOnRedirect; // websocket this.aggregateWebSocketFrameFragments = aggregateWebSocketFrameFragments; @@ -564,6 +567,11 @@ public boolean isValidateResponseHeaders() { return validateResponseHeaders; } + @Override + public boolean isStripAuthorizationOnRedirect() { + return stripAuthorizationOnRedirect; + } + // ssl @Override public boolean isUseOpenSsl() { @@ -800,6 +808,7 @@ public static class Builder { private boolean useProxySelector = defaultUseProxySelector(); private boolean useProxyProperties = defaultUseProxyProperties(); private boolean validateResponseHeaders = defaultValidateResponseHeaders(); + private boolean stripAuthorizationOnRedirect = false; // default value // websocket private boolean aggregateWebSocketFrameFragments = defaultAggregateWebSocketFrameFragments(); @@ -891,6 +900,7 @@ public Builder(AsyncHttpClientConfig config) { keepEncodingHeader = config.isKeepEncodingHeader(); proxyServerSelector = config.getProxyServerSelector(); validateResponseHeaders = config.isValidateResponseHeaders(); + stripAuthorizationOnRedirect = config.isStripAuthorizationOnRedirect(); // websocket aggregateWebSocketFrameFragments = config.isAggregateWebSocketFrameFragments(); @@ -1079,6 +1089,11 @@ public Builder setUseProxyProperties(boolean useProxyProperties) { return this; } + public Builder setStripAuthorizationOnRedirect(boolean value) { + stripAuthorizationOnRedirect = value; + return this; + } + // websocket public Builder setAggregateWebSocketFrameFragments(boolean aggregateWebSocketFrameFragments) { this.aggregateWebSocketFrameFragments = aggregateWebSocketFrameFragments; @@ -1444,6 +1459,7 @@ public DefaultAsyncHttpClientConfig build() { validateResponseHeaders, aggregateWebSocketFrameFragments, enablewebSocketCompression, + stripAuthorizationOnRedirect, connectTimeout, requestTimeout, readTimeout, diff --git a/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java b/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java index 324a4ce34..291d81844 100644 --- a/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java +++ b/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java @@ -15,13 +15,13 @@ */ package org.asynchttpclient.channel; +import java.util.Objects; + import org.asynchttpclient.proxy.ProxyServer; import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.uri.Uri; import org.jetbrains.annotations.Nullable; -import java.util.Objects; - @FunctionalInterface public interface ChannelPoolPartitioning { @@ -50,7 +50,7 @@ public Object getPartitionKey(Uri uri, @Nullable String virtualHost, @Nullable P targetHostBaseUrl, virtualHost, proxyServer.getHost(), - uri.isSecured() && proxyServer.getProxyType() == ProxyType.HTTP ? + uri.isSecured() && proxyServer.getProxyType().isHttp() ? proxyServer.getSecuredPort() : proxyServer.getPort(), proxyServer.getProxyType()); @@ -111,7 +111,8 @@ public String toString() { ", virtualHost=" + virtualHost + ", proxyHost=" + proxyHost + ", proxyPort=" + proxyPort + - ", proxyType=" + proxyType; + ", proxyType=" + proxyType + + ")"; } } } diff --git a/client/src/main/java/org/asynchttpclient/handler/resumable/ResumableAsyncHandler.java b/client/src/main/java/org/asynchttpclient/handler/resumable/ResumableAsyncHandler.java index 6b8794547..55a5fba14 100644 --- a/client/src/main/java/org/asynchttpclient/handler/resumable/ResumableAsyncHandler.java +++ b/client/src/main/java/org/asynchttpclient/handler/resumable/ResumableAsyncHandler.java @@ -20,7 +20,6 @@ import org.asynchttpclient.RequestBuilder; import org.asynchttpclient.Response; import org.asynchttpclient.Response.ResponseBuilder; -import org.asynchttpclient.handler.TransferCompletionHandler; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,7 +46,7 @@ * Beware that it registers a shutdown hook, that will cause a ClassLoader leak when used in an appserver and only redeploying the application. */ public class ResumableAsyncHandler implements AsyncHandler { - private static final Logger logger = LoggerFactory.getLogger(TransferCompletionHandler.class); + private static final Logger LOGGER = LoggerFactory.getLogger(ResumableAsyncHandler.class); private static final ResumableIndexThread resumeIndexThread = new ResumableIndexThread(); private static Map resumableIndex = Collections.emptyMap(); @@ -125,7 +124,7 @@ public void onThrowable(Throwable t) { if (decoratedAsyncHandler != null) { decoratedAsyncHandler.onThrowable(t); } else { - logger.debug("", t); + LOGGER.debug("", t); } } diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java index c5e4a97d0..c29c0f33d 100755 --- a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java +++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java @@ -187,10 +187,10 @@ public boolean cancel(boolean force) { return false; } - // cancel could happen before channel was attached - if (channel != null) { - Channels.setDiscard(channel); - Channels.silentlyCloseChannel(channel); + final Channel ch = channel; //atomic read, so that it won't end up in TOCTOU + if (ch != null) { + Channels.setDiscard(ch); + Channels.silentlyCloseChannel(ch); } if (ON_THROWABLE_CALLED_FIELD.getAndSet(this, 1) == 0) { diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java index c5c94c551..8d13361ae 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -67,6 +67,7 @@ import org.asynchttpclient.netty.request.NettyRequestSender; import org.asynchttpclient.netty.ssl.DefaultSslEngineFactory; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.uri.Uri; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -164,8 +165,8 @@ public ChannelManager(final AsyncHttpClientConfig config, Timer nettyTimer) { transportFactory = new EpollTransportFactory(); } else if (isInstanceof(eventLoopGroup, "io.netty.channel.kqueue.KQueueEventLoopGroup")) { transportFactory = new KQueueTransportFactory(); - } else if (isInstanceof(eventLoopGroup, "io.netty.incubator.channel.uring.IOUringEventLoopGroup")) { - transportFactory = new IoUringIncubatorTransportFactory(); + } else if (isInstanceof(eventLoopGroup, "io.netty.channel.uring.IOUringEventLoopGroup")) { + transportFactory = new IoUringTransportFactory(); } else { throw new IllegalArgumentException("Unknown event loop group " + eventLoopGroup.getClass().getSimpleName()); } @@ -189,8 +190,8 @@ public ChannelManager(final AsyncHttpClientConfig config, Timer nettyTimer) { // We will check if Epoll is available or not. If available, return EpollTransportFactory. // If none of the condition matches then no native transport is available, and we will throw an exception. if (!PlatformDependent.isWindows()) { - if (IoUringIncubatorTransportFactory.isAvailable() && !config.isUseOnlyEpollNativeTransport()) { - return new IoUringIncubatorTransportFactory(); + if (IoUringTransportFactory.isAvailable() && !config.isUseOnlyEpollNativeTransport()) { + return new IoUringTransportFactory(); } else if (EpollTransportFactory.isAvailable()) { return new EpollTransportFactory(); } @@ -386,14 +387,68 @@ public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, } if (requestUri.isSecured()) { - if (!isSslHandlerConfigured(pipeline)) { - SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort()); - whenHandshaked = sslHandler.handshakeFuture(); - pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler); + // For HTTPS targets, we always need to add/replace the SSL handler for the target connection + // even if there's already an SSL handler in the pipeline (which would be for an HTTPS proxy) + if (isSslHandlerConfigured(pipeline)) { + // Remove existing SSL handler (for proxy) and replace with SSL handler for target + pipeline.remove(SSL_HANDLER); } + SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort()); + whenHandshaked = sslHandler.handshakeFuture(); + pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler); pipeline.addAfter(SSL_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec()); } else { + // For HTTP targets, remove any existing SSL handler (from HTTPS proxy) since target is not secured + if (isSslHandlerConfigured(pipeline)) { + pipeline.remove(SSL_HANDLER); + } + pipeline.addBefore(AHC_HTTP_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec()); + } + + if (requestUri.isWebSocket()) { + pipeline.addAfter(AHC_HTTP_HANDLER, AHC_WS_HANDLER, wsHandler); + + if (config.isEnableWebSocketCompression()) { + pipeline.addBefore(AHC_WS_HANDLER, WS_COMPRESSOR_HANDLER, WebSocketClientCompressionHandler.INSTANCE); + } + + pipeline.remove(AHC_HTTP_HANDLER); + } + return whenHandshaked; + } + + public Future updatePipelineForHttpsTunneling(ChannelPipeline pipeline, Uri requestUri, ProxyServer proxyServer) { + Future whenHandshaked = null; + + // Remove HTTP codec as tunnel is established + if (pipeline.get(HTTP_CLIENT_CODEC) != null) { + pipeline.remove(HTTP_CLIENT_CODEC); + } + + if (requestUri.isSecured()) { + // For HTTPS proxy to HTTPS target, we need to establish target SSL over the proxy SSL tunnel + // The proxy SSL handler should remain as it provides the tunnel transport + // We need to add target SSL handler that will negotiate with the target through the tunnel + + SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort()); + whenHandshaked = sslHandler.handshakeFuture(); + + // For HTTPS proxy tunnel, add target SSL handler after the existing proxy SSL handler + // This creates a nested SSL setup: Target SSL -> Proxy SSL -> Network + if (isSslHandlerConfigured(pipeline)) { + // Insert target SSL handler after the proxy SSL handler + pipeline.addAfter(SSL_HANDLER, "target-ssl", sslHandler); + } else { + // This shouldn't happen for HTTPS proxy, but fallback + pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler); + } + + pipeline.addAfter("target-ssl", HTTP_CLIENT_CODEC, newHttpClientCodec()); + + } else { + // For HTTPS proxy to HTTP target, just add HTTP codec + // The proxy SSL handler provides the tunnel and remains pipeline.addBefore(AHC_HTTP_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec()); } @@ -406,6 +461,7 @@ public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, pipeline.remove(AHC_HTTP_HANDLER); } + return whenHandshaked; } @@ -429,7 +485,8 @@ public SslHandler addSslHandler(ChannelPipeline pipeline, Uri uri, String virtua } SslHandler sslHandler = createSslHandler(peerHost, peerPort); - if (hasSocksProxyHandler) { + // Check if SOCKS handler actually exists in the pipeline before trying to add after it + if (hasSocksProxyHandler && pipeline.get(SOCKS_HANDLER) != null) { pipeline.addAfter(SOCKS_HANDLER, SSL_HANDLER, sslHandler); } else { pipeline.addFirst(SSL_HANDLER, sslHandler); @@ -486,6 +543,10 @@ protected void initChannel(Channel channel) throws Exception { } }); + } else if (proxy != null && ProxyType.HTTPS.equals(proxy.getProxyType())) { + // For HTTPS proxies, use HTTP bootstrap but ensure SSL connection to proxy + // The SSL handler for connecting to the proxy will be added in the connect phase + promise.setSuccess(httpBootstrap); } else { promise.setSuccess(httpBootstrap); } @@ -554,4 +615,4 @@ public ClientStats getClientStats() { public boolean isOpen() { return channelPool.isOpen(); } -} +} \ No newline at end of file diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/IoUringIncubatorTransportFactory.java b/client/src/main/java/org/asynchttpclient/netty/channel/IoUringTransportFactory.java similarity index 56% rename from client/src/main/java/org/asynchttpclient/netty/channel/IoUringIncubatorTransportFactory.java rename to client/src/main/java/org/asynchttpclient/netty/channel/IoUringTransportFactory.java index 2065ef10b..a93250185 100644 --- a/client/src/main/java/org/asynchttpclient/netty/channel/IoUringIncubatorTransportFactory.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/IoUringTransportFactory.java @@ -15,30 +15,31 @@ */ package org.asynchttpclient.netty.channel; -import io.netty.incubator.channel.uring.IOUring; -import io.netty.incubator.channel.uring.IOUringEventLoopGroup; -import io.netty.incubator.channel.uring.IOUringSocketChannel; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.uring.IoUring; +import io.netty.channel.uring.IoUringIoHandler; +import io.netty.channel.uring.IoUringSocketChannel; import java.util.concurrent.ThreadFactory; -class IoUringIncubatorTransportFactory implements TransportFactory { +class IoUringTransportFactory implements TransportFactory { static boolean isAvailable() { try { - Class.forName("io.netty.incubator.channel.uring.IOUring"); + Class.forName("io.netty.channel.uring.IoUring"); } catch (ClassNotFoundException e) { return false; } - return IOUring.isAvailable(); + return IoUring.isAvailable(); } @Override - public IOUringSocketChannel newChannel() { - return new IOUringSocketChannel(); + public IoUringSocketChannel newChannel() { + return new IoUringSocketChannel(); } @Override - public IOUringEventLoopGroup newEventLoopGroup(int ioThreadsCount, ThreadFactory threadFactory) { - return new IOUringEventLoopGroup(ioThreadsCount, threadFactory); + public MultiThreadIoEventLoopGroup newEventLoopGroup(int ioThreadsCount, ThreadFactory threadFactory) { + return new MultiThreadIoEventLoopGroup(ioThreadsCount, threadFactory, IoUringIoHandler.newFactory()); } } diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java b/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java index 719733f8a..2b6a840f5 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java @@ -26,6 +26,7 @@ import org.asynchttpclient.netty.request.NettyRequestSender; import org.asynchttpclient.netty.timeout.TimeoutsHolder; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.uri.Uri; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -100,8 +101,57 @@ public void onSuccess(Channel channel, InetSocketAddress remoteAddress) { timeoutsHolder.setResolvedRemoteAddress(remoteAddress); ProxyServer proxyServer = future.getProxyServer(); + // For HTTPS proxies, establish SSL connection to the proxy server first + if (proxyServer != null && ProxyType.HTTPS.equals(proxyServer.getProxyType())) { + SslHandler sslHandler; + try { + sslHandler = channelManager.addSslHandler(channel.pipeline(), + Uri.create("https://" + proxyServer.getHost() + ":" + proxyServer.getSecuredPort()), + null, false); + } catch (Exception sslError) { + onFailure(channel, sslError); + return; + } + + final AsyncHandler asyncHandler = future.getAsyncHandler(); + + try { + asyncHandler.onTlsHandshakeAttempt(); + } catch (Exception e) { + LOGGER.error("onTlsHandshakeAttempt crashed", e); + onFailure(channel, e); + return; + } + + sslHandler.handshakeFuture().addListener(new SimpleFutureListener() { + @Override + protected void onSuccess(Channel value) { + try { + asyncHandler.onTlsHandshakeSuccess(sslHandler.engine().getSession()); + } catch (Exception e) { + LOGGER.error("onTlsHandshakeSuccess crashed", e); + NettyConnectListener.this.onFailure(channel, e); + return; + } + // After SSL handshake to proxy, continue with normal proxy request + writeRequest(channel); + } + + @Override + protected void onFailure(Throwable cause) { + try { + asyncHandler.onTlsHandshakeFailure(cause); + } catch (Exception e) { + LOGGER.error("onTlsHandshakeFailure crashed", e); + NettyConnectListener.this.onFailure(channel, e); + return; + } + NettyConnectListener.this.onFailure(channel, cause); + } + }); + // in case of proxy tunneling, we'll add the SslHandler later, after the CONNECT request - if ((proxyServer == null || proxyServer.getProxyType().isSocks()) && uri.isSecured()) { + } else if ((proxyServer == null || proxyServer.getProxyType().isSocks()) && uri.isSecured()) { SslHandler sslHandler; try { sslHandler = channelManager.addSslHandler(channel.pipeline(), uri, request.getVirtualHost(), proxyServer != null); diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java b/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java index 06ec46a2b..99a23c7e9 100755 --- a/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java @@ -21,6 +21,7 @@ import io.netty.handler.codec.DecoderResultProvider; import io.netty.handler.codec.http.HttpContent; import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpResponse; import io.netty.handler.codec.http.LastHttpContent; @@ -32,6 +33,7 @@ import org.asynchttpclient.netty.NettyResponseStatus; import org.asynchttpclient.netty.channel.ChannelManager; import org.asynchttpclient.netty.request.NettyRequestSender; +import org.asynchttpclient.util.HttpConstants.ResponseStatusCodes; import java.io.IOException; import java.net.InetSocketAddress; @@ -43,8 +45,11 @@ public HttpHandler(AsyncHttpClientConfig config, ChannelManager channelManager, super(config, channelManager, requestSender); } - private static boolean abortAfterHandlingStatus(AsyncHandler handler, NettyResponseStatus status) throws Exception { - return handler.onStatusReceived(status) == State.ABORT; + private static boolean abortAfterHandlingStatus(AsyncHandler handler, HttpMethod httpMethod, NettyResponseStatus status) throws Exception { + // For non-200 response of a CONNECT request, it's still unconnected. + // We need to either close the connection or reuse it but send CONNECT request again. + // The former one is easier or we have to attach more state to Channel. + return handler.onStatusReceived(status) == State.ABORT || httpMethod == HttpMethod.CONNECT && status.getStatusCode() != ResponseStatusCodes.OK_200; } private static boolean abortAfterHandlingHeaders(AsyncHandler handler, HttpHeaders responseHeaders) throws Exception { @@ -61,7 +66,7 @@ private void handleHttpResponse(final HttpResponse response, final Channel chann HttpHeaders responseHeaders = response.headers(); if (!interceptors.exitAfterIntercept(channel, future, handler, response, status, responseHeaders)) { - boolean abort = abortAfterHandlingStatus(handler, status) || abortAfterHandlingHeaders(handler, responseHeaders); + boolean abort = abortAfterHandlingStatus(handler, httpRequest.method(), status) || abortAfterHandlingHeaders(handler, responseHeaders); if (abort) { finishUpdate(future, channel, true); } diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ConnectSuccessInterceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ConnectSuccessInterceptor.java index 22e29dbfb..bf64e5909 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ConnectSuccessInterceptor.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ConnectSuccessInterceptor.java @@ -22,6 +22,7 @@ import org.asynchttpclient.netty.channel.ChannelManager; import org.asynchttpclient.netty.request.NettyRequestSender; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.uri.Uri; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,7 +46,18 @@ public boolean exitAfterHandlingConnect(Channel channel, NettyResponseFuture Uri requestUri = request.getUri(); LOGGER.debug("Connecting to proxy {} for scheme {}", proxyServer, requestUri.getScheme()); - final Future whenHandshaked = channelManager.updatePipelineForHttpTunneling(channel.pipeline(), requestUri); + + final Future whenHandshaked; + + // Special handling for HTTPS proxy tunneling + if (proxyServer != null && ProxyType.HTTPS.equals(proxyServer.getProxyType())) { + // For HTTPS proxy, we need special tunnel pipeline management + whenHandshaked = channelManager.updatePipelineForHttpsTunneling(channel.pipeline(), requestUri, proxyServer); + } else { + // Standard HTTP proxy or SOCKS proxy tunneling + whenHandshaked = channelManager.updatePipelineForHttpTunneling(channel.pipeline(), requestUri); + } + future.setReuseChannel(true); future.setConnectAllowed(false); diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java index e60495f80..40628a7e5 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java @@ -35,7 +35,6 @@ import org.slf4j.LoggerFactory; import java.util.HashSet; -import java.util.List; import java.util.Set; import static io.netty.handler.codec.http.HttpHeaderNames.AUTHORIZATION; @@ -73,11 +72,13 @@ public class Redirect30xInterceptor { private final AsyncHttpClientConfig config; private final NettyRequestSender requestSender; private final MaxRedirectException maxRedirectException; + private final boolean stripAuthorizationOnRedirect; Redirect30xInterceptor(ChannelManager channelManager, AsyncHttpClientConfig config, NettyRequestSender requestSender) { this.channelManager = channelManager; this.config = config; this.requestSender = requestSender; + stripAuthorizationOnRedirect = config.isStripAuthorizationOnRedirect(); // New flag maxRedirectException = unknownStackTrace(new MaxRedirectException("Maximum redirect reached: " + config.getMaxRedirects()), Redirect30xInterceptor.class, "exitAfterHandlingRedirect"); } @@ -127,7 +128,7 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture } } - requestBuilder.setHeaders(propagatedHeaders(request, realm, keepBody)); + requestBuilder.setHeaders(propagatedHeaders(request, realm, keepBody, stripAuthorizationOnRedirect)); // in case of a redirect from HTTP to HTTPS, future // attributes might change @@ -180,7 +181,7 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture return false; } - private static HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody) { + private static HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody, boolean stripAuthorization) { HttpHeaders headers = request.getHeaders() .remove(HOST) .remove(CONTENT_LENGTH); @@ -189,7 +190,7 @@ private static HttpHeaders propagatedHeaders(Request request, Realm realm, boole headers.remove(CONTENT_TYPE); } - if (realm != null && realm.getScheme() == AuthScheme.NTLM) { + if (stripAuthorization || (realm != null && realm.getScheme() == AuthScheme.NTLM)) { headers.remove(AUTHORIZATION) .remove(PROXY_AUTHORIZATION); } diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java index 9fff868b2..c929d35e2 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -54,6 +54,7 @@ import org.asynchttpclient.netty.channel.NettyConnectListener; import org.asynchttpclient.netty.timeout.TimeoutsHolder; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.resolver.RequestHostnameResolver; import org.asynchttpclient.uri.Uri; import org.asynchttpclient.ws.WebSocketUpgradeHandler; @@ -97,6 +98,13 @@ public NettyRequestSender(AsyncHttpClientConfig config, ChannelManager channelMa requestFactory = new NettyRequestFactory(config); } + // needConnect returns true if the request is secure/websocket and a HTTP proxy is set + private boolean needConnect(final Request request, final ProxyServer proxyServer) { + return proxyServer != null + && proxyServer.getProxyType().isHttp() + && (request.getUri().isSecured() || request.getUri().isWebSocket()); + } + public ListenableFuture sendRequest(final Request request, final AsyncHandler asyncHandler, NettyResponseFuture future) { if (isClosed()) { throw new IllegalStateException("Closed"); @@ -106,9 +114,7 @@ public ListenableFuture sendRequest(final Request request, final AsyncHan ProxyServer proxyServer = getProxyServer(config, request); // WebSockets use connect tunneling to work with proxies - if (proxyServer != null && proxyServer.getProxyType().isHttp() && - (request.getUri().isSecured() || request.getUri().isWebSocket()) && - !isConnectAlreadyDone(request, future)) { + if (needConnect(request, proxyServer) && !isConnectAlreadyDone(request, future)) { // Proxy with HTTPS or WebSocket: CONNECT for sure if (future != null && future.isConnectAllowed()) { // Perform CONNECT @@ -125,6 +131,8 @@ public ListenableFuture sendRequest(final Request request, final AsyncHan private static boolean isConnectAlreadyDone(Request request, NettyResponseFuture future) { return future != null + // If the channel can't be reused or closed, a CONNECT is still required + && future.isReuseChannel() && Channels.isChannelActive(future.channel()) && future.getNettyRequest() != null && future.getNettyRequest().getHttpRequest().method() == HttpMethod.CONNECT && !request.getMethod().equals(CONNECT); @@ -137,11 +145,19 @@ private static boolean isConnectAlreadyDone(Request request, NettyResponseFuture */ private ListenableFuture sendRequestWithCertainForceConnect(Request request, AsyncHandler asyncHandler, NettyResponseFuture future, ProxyServer proxyServer, boolean performConnectRequest) { - NettyResponseFuture newFuture = newNettyRequestAndResponseFuture(request, asyncHandler, future, proxyServer, performConnectRequest); Channel channel = getOpenChannel(future, request, proxyServer, asyncHandler); - return Channels.isChannelActive(channel) - ? sendRequestWithOpenChannel(newFuture, asyncHandler, channel) - : sendRequestWithNewChannel(request, proxyServer, newFuture, asyncHandler); + if (Channels.isChannelActive(channel)) { + NettyResponseFuture newFuture = newNettyRequestAndResponseFuture(request, asyncHandler, future, + proxyServer, performConnectRequest); + return sendRequestWithOpenChannel(newFuture, asyncHandler, channel); + } else { + // A new channel is not expected when performConnectRequest is false. We need to + // revisit the condition of sending + // the CONNECT request to the new channel. + NettyResponseFuture newFuture = newNettyRequestAndResponseFuture(request, asyncHandler, future, + proxyServer, needConnect(request, proxyServer)); + return sendRequestWithNewChannel(request, proxyServer, newFuture, asyncHandler); + } } /** @@ -322,7 +338,7 @@ private Future> resolveAddresses(Request request, Pr final Promise> promise = ImmediateEventExecutor.INSTANCE.newPromise(); if (proxy != null && !proxy.isIgnoredForHost(uri.getHost()) && proxy.getProxyType().isHttp()) { - int port = uri.isSecured() ? proxy.getSecuredPort() : proxy.getPort(); + int port = ProxyType.HTTPS.equals(proxy.getProxyType()) || uri.isSecured() ? proxy.getSecuredPort() : proxy.getPort(); InetSocketAddress unresolvedRemoteAddress = InetSocketAddress.createUnresolved(proxy.getHost(), port); scheduleRequestTimeout(future, unresolvedRemoteAddress); return RequestHostnameResolver.INSTANCE.resolve(request.getNameResolver(), unresolvedRemoteAddress, asyncHandler); diff --git a/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java b/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java index a96f6ffb1..323b75d5d 100644 --- a/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java +++ b/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java @@ -58,6 +58,9 @@ private SslContext buildSslContext(AsyncHttpClientConfig config) throws SSLExcep sslContextBuilder.trustManager(InsecureTrustManagerFactory.INSTANCE); } + sslContextBuilder.endpointIdentificationAlgorithm( + config.isDisableHttpsEndpointIdentificationAlgorithm() ? "" : "HTTPS"); + return configureSslContextBuilder(sslContextBuilder).build(); } diff --git a/client/src/main/java/org/asynchttpclient/netty/ssl/SslEngineFactoryBase.java b/client/src/main/java/org/asynchttpclient/netty/ssl/SslEngineFactoryBase.java index 2d6e5f5ef..7e55ac4de 100644 --- a/client/src/main/java/org/asynchttpclient/netty/ssl/SslEngineFactoryBase.java +++ b/client/src/main/java/org/asynchttpclient/netty/ssl/SslEngineFactoryBase.java @@ -19,7 +19,6 @@ import org.asynchttpclient.SslEngineFactory; import javax.net.ssl.SSLEngine; -import javax.net.ssl.SSLParameters; public abstract class SslEngineFactoryBase implements SslEngineFactory { @@ -30,10 +29,5 @@ protected String domain(String hostname) { protected void configureSslEngine(SSLEngine sslEngine, AsyncHttpClientConfig config) { sslEngine.setUseClientMode(true); - if (!config.isDisableHttpsEndpointIdentificationAlgorithm()) { - SSLParameters params = sslEngine.getSSLParameters(); - params.setEndpointIdentificationAlgorithm("HTTPS"); - sslEngine.setSSLParameters(params); - } } } diff --git a/client/src/main/java/org/asynchttpclient/proxy/ProxyType.java b/client/src/main/java/org/asynchttpclient/proxy/ProxyType.java index d1f74e70d..0963eda8c 100644 --- a/client/src/main/java/org/asynchttpclient/proxy/ProxyType.java +++ b/client/src/main/java/org/asynchttpclient/proxy/ProxyType.java @@ -16,7 +16,7 @@ package org.asynchttpclient.proxy; public enum ProxyType { - HTTP(true), SOCKS_V4(false), SOCKS_V5(false); + HTTP(true), HTTPS(true), SOCKS_V4(false), SOCKS_V5(false); private final boolean http; diff --git a/client/src/test/java/org/asynchttpclient/AutomaticDecompressionTest.java b/client/src/test/java/org/asynchttpclient/AutomaticDecompressionTest.java index dfd0a9446..8f57ffb88 100644 --- a/client/src/test/java/org/asynchttpclient/AutomaticDecompressionTest.java +++ b/client/src/test/java/org/asynchttpclient/AutomaticDecompressionTest.java @@ -22,6 +22,7 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; +import io.github.nettyplus.leakdetector.junit.NettyLeakDetectorExtension; import io.netty.handler.codec.compression.Brotli; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -35,11 +36,13 @@ import java.util.List; import java.util.stream.Collectors; import java.util.zip.GZIPOutputStream; +import org.junit.jupiter.api.extension.ExtendWith; import static org.junit.jupiter.api.Assertions.assertEquals; +@ExtendWith(NettyLeakDetectorExtension.class) public class AutomaticDecompressionTest { - private static final String UNCOMPRESSED_PAYLOAD = "a".repeat(500); + private static final String UNCOMPRESSED_PAYLOAD = "a".repeat(50_000); private static HttpServer HTTP_SERVER; diff --git a/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientConfigTest.java b/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientConfigTest.java new file mode 100644 index 000000000..1548d6812 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientConfigTest.java @@ -0,0 +1,30 @@ +package org.asynchttpclient; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DefaultAsyncHttpClientConfigTest { + @Test + void testStripAuthorizationOnRedirect_DefaultIsFalse() { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder().build(); + assertFalse(config.isStripAuthorizationOnRedirect(), "Default should be false"); + } + + @Test + void testStripAuthorizationOnRedirect_SetTrue() { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setStripAuthorizationOnRedirect(true) + .build(); + assertTrue(config.isStripAuthorizationOnRedirect(), "Should be true when set"); + } + + @Test + void testStripAuthorizationOnRedirect_SetFalse() { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setStripAuthorizationOnRedirect(false) + .build(); + assertFalse(config.isStripAuthorizationOnRedirect(), "Should be false when set to false"); + } +} diff --git a/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java b/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java index fc7a1c2db..f2f89d3f9 100644 --- a/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java +++ b/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java @@ -16,9 +16,9 @@ package org.asynchttpclient; import io.github.artsok.RepeatedIfExceptionsTest; +import io.netty.channel.MultiThreadIoEventLoopGroup; import io.netty.channel.epoll.EpollEventLoopGroup; import io.netty.channel.kqueue.KQueueEventLoopGroup; -import io.netty.incubator.channel.uring.IOUringEventLoopGroup; import io.netty.util.Timer; import org.asynchttpclient.cookie.CookieEvictionTask; import org.asynchttpclient.cookie.CookieStore; @@ -61,7 +61,7 @@ public void testNativeTransportWithoutEpollOnly() throws Exception { AsyncHttpClientConfig config = config().setUseNativeTransport(true).setUseOnlyEpollNativeTransport(false).build(); try (DefaultAsyncHttpClient client = (DefaultAsyncHttpClient) asyncHttpClient(config)) { assertDoesNotThrow(() -> client.prepareGet("https://www.google.com").execute().get()); - assertInstanceOf(IOUringEventLoopGroup.class, client.channelManager().getEventLoopGroup()); + assertInstanceOf(MultiThreadIoEventLoopGroup.class, client.channelManager().getEventLoopGroup()); } } diff --git a/client/src/test/java/org/asynchttpclient/MultipleHeaderTest.java b/client/src/test/java/org/asynchttpclient/MultipleHeaderTest.java index cf6dbc353..6414f6e4f 100644 --- a/client/src/test/java/org/asynchttpclient/MultipleHeaderTest.java +++ b/client/src/test/java/org/asynchttpclient/MultipleHeaderTest.java @@ -16,6 +16,7 @@ import io.netty.handler.codec.http.HttpHeaders; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import javax.net.ServerSocketFactory; import java.io.BufferedReader; @@ -39,6 +40,7 @@ /** * @author Hubert Iwaniuk */ +@Disabled("New Netty Release Prevent Invalid Line in HTTP Header") public class MultipleHeaderTest extends AbstractBasicTest { private static ExecutorService executorService; private static ServerSocket serverSocket; diff --git a/client/src/test/java/org/asynchttpclient/StripAuthorizationOnRedirectHttpTest.java b/client/src/test/java/org/asynchttpclient/StripAuthorizationOnRedirectHttpTest.java new file mode 100644 index 000000000..08c150c08 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/StripAuthorizationOnRedirectHttpTest.java @@ -0,0 +1,95 @@ +package org.asynchttpclient; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class StripAuthorizationOnRedirectHttpTest { + private static HttpServer server; + private static int port; + private static volatile String lastAuthHeader; + + @BeforeAll + public static void startServer() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + port = server.getAddress().getPort(); + server.createContext("/redirect", new RedirectHandler()); + server.createContext("/final", new FinalHandler()); + server.start(); + } + + @AfterAll + public static void stopServer() { + server.stop(0); + } + + static class RedirectHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) { + String auth = exchange.getRequestHeaders().getFirst("Authorization"); + lastAuthHeader = auth; + exchange.getResponseHeaders().add("Location", "http://localhost:" + port + "/final"); + try { + exchange.sendResponseHeaders(302, -1); + } catch (Exception ignored) { + } + exchange.close(); + } + } + + static class FinalHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) { + String auth = exchange.getRequestHeaders().getFirst("Authorization"); + lastAuthHeader = auth; + try { + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().close(); + } catch (Exception ignored) { + } + exchange.close(); + } + } + + @Test + void testAuthHeaderPropagatedByDefault() throws Exception { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setFollowRedirect(true) + .build(); + try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) { + lastAuthHeader = null; + client.prepareGet("http://localhost:" + port + "/redirect") + .setHeader("Authorization", "Bearer testtoken") + .execute() + .get(5, TimeUnit.SECONDS); + // By default, Authorization header is propagated to /final + assertEquals("Bearer testtoken", lastAuthHeader, "Authorization header should be present on redirect by default"); + } + } + + @Test + void testAuthHeaderStrippedWhenEnabled() throws Exception { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setFollowRedirect(true) + .setStripAuthorizationOnRedirect(true) + .build(); + try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) { + lastAuthHeader = null; + client.prepareGet("http://localhost:" + port + "/redirect") + .setHeader("Authorization", "Bearer testtoken") + .execute() + .get(5, TimeUnit.SECONDS); + // When enabled, Authorization header should be stripped on /final + assertNull(lastAuthHeader, "Authorization header should be stripped on redirect when enabled"); + } + } +} diff --git a/client/src/test/java/org/asynchttpclient/netty/NettyTest.java b/client/src/test/java/org/asynchttpclient/netty/NettyTest.java index f80c0911e..c7d7e1d1d 100644 --- a/client/src/test/java/org/asynchttpclient/netty/NettyTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/NettyTest.java @@ -2,9 +2,9 @@ import io.netty.channel.epoll.Epoll; import io.netty.channel.kqueue.KQueue; +import io.netty.channel.uring.IoUring; import io.netty.handler.codec.compression.Brotli; import io.netty.handler.codec.compression.Zstd; -import io.netty.incubator.channel.uring.IOUring; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledOnOs; import org.junit.jupiter.api.condition.OS; @@ -21,7 +21,7 @@ public void epollIsAvailableOnLinux() { @Test @EnabledOnOs(OS.LINUX) public void ioUringIsAvailableOnLinux() { - assertTrue(IOUring.isAvailable()); + assertTrue(IoUring.isAvailable()); } @Test diff --git a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyBasicTest.java b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyBasicTest.java new file mode 100644 index 000000000..29876708e --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyBasicTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.proxy; + +import io.github.artsok.RepeatedIfExceptionsTest; +import org.asynchttpclient.channel.ChannelPoolPartitioning; +import org.asynchttpclient.uri.Uri; + +import static org.asynchttpclient.Dsl.proxyServer; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Basic tests for HTTPS proxy type functionality without network calls. + */ +public class HttpsProxyBasicTest { + + @RepeatedIfExceptionsTest(repeats = 5) + public void testHttpsProxyTypeConfiguration() throws Exception { + // Test that HTTPS proxy type can be configured correctly + ProxyServer.Builder builder = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTPS); + + ProxyServer proxy = builder.build(); + + assertEquals(ProxyType.HTTPS, proxy.getProxyType()); + assertEquals(true, proxy.getProxyType().isHttp()); + assertEquals(8443, proxy.getSecuredPort()); + assertEquals(8080, proxy.getPort()); + assertEquals("proxy.example.com", proxy.getHost()); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testHttpsProxyTypeDefaultSecuredPort() { + // Test HTTPS proxy type with default secured port + ProxyServer proxy = proxyServer("proxy.example.com", 8080) + .setProxyType(ProxyType.HTTPS) + .build(); + + assertEquals(ProxyType.HTTPS, proxy.getProxyType()); + assertEquals(true, proxy.getProxyType().isHttp()); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testChannelPoolPartitioningWithHttpsProxy() { + // Test that HTTPS proxy creates correct partition keys for connection pooling + ProxyServer httpsProxy = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTPS) + .build(); + + Uri targetUri = Uri.create("https://target.example.com/test"); + ChannelPoolPartitioning partitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; + + Object partitionKey = partitioning.getPartitionKey(targetUri, null, httpsProxy); + + assertNotNull(partitionKey); + // The partition key should include the secured port for HTTPS proxy with HTTPS target + assertTrue(partitionKey.toString().contains("8443")); + assertTrue(partitionKey.toString().contains("HTTPS")); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testChannelPoolPartitioningHttpsProxyHttpTarget() { + // Test HTTPS proxy with HTTP target - should use normal port + ProxyServer httpsProxy = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTPS) + .build(); + + Uri targetUri = Uri.create("http://target.example.com/test"); + ChannelPoolPartitioning partitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; + + Object partitionKey = partitioning.getPartitionKey(targetUri, null, httpsProxy); + + assertNotNull(partitionKey); + // For HTTP target, should use normal proxy port + assertTrue(partitionKey.toString().contains("8080")); + assertTrue(partitionKey.toString().contains("HTTPS")); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testChannelPoolPartitioningWithHttpProxy() { + // Test that HTTP proxy creates correct partition keys for connection pooling + ProxyServer httpProxy = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTP) + .build(); + + Uri targetUri = Uri.create("https://target.example.com/test"); + ChannelPoolPartitioning partitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; + + Object partitionKey = partitioning.getPartitionKey(targetUri, null, httpProxy); + + assertNotNull(partitionKey); + // For HTTP proxy with secured target, should use secured port + assertTrue(partitionKey.toString().contains("8443")); + assertTrue(partitionKey.toString().contains("HTTP")); + } +} diff --git a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyIntegrationTest.java b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyIntegrationTest.java new file mode 100644 index 000000000..ef4614ba1 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyIntegrationTest.java @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2025 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.proxy; + +import io.github.artsok.RepeatedIfExceptionsTest; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.asynchttpclient.AbstractBasicTest; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.AsyncHttpClientConfig; +import org.asynchttpclient.RequestBuilder; +import org.asynchttpclient.Response; +import org.asynchttpclient.channel.ChannelPoolPartitioning; +import org.asynchttpclient.request.body.generator.ByteArrayBodyGenerator; +import org.asynchttpclient.test.EchoHandler; +import org.asynchttpclient.uri.Uri; +import org.asynchttpclient.util.HttpConstants; +import org.eclipse.jetty.proxy.ConnectHandler; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.AbstractHandler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import static org.asynchttpclient.Dsl.asyncHttpClient; +import static org.asynchttpclient.Dsl.config; +import static org.asynchttpclient.Dsl.get; +import static org.asynchttpclient.Dsl.post; +import static org.asynchttpclient.Dsl.proxyServer; +import static org.asynchttpclient.test.TestUtils.LARGE_IMAGE_BYTES; +import static org.asynchttpclient.test.TestUtils.addHttpConnector; +import static org.asynchttpclient.test.TestUtils.addHttpsConnector; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Comprehensive integration tests for HTTPS proxy functionality. + * Tests both HTTP and HTTPS proxy types to ensure functionality and compatibility. + */ +public class HttpsProxyIntegrationTest extends AbstractBasicTest { + + private List servers; + private int httpsProxyPort; + + @Override + public AbstractHandler configureHandler() throws Exception { + return new ProxyHandler(); + } + + /** + * Provides test parameters for HTTP proxy type only for now + * TODO: Add HTTPS proxy type once SSL bootstrap is implemented + */ + static Stream proxyTypeProvider() { + return Stream.of( + Arguments.of("HTTP Proxy", ProxyType.HTTP) + // Arguments.of("HTTPS Proxy", ProxyType.HTTPS) // TODO: Enable once HTTPS proxy SSL bootstrap is working + ); + } + + @Override + @BeforeEach + public void setUpGlobal() throws Exception { + servers = new ArrayList<>(); + + // Start HTTP proxy server + port1 = startServer(configureHandler(), false); + + // Start HTTPS target server + port2 = startServer(new EchoHandler(), true); + + // Start HTTPS proxy server + httpsProxyPort = startServer(configureHandler(), true); + + logger.info("Integration test servers started: HTTP proxy={}, HTTPS proxy={}, HTTPS target={}", + port1, httpsProxyPort, port2); + } + + private int startServer(Handler handler, boolean secure) throws Exception { + Server server = new Server(); + @SuppressWarnings("resource") + ServerConnector connector = secure ? addHttpsConnector(server) : addHttpConnector(server); + server.setHandler(handler); + server.start(); + servers.add(server); + return connector.getLocalPort(); + } + + @Override + @AfterEach + public void tearDownGlobal() { + servers.forEach(server -> { + try { + server.stop(); + } catch (Exception e) { + // couldn't stop server + } + }); + } + + @ParameterizedTest(name = "{0} - Basic Request") + @MethodSource("proxyTypeProvider") + public void testBasicRequestThroughProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : port1; + + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true))) { + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType)); + Response response = client.executeRequest(rb.build()).get(); + assertEquals(200, response.getStatusCode()); + + // Verify that the request went through the proxy + assertNotNull(response); + } + } + + @ParameterizedTest(name = "{0} - Multiple Requests") + @MethodSource("proxyTypeProvider") + public void testMultipleRequestsThroughProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : port1; + + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true).setKeepAlive(true))) { + ProxyServer proxy = proxyServer("localhost", proxyPort).setProxyType(proxyType).build(); + + // Execute multiple requests to test connection reuse + for (int i = 0; i < 3; i++) { + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxy); + Response response = client.executeRequest(rb.build()).get(); + assertEquals(200, response.getStatusCode(), "Request " + (i + 1) + " failed"); + } + } + } + + @ParameterizedTest(name = "{0} - Large Body") + @MethodSource("proxyTypeProvider") + public void testLargeRequestBodyThroughProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : port1; + + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true))) { + ProxyServer proxy = proxyServer("localhost", proxyPort).setProxyType(proxyType).build(); + + RequestBuilder rb = post(getTargetUrl2()) + .setProxyServer(proxy) + .setBody(new ByteArrayBodyGenerator(LARGE_IMAGE_BYTES)); + + Response response = client.executeRequest(rb.build()).get(); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().length() > 0); + } + } + + @ParameterizedTest(name = "{0} - Timeout Configuration") + @MethodSource("proxyTypeProvider") + public void testProxyTimeoutConfiguration(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : port1; + + AsyncHttpClientConfig config = config() + .setFollowRedirect(true) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofSeconds(5)) + .setRequestTimeout(Duration.ofSeconds(10)) + .build(); + + try (AsyncHttpClient client = asyncHttpClient(config)) { + ProxyServer proxy = proxyServer("localhost", proxyPort).setProxyType(proxyType).build(); + + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxy); + Response response = client.executeRequest(rb.build()).get(15, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testChannelPoolPartitioningWithHttpsProxy() throws Exception { + // Test that HTTPS proxy creates correct partition keys for connection pooling + ProxyServer httpsProxy = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTPS) + .build(); + + Uri targetUri = Uri.create("https://target.example.com/test"); + ChannelPoolPartitioning partitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; + + Object partitionKey = partitioning.getPartitionKey(targetUri, null, httpsProxy); + + assertNotNull(partitionKey); + // The partition key should include the secured port for HTTPS proxy + assertTrue(partitionKey.toString().contains("8443")); + assertTrue(partitionKey.toString().contains("HTTPS")); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testChannelPoolPartitioningWithHttpProxy() throws Exception { + // Test that HTTP proxy creates correct partition keys for connection pooling + ProxyServer httpProxy = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTP) + .build(); + + Uri targetUri = Uri.create("https://target.example.com/test"); + ChannelPoolPartitioning partitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; + + Object partitionKey = partitioning.getPartitionKey(targetUri, null, httpProxy); + + assertNotNull(partitionKey); + // For HTTP proxy with secured target, should use secured port + assertTrue(partitionKey.toString().contains("8443")); + assertTrue(partitionKey.toString().contains("HTTP")); + } + + public static class ProxyHandler extends ConnectHandler { + final static String HEADER_FORBIDDEN = "X-REJECT-REQUEST"; + + @Override + public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + if (HttpConstants.Methods.CONNECT.equalsIgnoreCase(request.getMethod())) { + String headerValue = request.getHeader(HEADER_FORBIDDEN); + if (headerValue == null) { + headerValue = ""; + } + switch (headerValue) { + case "1": + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + r.setHandled(true); + return; + case "2": + r.getHttpChannel().getConnection().close(); + r.setHandled(true); + return; + } + } + super.handle(s, r, request, response); + } + } +} diff --git a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTest.java b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTest.java index 6c4109aec..a6d4b6985 100644 --- a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTest.java +++ b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTest.java @@ -13,19 +13,36 @@ package org.asynchttpclient.proxy; import io.github.artsok.RepeatedIfExceptionsTest; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.asynchttpclient.AbstractBasicTest; import org.asynchttpclient.AsyncHttpClient; import org.asynchttpclient.AsyncHttpClientConfig; import org.asynchttpclient.RequestBuilder; import org.asynchttpclient.Response; +import org.asynchttpclient.proxy.ProxyServer.Builder; import org.asynchttpclient.request.body.generator.ByteArrayBodyGenerator; import org.asynchttpclient.test.EchoHandler; +import org.asynchttpclient.util.HttpConstants; import org.eclipse.jetty.proxy.ConnectHandler; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.handler.AbstractHandler; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.stream.Stream; import static org.asynchttpclient.Dsl.asyncHttpClient; import static org.asynchttpclient.Dsl.config; @@ -36,58 +53,95 @@ import static org.asynchttpclient.test.TestUtils.addHttpConnector; import static org.asynchttpclient.test.TestUtils.addHttpsConnector; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; /** * Proxy usage tests. */ public class HttpsProxyTest extends AbstractBasicTest { - private Server server2; + private List servers; + private int proxyPort; + private int httpsProxyPort; @Override public AbstractHandler configureHandler() throws Exception { - return new ConnectHandler(); + return new ProxyHandler(); + } + + /** + * Provides test parameters for HTTP proxy type working, HTTPS proxy tests added but with known SSL bootstrap issue + */ + static Stream proxyTypeProvider() { + return Stream.of( + Arguments.of("HTTP Proxy", ProxyType.HTTP) + // Note: HTTPS proxy tests will be enabled once SSL bootstrap implementation is completed + // Arguments.of("HTTPS Proxy", ProxyType.HTTPS) + ); } @Override @BeforeEach public void setUpGlobal() throws Exception { - server = new Server(); - ServerConnector connector = addHttpConnector(server); - server.setHandler(configureHandler()); - server.start(); - port1 = connector.getLocalPort(); + servers = new ArrayList<>(); + + // Start HTTP target server + port1 = startServer(new EchoHandler(), false); + + // Start HTTPS target server + port2 = startServer(new EchoHandler(), true); + + // Start HTTP proxy server + proxyPort = startServer(configureHandler(), false); + + // Start HTTPS proxy server + httpsProxyPort = startServer(configureHandler(), true); - server2 = new Server(); - ServerConnector connector2 = addHttpsConnector(server2); - server2.setHandler(new EchoHandler()); - server2.start(); - port2 = connector2.getLocalPort(); + logger.info("Local servers started successfully"); + } - logger.info("Local HTTP server started successfully"); + private int startServer(Handler handler, boolean secure) throws Exception { + Server server = new Server(); + @SuppressWarnings("resource") + ServerConnector connector = secure ? addHttpsConnector(server) : addHttpConnector(server); + server.setHandler(handler); + server.start(); + servers.add(server); + return connector.getLocalPort(); } @Override @AfterEach - public void tearDownGlobal() throws Exception { - server.stop(); - server2.stop(); + public void tearDownGlobal() { + servers.forEach(server -> { + try { + server.stop(); + } catch (Exception e) { + // couldn't stop server + } + }); } - @RepeatedIfExceptionsTest(repeats = 5) - public void testRequestProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testRequestProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true))) { - RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer("localhost", port1)); + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType)); Response response = client.executeRequest(rb.build()).get(); assertEquals(200, response.getStatusCode()); } } - @RepeatedIfExceptionsTest(repeats = 5) - public void testConfigProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testConfigProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + AsyncHttpClientConfig config = config() .setFollowRedirect(true) - .setProxyServer(proxyServer("localhost", port1).build()) + .setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType).build()) .setUseInsecureTrustManager(true) .build(); @@ -97,11 +151,14 @@ public void testConfigProxy() throws Exception { } } - @RepeatedIfExceptionsTest(repeats = 5) - public void testNoDirectRequestBodyWithProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testNoDirectRequestBodyWithProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + AsyncHttpClientConfig config = config() .setFollowRedirect(true) - .setProxyServer(proxyServer("localhost", port1).build()) + .setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType).build()) .setUseInsecureTrustManager(true) .build(); @@ -111,11 +168,14 @@ public void testNoDirectRequestBodyWithProxy() throws Exception { } } - @RepeatedIfExceptionsTest(repeats = 5) - public void testDecompressBodyWithProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testDecompressBodyWithProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + AsyncHttpClientConfig config = config() .setFollowRedirect(true) - .setProxyServer(proxyServer("localhost", port1).build()) + .setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType).build()) .setUseInsecureTrustManager(true) .build(); @@ -130,10 +190,13 @@ public void testDecompressBodyWithProxy() throws Exception { } } - @RepeatedIfExceptionsTest(repeats = 5) - public void testPooledConnectionsWithProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testPooledConnectionsWithProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + try (AsyncHttpClient asyncHttpClient = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true).setKeepAlive(true))) { - RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer("localhost", port1)); + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType)); Response response1 = asyncHttpClient.executeRequest(rb.build()).get(); assertEquals(200, response1.getStatusCode()); @@ -142,4 +205,110 @@ public void testPooledConnectionsWithProxy() throws Exception { assertEquals(200, response2.getStatusCode()); } } + + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testFailedConnectWithProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + + try (AsyncHttpClient asyncHttpClient = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true).setKeepAlive(true))) { + Builder proxyServerBuilder = proxyServer("localhost", proxyPort).setProxyType(proxyType); + proxyServerBuilder.setCustomHeaders(r -> new DefaultHttpHeaders().set(ProxyHandler.HEADER_FORBIDDEN, "1")); + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServerBuilder); + + Response response1 = asyncHttpClient.executeRequest(rb.build()).get(); + assertEquals(403, response1.getStatusCode()); + + Response response2 = asyncHttpClient.executeRequest(rb.build()).get(); + assertEquals(403, response2.getStatusCode()); + + Response response3 = asyncHttpClient.executeRequest(rb.build()).get(); + assertEquals(403, response3.getStatusCode()); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testClosedConnectionWithProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + + try (AsyncHttpClient asyncHttpClient = asyncHttpClient( + config().setFollowRedirect(true).setUseInsecureTrustManager(true).setKeepAlive(true))) { + Builder proxyServerBuilder = proxyServer("localhost", proxyPort).setProxyType(proxyType); + proxyServerBuilder.setCustomHeaders(r -> new DefaultHttpHeaders().set(ProxyHandler.HEADER_FORBIDDEN, "2")); + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServerBuilder); + + assertThrowsExactly(ExecutionException.class, () -> asyncHttpClient.executeRequest(rb.build()).get()); + assertThrowsExactly(ExecutionException.class, () -> asyncHttpClient.executeRequest(rb.build()).get()); + assertThrowsExactly(ExecutionException.class, () -> asyncHttpClient.executeRequest(rb.build()).get()); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testHttpsProxyType() throws Exception { + // Test that HTTPS proxy type can be configured and behaves correctly + ProxyServer.Builder builder = proxyServer("localhost", port1) + .setSecuredPort(443) + .setProxyType(ProxyType.HTTPS); + + ProxyServer proxy = builder.build(); + + assertEquals(ProxyType.HTTPS, proxy.getProxyType()); + assertEquals(true, proxy.getProxyType().isHttp()); + assertEquals(443, proxy.getSecuredPort()); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testHttpsProxyWithSecuredPortOnly() throws Exception { + // Test HTTPS proxy using only secured port (typical configuration) + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true))) { + ProxyServer httpsProxy = proxyServer("localhost", httpsProxyPort) + .setProxyType(ProxyType.HTTPS) + .build(); + + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(httpsProxy); + Response response = client.executeRequest(rb.build()).get(); + assertEquals(200, response.getStatusCode()); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testHttpsProxyWithAuthentication() throws Exception { + // Test HTTPS proxy with custom headers (simulating authentication) + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true))) { + ProxyServer httpsProxy = proxyServer("localhost", httpsProxyPort) + .setProxyType(ProxyType.HTTPS) + .setCustomHeaders(request -> new DefaultHttpHeaders().set("Proxy-Authorization", "Bearer test-token")) + .build(); + + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(httpsProxy); + Response response = client.executeRequest(rb.build()).get(); + assertEquals(200, response.getStatusCode()); + } + } + + public static class ProxyHandler extends ConnectHandler { + final static String HEADER_FORBIDDEN = "X-REJECT-REQUEST"; + + @Override + public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + if (HttpConstants.Methods.CONNECT.equalsIgnoreCase(request.getMethod())) { + String headerValue = request.getHeader(HEADER_FORBIDDEN); + if (headerValue == null) { + headerValue = ""; + } + switch (headerValue) { + case "1": + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + r.setHandled(true); + return; + case "2": + r.getHttpChannel().getConnection().close(); + r.setHandled(true); + return; + } + } + super.handle(s, r, request, response); + } + } } diff --git a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java new file mode 100644 index 000000000..e915e8666 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.proxy; + +import io.github.artsok.RepeatedIfExceptionsTest; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.AsyncHttpClientConfig; +import org.asynchttpclient.Response; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.asynchttpclient.Dsl.asyncHttpClient; +import static org.asynchttpclient.Dsl.config; +import static org.asynchttpclient.Dsl.get; +import static org.asynchttpclient.Dsl.proxyServer; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +@Testcontainers +public class HttpsProxyTestcontainersIntegrationTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(HttpsProxyTestcontainersIntegrationTest.class); + + private static final int SQUID_HTTP_PORT = 3128; + private static final int SQUID_HTTPS_PORT = 3129; + + private static final String TARGET_HTTP_URL = "http://httpbin.org/get"; + private static final String TARGET_HTTPS_URL = "https://www.example.com/"; + + private static boolean dockerAvailable = false; + private static GenericContainer squidProxy; + + @BeforeAll + static void checkDockerAvailability() { + try { + dockerAvailable = DockerClientFactory.instance().isDockerAvailable(); + LOGGER.info("Docker availability check: {}", dockerAvailable); + } catch (Exception e) { + LOGGER.warn("Failed to check Docker availability: {}", e.getMessage()); + dockerAvailable = false; + } + // Skip tests if Docker not available, unless force-enabled + if (!dockerAvailable && !"true".equals(System.getProperty("docker.tests"))) { + assumeTrue(false, "Docker is not available - skipping integration tests. Use -Ddocker.tests=true to force run."); + } + // Allow force-disabling Docker tests + if ("true".equals(System.getProperty("no.docker.tests"))) { + assumeTrue(false, "Docker tests disabled via -Dno.docker.tests=true"); + } + // Only start container if Docker is available + if (dockerAvailable) { + squidProxy = new GenericContainer<>( + new ImageFromDockerfile() + .withFileFromPath("Dockerfile", Path.of("src/test/resources/squid/Dockerfile")) + .withFileFromPath("squid.conf", Path.of("src/test/resources/squid/squid.conf")) + ) + .withExposedPorts(SQUID_HTTP_PORT, SQUID_HTTPS_PORT) + .withLogConsumer(new Slf4jLogConsumer(LOGGER).withPrefix("SQUID")) + .waitingFor(Wait.forLogMessage(".*Accepting HTTP.*", 1) + .withStartupTimeout(Duration.ofMinutes(2))); + squidProxy.start(); + } + } + + @AfterAll + static void stopContainer() { + if (squidProxy != null && squidProxy.isRunning()) { + squidProxy.stop(); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testHttpProxyToHttpTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing HTTP proxy to HTTP target"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", squidProxy.getMappedPort(SQUID_HTTP_PORT)) + .setProxyType(ProxyType.HTTP) + .build()) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTP_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("httpbin")); + LOGGER.info("HTTP proxy to HTTP target test passed"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testHttpsProxyToHttpTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing HTTPS proxy to HTTP target"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", squidProxy.getMappedPort(SQUID_HTTPS_PORT)) + .setProxyType(ProxyType.HTTPS) + .build()) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTP_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("httpbin")); + LOGGER.info("HTTPS proxy to HTTP target test passed"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testHttpProxyToHttpsTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing HTTP proxy to HTTPS target"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", squidProxy.getMappedPort(SQUID_HTTP_PORT)) + .setProxyType(ProxyType.HTTP) + .build()) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTPS_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("Example Domain") || + response.getResponseBody().contains("example")); + LOGGER.info("HTTP proxy to HTTPS target test passed"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testHttpsProxyToHttpsTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing HTTPS proxy to HTTPS target - validates issue #1907 fix"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", squidProxy.getMappedPort(SQUID_HTTPS_PORT)) + .setProxyType(ProxyType.HTTPS) + .build()) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTPS_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("Example Domain") || + response.getResponseBody().contains("example")); + LOGGER.info("HTTPS proxy to HTTPS target test passed - core issue #1907 RESOLVED!"); + } + } + + @Test + public void testDockerInfrastructureReady() { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Docker infrastructure test - validating container is ready"); + LOGGER.info("Squid HTTP proxy available at: localhost:{}", squidProxy.getMappedPort(SQUID_HTTP_PORT)); + LOGGER.info("Squid HTTPS proxy available at: localhost:{}", squidProxy.getMappedPort(SQUID_HTTPS_PORT)); + assertTrue(squidProxy.isRunning(), "Squid container should be running"); + assertTrue(squidProxy.getMappedPort(SQUID_HTTP_PORT) > 0, "HTTP port should be mapped"); + assertTrue(squidProxy.getMappedPort(SQUID_HTTPS_PORT) > 0, "HTTPS port should be mapped"); + LOGGER.info("Docker infrastructure is ready and accessible"); + } +} diff --git a/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTest.java b/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTest.java new file mode 100644 index 000000000..e1870721a --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTest.java @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2024 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.proxy; + +import io.github.artsok.RepeatedIfExceptionsTest; +import org.asynchttpclient.AbstractBasicTest; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.Response; +import org.asynchttpclient.testserver.SocksProxy; +import org.eclipse.jetty.server.handler.AbstractHandler; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.asynchttpclient.Dsl.asyncHttpClient; +import static org.asynchttpclient.Dsl.config; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests for SOCKS proxy support with both HTTP and HTTPS. + */ +public class SocksProxyTest extends AbstractBasicTest { + + @Override + public AbstractHandler configureHandler() throws Exception { + return new ProxyTest.ProxyHandler(); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testSocks4ProxyWithHttp() throws Exception { + // Start SOCKS proxy in background thread + Thread socksProxyThread = new Thread(() -> { + try { + new SocksProxy(60000); + } catch (Exception e) { + logger.error("Failed to establish SocksProxy", e); + } + }); + socksProxyThread.start(); + + // Give the proxy time to start + Thread.sleep(1000); + + try (AsyncHttpClient client = asyncHttpClient()) { + String target = "http://localhost:" + port1 + '/'; + Future f = client.prepareGet(target) + .setProxyServer(new ProxyServer.Builder("localhost", 8000).setProxyType(ProxyType.SOCKS_V4)) + .execute(); + + Response response = f.get(60, TimeUnit.SECONDS); + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testSocks5ProxyWithHttp() throws Exception { + // Start SOCKS proxy in background thread + Thread socksProxyThread = new Thread(() -> { + try { + new SocksProxy(60000); + } catch (Exception e) { + logger.error("Failed to establish SocksProxy", e); + } + }); + socksProxyThread.start(); + + // Give the proxy time to start + Thread.sleep(1000); + + try (AsyncHttpClient client = asyncHttpClient()) { + String target = "http://localhost:" + port1 + '/'; + Future f = client.prepareGet(target) + .setProxyServer(new ProxyServer.Builder("localhost", 8000).setProxyType(ProxyType.SOCKS_V5)) + .execute(); + + Response response = f.get(60, TimeUnit.SECONDS); + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } + } + + @Test + public void testSocks5ProxyWithHttpsDoesNotThrowException() throws Exception { + // This test specifically verifies that HTTPS requests through SOCKS5 proxy + // do not throw NoSuchElementException: socks anymore + + // Start SOCKS proxy in background thread + Thread socksProxyThread = new Thread(() -> { + try { + new SocksProxy(10000); // shorter time for test + } catch (Exception e) { + logger.error("Failed to establish SocksProxy", e); + } + }); + socksProxyThread.start(); + + // Give the proxy time to start + Thread.sleep(1000); + + try (AsyncHttpClient client = asyncHttpClient(config() + .setProxyServer(new ProxyServer.Builder("localhost", 8000).setProxyType(ProxyType.SOCKS_V5)) + .setConnectTimeout(Duration.ofMillis(5000)) + .setRequestTimeout(Duration.ofMillis(10000)))) { + + // This would previously throw: java.util.NoSuchElementException: socks + // We expect this to fail with connection timeout (since we don't have a real HTTPS target) + // but NOT with NoSuchElementException + + try { + Future f = client.prepareGet("https://httpbin.org/get").execute(); + f.get(8, TimeUnit.SECONDS); + // If we reach here, great! The SOCKS proxy worked + } catch (Exception e) { + // We should NOT see NoSuchElementException: socks anymore + String message = e.getMessage(); + if (message != null && message.contains("socks") && message.contains("NoSuchElementException")) { + throw new AssertionError("NoSuchElementException: socks still occurs", e); + } + // Other exceptions like connection timeout are expected since we don't have a real working SOCKS proxy setup + logger.info("Expected exception (not the SOCKS handler bug): " + e.getClass().getSimpleName() + ": " + message); + } + } + } + + @Test + public void testSocks4ProxyWithHttpsDoesNotThrowException() throws Exception { + // This test specifically verifies that HTTPS requests through SOCKS4 proxy + // do not throw NoSuchElementException: socks anymore + + // Start SOCKS proxy in background thread + Thread socksProxyThread = new Thread(() -> { + try { + new SocksProxy(10000); // shorter time for test + } catch (Exception e) { + logger.error("Failed to establish SocksProxy", e); + } + }); + socksProxyThread.start(); + + // Give the proxy time to start + Thread.sleep(1000); + + try (AsyncHttpClient client = asyncHttpClient(config() + .setProxyServer(new ProxyServer.Builder("localhost", 8000).setProxyType(ProxyType.SOCKS_V4)) + .setConnectTimeout(Duration.ofMillis(5000)) + .setRequestTimeout(Duration.ofMillis(10000)))) { + + // This would previously throw: java.util.NoSuchElementException: socks + // We expect this to fail with connection timeout (since we don't have a real HTTPS target) + // but NOT with NoSuchElementException + + try { + Future f = client.prepareGet("https://httpbin.org/get").execute(); + f.get(8, TimeUnit.SECONDS); + // If we reach here, great! The SOCKS proxy worked + } catch (Exception e) { + // We should NOT see NoSuchElementException: socks anymore + String message = e.getMessage(); + if (message != null && message.contains("socks") && message.contains("NoSuchElementException")) { + throw new AssertionError("NoSuchElementException: socks still occurs", e); + } + // Other exceptions like connection timeout are expected since we don't have a real working SOCKS proxy setup + logger.info("Expected exception (not the SOCKS handler bug): " + e.getClass().getSimpleName() + ": " + message); + } + } + } + + @Test + public void testIssue1913NoSuchElementExceptionSocks5() throws Exception { + // Reproduces the exact issue from GitHub issue #1913 with SOCKS5 + // This uses the exact code pattern from the issue report + var proxyServer = new ProxyServer.Builder("127.0.0.1", 1081) + .setProxyType(ProxyType.SOCKS_V5); + + try (var client = asyncHttpClient(config() + .setProxyServer(proxyServer.build()) + .setConnectTimeout(Duration.ofMillis(2000)) + .setRequestTimeout(Duration.ofMillis(5000)))) { + + // This would previously throw: java.util.NoSuchElementException: socks + // We expect this to fail with connection timeout (since proxy doesn't exist) + // but NOT with NoSuchElementException + + try { + var response = client.prepareGet("https://cloudflare.com/cdn-cgi/trace").execute().get(); + // If we reach here, great! The fix worked and proxy connection succeeded + logger.info("Connection successful: " + response.getStatusCode()); + } catch (Exception e) { + // Check that we don't get the NoSuchElementException: socks anymore + Throwable cause = e.getCause(); + String message = cause != null ? cause.getMessage() : e.getMessage(); + + // This should NOT contain the original error + if (message != null && message.contains("socks") && + (e.toString().contains("NoSuchElementException") || cause != null && cause.toString().contains("NoSuchElementException"))) { + throw new AssertionError("NoSuchElementException: socks still occurs - fix didn't work: " + e.toString()); + } + + // Other exceptions like connection timeout are expected since we don't have a working SOCKS proxy + logger.info("Expected exception (not the SOCKS handler bug): " + e.getClass().getSimpleName() + ": " + message); + } + } + } + + @Test + public void testIssue1913NoSuchElementExceptionSocks4() throws Exception { + // Reproduces the exact issue from GitHub issue #1913 with SOCKS4 + // This uses the exact code pattern from the issue report + var proxyServer = new ProxyServer.Builder("127.0.0.1", 1081) + .setProxyType(ProxyType.SOCKS_V4); + + try (var client = asyncHttpClient(config() + .setProxyServer(proxyServer.build()) + .setConnectTimeout(Duration.ofMillis(2000)) + .setRequestTimeout(Duration.ofMillis(5000)))) { + + try { + var response = client.prepareGet("https://cloudflare.com/cdn-cgi/trace").execute().get(); + logger.info("Connection successful: " + response.getStatusCode()); + } catch (Exception e) { + // Check that we don't get the NoSuchElementException: socks anymore + Throwable cause = e.getCause(); + String message = cause != null ? cause.getMessage() : e.getMessage(); + + if (message != null && message.contains("socks") && + (e.toString().contains("NoSuchElementException") || cause != null && cause.toString().contains("NoSuchElementException"))) { + throw new AssertionError("NoSuchElementException: socks still occurs - fix didn't work: " + e.toString()); + } + + logger.info("Expected exception (not the SOCKS handler bug): " + e.getClass().getSimpleName() + ": " + message); + } + } + } +} diff --git a/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTestcontainersIntegrationTest.java b/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTestcontainersIntegrationTest.java new file mode 100644 index 000000000..4308f388e --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTestcontainersIntegrationTest.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient.proxy; + +import io.github.artsok.RepeatedIfExceptionsTest; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.AsyncHttpClientConfig; +import org.asynchttpclient.Response; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.asynchttpclient.Dsl.asyncHttpClient; +import static org.asynchttpclient.Dsl.config; +import static org.asynchttpclient.Dsl.get; +import static org.asynchttpclient.Dsl.proxyServer; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Integration tests for SOCKS proxy support using Dante SOCKS server in TestContainers. + * This validates the fix for GitHub issue #1913. + */ +@Testcontainers +public class SocksProxyTestcontainersIntegrationTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(SocksProxyTestcontainersIntegrationTest.class); + + private static final int SOCKS_PORT = 1080; + + private static final String TARGET_HTTP_URL = "http://httpbin.org/get"; + private static final String TARGET_HTTPS_URL = "https://www.example.com/"; + + private static boolean dockerAvailable = false; + private static GenericContainer socksProxy; + + @BeforeAll + static void checkDockerAvailability() { + try { + dockerAvailable = DockerClientFactory.instance().isDockerAvailable(); + LOGGER.info("Docker availability check: {}", dockerAvailable); + } catch (Exception e) { + LOGGER.warn("Failed to check Docker availability: {}", e.getMessage()); + dockerAvailable = false; + } + // Skip tests if Docker not available, unless force-enabled + if (!dockerAvailable && !"true".equals(System.getProperty("docker.tests"))) { + LOGGER.info("Docker is not available - skipping integration tests. Use -Ddocker.tests=true to force run."); + return; // Don't start container if Docker not available + } + // Allow force-disabling Docker tests + if ("true".equals(System.getProperty("no.docker.tests"))) { + LOGGER.info("Docker tests disabled via -Dno.docker.tests=true"); + return; + } + // Only start container if Docker is available + if (dockerAvailable) { + try { + socksProxy = new GenericContainer<>( + new ImageFromDockerfile() + .withFileFromPath("Dockerfile", Path.of("src/test/resources/dante/Dockerfile")) + .withFileFromPath("sockd.conf", Path.of("src/test/resources/dante/sockd.conf")) + ) + .withExposedPorts(SOCKS_PORT) + .withLogConsumer(new Slf4jLogConsumer(LOGGER).withPrefix("DANTE")) + .waitingFor(Wait.forLogMessage(".*sockd.*", 1) + .withStartupTimeout(Duration.ofMinutes(2))); + socksProxy.start(); + LOGGER.info("Dante SOCKS proxy started successfully on port {}", socksProxy.getMappedPort(SOCKS_PORT)); + } catch (Exception e) { + LOGGER.warn("Failed to start Dante SOCKS proxy container: {}", e.getMessage()); + dockerAvailable = false; // Mark as unavailable if container start fails + } + } + } + + @AfterAll + static void stopContainer() { + if (socksProxy != null && socksProxy.isRunning()) { + socksProxy.stop(); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testSocks4ProxyToHttpTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing SOCKS4 proxy to HTTP target"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", socksProxy.getMappedPort(SOCKS_PORT)) + .setProxyType(ProxyType.SOCKS_V4) + .build()) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTP_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("httpbin")); + LOGGER.info("SOCKS4 proxy to HTTP target test passed"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testSocks5ProxyToHttpTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing SOCKS5 proxy to HTTP target"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", socksProxy.getMappedPort(SOCKS_PORT)) + .setProxyType(ProxyType.SOCKS_V5) + .build()) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTP_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("httpbin")); + LOGGER.info("SOCKS5 proxy to HTTP target test passed"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testSocks4ProxyToHttpsTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing SOCKS4 proxy to HTTPS target - validates issue #1913 fix"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", socksProxy.getMappedPort(SOCKS_PORT)) + .setProxyType(ProxyType.SOCKS_V4) + .build()) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTPS_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("Example Domain") || + response.getResponseBody().contains("example")); + LOGGER.info("SOCKS4 proxy to HTTPS target test passed - issue #1913 RESOLVED!"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testSocks5ProxyToHttpsTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing SOCKS5 proxy to HTTPS target - validates issue #1913 fix"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", socksProxy.getMappedPort(SOCKS_PORT)) + .setProxyType(ProxyType.SOCKS_V5) + .build()) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTPS_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("Example Domain") || + response.getResponseBody().contains("example")); + LOGGER.info("SOCKS5 proxy to HTTPS target test passed - issue #1913 RESOLVED!"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testIssue1913ReproductionWithRealProxy() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing exact issue #1913 reproduction with real SOCKS proxy"); + + // This reproduces the exact scenario from the GitHub issue but with a real working proxy + var proxyServer = proxyServer("localhost", socksProxy.getMappedPort(SOCKS_PORT)) + .setProxyType(ProxyType.SOCKS_V5); + + try (var client = asyncHttpClient(config() + .setProxyServer(proxyServer) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)))) { + + // This would previously throw: java.util.NoSuchElementException: socks + var response = client.prepareGet("https://www.example.com/").execute().get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("Example Domain") || + response.getResponseBody().contains("example")); + LOGGER.info("Issue #1913 reproduction test PASSED - NoSuchElementException: socks is FIXED!"); + } + } + + @Test + public void testDockerInfrastructureReady() { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Docker infrastructure test - validating Dante SOCKS container is ready"); + LOGGER.info("Dante SOCKS proxy available at: localhost:{}", socksProxy.getMappedPort(SOCKS_PORT)); + assertTrue(socksProxy.isRunning(), "Dante SOCKS container should be running"); + assertTrue(socksProxy.getMappedPort(SOCKS_PORT) > 0, "SOCKS port should be mapped"); + LOGGER.info("Dante SOCKS infrastructure is ready and accessible"); + } +} diff --git a/client/src/test/resources/dante/Dockerfile b/client/src/test/resources/dante/Dockerfile new file mode 100644 index 000000000..a98658439 --- /dev/null +++ b/client/src/test/resources/dante/Dockerfile @@ -0,0 +1,19 @@ +FROM ubuntu:22.04 + +# Install Dante SOCKS server +RUN apt-get update && \ + apt-get install -y dante-server && \ + rm -rf /var/lib/apt/lists/* + +# Copy dante configuration +COPY sockd.conf /etc/sockd.conf + +# Create run directory +RUN mkdir -p /var/run/sockd && \ + chmod 755 /var/run/sockd + +# Expose SOCKS port +EXPOSE 1080 + +# Run dante server (sockd binary is in /usr/sbin) +CMD ["/usr/sbin/sockd", "-f", "/etc/sockd.conf", "-D"] diff --git a/client/src/test/resources/dante/sockd.conf b/client/src/test/resources/dante/sockd.conf new file mode 100644 index 000000000..e4f7ed0fd --- /dev/null +++ b/client/src/test/resources/dante/sockd.conf @@ -0,0 +1,23 @@ +# Basic SOCKS proxy configuration for testing +# Allow all connections and methods for testing purposes + +# Server configuration - listen on all interfaces +internal: 0.0.0.0 port = 1080 +external: eth0 + +# Authentication method - no authentication for testing +socksmethod: none + +# Clients allowed to connect (all for testing) +client pass { + from: 0.0.0.0/0 to: 0.0.0.0/0 + log: error +} + +# Rules for SOCKS requests +socks pass { + from: 0.0.0.0/0 to: 0.0.0.0/0 + protocol: tcp udp + method: none + log: error +} diff --git a/client/src/test/resources/logback-test.xml b/client/src/test/resources/logback-test.xml index 4b6a08791..f9d903997 100644 --- a/client/src/test/resources/logback-test.xml +++ b/client/src/test/resources/logback-test.xml @@ -7,6 +7,7 @@ + diff --git a/client/src/test/resources/squid/Dockerfile b/client/src/test/resources/squid/Dockerfile new file mode 100644 index 000000000..5ba0372b7 --- /dev/null +++ b/client/src/test/resources/squid/Dockerfile @@ -0,0 +1,26 @@ +FROM ubuntu/squid:latest + +# Install OpenSSL for certificate generation +RUN apt-get update && \ + apt-get install -y openssl && \ + rm -rf /var/lib/apt/lists/* && \ + mkdir -p /etc/squid/certs /var/log/squid && \ + chown -R proxy:proxy /var/log/squid /etc/squid/certs + +# Generate self-signed certificate for localhost +RUN openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout /etc/squid/certs/proxy.key \ + -out /etc/squid/certs/proxy.crt \ + -subj "/CN=localhost" && \ + cat /etc/squid/certs/proxy.key /etc/squid/certs/proxy.crt > /etc/squid/certs/proxy.pem && \ + chmod 600 /etc/squid/certs/proxy.key /etc/squid/certs/proxy.pem && \ + chmod 644 /etc/squid/certs/proxy.crt && \ + chown -R proxy:proxy /etc/squid/certs + +# Copy squid configuration +COPY squid.conf /etc/squid/squid.conf +RUN chown proxy:proxy /etc/squid/squid.conf + +EXPOSE 3128 3129 + +CMD ["squid", "-f", "/etc/squid/squid.conf", "-NYCd", "1"] \ No newline at end of file diff --git a/client/src/test/resources/squid/squid.conf b/client/src/test/resources/squid/squid.conf new file mode 100644 index 000000000..5c317089f --- /dev/null +++ b/client/src/test/resources/squid/squid.conf @@ -0,0 +1,19 @@ +# HTTP and HTTPS proxy ports +http_port 0.0.0.0:3128 +https_port 0.0.0.0:3129 tls-cert=/etc/squid/certs/proxy.pem + +# Allow all access for testing +http_access allow all + +# Disable caching for testing +cache deny all + +# Logging configuration +access_log /var/log/squid/access.log squid +cache_log /var/log/squid/cache.log + +# Performance settings +maximum_object_size_in_memory 512 KB +maximum_object_size 1 GB +cache_dir null /tmp +pid_filename /var/run/squid.pid \ No newline at end of file diff --git a/pom.xml b/pom.xml index d02f7c7ee..95d773f35 100644 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ org.asynchttpclient async-http-client-project - 3.0.1 + 3.0.4 pom AHC/Project @@ -45,14 +45,14 @@ 11 UTF-8 - 4.1.115.Final - 0.0.25.Final - 1.17.0 + 4.2.5.Final + 1.18.0 2.0.16 - 1.5.6-8 + 1.5.7-2 2.0.1 - 1.5.12 - 26.0.1 + 1.5.18 + 26.0.2 + 2.0.1 @@ -105,14 +105,14 @@ org.junit junit-bom - 5.11.3 + 5.13.0 pom import io.github.nettyplus netty-leak-detector-junit-extension - 0.0.5 + 0.2.0 @@ -205,17 +205,17 @@ - io.netty.incubator - netty-incubator-transport-native-io_uring - ${netty.iouring} + io.netty + netty-transport-native-io_uring + ${netty.version} linux-x86_64 true - io.netty.incubator - netty-incubator-transport-native-io_uring - ${netty.iouring} + io.netty + netty-transport-native-io_uring + ${netty.version} linux-aarch_64 true @@ -293,7 +293,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 + 3.14.1 11 11 @@ -327,7 +327,7 @@ com.uber.nullaway nullaway - 0.12.1 + 0.12.6 @@ -393,15 +393,12 @@ - org.sonatype.plugins - nexus-staging-maven-plugin - 1.7.0 + org.sonatype.central + central-publishing-maven-plugin + 0.8.0 true - ossrh - https://oss.sonatype.org/ - false - false + central @@ -422,10 +419,38 @@ --pinentry-mode loopback + false + + + com.github.siom79.japicmp + japicmp-maven-plugin + 0.23.1 + + + RELEASE + ${project.version} + + + true + true + true + false + public + + + + + + cmp + + verify + + +