Skip to content

Commit f341bac

Browse files
authored
Support HTTP/2 after proxy CONNECT (#2250)
## Motivation When an HTTPS request uses an HTTP proxy, AHC establishes a `CONNECT` tunnel and then performs TLS with the target. If ALPN selects `h2`, the tunnel path currently leaves the HTTP/1.1 codec in place. The target then receives HTTP/1.1 bytes on a connection that negotiated HTTP/2 and closes it. ## Modification - Check the negotiated ALPN protocol after the target TLS handshake in both proxy tunnel paths. - When ALPN selects `h2`, upgrade the existing channel pipeline to HTTP/2 and register the physical tunnel connection with its existing pool partition key. - Preserve the existing HTTP/1.1 fallback and WebSocket behavior. - Add regression tests using local HTTP and HTTPS `CONNECT` proxies with a TLS HTTP/2 target. - Verify that the upgraded tunnel is registered and reused for a second request. - Preserve the existing public tunneling method signatures for binary compatibility. ## Result HTTPS requests through HTTP and HTTPS `CONNECT` proxies now send HTTP/2 frames when the target negotiates `h2`. The regression tests verify successful HTTP/2 responses and reuse of a single physical target connection. ## Testing - `./mvnw -pl client -Dtest=BasicHttp2Test,HttpsProxyTest test` — 60 tests passed. - Full client test suite passed as part of `verify`; the local run then stopped at artifact signing because `gpg` is not installed. - `./mvnw -pl client -DskipTests -Dgpg.skip=true verify` — packaging and API compatibility checks passed. Fixes #2241
1 parent c8071b5 commit f341bac

4 files changed

Lines changed: 79 additions & 6 deletions

File tree

client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import io.netty.handler.proxy.ProxyHandler;
5252
import io.netty.handler.proxy.Socks4ProxyHandler;
5353
import io.netty.handler.proxy.Socks5ProxyHandler;
54+
import io.netty.handler.ssl.ApplicationProtocolNames;
5455
import io.netty.handler.ssl.SslHandler;
5556
import io.netty.handler.stream.ChunkedWriteHandler;
5657
import io.netty.handler.timeout.IdleStateHandler;
@@ -123,6 +124,7 @@ public class ChannelManager {
123124
public static final String HTTP2_FRAME_CODEC = "http2-frame-codec";
124125
public static final String HTTP2_MULTIPLEX = "http2-multiplex";
125126
public static final String AHC_HTTP2_HANDLER = "ahc-http2";
127+
private static final String TARGET_SSL_HANDLER = "target-ssl";
126128
private static final Logger LOGGER = LoggerFactory.getLogger(ChannelManager.class);
127129
// Guards the one-time WARN emitted when a native transport was requested but is unavailable and we
128130
// fall back to NIO. Logged once per JVM to avoid spamming logs when many clients are created.
@@ -806,13 +808,13 @@ public Future<Channel> updatePipelineForHttpsTunneling(ChannelPipeline pipeline,
806808
// This creates a nested SSL setup: Target SSL -> Proxy SSL -> Network
807809
if (isSslHandlerConfigured(pipeline)) {
808810
// Insert target SSL handler after the proxy SSL handler
809-
pipeline.addAfter(SSL_HANDLER, "target-ssl", sslHandler);
811+
pipeline.addAfter(SSL_HANDLER, TARGET_SSL_HANDLER, sslHandler);
810812
} else {
811813
// This shouldn't happen for HTTPS proxy, but fallback
812814
pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler);
813815
}
814816

815-
pipeline.addAfter("target-ssl", HTTP_CLIENT_CODEC, newHttpClientCodec());
817+
pipeline.addAfter(TARGET_SSL_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec());
816818

817819
} else {
818820
// For HTTPS proxy to HTTP target, just add HTTP codec
@@ -1084,6 +1086,23 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception
10841086
}
10851087
}
10861088

