The supplied protocol IDs are advertised in the given order (preference order).
+ * If {@code ids} is {@code null} or empty, no {@code ALPN} header will be added.
+ *
+ * This is a convenience method equivalent to installing a {@link ConnectAlpnProvider}
+ * that always returns the same list.
+ *
+ * @param ids ALPN protocol IDs to advertise (for example {@code "h2"} and {@code "http/1.1"})
+ * @return this builder
+ * @since 5.6
+ */
+ public HttpClientBuilder setConnectAlpn(final String... ids) {
+ final List list = ids != null && ids.length > 0 ? Arrays.asList(ids) : Collections.emptyList();
+ this.connectAlpnProvider = (t, r) -> list;
+ return this;
+ }
+
/**
* Request exec chain customization and extension.
*
@@ -961,7 +986,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..785e46b6f6
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClassicConnectAlpnEndToEndDemo.java
@@ -0,0 +1,244 @@
+/*
+ * ====================================================================
+ * 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.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 .setConnectAlpn("h2","http/1.1")}.
+ *
+ * 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
+ .setConnectAlpn("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..4cd1ff401d
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/AlpnHeaderSupportTest.java
@@ -0,0 +1,133 @@
+/*
+ * ====================================================================
+ * 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.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+
+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() {
+ final String raw = "ws/é"; // \u00E9 -> 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_is_liberal_on_hex_case_and_incomplete_sequences() {
+ assertEquals("http/1.1", AlpnHeaderSupport.decodeId("http%2f1.1"));
+ // incomplete % — treat as literal
+ assertEquals("h2%", AlpnHeaderSupport.decodeId("h2%"));
+ assertEquals("h2%G1", AlpnHeaderSupport.decodeId("h2%G1"));
+ }
+
+ @Test
+ void format_and_parse_roundtrip_with_ows() {
+ final String v = "h2, http%2F1.1 ,ws";
+ final List ids = AlpnHeaderSupport.parseValue(v);
+ assertEquals(Arrays.asList("h2", "http/1.1", "ws"), ids);
+ assertEquals("h2, http%2F1.1, ws", AlpnHeaderSupport.formatValue(ids));
+ }
+
+ @Test
+ void parse_empty_and_blank() {
+ assertTrue(AlpnHeaderSupport.parseValue(null).isEmpty());
+ assertTrue(AlpnHeaderSupport.parseValue("").isEmpty());
+ assertTrue(AlpnHeaderSupport.parseValue(" , \t ").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
+ }
+
+ @Test
+ void utf8_roundtrip_works() {
+ final String raw = "ws/é";
+ final String enc = AlpnHeaderSupport.encodeId(raw);
+ assertEquals("ws%2F%C3%A9", enc);
+ assertEquals(raw, AlpnHeaderSupport.decodeId(enc));
+ }
+
+ @Test
+ void decoder_is_liberal() {
+ assertEquals("http/1.1", AlpnHeaderSupport.decodeId("http%2f1.1")); // lower hex ok
+ assertEquals("h2%", AlpnHeaderSupport.decodeId("h2%")); // incomplete stays literal
+ assertEquals("h2%G1", AlpnHeaderSupport.decodeId("h2%G1")); // invalid hex stays literal
+ }
+}
\ No newline at end of file
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..2a9214fc64 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;
@@ -338,4 +339,63 @@ public Answer isConnectedAnswer() {
}
}
+ @Test
+ void testEstablishRouteViaProxyTunnelAddsAlpnHeader() throws Exception {
+ // Build ConnectExec with ALPN provider
+ 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 org.apache.hc.core5.http.Header h = connect.getFirstHeader(HttpHeaders.ALPN);
+ Assertions.assertNotNull(h, "ALPN header must be present");
+ Assertions.assertEquals("h2, http%2F1.1", h.getValue());
+ }
+
+ @Test
+ void testEstablishRouteViaProxyTunnelSkipsAlpnHeaderWhenProviderEmpty() throws Exception {
+ // Provider returns empty -> no header
+ 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");
+ }
+
+
}