From 92c43fa53c68c42a773c8f237bd94fb974440f75 Mon Sep 17 00:00:00 2001 From: Rampreeth Ethiraj Date: Sat, 18 Jul 2026 17:48:15 +0530 Subject: [PATCH 1/3] Support HTTP/2 after proxy CONNECT --- .../netty/channel/ChannelManager.java | 31 +++++++++++++++++++ .../intercept/ConnectSuccessInterceptor.java | 6 ++-- .../org/asynchttpclient/BasicHttp2Test.java | 26 ++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) 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 e047d215e..6487a7eb0 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -51,6 +51,7 @@ import io.netty.handler.proxy.ProxyHandler; import io.netty.handler.proxy.Socks4ProxyHandler; import io.netty.handler.proxy.Socks5ProxyHandler; +import io.netty.handler.ssl.ApplicationProtocolNames; import io.netty.handler.ssl.SslHandler; import io.netty.handler.stream.ChunkedWriteHandler; import io.netty.handler.timeout.IdleStateHandler; @@ -629,6 +630,10 @@ private SslHandler createSslHandler(String peerHost, int peerPort, boolean http2 } public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, Uri requestUri) { + return updatePipelineForHttpTunneling(pipeline, requestUri, null); + } + + public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, Uri requestUri, Object partitionKey) { Future whenHandshaked = null; if (pipeline.get(HTTP_CLIENT_CODEC) != null) { @@ -646,6 +651,7 @@ public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, whenHandshaked = sslHandler.handshakeFuture(); pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler); pipeline.addAfter(SSL_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec()); + upgradePipelineToHttp2AfterHandshake(whenHandshaked, sslHandler, pipeline, requestUri, partitionKey); } else { // For HTTP targets, remove any existing SSL handler (from HTTPS proxy) since target is not secured @@ -668,6 +674,11 @@ public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, } public Future updatePipelineForHttpsTunneling(ChannelPipeline pipeline, Uri requestUri, ProxyServer proxyServer) { + return updatePipelineForHttpsTunneling(pipeline, requestUri, proxyServer, null); + } + + public Future updatePipelineForHttpsTunneling(ChannelPipeline pipeline, Uri requestUri, ProxyServer proxyServer, + Object partitionKey) { Future whenHandshaked = null; // Remove HTTP codec as tunnel is established @@ -694,6 +705,7 @@ public Future updatePipelineForHttpsTunneling(ChannelPipeline pipeline, } pipeline.addAfter("target-ssl", HTTP_CLIENT_CODEC, newHttpClientCodec()); + upgradePipelineToHttp2AfterHandshake(whenHandshaked, sslHandler, pipeline, requestUri, partitionKey); } else { // For HTTPS proxy to HTTP target, just add HTTP codec @@ -714,6 +726,25 @@ public Future updatePipelineForHttpsTunneling(ChannelPipeline pipeline, return whenHandshaked; } + private void upgradePipelineToHttp2AfterHandshake(Future whenHandshaked, + SslHandler sslHandler, + ChannelPipeline pipeline, + Uri requestUri, + Object partitionKey) { + if (!config.isHttp2Enabled() || requestUri.isWebSocket()) { + return; + } + + whenHandshaked.addListener(f -> { + if (f.isSuccess() && ApplicationProtocolNames.HTTP_2.equals(sslHandler.applicationProtocol())) { + upgradePipelineToHttp2(pipeline); + if (partitionKey != null) { + registerHttp2Connection(partitionKey, pipeline.channel()); + } + } + }); + } + public SslHandler addSslHandler(ChannelPipeline pipeline, Uri uri, String virtualHost, boolean hasSocksProxyHandler) { String peerHost; int peerPort; 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 696da9272..f2487dce3 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 @@ -55,14 +55,16 @@ public boolean exitAfterHandlingConnect(Channel channel, NettyResponseFuture LOGGER.debug("Connecting to proxy {} for scheme {}", proxyServer, requestUri.getScheme()); final Future whenHandshaked; + Object partitionKey = future.getPartitionKey(); // 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); + whenHandshaked = channelManager.updatePipelineForHttpsTunneling(channel.pipeline(), requestUri, proxyServer, + partitionKey); } else { // Standard HTTP proxy or SOCKS proxy tunneling - whenHandshaked = channelManager.updatePipelineForHttpTunneling(channel.pipeline(), requestUri); + whenHandshaked = channelManager.updatePipelineForHttpTunneling(channel.pipeline(), requestUri, partitionKey); } future.setReuseChannel(true); diff --git a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java index 9e97a7b42..ec0bd2cb4 100644 --- a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java +++ b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java @@ -49,6 +49,9 @@ import io.netty.pkitesting.X509Bundle; import io.netty.util.concurrent.GlobalEventExecutor; import org.asynchttpclient.test.EventCollectingHandler; +import org.eclipse.jetty.proxy.ConnectHandler; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -80,7 +83,9 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.asynchttpclient.Dsl.asyncHttpClient; import static org.asynchttpclient.Dsl.config; +import static org.asynchttpclient.Dsl.proxyServer; import static org.asynchttpclient.test.TestUtils.AsyncCompletionHandlerAdapter; +import static org.asynchttpclient.test.TestUtils.addHttpConnector; import static org.asynchttpclient.util.DateUtils.unpreciseMillisTime; import static org.asynchttpclient.util.ThrowableUtil.unknownStackTrace; import static org.junit.jupiter.api.Assertions.*; @@ -430,6 +435,27 @@ private AsyncHttpClient http2ClientWithConfig(Consumer Date: Sat, 18 Jul 2026 17:48:15 +0530 Subject: [PATCH 2/3] Test HTTP/2 proxy tunnel reuse --- .../org/asynchttpclient/BasicHttp2Test.java | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java index ec0bd2cb4..a0dcc662a 100644 --- a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java +++ b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java @@ -48,6 +48,8 @@ import io.netty.pkitesting.CertificateBuilder; import io.netty.pkitesting.X509Bundle; import io.netty.util.concurrent.GlobalEventExecutor; +import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.test.EventCollectingHandler; import org.eclipse.jetty.proxy.ConnectHandler; import org.eclipse.jetty.server.Server; @@ -86,6 +88,7 @@ import static org.asynchttpclient.Dsl.proxyServer; import static org.asynchttpclient.test.TestUtils.AsyncCompletionHandlerAdapter; import static org.asynchttpclient.test.TestUtils.addHttpConnector; +import static org.asynchttpclient.test.TestUtils.addHttpsConnector; import static org.asynchttpclient.util.DateUtils.unpreciseMillisTime; import static org.asynchttpclient.util.ThrowableUtil.unknownStackTrace; import static org.junit.jupiter.api.Assertions.*; @@ -436,21 +439,44 @@ private AsyncHttpClient http2ClientWithConfig(Consumer Date: Sun, 19 Jul 2026 08:13:02 +0530 Subject: [PATCH 3/3] Address HTTP/2 proxy CONNECT review feedback --- .../netty/channel/ChannelManager.java | 52 +++++++------------ .../intercept/ConnectSuccessInterceptor.java | 6 +-- .../netty/request/NettyRequestSender.java | 4 ++ .../org/asynchttpclient/BasicHttp2Test.java | 48 +++++++++-------- ...tpsProxyTestcontainersIntegrationTest.java | 4 -- 5 files changed, 51 insertions(+), 63 deletions(-) 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 6487a7eb0..4204b1768 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -122,6 +122,7 @@ public class ChannelManager { public static final String HTTP2_FRAME_CODEC = "http2-frame-codec"; public static final String HTTP2_MULTIPLEX = "http2-multiplex"; public static final String AHC_HTTP2_HANDLER = "ahc-http2"; + private static final String TARGET_SSL_HANDLER = "target-ssl"; private static final Logger LOGGER = LoggerFactory.getLogger(ChannelManager.class); // Guards the one-time WARN emitted when a native transport was requested but is unavailable and we // fall back to NIO. Logged once per JVM to avoid spamming logs when many clients are created. @@ -630,10 +631,6 @@ private SslHandler createSslHandler(String peerHost, int peerPort, boolean http2 } public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, Uri requestUri) { - return updatePipelineForHttpTunneling(pipeline, requestUri, null); - } - - public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, Uri requestUri, Object partitionKey) { Future whenHandshaked = null; if (pipeline.get(HTTP_CLIENT_CODEC) != null) { @@ -651,7 +648,6 @@ public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, whenHandshaked = sslHandler.handshakeFuture(); pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler); pipeline.addAfter(SSL_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec()); - upgradePipelineToHttp2AfterHandshake(whenHandshaked, sslHandler, pipeline, requestUri, partitionKey); } else { // For HTTP targets, remove any existing SSL handler (from HTTPS proxy) since target is not secured @@ -674,11 +670,6 @@ public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, } public Future updatePipelineForHttpsTunneling(ChannelPipeline pipeline, Uri requestUri, ProxyServer proxyServer) { - return updatePipelineForHttpsTunneling(pipeline, requestUri, proxyServer, null); - } - - public Future updatePipelineForHttpsTunneling(ChannelPipeline pipeline, Uri requestUri, ProxyServer proxyServer, - Object partitionKey) { Future whenHandshaked = null; // Remove HTTP codec as tunnel is established @@ -698,14 +689,13 @@ public Future updatePipelineForHttpsTunneling(ChannelPipeline pipeline, // 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); + pipeline.addAfter(SSL_HANDLER, TARGET_SSL_HANDLER, 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()); - upgradePipelineToHttp2AfterHandshake(whenHandshaked, sslHandler, pipeline, requestUri, partitionKey); + pipeline.addAfter(TARGET_SSL_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec()); } else { // For HTTPS proxy to HTTP target, just add HTTP codec @@ -726,25 +716,6 @@ public Future updatePipelineForHttpsTunneling(ChannelPipeline pipeline, return whenHandshaked; } - private void upgradePipelineToHttp2AfterHandshake(Future whenHandshaked, - SslHandler sslHandler, - ChannelPipeline pipeline, - Uri requestUri, - Object partitionKey) { - if (!config.isHttp2Enabled() || requestUri.isWebSocket()) { - return; - } - - whenHandshaked.addListener(f -> { - if (f.isSuccess() && ApplicationProtocolNames.HTTP_2.equals(sslHandler.applicationProtocol())) { - upgradePipelineToHttp2(pipeline); - if (partitionKey != null) { - registerHttp2Connection(partitionKey, pipeline.channel()); - } - } - }); - } - public SslHandler addSslHandler(ChannelPipeline pipeline, Uri uri, String virtualHost, boolean hasSocksProxyHandler) { String peerHost; int peerPort; @@ -996,6 +967,23 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception } } + /** + * Upgrades and registers a proxy tunnel when the target TLS handshake negotiated HTTP/2. + * The caller controls when this runs so the upgrade can happen immediately before the + * tunneled request is sent. + */ + public void upgradePipelineToHttp2AfterProxyConnect(ChannelPipeline pipeline, Object partitionKey) { + SslHandler targetSslHandler = (SslHandler) pipeline.get(TARGET_SSL_HANDLER); + if (targetSslHandler == null) { + targetSslHandler = (SslHandler) pipeline.get(SSL_HANDLER); + } + if (targetSslHandler != null + && ApplicationProtocolNames.HTTP_2.equals(targetSslHandler.applicationProtocol())) { + upgradePipelineToHttp2(pipeline); + registerHttp2Connection(partitionKey, pipeline.channel()); + } + } + public void upgradePipelineForWebSockets(ChannelPipeline pipeline) { pipeline.addAfter(HTTP_CLIENT_CODEC, WS_ENCODER_HANDLER, new WebSocket08FrameEncoder(true)); pipeline.addAfter(WS_ENCODER_HANDLER, WS_DECODER_HANDLER, new WebSocket08FrameDecoder(false, 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 f2487dce3..696da9272 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 @@ -55,16 +55,14 @@ public boolean exitAfterHandlingConnect(Channel channel, NettyResponseFuture LOGGER.debug("Connecting to proxy {} for scheme {}", proxyServer, requestUri.getScheme()); final Future whenHandshaked; - Object partitionKey = future.getPartitionKey(); // 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, - partitionKey); + whenHandshaked = channelManager.updatePipelineForHttpsTunneling(channel.pipeline(), requestUri, proxyServer); } else { // Standard HTTP proxy or SOCKS proxy tunneling - whenHandshaked = channelManager.updatePipelineForHttpTunneling(channel.pipeline(), requestUri, partitionKey); + whenHandshaked = channelManager.updatePipelineForHttpTunneling(channel.pipeline(), requestUri); } future.setReuseChannel(true); 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 6df2e70f1..dfd97722b 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -1289,6 +1289,10 @@ public void drainChannelAndExecuteNextRequest(final Channel channel, final Netty public void call() { whenHandshaked.addListener(f -> { if (f.isSuccess()) { + if (!nextRequest.getUri().isWebSocket()) { + channelManager.upgradePipelineToHttp2AfterProxyConnect( + channel.pipeline(), future.getPartitionKey()); + } sendNextRequest(nextRequest, future); } else { future.abort(f.cause()); diff --git a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java index a0dcc662a..e4e9e8bd6 100644 --- a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java +++ b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java @@ -452,31 +452,33 @@ private void assertHttpsRequestsThroughProxyNegotiateAndReuseHttp2AfterConnect(b Server proxy = new Server(); ServerConnector proxyConnector = secureProxy ? addHttpsConnector(proxy) : addHttpConnector(proxy); proxy.setHandler(new ConnectHandler()); - proxy.start(); - try (AsyncHttpClient client = http2Client()) { - ProxyServer.Builder proxyBuilder = proxyServer("127.0.0.1", proxyConnector.getLocalPort()); - if (secureProxy) { - proxyBuilder.setProxyType(ProxyType.HTTPS); - } - ProxyServer proxyServer = proxyBuilder.build(); - Response firstResponse = client.prepareGet(httpsUrl("/hello")) - .setProxyServer(proxyServer) - .execute() - .get(30, SECONDS); - Response secondResponse = client.prepareGet(httpsUrl("/hello")) - .setProxyServer(proxyServer) - .execute() - .get(30, SECONDS); + try { + proxy.start(); + try (AsyncHttpClient client = http2Client()) { + ProxyServer.Builder proxyBuilder = proxyServer("127.0.0.1", proxyConnector.getLocalPort()); + if (secureProxy) { + proxyBuilder.setProxyType(ProxyType.HTTPS); + } + ProxyServer proxyServer = proxyBuilder.build(); + Response firstResponse = client.prepareGet(httpsUrl("/hello")) + .setProxyServer(proxyServer) + .execute() + .get(30, SECONDS); + Response secondResponse = client.prepareGet(httpsUrl("/hello")) + .setProxyServer(proxyServer) + .execute() + .get(30, SECONDS); - assertNotNull(firstResponse); - assertEquals(200, firstResponse.getStatusCode()); - assertEquals(HttpProtocol.HTTP_2, firstResponse.getProtocol()); - assertNotNull(secondResponse); - assertEquals(200, secondResponse.getStatusCode()); - assertEquals(HttpProtocol.HTTP_2, secondResponse.getProtocol()); - assertEquals(1, serverChildChannels.size(), - "the HTTP/2 tunnel connection should be registered and reused"); + assertNotNull(firstResponse); + assertEquals(200, firstResponse.getStatusCode()); + assertEquals(HttpProtocol.HTTP_2, firstResponse.getProtocol()); + assertNotNull(secondResponse); + assertEquals(200, secondResponse.getStatusCode()); + assertEquals(HttpProtocol.HTTP_2, secondResponse.getProtocol()); + assertEquals(1, serverChildChannels.size(), + "the HTTP/2 tunnel connection should be registered and reused"); + } } finally { proxy.stop(); } diff --git a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java index 94c4384f4..683e74c90 100644 --- a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java +++ b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java @@ -146,8 +146,6 @@ public void testHttpProxyToHttpsTarget() throws Exception { .setProxyType(ProxyType.HTTP) .build()) .setUseInsecureTrustManager(true) - // HTTP/2 ALPN upgrade after proxy CONNECT tunnel is not yet supported - .setHttp2Enabled(false) .setConnectTimeout(Duration.ofMillis(10000)) .setRequestTimeout(Duration.ofMillis(30000)) .build(); @@ -169,8 +167,6 @@ public void testHttpsProxyToHttpsTarget() throws Exception { .setProxyType(ProxyType.HTTPS) .build()) .setUseInsecureTrustManager(true) - // HTTP/2 ALPN upgrade after proxy CONNECT tunnel is not yet supported - .setHttp2Enabled(false) .setConnectTimeout(Duration.ofMillis(10000)) .setRequestTimeout(Duration.ofMillis(30000)) .build();