1089+
/**
1090+
* Upgrades and registers a proxy tunnel when the target TLS handshake negotiated HTTP/2.
1091+
* The caller controls when this runs so the upgrade can happen immediately before the
1092+
* tunneled request is sent.
1093+
*/
1094+
public void upgradePipelineToHttp2AfterProxyConnect(ChannelPipeline pipeline, Object partitionKey) {
1095+
SslHandler targetSslHandler = (SslHandler) pipeline.get(TARGET_SSL_HANDLER);
1096+
if (targetSslHandler == null) {
1097+
targetSslHandler = (SslHandler) pipeline.get(SSL_HANDLER);
1098+
}
1099+
if (targetSslHandler != null
1100+
&& ApplicationProtocolNames.HTTP_2.equals(targetSslHandler.applicationProtocol())) {
1101+
upgradePipelineToHttp2(pipeline);
1102+
registerHttp2Connection(partitionKey, pipeline.channel());
1103+
}
1104+
}
1105+
10871106
public void upgradePipelineForWebSockets(ChannelPipeline pipeline) {
10881107
pipeline.addAfter(HTTP_CLIENT_CODEC, WS_ENCODER_HANDLER, new WebSocket08FrameEncoder(true));
10891108
pipeline.addAfter(WS_ENCODER_HANDLER, WS_DECODER_HANDLER, new WebSocket08FrameDecoder(false,

client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,6 +1457,10 @@ public void drainChannelAndExecuteNextRequest(final Channel channel, final Netty
14571457
public void call() {
14581458
whenHandshaked.addListener(f -> {
14591459
if (f.isSuccess()) {
1460+
if (!nextRequest.getUri().isWebSocket()) {
1461+
channelManager.upgradePipelineToHttp2AfterProxyConnect(
1462+
channel.pipeline(), future.getPartitionKey());
1463+
}
14601464
sendNextRequest(nextRequest, future);
14611465
} else {
14621466
future.abort(f.cause());

client/src/test/java/org/asynchttpclient/BasicHttp2Test.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@
4848
import io.netty.pkitesting.CertificateBuilder;
4949
import io.netty.pkitesting.X509Bundle;
5050
import io.netty.util.concurrent.GlobalEventExecutor;
51+
import org.asynchttpclient.proxy.ProxyServer;
52+
import org.asynchttpclient.proxy.ProxyType;
5153
import org.asynchttpclient.test.EventCollectingHandler;
54+
import org.eclipse.jetty.proxy.ConnectHandler;
55+
import org.eclipse.jetty.server.Server;
56+
import org.eclipse.jetty.server.ServerConnector;
5257
import org.junit.jupiter.api.AfterEach;
5358
import org.junit.jupiter.api.BeforeEach;
5459
import org.junit.jupiter.api.Test;
@@ -80,7 +85,10 @@
8085
import static java.util.concurrent.TimeUnit.SECONDS;
8186
import static org.asynchttpclient.Dsl.asyncHttpClient;
8287
import static org.asynchttpclient.Dsl.config;
88+
import static org.asynchttpclient.Dsl.proxyServer;
8389
import static org.asynchttpclient.test.TestUtils.AsyncCompletionHandlerAdapter;
90+
import static org.asynchttpclient.test.TestUtils.addHttpConnector;
91+
import static org.asynchttpclient.test.TestUtils.addHttpsConnector;
8492
import static org.asynchttpclient.util.DateUtils.unpreciseMillisTime;
8593
import static org.asynchttpclient.util.ThrowableUtil.unknownStackTrace;
8694
import static org.junit.jupiter.api.Assertions.*;
@@ -430,6 +438,52 @@ private AsyncHttpClient http2ClientWithConfig(Consumer<DefaultAsyncHttpClientCon
430438
return asyncHttpClient(builder);
431439
}
432440

441+
@Test
442+
public void httpsRequestsThroughHttpProxyNegotiateAndReuseHttp2AfterConnect() throws Exception {
443+
assertHttpsRequestsThroughProxyNegotiateAndReuseHttp2AfterConnect(false);
444+
}
445+
446+
@Test
447+
public void httpsRequestsThroughHttpsProxyNegotiateAndReuseHttp2AfterConnect() throws Exception {
448+
assertHttpsRequestsThroughProxyNegotiateAndReuseHttp2AfterConnect(true);
449+
}
450+
451+
private void assertHttpsRequestsThroughProxyNegotiateAndReuseHttp2AfterConnect(boolean secureProxy) throws Exception {
452+
Server proxy = new Server();
453+
ServerConnector proxyConnector = secureProxy ? addHttpsConnector(proxy) : addHttpConnector(proxy);
454+
proxy.setHandler(new ConnectHandler());
455+
456+
try {
457+
proxy.start();
458+
try (AsyncHttpClient client = http2Client()) {
459+
ProxyServer.Builder proxyBuilder = proxyServer("127.0.0.1", proxyConnector.getLocalPort());
460+
if (secureProxy) {
461+
proxyBuilder.setProxyType(ProxyType.HTTPS);
462+
}
463+
ProxyServer proxyServer = proxyBuilder.build();
464+
Response firstResponse = client.prepareGet(httpsUrl("/hello"))
465+
.setProxyServer(proxyServer)
466+
.execute()
467+
.get(30, SECONDS);
468+
Response secondResponse = client.prepareGet(httpsUrl("/hello"))
469+
.setProxyServer(proxyServer)
470+
.execute()
471+
.get(30, SECONDS);
472+
473+
assertNotNull(firstResponse);
474+
assertEquals(200, firstResponse.getStatusCode());
475+
assertEquals(HttpProtocol.HTTP_2, firstResponse.getProtocol());
476+
assertNotNull(secondResponse);
477+
assertEquals(200, secondResponse.getStatusCode());
478+
assertEquals(HttpProtocol.HTTP_2, secondResponse.getProtocol());
479+
assertEquals(1, serverChildChannels.size(),
480+
"the HTTP/2 tunnel connection should be registered and reused");
481+
}
482+
} finally {
483+
proxy.stop();
484+
}
485+
}
486+
433487
/**
434488
* With {@link LoadBalance#ROUND_ROBIN}, a host resolving to several IPs gets one HTTP/2
435489
* connection per IP (the registry is keyed by the IP-aware partition key), so requests are spread

client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,6 @@ public void testHttpProxyToHttpsTarget() throws Exception {
146146
.setProxyType(ProxyType.HTTP)
147147
.build())
148148
.setUseInsecureTrustManager(true)
149-
// HTTP/2 ALPN upgrade after proxy CONNECT tunnel is not yet supported
150-
.setHttp2Enabled(false)
151149
.setConnectTimeout(Duration.ofMillis(10000))
152150
.setRequestTimeout(Duration.ofMillis(30000))
153151
.build();
@@ -169,8 +167,6 @@ public void testHttpsProxyToHttpsTarget() throws Exception {
169167
.setProxyType(ProxyType.HTTPS)
170168
.build())
171169
.setUseInsecureTrustManager(true)
172-
// HTTP/2 ALPN upgrade after proxy CONNECT tunnel is not yet supported
173-
.setHttp2Enabled(false)
174170
.setConnectTimeout(Duration.ofMillis(10000))
175171
.setRequestTimeout(Duration.ofMillis(30000))
176172
.build();

0 commit comments

Comments
 (0)