out = new ArrayList<>(tokens.size());
+ for (final String token : tokens) {
+ out.add(decodeId(token));
+ }
+ return out;
+ }
+
+ /**
+ * Encodes a single raw protocol ID to canonical token form using the HTTP token codec
+ * from core, which keeps RFC 7230 {@code tchar} octets literal and percent-encodes the
+ * rest (including {@code '%'}) with uppercase hexadecimal.
+ */
+ public static String encodeId(final String id) {
+ Args.notBlank(id, "id");
+ return PercentCodec.HTTP_TOKEN.encode(id);
+ }
+
+ /**
+ * Decodes a percent-encoded token to a raw protocol ID using UTF-8.
+ *
+ * A {@code '%'} that is not followed by two hexadecimal digits is a malformed
+ * token and is rejected as a protocol error.
+ *
+ * @throws ProtocolException if the token contains malformed percent-encoding.
+ */
+ public static String decodeId(final String token) throws ProtocolException {
+ Args.notBlank(token, "token");
+ for (int i = 0; i < token.length(); i++) {
+ if (token.charAt(i) == '%') {
+ if (i + 2 >= token.length()
+ || Character.digit(token.charAt(i + 1), 16) < 0
+ || Character.digit(token.charAt(i + 2), 16) < 0) {
+ throw new ProtocolException("Malformed percent-encoding in ALPN protocol id: " + token);
+ }
+ i += 2;
+ }
+ }
+ return PercentCodec.decode(token, StandardCharsets.UTF_8);
+ }
+
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java
index d8dd016339..2ce6a97d19 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java
@@ -30,6 +30,7 @@
import java.io.IOException;
import java.io.InterruptedIOException;
import java.nio.ByteBuffer;
+import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@@ -47,6 +48,7 @@
import org.apache.hc.client5.http.auth.ChallengeType;
import org.apache.hc.client5.http.auth.MalformedChallengeException;
import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.AlpnHeaderSupport;
import org.apache.hc.client5.http.impl.auth.AuthCacheKeeper;
import org.apache.hc.client5.http.impl.auth.AuthenticationHandler;
import org.apache.hc.client5.http.impl.routing.BasicRouteDirector;
@@ -76,6 +78,8 @@
import org.apache.hc.core5.http.nio.RequestChannel;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.http2.ssl.H2TlsSupport;
import org.apache.hc.core5.util.Args;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -426,6 +430,15 @@ public void produceRequest(final RequestChannel requestChannel,
final HttpRequest connect = new BasicHttpRequest(Method.CONNECT, nextHop, nextHop.toHostString());
connect.setVersion(HttpVersion.HTTP_1_1);
+ // RFC 7639: advertise the same ALPN protocols the tunnel's TLS layer will offer, derived
+ // from the target's HttpVersionPolicy published on the context by the connection manager,
+ // so the header cannot diverge from the protocol actually negotiated inside the tunnel.
+ if (scope.route.isSecure()) {
+ final HttpVersionPolicy configured = clientContext.getHttpVersionPolicy();
+ final HttpVersionPolicy versionPolicy = configured != null ? configured : HttpVersionPolicy.NEGOTIATE;
+ connect.setHeader(AlpnHeaderSupport.formatValue(
+ Arrays.asList(H2TlsSupport.selectApplicationProtocols(versionPolicy))));
+ }
proxyHttpProcessor.process(connect, null, clientContext);
authenticator.addAuthResponse(proxy, ChallengeType.PROXY, connect, proxyAuthExchange, clientContext);
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java
index fc0f8d5106..422e3bb943 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java
@@ -28,6 +28,7 @@
package org.apache.hc.client5.http.impl.classic;
import java.io.IOException;
+import java.util.Arrays;
import org.apache.hc.client5.http.AuthenticationStrategy;
import org.apache.hc.client5.http.EndpointInfo;
@@ -40,6 +41,7 @@
import org.apache.hc.client5.http.classic.ExecChainHandler;
import org.apache.hc.client5.http.classic.ExecRuntime;
import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.AlpnHeaderSupport;
import org.apache.hc.client5.http.impl.auth.AuthCacheKeeper;
import org.apache.hc.client5.http.impl.auth.AuthenticationHandler;
import org.apache.hc.client5.http.impl.routing.BasicRouteDirector;
@@ -65,6 +67,8 @@
import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
import org.apache.hc.core5.http.message.StatusLine;
import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.http2.ssl.H2TlsSupport;
import org.apache.hc.core5.util.Args;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -139,7 +143,6 @@ public ClassicHttpResponse execute(
step = this.routeDirector.nextStep(route, fact);
switch (step) {
-
case HttpRouteDirector.CONNECT_TARGET:
execRuntime.connectEndpoint(context);
tracker.connectTarget(route.isSecure());
@@ -162,11 +165,8 @@ public ClassicHttpResponse execute(
}
break;
- case HttpRouteDirector.TUNNEL_PROXY: {
- // Proxy chains are not supported by HttpClient.
- // Fail fast instead of attempting an untested tunnel to an intermediate proxy.
+ case HttpRouteDirector.TUNNEL_PROXY:
throw new HttpException("Proxy chains are not supported.");
- }
case HttpRouteDirector.LAYER_PROTOCOL:
execRuntime.upgradeTls(context);
@@ -197,14 +197,6 @@ public ClassicHttpResponse execute(
}
}
- /**
- * Creates a tunnel to the target server.
- * The connection must be established to the (last) proxy.
- * A CONNECT request for tunnelling through the proxy will
- * be created and sent, the response received and checked.
- * This method does not processChallenge the connection with
- * information about the tunnel, that is left to the caller.
- */
private ClassicHttpResponse createTunnelToTarget(
final String exchangeId,
final HttpRoute route,
@@ -228,6 +220,16 @@ private ClassicHttpResponse createTunnelToTarget(
final ClassicHttpRequest connect = new BasicClassicHttpRequest(Method.CONNECT, target, authority);
connect.setVersion(HttpVersion.HTTP_1_1);
+ // RFC 7639: advertise the same ALPN protocols the tunnel's TLS layer will offer, derived
+ // from the target's HttpVersionPolicy published on the context by the connection manager,
+ // so the header cannot diverge from the protocol actually negotiated inside the tunnel.
+ if (route.isSecure()) {
+ final HttpVersionPolicy configured = context.getHttpVersionPolicy();
+ final HttpVersionPolicy versionPolicy = configured != null ? configured : HttpVersionPolicy.NEGOTIATE;
+ connect.setHeader(AlpnHeaderSupport.formatValue(
+ Arrays.asList(H2TlsSupport.selectApplicationProtocols(versionPolicy))));
+ }
+
this.proxyHttpProcessor.process(connect, null, context);
while (response == null) {
@@ -262,12 +264,10 @@ private ClassicHttpResponse createTunnelToTarget(
authCacheKeeper.updateOnResponse(proxy, null, proxyAuthExchange, context);
}
if (updated) {
- // Retry request
if (this.reuseStrategy.keepAlive(connect, response, context)) {
if (LOG.isDebugEnabled()) {
LOG.debug("{} connection kept alive", exchangeId);
}
- // Consume response content
final HttpEntity entity = response.getEntity();
EntityUtils.consume(entity);
} else {
@@ -295,26 +295,11 @@ private ClassicHttpResponse createTunnelToTarget(
return null;
}
- /**
- * Creates a tunnel to an intermediate proxy.
- * This method is not implemented in this class.
- * It just throws an exception here.
- */
private boolean createTunnelToProxy(
final HttpRoute route,
final int hop,
final HttpClientContext context) throws HttpException {
-
- // Have a look at createTunnelToTarget and replicate the parts
- // you need in a custom derived class. If your proxies don't require
- // authentication, it is not too hard. But for the stock version of
- // HttpClient, we cannot make such simplifying assumptions and would
- // have to include proxy authentication code. The HttpComponents team
- // is currently not in a position to support rarely used code of this
- // complexity. Feel free to submit patches that refactor the code in
- // createTunnelToTarget to facilitate re-use for proxy tunnelling.
-
throw new HttpException("Proxy chains are not supported.");
}
-}
+}
\ No newline at end of file
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManager.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManager.java
index 291b8e4baf..2945e8ed7d 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManager.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManager.java
@@ -51,6 +51,7 @@
import org.apache.hc.client5.http.io.HttpClientConnectionOperator;
import org.apache.hc.client5.http.io.LeaseRequest;
import org.apache.hc.client5.http.io.ManagedHttpClientConnection;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
import org.apache.hc.client5.http.ssl.TlsSocketStrategy;
import org.apache.hc.core5.annotation.Contract;
@@ -552,6 +553,10 @@ public void connect(final ConnectionEndpoint endpoint, final TimeValue timeout,
final HttpHost firstHop = route.getProxyHost() != null ? route.getProxyHost() : route.getTargetHost();
final SocketConfig socketConfig = resolveSocketConfig(route);
final ConnectionConfig connectionConfig = resolveConnectionConfig(route);
+ final TlsConfig tlsConfig = resolveTlsConfig(route.getTargetHost());
+ if (context != null) {
+ HttpClientContext.cast(context).setHttpVersionPolicy(tlsConfig.getHttpVersionPolicy());
+ }
final Timeout connectTimeout = timeout != null ? Timeout.of(timeout.getDuration(), timeout.getTimeUnit()) : connectionConfig.getConnectTimeout();
if (LOG.isDebugEnabled()) {
LOG.debug("{} connecting endpoint to {} ({})", ConnPoolSupport.getId(endpoint), firstHop, connectTimeout);
@@ -565,7 +570,7 @@ public void connect(final ConnectionEndpoint endpoint, final TimeValue timeout,
route.getLocalSocketAddress(),
connectTimeout,
socketConfig,
- route.isTunnelled() ? null : resolveTlsConfig(route.getTargetHost()),
+ route.isTunnelled() ? null : tlsConfig,
context);
if (LOG.isDebugEnabled()) {
LOG.debug("{} connected {}", ConnPoolSupport.getId(endpoint), ConnPoolSupport.getId(conn));
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
index 1f74d5d60f..6cb2b6b39f 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
@@ -50,6 +50,7 @@
import org.apache.hc.client5.http.nio.AsyncClientConnectionOperator;
import org.apache.hc.client5.http.nio.AsyncConnectionEndpoint;
import org.apache.hc.client5.http.nio.ManagedAsyncClientConnection;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
import org.apache.hc.core5.annotation.Contract;
import org.apache.hc.core5.annotation.Internal;
@@ -504,13 +505,17 @@ public Future connect(
if (LOG.isDebugEnabled()) {
LOG.debug("{} connecting endpoint to {} ({})", ConnPoolSupport.getId(endpoint), firstHop, connectTimeout);
}
+ final TlsConfig targetTlsConfig = resolveTlsConfig(route.getTargetHost());
+ if (context != null) {
+ HttpClientContext.cast(context).setHttpVersionPolicy(targetTlsConfig.getHttpVersionPolicy());
+ }
final Object connectAttachment;
if (route.isTunnelled()) {
connectAttachment = null;
} else if (attachment instanceof TlsConfig) {
connectAttachment = attachment;
} else {
- connectAttachment = resolveTlsConfig(route.getTargetHost());
+ connectAttachment = targetTlsConfig;
}
final Future connectFuture = connectionOperator.connect(
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java
index 575f67cd64..7ba111d4a7 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java
@@ -53,6 +53,7 @@
import org.apache.hc.core5.http.config.Lookup;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.http.protocol.HttpCoreContext;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
/**
* Client execution {@link HttpContext}. This class can be re-used for
@@ -201,6 +202,7 @@ public static HttpClientContext create() {
private AuthCache authCache;
private Object userToken;
private RequestConfig requestConfig;
+ private HttpVersionPolicy versionPolicy;
/**
* Stores the {@code nextnonce} value provided by the server in an HTTP response.
@@ -488,6 +490,27 @@ public void setNextNonce(final String nextNonce) {
this.nextNonce = nextNonce;
}
+ /**
+ * Represents the {@link HttpVersionPolicy} resolved for the target of the current route. The
+ * connection manager populates this attribute before the connection is established so that
+ * protocol interceptors can act on the effective TLS policy, for instance to advertise the
+ * matching ALPN protocol identifiers on a {@code CONNECT} request.
+ *
+ * @since 5.7
+ */
+ @Internal
+ public HttpVersionPolicy getHttpVersionPolicy() {
+ return versionPolicy;
+ }
+
+ /**
+ * @since 5.7
+ */
+ @Internal
+ public void setHttpVersionPolicy(final HttpVersionPolicy versionPolicy) {
+ this.versionPolicy = versionPolicy;
+ }
+
/**
* Internal adaptor class that delegates all its method calls to a plain {@link HttpContext}.
* To be removed in the future.
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/AlpnHeaderSupportTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/AlpnHeaderSupportTest.java
new file mode 100644
index 0000000000..1530e75c93
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/AlpnHeaderSupportTest.java
@@ -0,0 +1,135 @@
+/*
+ * ====================================================================
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.ProtocolException;
+import org.apache.hc.core5.http.message.BasicHeader;
+import org.junit.jupiter.api.Test;
+
+class AlpnHeaderSupportTest {
+
+ @Test
+ void encodes_slash_and_percent_and_space() {
+ assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1"));
+ assertEquals("h2%25", AlpnHeaderSupport.encodeId("h2%"));
+ assertEquals("foo%20bar", AlpnHeaderSupport.encodeId("foo bar"));
+ }
+
+ @Test
+ void encodes_unicode_utf8() throws Exception {
+ final String raw = "ws/é"; // é -> C3 A9
+ final String enc = AlpnHeaderSupport.encodeId(raw);
+ assertEquals("ws%2F%C3%A9", enc);
+ assertEquals(raw, AlpnHeaderSupport.decodeId(enc));
+ }
+
+ @Test
+ void keeps_tchar_plain_and_upper_hex() {
+ assertEquals("h2", AlpnHeaderSupport.encodeId("h2"));
+ assertEquals("A1+B", AlpnHeaderSupport.encodeId("A1+B")); // '+' is a tchar → stays literal
+ assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1")); // slash encoded, hex uppercase
+ }
+
+ @Test
+ void decode_accepts_lowercase_hex() throws Exception {
+ assertEquals("http/1.1", AlpnHeaderSupport.decodeId("http%2f1.1"));
+ }
+
+ @Test
+ void decode_rejects_malformed_percent_encoding() {
+ // a trailing '%' with no hex digits is a protocol error
+ assertThrows(ProtocolException.class, () -> AlpnHeaderSupport.decodeId("h2%"));
+ // a '%' followed by a non-hex digit is a protocol error
+ assertThrows(ProtocolException.class, () -> AlpnHeaderSupport.decodeId("h2%G1"));
+ }
+
+ @Test
+ void format_and_parse_roundtrip_with_ows() throws Exception {
+ final String v = "h2, http%2F1.1 ,ws";
+ final Header header = new BasicHeader(HttpHeaders.ALPN, v);
+
+ final List ids = AlpnHeaderSupport.parseValue(header);
+ assertEquals(Arrays.asList("h2", "http/1.1", "ws"), ids);
+
+ assertEquals("h2, http%2F1.1, ws", AlpnHeaderSupport.formatValue(ids).getValue());
+ }
+
+ @Test
+ void parse_rejects_malformed_token() {
+ final Header header = new BasicHeader(HttpHeaders.ALPN, "h2, http%2");
+ assertThrows(ProtocolException.class, () -> AlpnHeaderSupport.parseValue(header));
+ }
+
+ @Test
+ void parse_empty() throws Exception {
+ assertTrue(AlpnHeaderSupport.parseValue(new BasicHeader(HttpHeaders.ALPN, "")).isEmpty());
+ }
+
+ @Test
+ void all_tchar_pass_through() {
+ // digits
+ for (char c = '0'; c <= '9'; c++) {
+ assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c)));
+ }
+ // uppercase letters
+ for (char c = 'A'; c <= 'Z'; c++) {
+ assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c)));
+ }
+ // lowercase letters
+ for (char c = 'a'; c <= 'z'; c++) {
+ assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c)));
+ }
+ // the symbol set (minus '%' which must be encoded)
+ final String symbols = "!#$&'*+-.^_`|~";
+ for (int i = 0; i < symbols.length(); i++) {
+ final String s = String.valueOf(symbols.charAt(i));
+ assertEquals(s, AlpnHeaderSupport.encodeId(s));
+ }
+ }
+
+ @Test
+ void percent_is_always_encoded_and_uppercase_hex() {
+ assertEquals("%25", AlpnHeaderSupport.encodeId("%")); // '%' must be encoded
+ assertEquals("h2%25", AlpnHeaderSupport.encodeId("h2%")); // stays uppercase hex
+ }
+
+ @Test
+ void non_tchar_bytes_are_percent_encoded_uppercase() {
+ assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1")); // 'F' uppercase
+ assertEquals("foo%20bar", AlpnHeaderSupport.encodeId("foo bar")); // space → %20
+ }
+
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncConnectExec.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncConnectExec.java
new file mode 100644
index 0000000000..0551a69f5f
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncConnectExec.java
@@ -0,0 +1,155 @@
+/*
+ * ====================================================================
+ * 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.async;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.hc.client5.http.AuthenticationStrategy;
+import org.apache.hc.client5.http.HttpRoute;
+import org.apache.hc.client5.http.async.AsyncExecCallback;
+import org.apache.hc.client5.http.async.AsyncExecChain;
+import org.apache.hc.client5.http.async.AsyncExecRuntime;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.concurrent.Cancellable;
+import org.apache.hc.core5.concurrent.CancellableDependency;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler;
+import org.apache.hc.core5.http.nio.RequestChannel;
+import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.http.support.BasicRequestBuilder;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+class TestAsyncConnectExec {
+
+ @Mock
+ private HttpProcessor proxyHttpProcessor;
+ @Mock
+ private AuthenticationStrategy proxyAuthStrategy;
+ @Mock
+ private AsyncExecChain chain;
+ @Mock
+ private AsyncExecRuntime execRuntime;
+
+ private HttpHost target;
+ private HttpHost proxy;
+
+ @BeforeEach
+ void setup() {
+ MockitoAnnotations.openMocks(this);
+ target = new HttpHost("https", "foo", 443);
+ proxy = new HttpHost("bar", 8888);
+ }
+
+ /**
+ * Drives the connect exec through a secure proxy tunnel and returns the {@code CONNECT} request
+ * that the tunnelling exchange handler produces.
+ */
+ private HttpRequest tunnelConnectRequest(final AsyncConnectExec exec, final HttpRoute route,
+ final HttpVersionPolicy versionPolicy) throws Exception {
+ final HttpClientContext context = HttpClientContext.create();
+ if (versionPolicy != null) {
+ context.setHttpVersionPolicy(versionPolicy);
+ }
+ final HttpRequest request = BasicRequestBuilder.get("https://foo/test").build();
+ final CancellableDependency dependency = Mockito.mock(CancellableDependency.class);
+ final AsyncExecChain.Scope scope = new AsyncExecChain.Scope(
+ "test", route, request, dependency, context, execRuntime, null, new AtomicInteger(1));
+
+ Mockito.when(execRuntime.isEndpointAcquired()).thenReturn(false);
+ Mockito.when(execRuntime.isEndpointConnected()).thenReturn(false);
+ Mockito.doAnswer(invocation -> {
+ invocation.getArgument(4, FutureCallback.class).completed(execRuntime);
+ return Mockito.mock(Cancellable.class);
+ }).when(execRuntime).acquireEndpoint(
+ Mockito.anyString(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any());
+ Mockito.doAnswer(invocation -> {
+ invocation.getArgument(1, FutureCallback.class).completed(execRuntime);
+ return Mockito.mock(Cancellable.class);
+ }).when(execRuntime).connectEndpoint(Mockito.any(), Mockito.any());
+
+ final AtomicReference handlerRef = new AtomicReference<>();
+ Mockito.doAnswer(invocation -> {
+ handlerRef.set(invocation.getArgument(1, AsyncClientExchangeHandler.class));
+ return Mockito.mock(Cancellable.class);
+ }).when(execRuntime).execute(Mockito.anyString(), Mockito.any(), Mockito.any());
+
+ exec.execute(request, null, scope, chain, Mockito.mock(AsyncExecCallback.class));
+
+ final AsyncClientExchangeHandler handler = handlerRef.get();
+ Assertions.assertNotNull(handler, "CONNECT exchange handler must have been submitted for execution");
+
+ final AtomicReference connectRef = new AtomicReference<>();
+ final RequestChannel requestChannel = Mockito.mock(RequestChannel.class);
+ Mockito.doAnswer(invocation -> {
+ connectRef.set(invocation.getArgument(0, HttpRequest.class));
+ return null;
+ }).when(requestChannel).sendRequest(Mockito.any(), Mockito.any(), Mockito.any());
+ handler.produceRequest(requestChannel, context);
+ return connectRef.get();
+ }
+
+ @Test
+ void testEstablishRouteViaProxyTunnelAddsAlpnHeader() throws Exception {
+ // No HttpVersionPolicy on the context: the interceptor falls back to NEGOTIATE, so the
+ // tunnel's TLS layer offers both protocols and the ALPN header advertises the same set.
+ final AsyncConnectExec exec = new AsyncConnectExec(proxyHttpProcessor, proxyAuthStrategy, null, true);
+ final HttpRoute route = new HttpRoute(target, null, proxy, true);
+
+ final HttpRequest connect = tunnelConnectRequest(exec, route, null);
+
+ Assertions.assertEquals("CONNECT", connect.getMethod());
+ final Header h = connect.getFirstHeader(HttpHeaders.ALPN);
+ Assertions.assertNotNull(h, "ALPN header must be present");
+ Assertions.assertEquals("h2, http%2F1.1", h.getValue());
+ }
+
+ @Test
+ void testEstablishRouteViaProxyTunnelAlpnHeaderReflectsVersionPolicy() throws Exception {
+ // A FORCE_HTTP_1 policy published on the context must be reflected verbatim: only http/1.1
+ // is advertised, so the header can never contradict the protocol negotiated inside the tunnel.
+ final AsyncConnectExec exec = new AsyncConnectExec(proxyHttpProcessor, proxyAuthStrategy, null, true);
+ final HttpRoute route = new HttpRoute(target, null, proxy, true);
+
+ final HttpRequest connect = tunnelConnectRequest(exec, route, HttpVersionPolicy.FORCE_HTTP_1);
+
+ final Header h = connect.getFirstHeader(HttpHeaders.ALPN);
+ Assertions.assertNotNull(h, "ALPN header must be present");
+ Assertions.assertEquals("http%2F1.1", h.getValue());
+ }
+
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestConnectExec.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestConnectExec.java
index 1cc9a5504b..57a8f1be08 100644
--- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestConnectExec.java
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestConnectExec.java
@@ -47,6 +47,7 @@
import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.ConnectionReuseStrategy;
+import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.HttpHost;
@@ -55,6 +56,7 @@
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -324,7 +326,6 @@ static class ConnectionState {
private boolean connected;
public Answer> connectAnswer() {
-
return invocationOnMock -> {
connected = true;
return null;
@@ -332,10 +333,69 @@ public Answer> connectAnswer() {
}
public Answer isConnectedAnswer() {
-
return invocationOnMock -> connected;
-
}
}
+ @Test
+ void testEstablishRouteViaProxyTunnelAddsAlpnHeader() throws Exception {
+ // No HttpVersionPolicy on the context: the interceptor falls back to NEGOTIATE, so the
+ // tunnel's TLS layer offers both protocols and the ALPN header advertises the same set.
+ exec = new ConnectExec(reuseStrategy, proxyHttpProcessor, proxyAuthStrategy, null, true);
+
+ final HttpRoute route = new HttpRoute(target, null, proxy, true);
+ final HttpClientContext context = HttpClientContext.create();
+ final ClassicHttpRequest request = new HttpGet("http://bar/test");
+ final ClassicHttpResponse response = new BasicClassicHttpResponse(200, "OK");
+
+ final ConnectionState connectionState = new ConnectionState();
+ Mockito.doAnswer(connectionState.connectAnswer()).when(execRuntime).connectEndpoint(Mockito.any());
+ Mockito.when(execRuntime.isEndpointConnected()).thenAnswer(connectionState.isConnectedAnswer());
+ Mockito.when(execRuntime.execute(Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(response);
+
+ final ExecChain.Scope scope = new ExecChain.Scope("test", route, request, execRuntime, context);
+ exec.execute(request, scope, execChain);
+
+ final ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(ClassicHttpRequest.class);
+ Mockito.verify(execRuntime).execute(Mockito.anyString(), reqCaptor.capture(), Mockito.same(context));
+
+ final ClassicHttpRequest connect = reqCaptor.getValue();
+ Assertions.assertEquals("CONNECT", connect.getMethod());
+ Assertions.assertEquals("foo:80", connect.getRequestUri());
+
+ final Header h = connect.getFirstHeader(HttpHeaders.ALPN);
+ Assertions.assertNotNull(h, "ALPN header must be present");
+ Assertions.assertEquals(HttpHeaders.ALPN, h.getName());
+ Assertions.assertEquals("h2, http%2F1.1", h.getValue());
+ }
+
+ @Test
+ void testEstablishRouteViaProxyTunnelAlpnHeaderReflectsVersionPolicy() throws Exception {
+ // A FORCE_HTTP_1 policy published on the context must be reflected verbatim: only http/1.1
+ // is advertised, so the header can never contradict the protocol negotiated inside the tunnel.
+ exec = new ConnectExec(reuseStrategy, proxyHttpProcessor, proxyAuthStrategy, null, true);
+
+ final HttpRoute route = new HttpRoute(target, null, proxy, true);
+ final HttpClientContext context = HttpClientContext.create();
+ context.setHttpVersionPolicy(HttpVersionPolicy.FORCE_HTTP_1);
+ final ClassicHttpRequest request = new HttpGet("http://bar/test");
+ final ClassicHttpResponse response = new BasicClassicHttpResponse(200, "OK");
+
+ final ConnectionState connectionState = new ConnectionState();
+ Mockito.doAnswer(connectionState.connectAnswer()).when(execRuntime).connectEndpoint(Mockito.any());
+ Mockito.when(execRuntime.isEndpointConnected()).thenAnswer(connectionState.isConnectedAnswer());
+ Mockito.when(execRuntime.execute(Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(response);
+
+ final ExecChain.Scope scope = new ExecChain.Scope("test", route, request, execRuntime, context);
+ exec.execute(request, scope, execChain);
+
+ final ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(ClassicHttpRequest.class);
+ Mockito.verify(execRuntime).execute(Mockito.anyString(), reqCaptor.capture(), Mockito.same(context));
+
+ final ClassicHttpRequest connect = reqCaptor.getValue();
+ final Header h = connect.getFirstHeader(HttpHeaders.ALPN);
+ Assertions.assertNotNull(h, "ALPN header must be present");
+ Assertions.assertEquals("http%2F1.1", h.getValue());
+ }
+
}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/TestPoolingHttpClientConnectionManager.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/TestPoolingHttpClientConnectionManager.java
index c21c9fb82c..62f9f7c24f 100644
--- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/TestPoolingHttpClientConnectionManager.java
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/TestPoolingHttpClientConnectionManager.java
@@ -53,6 +53,7 @@
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.config.Lookup;
import org.apache.hc.core5.http.io.SocketConfig;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
import org.apache.hc.core5.pool.PoolEntry;
import org.apache.hc.core5.pool.StrictConnPool;
import org.apache.hc.core5.util.TimeValue;
@@ -262,6 +263,7 @@ void testTargetConnect() throws Exception {
mgr.setDefaultConnectionConfig(connectionConfig);
final TlsConfig tlsConfig = TlsConfig.custom()
.setHandshakeTimeout(345, TimeUnit.MILLISECONDS)
+ .setVersionPolicy(HttpVersionPolicy.FORCE_HTTP_2)
.build();
mgr.setDefaultTlsConfig(tlsConfig);
@@ -279,6 +281,8 @@ void testTargetConnect() throws Exception {
mgr.connect(endpoint1, null, context);
+ // connect() publishes the target's HttpVersionPolicy on the context for the interceptors
+ Assertions.assertEquals(HttpVersionPolicy.FORCE_HTTP_2, context.getHttpVersionPolicy());
Mockito.verify(dnsResolver, Mockito.times(1)).resolve("somehost", 8443);
Mockito.verify(schemePortResolver, Mockito.times(1)).resolve(target.getSchemeName(), target);
Mockito.verify(detachedSocketFactory, Mockito.times(1)).create("https", null);