From 7d53dbb669850244e05804a1663b28a1841fb6b5 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Mon, 21 Jul 2025 13:51:12 +0200 Subject: [PATCH] HTTPCLIENT-2369: implemented HTTPS-proxy tunnelling (TLS-in-TLS) for both classic and async clients via ProxyTlsConnectionOperator / ProxyTlsAsyncConnectionOperator and new useHttpsProxyTunnelling builder switches. --- ...ingHttpClientConnectionManagerBuilder.java | 23 +++ .../impl/io/ProxyTlsConnectionOperator.java | 108 ++++++++++++ ...ngAsyncClientConnectionManagerBuilder.java | 26 ++- .../nio/ProxyTlsAsyncConnectionOperator.java | 160 ++++++++++++++++++ .../io/ProxyTlsConnectionOperatorTest.java | 103 +++++++++++ .../ProxyTlsAsyncConnectionOperatorTest.java | 134 +++++++++++++++ 6 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/ProxyTlsConnectionOperator.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/ProxyTlsAsyncConnectionOperator.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/ProxyTlsConnectionOperatorTest.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/ProxyTlsAsyncConnectionOperatorTest.java diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManagerBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManagerBuilder.java index d76c2720bc..f6eb4e72de 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManagerBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManagerBuilder.java @@ -87,6 +87,8 @@ public class PoolingHttpClientConnectionManagerBuilder { private Resolver connectionConfigResolver; private Resolver tlsConfigResolver; + private boolean enableHttpsProxyTunnelling; // <— default false + private boolean systemProperties; private int maxConnTotal; @@ -304,11 +306,32 @@ public final PoolingHttpClientConnectionManagerBuilder useSystemProperties() { return this; } + /** + * Enables double-TLS tunnelling when the route contains an {@code https://} proxy. + * + * @return this builder for method chaining + * @since 5.6 + */ + public PoolingHttpClientConnectionManagerBuilder useHttpsProxyTunnelling() { + this.enableHttpsProxyTunnelling = true; + return this; + } + @Internal protected HttpClientConnectionOperator createConnectionOperator( final SchemePortResolver schemePortResolver, final DnsResolver dnsResolver, final TlsSocketStrategy tlsSocketStrategy) { + + if (enableHttpsProxyTunnelling) { + return new ProxyTlsConnectionOperator( + RegistryBuilder.create() + .register(URIScheme.HTTPS.id, tlsSocketStrategy) + .build(), + schemePortResolver, + dnsResolver); + } + return new DefaultHttpClientConnectionOperator(schemePortResolver, dnsResolver, RegistryBuilder.create() .register(URIScheme.HTTPS.id, tlsSocketStrategy) diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/ProxyTlsConnectionOperator.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/ProxyTlsConnectionOperator.java new file mode 100644 index 0000000000..e85e0069db --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/ProxyTlsConnectionOperator.java @@ -0,0 +1,108 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.io; + +import java.io.IOException; +import java.net.Socket; + +import javax.net.ssl.SSLSocket; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SchemePortResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.io.ManagedHttpClientConnection; +import org.apache.hc.client5.http.ssl.TlsSocketStrategy; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.Lookup; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.net.NamedEndpoint; + +/** + * Connection-operator that supports “double-TLS” proxy tunnelling for the + * classic / blocking transport. + * + *
+ *   client ── TLS#1 ──► HTTPS-proxy  CONNECT  origin ── TLS#2 ──►
+ * 
+ * + *

The operator lets the default implementation build the plain tunnel, + * then layers a second TLS handshake on the already-encrypted socket and + * re-binds it to the pooled connection.

+ * + * @since 5.6 + */ +public final class ProxyTlsConnectionOperator extends DefaultHttpClientConnectionOperator { + + private final TlsSocketStrategy tlsStrategy; + + /** + * System-defaults constructor. + */ + public ProxyTlsConnectionOperator(final Lookup tlsLookup) { + this(tlsLookup, null, SystemDefaultDnsResolver.INSTANCE); + } + + /** + * Full-control constructor. + */ + public ProxyTlsConnectionOperator(final Lookup tlsLookup, + final SchemePortResolver schemePortResolver, + final DnsResolver dnsResolver) { + super(schemePortResolver, dnsResolver, tlsLookup); + this.tlsStrategy = tlsLookup.lookup(URIScheme.HTTPS.id); + if (this.tlsStrategy == null) { + throw new IllegalArgumentException( + "Lookup must contain a TlsSocketStrategy for scheme 'https'"); + } + } + + @Override + public void upgrade(final ManagedHttpClientConnection conn, + final HttpHost endpointHost, + final NamedEndpoint endpointName, + final Object attachment, + final HttpContext context) throws IOException { + + final Socket raw = conn.getSocket(); + if (raw == null) { + throw new ConnectionClosedException("Connection already closed"); + } + + /* Layer TLS#2 on top of the proxy TLS session */ + final SSLSocket layered = tlsStrategy.upgrade( + raw, + endpointHost.getHostName(), + endpointHost.getPort(), + attachment, + context); + + /* Re-bind so the pool sees the secure socket */ + conn.bind(layered, raw); + } +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManagerBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManagerBuilder.java index f6c93457d9..672d4ab5b1 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManagerBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManagerBuilder.java @@ -90,6 +90,9 @@ public class PoolingAsyncClientConnectionManagerBuilder { private Resolver tlsConfigResolver; private boolean messageMultiplexing; + private boolean enableHttpsAyncProxyTunnelling; // <— default false + + public static PoolingAsyncClientConnectionManagerBuilder create() { return new PoolingAsyncClientConnectionManagerBuilder(); } @@ -256,6 +259,16 @@ public final PoolingAsyncClientConnectionManagerBuilder useSystemProperties() { return this; } + /** + * Enables TLS-in-TLS tunnelling for the reactor / async transport. + * + * @since 5.6 + */ + public PoolingAsyncClientConnectionManagerBuilder useHttpsAsyncProxyTunnelling() { + this.enableHttpsAyncProxyTunnelling = true; + return this; + } + /** * Use experimental connections pool implementation that acts as a caching facade * in front of a standard connection pool and shares already leased connections @@ -306,8 +319,18 @@ public PoolingAsyncClientConnectionManager build() { } } } + + final AsyncClientConnectionOperator operator = enableHttpsAyncProxyTunnelling + ? new ProxyTlsAsyncConnectionOperator( + RegistryBuilder.create() + .register(URIScheme.HTTPS.id, tlsStrategyCopy) + .build(), + schemePortResolver, + dnsResolver) + : createConnectionOperator(tlsStrategyCopy, schemePortResolver, dnsResolver); + final PoolingAsyncClientConnectionManager poolingmgr = new PoolingAsyncClientConnectionManager( - createConnectionOperator(tlsStrategyCopy, schemePortResolver, dnsResolver), + operator, poolConcurrencyPolicy, poolReusePolicy, null, @@ -323,4 +346,5 @@ public PoolingAsyncClientConnectionManager build() { return poolingmgr; } + } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/ProxyTlsAsyncConnectionOperator.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/ProxyTlsAsyncConnectionOperator.java new file mode 100644 index 0000000000..7bf425d5e5 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/ProxyTlsAsyncConnectionOperator.java @@ -0,0 +1,160 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.nio; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SchemePortResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.impl.ConnPoolSupport; +import org.apache.hc.client5.http.nio.ManagedAsyncClientConnection; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.Lookup; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.ssl.TransportSecurityLayer; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Connection-operator that enables double-TLS tunnelling for the + * reactor / async transport. + * + *
+ *   client ── TLS#1 ──► HTTPS-proxy   CONNECT   origin ── TLS#2 ──►
+ * 
+ * + *

The first hop (client → proxy) may already be protected by TLS when the + * proxy itself speaks HTTPS. If {@link ManagedAsyncClientConnection#getTlsDetails()} + * returns non-{@code null}, this operator skips the standard upgrade path and + * immediately starts a second TLS handshake to the target host inside + * the tunnel. For plain-HTTP proxies the logic falls back to + * {@link DefaultAsyncClientConnectionOperator#upgrade(ManagedAsyncClientConnection, HttpHost, NamedEndpoint, Object, HttpContext, FutureCallback)} unchanged.

+ * + * @since 5.6 + */ +public final class ProxyTlsAsyncConnectionOperator extends DefaultAsyncClientConnectionOperator { + + private static final Logger LOG = + LoggerFactory.getLogger(ProxyTlsAsyncConnectionOperator.class); + + private final TlsStrategy tlsStrategy; + + /** + * Builds an operator that uses the system-default DNS resolver and + * scheme-port resolver. + * + * @param tlsLookup registry that must contain a {@link TlsStrategy} + * under the {@code "https"} scheme. + */ + public ProxyTlsAsyncConnectionOperator(final Lookup tlsLookup) { + this(tlsLookup, null, SystemDefaultDnsResolver.INSTANCE); + } + + /** + * Full-control constructor. + * + * @param tlsLookup registry containing the TLS strategy. + * @param schemePortResolver optional custom scheme-port resolver. + * @param dnsResolver optional custom DNS resolver. + */ + public ProxyTlsAsyncConnectionOperator(final Lookup tlsLookup, + final SchemePortResolver schemePortResolver, + final DnsResolver dnsResolver) { + super(tlsLookup, schemePortResolver, dnsResolver); + this.tlsStrategy = tlsLookup.lookup(URIScheme.HTTPS.id); + } + + @Override + public void upgrade(final ManagedAsyncClientConnection connection, + final HttpHost endpointHost, + final NamedEndpoint endpointName, + final Object attachment, + final HttpContext context, + final FutureCallback callback) { + + final NamedEndpoint tlsName = + endpointName != null ? endpointName : endpointHost; + + if (connection.getTlsDetails() != null) { + if (LOG.isDebugEnabled()) { + LOG.debug("{} proxy hop is already TLS, starting inner TLS to {}", + ConnPoolSupport.getId(connection), tlsName); + } + startInnerTls(connection, tlsName, attachment, callback); + return; + } + + /* Plain HTTP proxy → standard upgrade path */ + super.upgrade(connection, endpointHost, endpointName, attachment, context, callback); + } + + /** + * Initiates TLS#2 inside the CONNECT tunnel. + */ + private void startInnerTls(final ManagedAsyncClientConnection connection, + final NamedEndpoint tlsName, + final Object attachment, + final FutureCallback callback) { + try { + tlsStrategy.upgrade( + connection, + tlsName, + attachment, + Timeout.ofMinutes(1), + new FutureCallback() { + @Override + public void completed(final TransportSecurityLayer result) { + if (callback != null) { + callback.completed(connection); + } + } + + @Override + public void failed(final Exception ex) { + if (callback != null) { + callback.failed(ex); + } + } + + @Override + public void cancelled() { + if (callback != null) { + callback.cancelled(); + } + } + }); + } catch (final Exception ex) { + if (callback != null) { + callback.failed(ex); + } + } + } +} \ No newline at end of file diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/ProxyTlsConnectionOperatorTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/ProxyTlsConnectionOperatorTest.java new file mode 100644 index 0000000000..09246ec2eb --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/ProxyTlsConnectionOperatorTest.java @@ -0,0 +1,103 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.io; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.Socket; + +import javax.net.ssl.SSLSocket; + +import org.apache.hc.client5.http.io.ManagedHttpClientConnection; +import org.apache.hc.client5.http.ssl.TlsSocketStrategy; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.Lookup; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Unit-tests for {@link ProxyTlsConnectionOperator}. + */ +class ProxyTlsConnectionOperatorTest { + + @Test + void upgrade_layersSecondTls_andBinds() throws Exception { + /* -------- test doubles -------- */ + final TlsSocketStrategy tlsStrategy = Mockito.mock(TlsSocketStrategy.class); + final ManagedHttpClientConnection conn = Mockito.mock(ManagedHttpClientConnection.class); + final Socket raw = Mockito.mock(Socket.class); + final SSLSocket layered = Mockito.mock(SSLSocket.class); + + when(conn.getSocket()).thenReturn(raw); + when(tlsStrategy.upgrade( + same(raw), eq("example.com"), eq(443), + isNull(), any())) + .thenReturn(layered); + + final Lookup lookup = RegistryBuilder.create() + .register(URIScheme.HTTPS.id, tlsStrategy) + .build(); + + final ProxyTlsConnectionOperator op = new ProxyTlsConnectionOperator(lookup); + + final HttpHost endpoint = new HttpHost("https", "example.com", 443); + + op.upgrade(conn, endpoint, null, null, HttpCoreContext.create()); + + verify(tlsStrategy).upgrade( + same(raw), eq("example.com"), eq(443), + isNull(), any()); + verify(conn).bind(layered, raw); + } + + @Test + void upgrade_throwsWhenSocketMissing() throws Exception { + final TlsSocketStrategy tlsStrategy = Mockito.mock(TlsSocketStrategy.class); + final ManagedHttpClientConnection conn = Mockito.mock(ManagedHttpClientConnection.class); + when(conn.getSocket()).thenReturn(null); + + final Lookup lookup = RegistryBuilder.create() + .register(URIScheme.HTTPS.id, tlsStrategy) + .build(); + + final ProxyTlsConnectionOperator op = new ProxyTlsConnectionOperator(lookup); + final HttpHost endpoint = new HttpHost("https", "example.com", 443); + + assertThrows(ConnectionClosedException.class, + () -> op.upgrade(conn, endpoint, null, null, HttpCoreContext.create())); + } +} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/ProxyTlsAsyncConnectionOperatorTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/ProxyTlsAsyncConnectionOperatorTest.java new file mode 100644 index 0000000000..42a6c003d8 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/ProxyTlsAsyncConnectionOperatorTest.java @@ -0,0 +1,134 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.nio; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import javax.net.ssl.SSLSession; + +import org.apache.hc.client5.http.nio.ManagedAsyncClientConnection; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.Lookup; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.apache.hc.core5.reactor.ssl.TransportSecurityLayer; +import org.junit.jupiter.api.Test; + +/** + * Unit-tests for {@link ProxyTlsAsyncConnectionOperator}. + */ +class ProxyTlsAsyncConnectionOperatorTest { + + private static Lookup registryWith(final TlsStrategy strategy) { + return RegistryBuilder.create() + .register(URIScheme.HTTPS.id, strategy) + .build(); + } + + /* -------------------------------------------------------------- + * Happy path – outer handshake succeeds + * -------------------------------------------------------------- */ + @Test + void upgrade_passesThroughOnSuccess() throws Exception { + + /* ----- mocks ----- */ + final TlsStrategy tls = mock(TlsStrategy.class); + final ManagedAsyncClientConnection conn = mock(ManagedAsyncClientConnection.class, + withSettings().extraInterfaces(TransportSecurityLayer.class)); + @SuppressWarnings("unchecked") final FutureCallback userCb = mock(FutureCallback.class); + + /* tls.upgrade -> immediately call cb.completed(...) */ + doAnswer(inv -> { + final FutureCallback cb = inv.getArgument(4); + cb.completed((TransportSecurityLayer) inv.getArgument(0)); + return null; + }).when(tls).upgrade(any(TransportSecurityLayer.class), any(), any(), any(), any()); + + final ProxyTlsAsyncConnectionOperator op = + new ProxyTlsAsyncConnectionOperator(registryWith(tls)); + + final HttpHost target = new HttpHost("https", "example.com", 443); + + assertDoesNotThrow(() -> + op.upgrade(conn, target, null, null, + HttpCoreContext.create(), userCb)); + + verify(tls, times(1)) + .upgrade(any(TransportSecurityLayer.class), eq(target), any(), any(), any()); + verify(userCb).completed(conn); + } + + @Test + void upgrade_performsInnerTlsWhenProxyIsSecure() throws Exception { + + final TlsStrategy tls = mock(TlsStrategy.class); + + final ManagedAsyncClientConnection conn = mock( + ManagedAsyncClientConnection.class, + withSettings().extraInterfaces(TransportSecurityLayer.class)); + + // Mark the proxy hop as already secured by TLS + when(conn.getTlsDetails()) + .thenReturn(new TlsDetails(mock(SSLSession.class), null)); + + @SuppressWarnings("unchecked") final FutureCallback userCb = mock(FutureCallback.class); + + // tls.upgrade(...) -> immediately complete + doAnswer(inv -> { + final FutureCallback cb = inv.getArgument(4); + cb.completed(inv.getArgument(0)); + return null; + }).when(tls).upgrade(any(TransportSecurityLayer.class), any(), any(), any(), any()); + + final ProxyTlsAsyncConnectionOperator op = + new ProxyTlsAsyncConnectionOperator(registryWith(tls)); + + final HttpHost target = new HttpHost("https", "example.com", 443); + + assertDoesNotThrow(() -> + op.upgrade(conn, target, null, null, + HttpCoreContext.create(), userCb)); + + /* proxy already over TLS → only one inner-handshake call */ + verify(tls, times(1)) + .upgrade(any(TransportSecurityLayer.class), eq(target), any(), any(), any()); + verify(userCb).completed(conn); + } + +}