Skip to content

Commit 420474c

Browse files
authored
HTTPS Proxy Support (#2109)
Motivation: AHC only supports HTTP proxy at the moment, not HTTPS. HTTPS is required in many environments because CONNECT has to be encrypted to prevent eavesdropping. Modification: Added HTTPS proxy support. Fixes: #1907
1 parent 8f7e249 commit 420474c

File tree

15 files changed

+976
-53
lines changed

15 files changed

+976
-53
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ MANIFEST.MF
1919
work
2020
atlassian-ide-plugin.xml
2121
/bom/.flattened-pom.xml
22+
23+
# Docker volumes and logs (but keep configuration)
24+
docker/squid/logs/
25+
docker/nginx/logs/

client/pom.xml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,5 +188,88 @@
188188
<version>2.1.6</version>
189189
<scope>test</scope>
190190
</dependency>
191+
192+
<!-- Testcontainers for Docker-based integration tests -->
193+
<dependency>
194+
<groupId>org.testcontainers</groupId>
195+
<artifactId>testcontainers</artifactId>
196+
<version>${testcontainers.version}</version>
197+
<scope>test</scope>
198+
</dependency>
199+
<dependency>
200+
<groupId>org.testcontainers</groupId>
201+
<artifactId>junit-jupiter</artifactId>
202+
<version>${testcontainers.version}</version>
203+
<scope>test</scope>
204+
</dependency>
191205
</dependencies>
206+
207+
<profiles>
208+
<profile>
209+
<id>docker-tests</id>
210+
<activation>
211+
<property>
212+
<name>docker.tests</name>
213+
<value>true</value>
214+
</property>
215+
</activation>
216+
<build>
217+
<plugins>
218+
<plugin>
219+
<groupId>org.apache.maven.plugins</groupId>
220+
<artifactId>maven-surefire-plugin</artifactId>
221+
<configuration>
222+
<systemPropertyVariables>
223+
<skip.docker.tests>false</skip.docker.tests>
224+
<docker.available>true</docker.available>
225+
</systemPropertyVariables>
226+
</configuration>
227+
</plugin>
228+
</plugins>
229+
</build>
230+
</profile>
231+
<profile>
232+
<id>testcontainers-auto</id>
233+
<activation>
234+
<activeByDefault>true</activeByDefault>
235+
</activation>
236+
<build>
237+
<plugins>
238+
<plugin>
239+
<groupId>org.apache.maven.plugins</groupId>
240+
<artifactId>maven-surefire-plugin</artifactId>
241+
<configuration>
242+
<systemPropertyVariables>
243+
<skip.docker.tests>true</skip.docker.tests>
244+
<!-- Let Testcontainers auto-detect Docker -->
245+
</systemPropertyVariables>
246+
</configuration>
247+
</plugin>
248+
</plugins>
249+
</build>
250+
</profile>
251+
<profile>
252+
<id>no-docker-tests</id>
253+
<activation>
254+
<property>
255+
<name>no.docker.tests</name>
256+
<value>true</value>
257+
</property>
258+
</activation>
259+
<build>
260+
<plugins>
261+
<plugin>
262+
<groupId>org.apache.maven.plugins</groupId>
263+
<artifactId>maven-surefire-plugin</artifactId>
264+
<configuration>
265+
<systemPropertyVariables>
266+
<skip.docker.tests>true</skip.docker.tests>
267+
<testcontainers.mode>disabled</testcontainers.mode>
268+
</systemPropertyVariables>
269+
</configuration>
270+
</plugin>
271+
</plugins>
272+
</build>
273+
</profile>
274+
</profiles>
192275
</project>

client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public Object getPartitionKey(Uri uri, @Nullable String virtualHost, @Nullable P
5050
targetHostBaseUrl,
5151
virtualHost,
5252
proxyServer.getHost(),
53-
uri.isSecured() && proxyServer.getProxyType() == ProxyType.HTTP ?
53+
uri.isSecured() && proxyServer.getProxyType().isHttp() ?
5454
proxyServer.getSecuredPort() :
5555
proxyServer.getPort(),
5656
proxyServer.getProxyType());

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

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
import org.asynchttpclient.netty.request.NettyRequestSender;
6868
import org.asynchttpclient.netty.ssl.DefaultSslEngineFactory;
6969
import org.asynchttpclient.proxy.ProxyServer;
70+
import org.asynchttpclient.proxy.ProxyType;
7071
import org.asynchttpclient.uri.Uri;
7172
import org.slf4j.Logger;
7273
import org.slf4j.LoggerFactory;
@@ -386,14 +387,68 @@ public Future<Channel> updatePipelineForHttpTunneling(ChannelPipeline pipeline,
386387
}
387388

388389
if (requestUri.isSecured()) {
389-
if (!isSslHandlerConfigured(pipeline)) {
390-
SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort());
391-
whenHandshaked = sslHandler.handshakeFuture();
392-
pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler);
390+
// For HTTPS targets, we always need to add/replace the SSL handler for the target connection
391+
// even if there's already an SSL handler in the pipeline (which would be for an HTTPS proxy)
392+
if (isSslHandlerConfigured(pipeline)) {
393+
// Remove existing SSL handler (for proxy) and replace with SSL handler for target
394+
pipeline.remove(SSL_HANDLER);
393395
}
396+
SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort());
397+
whenHandshaked = sslHandler.handshakeFuture();
398+
pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler);
394399
pipeline.addAfter(SSL_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec());
395400

