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..27dfd98618 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
@@ -34,6 +34,7 @@
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hc.client5.http.AuthenticationStrategy;
+import org.apache.hc.client5.http.ConnectAlpnProvider;
import org.apache.hc.client5.http.EndpointInfo;
import org.apache.hc.client5.http.HttpRoute;
import org.apache.hc.client5.http.RouteTracker;
@@ -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;
@@ -99,11 +101,23 @@ public final class AsyncConnectExec implements AsyncExecChainHandler {
private final AuthCacheKeeper authCacheKeeper;
private final HttpRouteDirector routeDirector;
+ private final ConnectAlpnProvider alpnProvider;
+
+
public AsyncConnectExec(
final HttpProcessor proxyHttpProcessor,
final AuthenticationStrategy proxyAuthStrategy,
final SchemePortResolver schemePortResolver,
final boolean authCachingDisabled) {
+ this(proxyHttpProcessor, proxyAuthStrategy, schemePortResolver, authCachingDisabled, null);
+ }
+
+ public AsyncConnectExec(
+ final HttpProcessor proxyHttpProcessor,
+ final AuthenticationStrategy proxyAuthStrategy,
+ final SchemePortResolver schemePortResolver,
+ final boolean authCachingDisabled,
+ final ConnectAlpnProvider alpnProvider) {
Args.notNull(proxyHttpProcessor, "Proxy HTTP processor");
Args.notNull(proxyAuthStrategy, "Proxy authentication strategy");
this.proxyHttpProcessor = proxyHttpProcessor;
@@ -111,6 +125,7 @@ public AsyncConnectExec(
this.authenticator = new AuthenticationHandler();
this.authCacheKeeper = authCachingDisabled ? null : new AuthCacheKeeper(schemePortResolver);
this.routeDirector = BasicRouteDirector.INSTANCE;
+ this.alpnProvider = alpnProvider;
}
static class State {
@@ -275,7 +290,7 @@ public void cancelled() {
if (LOG.isDebugEnabled()) {
LOG.debug("{} create tunnel", exchangeId);
}
- createTunnel(state, proxy, target, scope, new AsyncExecCallback() {
+ createTunnel(state, proxy, target, route, scope, new AsyncExecCallback() {
@Override
public AsyncDataConsumer handleResponse(final HttpResponse response, final EntityDetails entityDetails) throws HttpException, IOException {
@@ -380,6 +395,7 @@ private void createTunnel(
final State state,
final HttpHost proxy,
final HttpHost nextHop,
+ final HttpRoute route,
final AsyncExecChain.Scope scope,
final AsyncExecCallback asyncExecCallback) {
@@ -426,6 +442,13 @@ public void produceRequest(final RequestChannel requestChannel,
final HttpRequest connect = new BasicHttpRequest(Method.CONNECT, nextHop, nextHop.toHostString());
connect.setVersion(HttpVersion.HTTP_1_1);
+ // --- RFC 7639: inject ALPN header (if provided) ----------------
+ if (alpnProvider != null) {
+ final List alpn = alpnProvider.getAlpnForTunnel(nextHop, route);
+ if (alpn != null && !alpn.isEmpty()) {
+ connect.setHeader(AlpnHeaderSupport.formatValue(alpn));
+ }
+ }
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/async/HttpAsyncClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java
index d010ac8617..11470c7b0f 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java
@@ -39,6 +39,7 @@
import java.util.function.UnaryOperator;
import org.apache.hc.client5.http.AuthenticationStrategy;
+import org.apache.hc.client5.http.ConnectAlpnProvider;
import org.apache.hc.client5.http.ConnectionKeepAliveStrategy;
import org.apache.hc.client5.http.EarlyHintsListener;
import org.apache.hc.client5.http.HttpRequestRetryStrategy;
@@ -257,6 +258,8 @@ private ExecInterceptorEntry(
private boolean tlsRequired;
+ private ConnectAlpnProvider connectAlpnProvider;
+
/**
* Maps {@code Content-Encoding} tokens to decoder factories in insertion order.
*/
@@ -936,6 +939,19 @@ public final HttpAsyncClientBuilder disableRequestPriority() {
return this;
}
+ /**
+ * Sets the {@link ConnectAlpnProvider} used to populate the {@code ALPN} header
+ * (RFC 7639) on {@code CONNECT} requests when establishing an HTTP tunnel through a proxy.
+ *
+ * @param connectAlpnProvider the provider, or {@code null} to not send an {@code ALPN} header.
+ * @return this builder.
+ * @since 5.7
+ */
+ public HttpAsyncClientBuilder setConnectAlpnProvider(final ConnectAlpnProvider connectAlpnProvider) {
+ this.connectAlpnProvider = connectAlpnProvider;
+ return this;
+ }
+
/**
* Registers a global {@link org.apache.hc.client5.http.EarlyHintsListener}
* that will be notified when the client receives {@code 103 Early Hints}
@@ -1086,7 +1102,8 @@ public CloseableHttpAsyncClient build() {
new DefaultHttpProcessor(new RequestTargetHost(), new RequestUserAgent(userAgentCopy)),
proxyAuthStrategyCopy,
schemePortResolver != null ? schemePortResolver : DefaultSchemePortResolver.INSTANCE,
- authCachingDisabled),
+ authCachingDisabled,
+ connectAlpnProvider),
ChainElement.CONNECT.name());
if (earlyHintsListener != null) {
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..032ae25a68 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,8 +28,10 @@
package org.apache.hc.client5.http.impl.classic;
import java.io.IOException;
+import java.util.List;
import org.apache.hc.client5.http.AuthenticationStrategy;
+import org.apache.hc.client5.http.ConnectAlpnProvider;
import org.apache.hc.client5.http.EndpointInfo;
import org.apache.hc.client5.http.HttpRoute;
import org.apache.hc.client5.http.RouteTracker;
@@ -40,6 +42,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;
@@ -89,12 +92,24 @@ public final class ConnectExec implements ExecChainHandler {
private final AuthCacheKeeper authCacheKeeper;
private final HttpRouteDirector routeDirector;
+ private final ConnectAlpnProvider alpnProvider;
+
public ConnectExec(
final ConnectionReuseStrategy reuseStrategy,
final HttpProcessor proxyHttpProcessor,
final AuthenticationStrategy proxyAuthStrategy,
final SchemePortResolver schemePortResolver,
final boolean authCachingDisabled) {
+ this(reuseStrategy, proxyHttpProcessor, proxyAuthStrategy, schemePortResolver, authCachingDisabled, null);
+ }
+
+ public ConnectExec(
+ final ConnectionReuseStrategy reuseStrategy,
+ final HttpProcessor proxyHttpProcessor,
+ final AuthenticationStrategy proxyAuthStrategy,
+ final SchemePortResolver schemePortResolver,
+ final boolean authCachingDisabled,
+ final ConnectAlpnProvider alpnProvider) {
Args.notNull(reuseStrategy, "Connection reuse strategy");
Args.notNull(proxyHttpProcessor, "Proxy HTTP processor");
Args.notNull(proxyAuthStrategy, "Proxy authentication strategy");
@@ -104,6 +119,7 @@ public ConnectExec(
this.authenticator = new AuthenticationHandler();
this.authCacheKeeper = authCachingDisabled ? null : new AuthCacheKeeper(schemePortResolver);
this.routeDirector = BasicRouteDirector.INSTANCE;
+ this.alpnProvider = alpnProvider;
}
@Override
@@ -139,7 +155,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 +177,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 +209,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 +232,14 @@ private ClassicHttpResponse createTunnelToTarget(
final ClassicHttpRequest connect = new BasicClassicHttpRequest(Method.CONNECT, target, authority);
connect.setVersion(HttpVersion.HTTP_1_1);
+ // --- RFC 7639: inject ALPN header (if provided) --------------------
+ if (alpnProvider != null) {
+ final List alpn = alpnProvider.getAlpnForTunnel(target, route);
+ if (alpn != null && !alpn.isEmpty()) {
+ connect.setHeader(AlpnHeaderSupport.formatValue(alpn));
+ }
+ }
+
this.proxyHttpProcessor.process(connect, null, context);
while (response == null) {
@@ -262,12 +274,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 +305,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/classic/HttpClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java
index 6639349323..ca0f6496b0 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java
@@ -39,6 +39,7 @@
import java.util.function.UnaryOperator;
import org.apache.hc.client5.http.AuthenticationStrategy;
+import org.apache.hc.client5.http.ConnectAlpnProvider;
import org.apache.hc.client5.http.ConnectionKeepAliveStrategy;
import org.apache.hc.client5.http.HttpRequestRetryStrategy;
import org.apache.hc.client5.http.SchemePortResolver;
@@ -218,6 +219,8 @@ private ExecInterceptorEntry(
private List closeables;
+ private ConnectAlpnProvider connectAlpnProvider;
+
public static HttpClientBuilder create() {
return new HttpClientBuilder();
}
@@ -811,6 +814,19 @@ public final HttpClientBuilder setTlsRequired(final boolean tlsRequired) {
return this;
}
+ /**
+ * Sets the {@link ConnectAlpnProvider} used to populate the {@code ALPN} header
+ * (RFC 7639) on {@code CONNECT} requests when establishing an HTTP tunnel through a proxy.
+ *
+ * @param connectAlpnProvider the provider, or {@code null} to not send an {@code ALPN} header.
+ * @return this builder.
+ * @since 5.7
+ */
+ public HttpClientBuilder setConnectAlpnProvider(final ConnectAlpnProvider connectAlpnProvider) {
+ this.connectAlpnProvider = connectAlpnProvider;
+ return this;
+ }
+
/**
* Request exec chain customization and extension.
*
@@ -951,7 +967,8 @@ public CloseableHttpClient build() {
new DefaultHttpProcessor(new RequestTargetHost(), new RequestUserAgent(userAgentCopy)),
proxyAuthStrategyCopy,
schemePortResolver != null ? schemePortResolver : DefaultSchemePortResolver.INSTANCE,
- authCachingDisabled),
+ authCachingDisabled,
+ connectAlpnProvider),
ChainElement.CONNECT.name());
execChainDefinition.addFirst(
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClassicConnectAlpnEndToEndDemo.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClassicConnectAlpnEndToEndDemo.java
new file mode 100644
index 0000000000..3241c905c5
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClassicConnectAlpnEndToEndDemo.java
@@ -0,0 +1,245 @@
+/*
+ * ====================================================================
+ * 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.examples;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import org.apache.hc.client5.http.HttpRoute;
+import org.apache.hc.client5.http.RouteInfo;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.routing.HttpRoutePlanner;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.impl.bootstrap.HttpServer;
+import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap;
+import org.apache.hc.core5.http.io.HttpClientResponseHandler;
+import org.apache.hc.core5.http.io.HttpRequestHandler;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.hc.core5.http.message.StatusLine;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.util.Timeout;
+
+/**
+ * Example: advertise ALPN on a proxy {@code CONNECT}.
+ *
+ *
This demo starts:
+ *
+ * - a tiny classic origin server on {@code localhost},
+ * - a minimal blocking proxy that prints the received {@code ALPN} header,
+ * - a classic client forced to tunnel via {@code CONNECT} and configured with
+ * {@code .setConnectAlpnProvider(...)}.
+ *
+ * The proxy logs a line like {@code ALPN: h2, http%2F1.1}, and the client receives {@code 200 OK}.
+ *
+ * Tip: Keep the request host consistent with the server’s canonical host to avoid
+ * {@code 421 Misdirected Request}. This example uses {@code localhost} for both.
+ *
+ * @since 5.6
+ */
+public final class ClassicConnectAlpnEndToEndDemo {
+
+ public static void main(final String[] args) throws Exception {
+ // ---- Origin server (classic)
+ final HttpServer origin = ServerBootstrap.bootstrap()
+ .setListenerPort(0)
+ .setCanonicalHostName("localhost")
+ .register("/hello", new HelloHandler())
+ .create();
+ origin.start();
+ final int originPort = origin.getLocalPort();
+
+ // ---- Tiny blocking proxy printing ALPN and tunneling bytes
+ final ServerSocket proxyServer = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
+ final int proxyPort = proxyServer.getLocalPort();
+ final ExecutorService proxyPool = Executors.newCachedThreadPool();
+ proxyPool.submit(() -> {
+ try {
+ while (!proxyServer.isClosed()) {
+ final Socket clientSock = proxyServer.accept(); // blocks
+ proxyPool.submit(() -> handleConnectClient(clientSock));
+ }
+ } catch (final java.net.SocketException closed) {
+ // server socket closed while blocking in accept() -> exit quietly
+ if (!proxyServer.isClosed()) {
+ System.out.println("[proxy] accept error: " + closed);
+ }
+ } catch (final Exception ex) {
+ System.out.println("[proxy] error: " + ex);
+ }
+ return null;
+ });
+
+
+ // ---- Client forcing CONNECT even for HTTP (so the demo stays TLS-free)
+ final HttpRoutePlanner alwaysTunnelPlanner = (target, context) -> new HttpRoute(
+ target,
+ null,
+ new HttpHost("127.0.0.1", proxyPort),
+ false,
+ RouteInfo.TunnelType.TUNNELLED,
+ RouteInfo.LayerType.PLAIN);
+
+ final RequestConfig reqCfg = RequestConfig.custom()
+ .setResponseTimeout(Timeout.ofSeconds(10))
+ .build();
+
+ try (final CloseableHttpClient client = HttpClients.custom()
+ .setDefaultRequestConfig(reqCfg)
+ .setRoutePlanner(alwaysTunnelPlanner)
+ // Advertise ALPN on CONNECT
+ .setConnectAlpnProvider((t, r) -> Arrays.asList("h2", "http/1.1"))
+ .build()) {
+
+ final String url = "http://localhost:" + originPort + "/hello";
+ final HttpGet get = new HttpGet(url);
+
+ final HttpClientResponseHandler handler = response -> {
+ System.out.println("[client] " + new StatusLine(response));
+ final int code = response.getCode();
+ if (code >= 200 && code < 300) {
+ return EntityUtils.toString(response.getEntity());
+ }
+ throw new IOException("Unexpected response code " + code);
+ };
+
+ final String body = client.execute(get, handler);
+ System.out.println("[client] body: " + body);
+ } finally {
+ origin.close(CloseMode.GRACEFUL);
+ proxyServer.close(); // triggers SocketException in accept()
+ proxyPool.shutdown(); // let workers finish naturally
+ proxyPool.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS);
+ }
+ }
+
+ // ---- Minimal CONNECT proxy (blocking) ----
+ private static void handleConnectClient(final Socket client) {
+ try (final Socket clientSock = client;
+ final BufferedReader in = new BufferedReader(new InputStreamReader(clientSock.getInputStream(), StandardCharsets.ISO_8859_1));
+ final OutputStream out = clientSock.getOutputStream()) {
+
+ final String requestLine = in.readLine();
+ if (requestLine == null || !requestLine.toUpperCase(Locale.ROOT).startsWith("CONNECT ")) {
+ writeSimple(out, "HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\n\r\n");
+ return;
+ }
+ final String hostPort = requestLine.split("\\s+")[1];
+ String alpnHeader = null;
+
+ String line;
+ while ((line = in.readLine()) != null && !line.isEmpty()) {
+ final int idx = line.indexOf(':');
+ if (idx > 0) {
+ final String name = line.substring(0, idx).trim();
+ final String value = line.substring(idx + 1).trim();
+ if ("ALPN".equalsIgnoreCase(name)) {
+ alpnHeader = value;
+ }
+ }
+ }
+
+ System.out.println("[proxy] CONNECT " + hostPort);
+ System.out.println("[proxy] ALPN: " + (alpnHeader != null ? alpnHeader : ""));
+
+ final String[] hp = hostPort.split(":");
+ final String host = hp[0];
+ final int port = Integer.parseInt(hp[1]);
+
+ final Socket origin = new Socket();
+ origin.connect(new InetSocketAddress(host, port), 3000);
+
+ writeSimple(out, "HTTP/1.1 200 Connection Established\r\n\r\n");
+
+ final InputStream clientIn = clientSock.getInputStream();
+ final OutputStream clientOut = clientSock.getOutputStream();
+ final InputStream originIn = origin.getInputStream();
+ final OutputStream originOut = origin.getOutputStream();
+
+ final Thread t1 = new Thread(() -> pump(clientIn, originOut));
+ final Thread t2 = new Thread(() -> pump(originIn, clientOut));
+ t1.start();
+ t2.start();
+ t1.join();
+ t2.join();
+ origin.close();
+
+ } catch (final Exception ex) {
+ System.out.println("[proxy] error: " + ex);
+ }
+ }
+
+ private static void writeSimple(final OutputStream out, final String s) throws IOException {
+ out.write(s.getBytes(StandardCharsets.ISO_8859_1));
+ out.flush();
+ }
+
+ private static void pump(final InputStream in, final OutputStream out) {
+ final byte[] buf = new byte[8192];
+ try {
+ int n;
+ while ((n = in.read(buf)) >= 0) {
+ out.write(buf, 0, n);
+ out.flush();
+ }
+ } catch (final IOException ignore) {
+ }
+ try {
+ out.flush();
+ } catch (final IOException ignore) {
+ }
+ }
+
+ private static final class HelloHandler implements HttpRequestHandler {
+ @Override
+ public void handle(final ClassicHttpRequest request,
+ final ClassicHttpResponse response,
+ final HttpContext context) {
+ response.setCode(200);
+ response.setEntity(new StringEntity("Hello through the tunnel!", ContentType.TEXT_PLAIN));
+ }
+ }
+}
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/classic/TestConnectExec.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestConnectExec.java
index 1cc9a5504b..3f865ef9c5 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
@@ -31,6 +31,7 @@
import java.util.Collections;
import org.apache.hc.client5.http.AuthenticationStrategy;
+import org.apache.hc.client5.http.ConnectAlpnProvider;
import org.apache.hc.client5.http.HttpRoute;
import org.apache.hc.client5.http.RouteInfo;
import org.apache.hc.client5.http.auth.AuthScope;
@@ -47,6 +48,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;
@@ -324,7 +326,6 @@ static class ConnectionState {
private boolean connected;
public Answer> connectAnswer() {
-
return invocationOnMock -> {
connected = true;
return null;
@@ -332,10 +333,65 @@ public Answer> connectAnswer() {
}
public Answer isConnectedAnswer() {
-
return invocationOnMock -> connected;
-
}
}
+ @Test
+ void testEstablishRouteViaProxyTunnelAddsAlpnHeader() throws Exception {
+ final ConnectAlpnProvider provider = (t, r) -> java.util.Arrays.asList("h2", "http/1.1");
+ exec = new ConnectExec(reuseStrategy, proxyHttpProcessor, proxyAuthStrategy, null, true, provider);
+
+ 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 testEstablishRouteViaProxyTunnelSkipsAlpnHeaderWhenProviderEmpty() throws Exception {
+ final ConnectAlpnProvider provider = (t, r) -> java.util.Collections.emptyList();
+ exec = new ConnectExec(reuseStrategy, proxyHttpProcessor, proxyAuthStrategy, null, true, provider);
+
+ 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.assertNull(connect.getFirstHeader(HttpHeaders.ALPN),
+ "ALPN header must NOT be present when provider returns empty");
+ }
+
}