Skip to content

Commit 16b5442

Browse files
committed
RFC 7639 ALPN.
Add ConnectAlpnProvider and inject ALPN header in ConnectExec/AsyncConnectExec. Provide builder hooks for fixed list or provider-driven values.
1 parent f4027e7 commit 16b5442

File tree

9 files changed

+754
-3
lines changed

9 files changed

+754
-3
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: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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+
30+
import java.nio.charset.StandardCharsets;
31+
import java.util.ArrayList;
32+
import java.util.Collections;
33+
import java.util.List;
34+
35+
import org.apache.hc.core5.annotation.Contract;
36+
import org.apache.hc.core5.annotation.Internal;
37+
import org.apache.hc.core5.annotation.ThreadingBehavior;
38+
import org.apache.hc.core5.http.message.MessageSupport;
39+
import org.apache.hc.core5.http.message.ParserCursor;
40+
import org.apache.hc.core5.util.Args;
41+
42+
/**
43+
* Codec for the HTTP {@code ALPN} header field (RFC 7639).
44+
*
45+
* @since 5.4
46+
*/
47+
@Contract(threading = ThreadingBehavior.IMMUTABLE)
48+
@Internal
49+
public final class AlpnHeaderSupport {
50+
51+
private static final char[] HEXADECIMAL = "0123456789ABCDEF".toCharArray();
52+
53+
private AlpnHeaderSupport() {
54+
}
55+
56+
/**
57+
* Formats a list of raw ALPN protocol IDs into a single {@code ALPN} header value.
58+
*/
59+
public static String formatValue(final List<String> protocolIds) {
60+
Args.notEmpty(protocolIds, "protocolIds");
61+
final StringBuilder sb = new StringBuilder();
62+
boolean first = true;
63+
for (final String id : protocolIds) {
64+
if (!first) {
65+
sb.append(", ");
66+
}
67+
sb.append(encodeId(id));
68+
first = false;
69+
}
70+
return sb.toString();
71+
}
72+
73+
/**
74+
* Parses an {@code ALPN} header value into decoded protocol IDs.
75+
*/
76+
public static List<String> parseValue(final String value) {
77+
if (value == null || value.isEmpty()) {
78+
return Collections.emptyList();
79+
}
80+
final List<String> out = new ArrayList<>();
81+
final ParserCursor cursor = new ParserCursor(0, value.length());
82+
MessageSupport.parseTokens(value, cursor, token -> {
83+
if (!token.isEmpty()) {
84+
out.add(decodeId(token));
85+
}
86+
});
87+
return out;
88+
}
89+
90+
/**
91+
* Encodes a single raw protocol ID to canonical token form.
92+
*/
93+
public static String encodeId(final String id) {
94+
Args.notBlank(id, "id");
95+
final byte[] bytes = id.getBytes(StandardCharsets.UTF_8);
96+
final StringBuilder sb = new StringBuilder(bytes.length);
97+
for (final byte b0 : bytes) {
98+
final int b = b0 & 0xFF;
99+
if (b == '%' || !isTchar(b)) {
100+
appendPctEncoded(b, sb);
101+
} else {
102+
sb.append((char) b);
103+
}
104+
}
105+
return sb.toString();
106+
}
107+
108+
/**
109+
* Decodes percent-encoded token to raw ID using UTF-8.
110+
* Accepts lowercase hex; malformed/incomplete sequences are left literal.
111+
*/
112+
public static String decodeId(final String token) {
113+
Args.notBlank(token, "token");
114+
final byte[] buf = new byte[token.length()];
115+
int bi = 0;
116+
for (int i = 0; i < token.length(); ) {
117+
final char c = token.charAt(i);
118+
if (c == '%' && i + 2 < token.length()) {
119+
final int hi = hexVal(token.charAt(i + 1));
120+
final int lo = hexVal(token.charAt(i + 2));
121+
if (hi >= 0 && lo >= 0) {
122+
buf[bi++] = (byte) ((hi << 4) | lo);
123+
i += 3;
124+
continue;
125+
}
126+
}
127+
buf[bi++] = (byte) c;
128+
i++;
129+
}
130+
return new String(buf, 0, bi, StandardCharsets.UTF_8);
131+
}
132+
133+
// RFC7230 tchar minus '%' (RFC7639 requires '%' be percent-encoded)
134+
private static boolean isTchar(final int c) {
135+
if (c >= '0' && c <= '9') {
136+
return true;
137+
}
138+
if (c >= 'A' && c <= 'Z') {
139+
return true;
140+
}
141+
if (c >= 'a' && c <= 'z') {
142+
return true;
143+
}
144+
switch (c) {
145+
case '!':
146+
case '#':
147+
case '$':
148+
case '&':
149+
case '\'':
150+
case '*':
151+
case '+':
152+
case '-':
153+
case '.':
154+
case '^':
155+
case '_':
156+
case '`':
157+
case '|':
158+
case '~':
159+
return true;
160+
default:
161+
return false;
162+
}
163+
}
164+
165+
private static void appendPctEncoded(final int b, final StringBuilder sb) {
166+
sb.append('%');
167+
sb.append(HEXADECIMAL[(b >>> 4) & 0x0F]);
168+
sb.append(HEXADECIMAL[b & 0x0F]);
169+
}
170+
171+
private static int hexVal(final char c) {
172+
if (c >= '0' && c <= '9') {
173+
return c - '0';
174+
}
175+
if (c >= 'A' && c <= 'F') {
176+
return 10 + (c - 'A');
177+
}
178+
if (c >= 'a' && c <= 'f') {
179+
return 10 + (c - 'a');
180+
}
181+
return -1;
182+
}
183+
}

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

