Skip to content

Commit e343841

Browse files
committed
Add RFC7639 ALPN header codec.
Emit ALPN on CONNECT tunnels from classic and async exec when configured. Use canonical percent-encoding with liberal decoding for protocol IDs.
1 parent 68435f2 commit e343841

File tree

9 files changed

+739
-37
lines changed

9 files changed

+739
-37
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
package org.apache.hc.client5.http;
28+
29+
import java.util.List;
30+
31+
import org.apache.hc.core5.http.HttpHost;
32+
33+
/**
34+
* Supplies the Application-Layer Protocol Negotiation (ALPN) protocol IDs
35+
* to advertise in the HTTP {@code ALPN} header on a {@code CONNECT} request
36+
* (RFC 7639).
37+
*
38+
* <p>If this method returns {@code null} or an empty list, the client will
39+
* not add the {@code ALPN} header.</p>
40+
*
41+
* <p>Implementations should be fast and side-effect free; it may be invoked
42+
* for each CONNECT attempt.</p>
43+
*
44+
* @since 5.6
45+
*/
46+
@FunctionalInterface
47+
public interface ConnectAlpnProvider {
48+
49+
/**
50+
* Returns the ALPN protocol IDs to advertise for a tunnel to {@code target}
51+
* over the given {@code route}.
52+
*
53+
* @param target the origin server the tunnel will connect to (non-null)
54+
* @param route the planned connection route, including proxy info (non-null)
55+
* @return list of protocol IDs (e.g., {@code "h2"}, {@code "http/1.1"});
56+
* {@code null} or empty to omit the header
57+
*/
58+
List<String> getAlpnForTunnel(HttpHost target, HttpRoute route);
59+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
package org.apache.hc.client5.http.impl;
28+
29+
import java.nio.charset.StandardCharsets;
30+
import java.util.ArrayList;
31+
import java.util.Collections;
32+
import java.util.List;
33+
34+
import org.apache.hc.core5.annotation.Contract;
35+
import org.apache.hc.core5.annotation.Internal;
36+
import org.apache.hc.core5.annotation.ThreadingBehavior;
37+
import org.apache.hc.core5.http.Header;
38+
import org.apache.hc.core5.http.HttpHeaders;
39+
import org.apache.hc.core5.http.message.MessageSupport;
40+
import org.apache.hc.core5.net.PercentCodec;
41+
import org.apache.hc.core5.util.Args;
42+
43+
/**
44+
* Codec for the HTTP {@code ALPN} header field (RFC 7639).
45+
*
46+
* @since 5.7
47+
*/
48+
@Contract(threading = ThreadingBehavior.IMMUTABLE)
49+
@Internal
50+
public final class AlpnHeaderSupport {
51+
52+
private static final char[] HEXADECIMAL = "0123456789ABCDEF".toCharArray();
53+
54+
private AlpnHeaderSupport() {
55+
}
56+
57+
/**
58+
* Formats a list of raw ALPN protocol IDs into a single {@code ALPN} header.
59+
*/
60+
public static Header formatValue(final List<String> protocolIds) {
61+
Args.notEmpty(protocolIds, "protocolIds");
62+
return MessageSupport.headerOfTokens(HttpHeaders.ALPN, protocolIds, AlpnHeaderSupport::encodeId);
63+
}
64+
65+
/**
66+
* Parses an {@code ALPN} header into decoded protocol IDs.
67+
*/
68+
public static List<String> parseValue(final Header header) {
69+
if (header == null) {
70+
return Collections.emptyList();
71+
}
72+
final List<String> out = new ArrayList<>();
73+
MessageSupport.parseTokens(header, token -> {
74+
if (!token.isEmpty()) {
75+
out.add(decodeId(token));
76+
}
77+
});
78+
return out;
79+
}
80+
81+
/**
82+
* Encodes a single raw protocol ID to canonical token form.
83+
*/
84+
public static String encodeId(final String id) {
85+
Args.notBlank(id, "id");
86+
final byte[] bytes = id.getBytes(StandardCharsets.UTF_8);
87+
final StringBuilder sb = new StringBuilder(bytes.length);
88+
for (final byte b0 : bytes) {
89+
final int b = b0 & 0xFF;
90+
if (b == '%' || !isTchar(b)) {
91+
appendPctEncoded(b, sb);
92+
} else {
93+
sb.append((char) b);
94+
}
95+
}
96+
return sb.toString();
97+
}
98+
99+
/**
100+
* Decodes percent-encoded token to raw ID using UTF-8.
101+
* Malformed / incomplete sequences are left literal.
102+
*/
103+
public static String decodeId(final String token) {
104+
Args.notBlank(token, "token");
105+
try {
106+
return PercentCodec.decode(token, StandardCharsets.UTF_8);
107+
} catch (final IllegalArgumentException ex) {
108+
return token;
109+
}
110+
}
111+
112+
// RFC7230 tchar minus '%' (RFC7639 requires '%' be percent-encoded)
113+
private static boolean isTchar(final int c) {
114+
if (c >= '0' && c <= '9') {
115+
return true;
116+
}
117+
if (c >= 'A' && c <= 'Z') {
118+
return true;
119+
}
120+
if (c >= 'a' && c <= 'z') {
121+
return true;
122+
}
123+
switch (c) {
124+
case '!':
125+
case '#':
126+
case '$':
127+
case '&':
128+
case '\'':
129+
case '*':
130+
case '+':
131+
case '-':
132+
case '.':
133+
case '^':
134+
case '_':
135+
case '`':
136+
case '|':
137+
case '~':
138+
return true;
139+
default:
140+
return false;
141+
}
142+
}
143+
144+
private static void appendPctEncoded(final int b, final StringBuilder sb) {
145+
sb.append('%');
146+
sb.append(HEXADECIMAL[(b >>> 4) & 0x0F]);
147+
sb.append(HEXADECIMAL[b & 0x0F]);
148+
}
149+
}

httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import java.util.concurrent.atomic.AtomicReference;
3535

3636
import org.apache.hc.client5.http.AuthenticationStrategy;
37+
import org.apache.hc.client5.http.ConnectAlpnProvider;
3738
import org.apache.hc.client5.http.EndpointInfo;
3839
import org.apache.hc.client5.http.HttpRoute;
3940
import org.apache.hc.client5.http.RouteTracker;
@@ -47,6 +48,7 @@
4748
import org.apache.hc.client5.http.auth.ChallengeType;
4849
import org.apache.hc.client5.http.auth.MalformedChallengeException;
4950
import org.apache.hc.client5.http.config.RequestConfig;
51+
import org.apache.hc.client5.http.impl.AlpnHeaderSupport;
5052
import org.apache.hc.client5.http.impl.auth.AuthCacheKeeper;
5153
import org.apache.hc.client5.http.impl.auth.AuthenticationHandler;
5254
import org.apache.hc.client5.http.impl.routing.BasicRouteDirector;
@@ -99,18 +101,31 @@ public final class AsyncConnectExec implements AsyncExecChainHandler {
99101
private final AuthCacheKeeper authCacheKeeper;
100102
private final HttpRouteDirector routeDirector;
101103

104+
private final ConnectAlpnProvider alpnProvider;
105+
106+
102107
public AsyncConnectExec(
103108
final HttpProcessor proxyHttpProcessor,
104109
final AuthenticationStrategy proxyAuthStrategy,
105110
final SchemePortResolver schemePortResolver,
106111
final boolean authCachingDisabled) {
112+
this(proxyHttpProcessor, proxyAuthStrategy, schemePortResolver, authCachingDisabled, null);
113+
}
114+
115+
public AsyncConnectExec(
116+
final HttpProcessor proxyHttpProcessor,
117+
final AuthenticationStrategy proxyAuthStrategy,
118+
final SchemePortResolver schemePortResolver,
119+
final boolean authCachingDisabled,
120+
final ConnectAlpnProvider alpnProvider) {
107121
Args.notNull(proxyHttpProcessor, "Proxy HTTP processor");
108122
Args.notNull(proxyAuthStrategy, "Proxy authentication strategy");
109123
this.proxyHttpProcessor = proxyHttpProcessor;
110124
this.proxyAuthStrategy = proxyAuthStrategy;
111125
this.authenticator = new AuthenticationHandler();
112126
this.authCacheKeeper = authCachingDisabled ? null : new AuthCacheKeeper(schemePortResolver);
113127
this.routeDirector = BasicRouteDirector.INSTANCE;
128+
this.alpnProvider = alpnProvider;
114129
}
115130

116131
static class State {
@@ -275,7 +290,7 @@ public void cancelled() {
275290
if (LOG.isDebugEnabled()) {
276291
LOG.debug("{} create tunnel", exchangeId);
277292
}
278-
createTunnel(state, proxy, target, scope, new AsyncExecCallback() {
293+
createTunnel(state, proxy, target, route, scope, new AsyncExecCallback() {
279294

280295
@Override
281296
public AsyncDataConsumer handleResponse(final HttpResponse response, final EntityDetails entityDetails) throws HttpException, IOException {
@@ -380,6 +395,7 @@ private void createTunnel(
380395
final State state,
381396
final HttpHost proxy,
382397
final HttpHost nextHop,
398+
final HttpRoute route,
383399
final AsyncExecChain.Scope scope,
384400
final AsyncExecCallback asyncExecCallback) {
385401

@@ -426,6 +442,13 @@ public void produceRequest(final RequestChannel requestChannel,
426442
final HttpRequest connect = new BasicHttpRequest(Method.CONNECT, nextHop, nextHop.toHostString());
427443
connect.setVersion(HttpVersion.HTTP_1_1);
428444

445+
// --- RFC 7639: inject ALPN header (if provided) ----------------
446+
if (alpnProvider != null) {
447+
final List<String> alpn = alpnProvider.getAlpnForTunnel(nextHop, route);
448+
if (alpn != null && !alpn.isEmpty()) {
449+
connect.setHeader(AlpnHeaderSupport.formatValue(alpn));
450+
}
451+
}
429452
proxyHttpProcessor.process(connect, null, clientContext);
430453
authenticator.addAuthResponse(proxy, ChallengeType.PROXY, connect, proxyAuthExchange, clientContext);
431454

httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
import java.io.Closeable;
3131
import java.net.ProxySelector;
3232
import java.util.ArrayList;
33+
import java.util.Arrays;
3334
import java.util.Collection;
35+
import java.util.Collections;
3436
import java.util.LinkedHashMap;
3537
import java.util.LinkedList;
3638
import java.util.List;
@@ -40,6 +42,7 @@
4042
import java.util.function.UnaryOperator;
4143

4244
import org.apache.hc.client5.http.AuthenticationStrategy;
45+
import org.apache.hc.client5.http.ConnectAlpnProvider;
4346
import org.apache.hc.client5.http.ConnectionKeepAliveStrategy;
4447
import org.apache.hc.client5.http.EarlyHintsListener;
4548
import org.apache.hc.client5.http.HttpRequestRetryStrategy;
@@ -276,6 +279,8 @@ private ExecInterceptorEntry(
276279
private boolean tlsRequired;
277280

278281

282+
private ConnectAlpnProvider connectAlpnProvider;
283+
279284
/**
280285
* Maps {@code Content-Encoding} tokens to decoder factories in insertion order.
281286
*/
@@ -944,6 +949,26 @@ public final HttpAsyncClientBuilder disableRequestPriority() {
944949
return this;
945950
}
946951

952+
/**
953+
* Configures the {@code ALPN} header to be sent on {@code CONNECT}
954+
* requests when establishing an HTTP tunnel through a proxy.
955+
*
956+
* <p>The supplied protocol IDs are advertised in the given order (preference order).
957+
* If {@code ids} is {@code null} or empty, no {@code ALPN} header will be added.</p>
958+
*
959+
* <p>This is a convenience method equivalent to installing a {@link ConnectAlpnProvider}
960+
* that always returns the same list.</p>
961+
*
962+
* @param ids ALPN protocol IDs to advertise (for example {@code "h2"} and {@code "http/1.1"})
963+
* @return this builder
964+
* @since 5.6
965+
*/
966+
public HttpAsyncClientBuilder setConnectAlpn(final String... ids) {
967+
final List<String> list = ids != null && ids.length > 0 ? Arrays.asList(ids) : Collections.emptyList();
968+
this.connectAlpnProvider = (t, r) -> list;
969+
return this;
970+
}
971+
947972
/**
948973
* Registers a global {@link org.apache.hc.client5.http.EarlyHintsListener}
949974
* that will be notified when the client receives {@code 103 Early Hints}
@@ -1103,7 +1128,8 @@ public CloseableHttpAsyncClient build() {
11031128
new DefaultHttpProcessor(new RequestTargetHost(), new RequestUserAgent(userAgentCopy)),
11041129
proxyAuthStrategyCopy,
11051130
schemePortResolver != null ? schemePortResolver : DefaultSchemePortResolver.INSTANCE,
1106-
authCachingDisabled),
1131+
authCachingDisabled,
1132+
connectAlpnProvider),
11071133
ChainElement.CONNECT.name());
11081134

11091135
if (earlyHintsListener != null) {

0 commit comments

Comments
 (0)