diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/ConnectAlpnProvider.java b/httpclient5/src/main/java/org/apache/hc/client5/http/ConnectAlpnProvider.java new file mode 100644 index 0000000000..112c02d9ba --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/ConnectAlpnProvider.java @@ -0,0 +1,59 @@ +/* + * ==================================================================== + * 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; + +import java.util.List; + +import org.apache.hc.core5.http.HttpHost; + +/** + * Supplies the Application-Layer Protocol Negotiation (ALPN) protocol IDs + * to advertise in the HTTP {@code ALPN} header on a {@code CONNECT} request + * (RFC 7639). + * + *

If this method returns {@code null} or an empty list, the client will + * not add the {@code ALPN} header.

+ * + *

Implementations should be fast and side-effect free; it may be invoked + * for each CONNECT attempt.

+ * + * @since 5.6 + */ +@FunctionalInterface +public interface ConnectAlpnProvider { + + /** + * Returns the ALPN protocol IDs to advertise for a tunnel to {@code target} + * over the given {@code route}. + * + * @param target the origin server the tunnel will connect to (non-null) + * @param route the planned connection route, including proxy info (non-null) + * @return list of protocol IDs (e.g., {@code "h2"}, {@code "http/1.1"}); + * {@code null} or empty to omit the header + */ + List getAlpnForTunnel(HttpHost target, HttpRoute route); +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/AlpnHeaderSupport.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/AlpnHeaderSupport.java new file mode 100644 index 0000000000..18560ccc7b --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/AlpnHeaderSupport.java @@ -0,0 +1,183 @@ +/* + * ==================================================================== + * 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 java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.message.MessageSupport; +import org.apache.hc.core5.http.message.ParserCursor; +import org.apache.hc.core5.util.Args; + +/** + * Codec for the HTTP {@code ALPN} header field (RFC 7639). + * + * @since 5.6 + */ +@Contract(threading = ThreadingBehavior.IMMUTABLE) +@Internal +public final class AlpnHeaderSupport { + + private static final char[] HEXADECIMAL = "0123456789ABCDEF".toCharArray(); + + private AlpnHeaderSupport() { + } + + /** + * Formats a list of raw ALPN protocol IDs into a single {@code ALPN} header value. + */ + public static String formatValue(final List protocolIds) { + Args.notEmpty(protocolIds, "protocolIds"); + final StringBuilder sb = new StringBuilder(); + boolean first = true; + for (final String id : protocolIds) { + if (!first) { + sb.append(", "); + } + sb.append(encodeId(id)); + first = false; + } + return sb.toString(); + } + + /** + * Parses an {@code ALPN} header value into decoded protocol IDs. + */ + public static List parseValue(final String value) { + if (value == null || value.isEmpty()) { + return Collections.emptyList(); + } + final List out = new ArrayList<>(); + final ParserCursor cursor = new ParserCursor(0, value.length()); + MessageSupport.parseTokens(value, cursor, token -> { + if (!token.isEmpty()) { + out.add(decodeId(token)); + } + }); + return out; + } + + /** + * Encodes a single raw protocol ID to canonical token form. + */ + public static String encodeId(final String id) { + Args.notBlank(id, "id"); + final byte[] bytes = id.getBytes(StandardCharsets.UTF_8); + final StringBuilder sb = new StringBuilder(bytes.length); + for (final byte b0 : bytes) { + final int b = b0 & 0xFF; + if (b == '%' || !isTchar(b)) { + appendPctEncoded(b, sb); + } else { + sb.append((char) b); + } + } + return sb.toString(); + } + + /** + * Decodes percent-encoded token to raw ID using UTF-8. + * Accepts lowercase hex; malformed/incomplete sequences are left literal. + */ + public static String decodeId(final String token) { + Args.notBlank(token, "token"); + final byte[] buf = new byte[token.length()]; + int bi = 0; + for (int i = 0; i < token.length(); ) { + final char c = token.charAt(i); + if (c == '%' && i + 2 < token.length()) { + final int hi = hexVal(token.charAt(i + 1)); + final int lo = hexVal(token.charAt(i + 2)); + if (hi >= 0 && lo >= 0) { + buf[bi++] = (byte) ((hi << 4) | lo); + i += 3; + continue; + } + } + buf[bi++] = (byte) c; + i++; + } + return new String(buf, 0, bi, StandardCharsets.UTF_8); + } + + // RFC7230 tchar minus '%' (RFC7639 requires '%' be percent-encoded) + private static boolean isTchar(final int c) { + if (c >= '0' && c <= '9') { + return true; + } + if (c >= 'A' && c <= 'Z') { + return true; + } + if (c >= 'a' && c <= 'z') { + return true; + } + switch (c) { + case '!': + case '#': + case '$': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + default: + return false; + } + } + + private static void appendPctEncoded(final int b, final StringBuilder sb) { + sb.append('%'); + sb.append(HEXADECIMAL[(b >>> 4) & 0x0F]); + sb.append(HEXADECIMAL[b & 0x0F]); + } + + private static int hexVal(final char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'A' && c <= 'F') { + return 10 + (c - 'A'); + } + if (c >= 'a' && c <= 'f') { + return 10 + (c - 'a'); + } + return -1; + } +} 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..810401abe3 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; @@ -60,6 +62,7 @@ import org.apache.hc.core5.http.EntityDetails; 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; import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpResponse; @@ -99,11 +102,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 +126,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 +291,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 +396,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 +443,14 @@ 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.addHeader(HttpHeaders.ALPN, 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 b29ca5f768..9ae9820dce 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 @@ -30,7 +30,9 @@ import java.io.Closeable; import java.net.ProxySelector; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; @@ -40,6 +42,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; @@ -272,6 +275,8 @@ private ExecInterceptorEntry( private boolean priorityHeaderDisabled; + private ConnectAlpnProvider connectAlpnProvider; + /** * Maps {@code Content-Encoding} tokens to decoder factories in insertion order. */ @@ -909,6 +914,26 @@ public final HttpAsyncClientBuilder disableRequestPriority() { return this; } + /** + * Configures the {@code ALPN} header to be sent on {@code CONNECT} + * requests when establishing an HTTP tunnel through a proxy. + * + *

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 HttpAsyncClientBuilder setConnectAlpn(final String... ids) { + final List list = ids != null && ids.length > 0 ? Arrays.asList(ids) : Collections.emptyList(); + this.connectAlpnProvider = (t, r) -> list; + return this; + } + /** * Registers a global {@link org.apache.hc.client5.http.EarlyHintsListener} * that will be notified when the client receives {@code 103 Early Hints} @@ -1068,7 +1093,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..12b97cf9a2 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 @@ -228,6 +244,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.addHeader(HttpHeaders.ALPN, AlpnHeaderSupport.formatValue(alpn)); + } + } + this.proxyHttpProcessor.process(connect, null, context); while (response == null) { 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 3fada22452..c8898ba4bc 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 @@ -30,7 +30,9 @@ import java.io.Closeable; import java.net.ProxySelector; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; @@ -40,6 +42,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; @@ -239,6 +242,8 @@ private ExecInterceptorEntry( private List closeables; + private ConnectAlpnProvider connectAlpnProvider; + public static HttpClientBuilder create() { return new HttpClientBuilder(); } @@ -807,6 +812,26 @@ public final HttpClientBuilder setProxySelector(final ProxySelector proxySelecto return this; } + /** + * Configures the {@code ALPN} header to be sent on {@code CONNECT} + * requests when establishing an HTTP tunnel through a proxy. + * + *

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"); + } + + }