Lines changed: 26 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;
@@ -60,6 +62,7 @@
6062
import org.apache.hc.core5.http.EntityDetails;
6163
import org.apache.hc.core5.http.Header;
6264
import org.apache.hc.core5.http.HttpException;
65+
import org.apache.hc.core5.http.HttpHeaders;
6366
import org.apache.hc.core5.http.HttpHost;
6467
import org.apache.hc.core5.http.HttpRequest;
6568
import org.apache.hc.core5.http.HttpResponse;
@@ -99,18 +102,31 @@ public final class AsyncConnectExec implements AsyncExecChainHandler {
99102
private final AuthCacheKeeper authCacheKeeper;
100103
private final HttpRouteDirector routeDirector;
101104

105+
private final ConnectAlpnProvider alpnProvider;
106+
107+
102108
public AsyncConnectExec(
103109
final HttpProcessor proxyHttpProcessor,
104110
final AuthenticationStrategy proxyAuthStrategy,
105111
final SchemePortResolver schemePortResolver,
106112
final boolean authCachingDisabled) {
113+
this(proxyHttpProcessor, proxyAuthStrategy, schemePortResolver, authCachingDisabled, null);
114+
}
115+
116+
public AsyncConnectExec(
117+
final HttpProcessor proxyHttpProcessor,
118+
final AuthenticationStrategy proxyAuthStrategy,
119+
final SchemePortResolver schemePortResolver,
120+
final boolean authCachingDisabled,
121+
final ConnectAlpnProvider alpnProvider) {
107122
Args.notNull(proxyHttpProcessor, "Proxy HTTP processor");
108123
Args.notNull(proxyAuthStrategy, "Proxy authentication strategy");
109124
this.proxyHttpProcessor = proxyHttpProcessor;
110125
this.proxyAuthStrategy = proxyAuthStrategy;
111126
this.authenticator = new AuthenticationHandler();
112127
this.authCacheKeeper = authCachingDisabled ? null : new AuthCacheKeeper(schemePortResolver);
113128
this.routeDirector = BasicRouteDirector.INSTANCE;
129+
this.alpnProvider = alpnProvider;
114130
}
115131

116132
static class State {
@@ -275,7 +291,7 @@ public void cancelled() {
275291
if (LOG.isDebugEnabled()) {
276292
LOG.debug("{} create tunnel", exchangeId);
277293
}
278-
createTunnel(state, proxy, target, scope, new AsyncExecCallback() {
294+
createTunnel(state, proxy, target, route, scope, new AsyncExecCallback() {
279295

280296
@Override
281297
public AsyncDataConsumer handleResponse(final HttpResponse response, final EntityDetails entityDetails) throws HttpException, IOException {
@@ -380,6 +396,7 @@ private void createTunnel(
380396
final State state,
381397
final HttpHost proxy,
382398
final HttpHost nextHop,
399+
final HttpRoute route,
383400
final AsyncExecChain.Scope scope,
384401
final AsyncExecCallback asyncExecCallback) {
385402

@@ -426,6 +443,14 @@ public void produceRequest(final RequestChannel requestChannel,
426443
final HttpRequest connect = new BasicHttpRequest(Method.CONNECT, nextHop, nextHop.toHostString());
427444
connect.setVersion(HttpVersion.HTTP_1_1);
428445

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

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
import java.security.AccessController;
3333
import java.security.PrivilegedAction;
3434
import java.util.ArrayList;
35+
import java.util.Arrays;
3536
import java.util.Collection;
37+
import java.util.Collections;
3638
import java.util.LinkedHashMap;
3739
import java.util.LinkedList;
3840
import java.util.List;
@@ -42,6 +44,7 @@
4244
import java.util.function.UnaryOperator;
4345

4446
import org.apache.hc.client5.http.AuthenticationStrategy;
47+
import org.apache.hc.client5.http.ConnectAlpnProvider;
4548
import org.apache.hc.client5.http.ConnectionKeepAliveStrategy;
4649
import org.apache.hc.client5.http.EarlyHintsListener;
4750
import org.apache.hc.client5.http.HttpRequestRetryStrategy;
@@ -269,6 +272,8 @@ private ExecInterceptorEntry(
269272

270273
private EarlyHintsListener earlyHintsListener;
271274

275+
private ConnectAlpnProvider connectAlpnProvider;
276+
272277
/**
273278
* Maps {@code Content-Encoding} tokens to decoder factories in insertion order.
274279
*/
@@ -896,6 +901,12 @@ public HttpAsyncClientBuilder disableContentCompression() {
896901
return this;
897902
}
898903

904+
public HttpAsyncClientBuilder setConnectAlpn(final String... ids) {
905+
final List<String> list = ids != null && ids.length > 0 ? Arrays.asList(ids) : Collections.emptyList();
906+
this.connectAlpnProvider = (t, r) -> list;
907+
return this;
908+
}
909+
899910
/**
900911
* Registers a global {@link org.apache.hc.client5.http.EarlyHintsListener}
901912
* that will be notified when the client receives {@code 103 Early Hints}
@@ -1051,7 +1062,8 @@ public CloseableHttpAsyncClient build() {
10511062
new DefaultHttpProcessor(new RequestTargetHost(), new RequestUserAgent(userAgentCopy)),
10521063
proxyAuthStrategyCopy,
10531064
schemePortResolver != null ? schemePortResolver : DefaultSchemePortResolver.INSTANCE,
1054-
authCachingDisabled),
1065+
authCachingDisabled,
1066+
connectAlpnProvider),
10551067
ChainElement.CONNECT.name());
10561068

10571069
if (earlyHintsListener != null) {

0 commit comments

Comments
 (0)