396401
} else {
402+
// For HTTP targets, remove any existing SSL handler (from HTTPS proxy) since target is not secured
403+
if (isSslHandlerConfigured(pipeline)) {
404+
pipeline.remove(SSL_HANDLER);
405+
}
406+
pipeline.addBefore(AHC_HTTP_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec());
407+
}
408+
409+
if (requestUri.isWebSocket()) {
410+
pipeline.addAfter(AHC_HTTP_HANDLER, AHC_WS_HANDLER, wsHandler);
411+
412+
if (config.isEnableWebSocketCompression()) {
413+
pipeline.addBefore(AHC_WS_HANDLER, WS_COMPRESSOR_HANDLER, WebSocketClientCompressionHandler.INSTANCE);
414+
}
415+
416+
pipeline.remove(AHC_HTTP_HANDLER);
417+
}
418+
return whenHandshaked;
419+
}
420+
421+
public Future<Channel> updatePipelineForHttpsTunneling(ChannelPipeline pipeline, Uri requestUri, ProxyServer proxyServer) {
422+
Future<Channel> whenHandshaked = null;
423+
424+
// Remove HTTP codec as tunnel is established
425+
if (pipeline.get(HTTP_CLIENT_CODEC) != null) {
426+
pipeline.remove(HTTP_CLIENT_CODEC);
427+
}
428+
429+
if (requestUri.isSecured()) {
430+
// For HTTPS proxy to HTTPS target, we need to establish target SSL over the proxy SSL tunnel
431+
// The proxy SSL handler should remain as it provides the tunnel transport
432+
// We need to add target SSL handler that will negotiate with the target through the tunnel
433+
434+
SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort());
435+
whenHandshaked = sslHandler.handshakeFuture();
436+
437+
// For HTTPS proxy tunnel, add target SSL handler after the existing proxy SSL handler
438+
// This creates a nested SSL setup: Target SSL -> Proxy SSL -> Network
439+
if (isSslHandlerConfigured(pipeline)) {
440+
// Insert target SSL handler after the proxy SSL handler
441+
pipeline.addAfter(SSL_HANDLER, "target-ssl", sslHandler);
442+
} else {
443+
// This shouldn't happen for HTTPS proxy, but fallback
444+
pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler);
445+
}
446+
447+
pipeline.addAfter("target-ssl", HTTP_CLIENT_CODEC, newHttpClientCodec());
448+
449+
} else {
450+
// For HTTPS proxy to HTTP target, just add HTTP codec
451+
// The proxy SSL handler provides the tunnel and remains
397452
pipeline.addBefore(AHC_HTTP_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec());
398453
}
399454

@@ -406,6 +461,7 @@ public Future<Channel> updatePipelineForHttpTunneling(ChannelPipeline pipeline,
406461

407462
pipeline.remove(AHC_HTTP_HANDLER);
408463
}
464+
409465
return whenHandshaked;
410466
}
411467

@@ -486,6 +542,10 @@ protected void initChannel(Channel channel) throws Exception {
486542
}
487543
});
488544

545+
} else if (proxy != null && ProxyType.HTTPS.equals(proxy.getProxyType())) {
546+
// For HTTPS proxies, use HTTP bootstrap but ensure SSL connection to proxy
547+
// The SSL handler for connecting to the proxy will be added in the connect phase
548+
promise.setSuccess(httpBootstrap);
489549
} else {
490550
promise.setSuccess(httpBootstrap);
491551
}

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import org.asynchttpclient.netty.request.NettyRequestSender;
2727
import org.asynchttpclient.netty.timeout.TimeoutsHolder;
2828
import org.asynchttpclient.proxy.ProxyServer;
29+
import org.asynchttpclient.proxy.ProxyType;
2930
import org.asynchttpclient.uri.Uri;
3031
import org.slf4j.Logger;
3132
import org.slf4j.LoggerFactory;
@@ -100,8 +101,57 @@ public void onSuccess(Channel channel, InetSocketAddress remoteAddress) {
100101
timeoutsHolder.setResolvedRemoteAddress(remoteAddress);
101102
ProxyServer proxyServer = future.getProxyServer();
102103

104+
// For HTTPS proxies, establish SSL connection to the proxy server first
105+
if (proxyServer != null && ProxyType.HTTPS.equals(proxyServer.getProxyType())) {
106+
SslHandler sslHandler;
107+
try {
108+
sslHandler = channelManager.addSslHandler(channel.pipeline(),
109+
Uri.create("https://" + proxyServer.getHost() + ":" + proxyServer.getSecuredPort()),
110+
null, false);
111+
} catch (Exception sslError) {
112+
onFailure(channel, sslError);
113+
return;
114+
}
115+
116+
final AsyncHandler<?> asyncHandler = future.getAsyncHandler();
117+
118+
try {
119+
asyncHandler.onTlsHandshakeAttempt();
120+
} catch (Exception e) {
121+
LOGGER.error("onTlsHandshakeAttempt crashed", e);
122+
onFailure(channel, e);
123+
return;
124+
}
125+
126+
sslHandler.handshakeFuture().addListener(new SimpleFutureListener<Channel>() {
127+
@Override
128+
protected void onSuccess(Channel value) {
129+
try {
130+
asyncHandler.onTlsHandshakeSuccess(sslHandler.engine().getSession());
131+
} catch (Exception e) {
132+
LOGGER.error("onTlsHandshakeSuccess crashed", e);
133+
NettyConnectListener.this.onFailure(channel, e);
134+
return;
135+
}
136+
// After SSL handshake to proxy, continue with normal proxy request
137+
writeRequest(channel);
138+
}
139+
140+
@Override
141+
protected void onFailure(Throwable cause) {
142+
try {
143+
asyncHandler.onTlsHandshakeFailure(cause);
144+
} catch (Exception e) {
145+
LOGGER.error("onTlsHandshakeFailure crashed", e);
146+
NettyConnectListener.this.onFailure(channel, e);
147+
return;
148+
}
149+
NettyConnectListener.this.onFailure(channel, cause);
150+
}
151+
});
152+
103153
// in case of proxy tunneling, we'll add the SslHandler later, after the CONNECT request
104-
if ((proxyServer == null || proxyServer.getProxyType().isSocks()) && uri.isSecured()) {
154+
} else if ((proxyServer == null || proxyServer.getProxyType().isSocks()) && uri.isSecured()) {
105155
SslHandler sslHandler;
106156
try {
107157
sslHandler = channelManager.addSslHandler(channel.pipeline(), uri, request.getVirtualHost(), proxyServer != null);

client/src/main/java/org/asynchttpclient/netty/handler/intercept/ConnectSuccessInterceptor.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.asynchttpclient.netty.channel.ChannelManager;
2323
import org.asynchttpclient.netty.request.NettyRequestSender;
2424
import org.asynchttpclient.proxy.ProxyServer;
25+
import org.asynchttpclient.proxy.ProxyType;
2526
import org.asynchttpclient.uri.Uri;
2627
import org.slf4j.Logger;
2728
import org.slf4j.LoggerFactory;
@@ -45,7 +46,18 @@ public boolean exitAfterHandlingConnect(Channel channel, NettyResponseFuture<?>
4546

4647
Uri requestUri = request.getUri();
4748
LOGGER.debug("Connecting to proxy {} for scheme {}", proxyServer, requestUri.getScheme());
48-
final Future<Channel> whenHandshaked = channelManager.updatePipelineForHttpTunneling(channel.pipeline(), requestUri);
49+
50+
final Future<Channel> whenHandshaked;
51+
52+
// Special handling for HTTPS proxy tunneling
53+
if (proxyServer != null && ProxyType.HTTPS.equals(proxyServer.getProxyType())) {
54+
// For HTTPS proxy, we need special tunnel pipeline management
55+
whenHandshaked = channelManager.updatePipelineForHttpsTunneling(channel.pipeline(), requestUri, proxyServer);
56+
} else {
57+
// Standard HTTP proxy or SOCKS proxy tunneling
58+
whenHandshaked = channelManager.updatePipelineForHttpTunneling(channel.pipeline(), requestUri);
59+
}
60+
4961
future.setReuseChannel(true);
5062
future.setConnectAllowed(false);
5163

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import org.asynchttpclient.netty.channel.NettyConnectListener;
5555
import org.asynchttpclient.netty.timeout.TimeoutsHolder;
5656
import org.asynchttpclient.proxy.ProxyServer;
57+
import org.asynchttpclient.proxy.ProxyType;
5758
import org.asynchttpclient.resolver.RequestHostnameResolver;
5859
import org.asynchttpclient.uri.Uri;
5960
import org.asynchttpclient.ws.WebSocketUpgradeHandler;
@@ -337,7 +338,7 @@ private <T> Future<List<InetSocketAddress>> resolveAddresses(Request request, Pr
337338
final Promise<List<InetSocketAddress>> promise = ImmediateEventExecutor.INSTANCE.newPromise();
338339

339340
if (proxy != null && !proxy.isIgnoredForHost(uri.getHost()) && proxy.getProxyType().isHttp()) {
340-
int port = uri.isSecured() ? proxy.getSecuredPort() : proxy.getPort();
341+
int port = ProxyType.HTTPS.equals(proxy.getProxyType()) || uri.isSecured() ? proxy.getSecuredPort() : proxy.getPort();
341342
InetSocketAddress unresolvedRemoteAddress = InetSocketAddress.createUnresolved(proxy.getHost(), port);
342343
scheduleRequestTimeout(future, unresolvedRemoteAddress);
343344
return RequestHostnameResolver.INSTANCE.resolve(request.getNameResolver(), unresolvedRemoteAddress, asyncHandler);

client/src/main/java/org/asynchttpclient/proxy/ProxyType.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
package org.asynchttpclient.proxy;
1717

1818
public enum ProxyType {
19-
HTTP(true), SOCKS_V4(false), SOCKS_V5(false);
19+
HTTP(true), HTTPS(true), SOCKS_V4(false), SOCKS_V5(false);
2020

2121
private final boolean http;
2222

0 commit comments

Comments
 (0)