Skip to content

Commit 8f872b6

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 26d51f6 commit 8f872b6

File tree

9 files changed

+755
-51
lines changed

9 files changed

+755
-51
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: 27 additions & 49 deletions
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,32 @@ public final class AsyncConnectExec implements AsyncExecChainHandler {
99102
private final AuthCacheKeeper authCacheKeeper;
100103
private final HttpRouteDirector routeDirector;
101104

105+
// NEW: optional ALPN provider
106+
private final ConnectAlpnProvider alpnProvider;
107+
108+
102109
public AsyncConnectExec(
103110
final HttpProcessor proxyHttpProcessor,
104111
final AuthenticationStrategy proxyAuthStrategy,
105112
final SchemePortResolver schemePortResolver,
106113
final boolean authCachingDisabled) {
114+
this(proxyHttpProcessor, proxyAuthStrategy, schemePortResolver, authCachingDisabled, null);
115+
}
116+
117+
public AsyncConnectExec(
118+
final HttpProcessor proxyHttpProcessor,
119+
final AuthenticationStrategy proxyAuthStrategy,
120+
final SchemePortResolver schemePortResolver,
121+
final boolean authCachingDisabled,
122+
final ConnectAlpnProvider alpnProvider) {
107123
Args.notNull(proxyHttpProcessor, "Proxy HTTP processor");
108124
Args.notNull(proxyAuthStrategy, "Proxy authentication strategy");
109125
this.proxyHttpProcessor = proxyHttpProcessor;
110126
this.proxyAuthStrategy = proxyAuthStrategy;
111127
this.authenticator = new AuthenticationHandler();
112128
this.authCacheKeeper = authCachingDisabled ? null : new AuthCacheKeeper(schemePortResolver);
113129
this.routeDirector = BasicRouteDirector.INSTANCE;
130+
this.alpnProvider = alpnProvider;
114131
}
115132

116133
static class State {
@@ -275,55 +292,7 @@ public void cancelled() {
275292
if (LOG.isDebugEnabled()) {
276293
LOG.debug("{} create tunnel", exchangeId);
277294
}
278-
createTunnel(state, proxy, target, scope, new AsyncExecCallback() {
279-
280-
@Override
281-
public AsyncDataConsumer handleResponse(final HttpResponse response, final EntityDetails entityDetails) throws HttpException, IOException {
282-
return asyncExecCallback.handleResponse(response, entityDetails);
283-
}
284-
285-
@Override
286-
public void handleInformationResponse(final HttpResponse response) throws HttpException, IOException {
287-
asyncExecCallback.handleInformationResponse(response);
288-
}
289-
290-
@Override
291-
public void completed() {
292-
if (!execRuntime.isEndpointConnected()) {
293-
// Remote endpoint disconnected. Need to start over
294-
if (LOG.isDebugEnabled()) {
295-
LOG.debug("{} proxy disconnected", exchangeId);
296-
}
297-
state.tracker.reset();
298-
}
299-
if (state.challenged) {
300-
if (LOG.isDebugEnabled()) {
301-
LOG.debug("{} proxy authentication required", exchangeId);
302-
}
303-
proceedToNextHop(state, request, entityProducer, scope, chain, asyncExecCallback);
304-
} else {
305-
if (state.tunnelRefused) {
306-
if (LOG.isDebugEnabled()) {
307-
LOG.debug("{} tunnel refused", exchangeId);
308-
}
309-
asyncExecCallback.completed();
310-
} else {
311-
if (LOG.isDebugEnabled()) {
312-
LOG.debug("{} tunnel to target created", exchangeId);
313-
}
314-
tracker.tunnelTarget(false);
315-
proceedToNextHop(state, request, entityProducer, scope, chain, asyncExecCallback);
316-
}
317-
}
318-
}
319-
320-
@Override
321-
public void failed(final Exception cause) {
322-
execRuntime.markConnectionNonReusable();
323-
asyncExecCallback.failed(cause);
324-
}
325-
326-
});
295+
createTunnel(state, proxy, target, route, scope, asyncExecCallback); // pass route
327296
break;
328297

329298
case HttpRouteDirector.TUNNEL_PROXY:
@@ -380,6 +349,7 @@ private void createTunnel(
380349
final State state,
381350
final HttpHost proxy,
382351
final HttpHost nextHop,
352+
final HttpRoute route,
383353
final AsyncExecChain.Scope scope,
384354
final AsyncExecCallback asyncExecCallback) {
385355

@@ -426,6 +396,14 @@ public void produceRequest(final RequestChannel requestChannel,
426396
final HttpRequest connect = new BasicHttpRequest(Method.CONNECT, nextHop, nextHop.toHostString());
427397
connect.setVersion(HttpVersion.HTTP_1_1);
428398

399+
// --- RFC 7639: inject ALPN header (if provided) ----------------
400+
if (alpnProvider != null) {
401+
final List<String> alpn = alpnProvider.getAlpnForTunnel(nextHop, route);
402+
if (alpn != null && !alpn.isEmpty()) {
403+
connect.addHeader(HttpHeaders.ALPN, AlpnHeaderSupport.formatValue(alpn));
404+
}
405+
}
406+
429407
proxyHttpProcessor.process(connect, null, clientContext);
430408
authenticator.addAuthResponse(proxy, ChallengeType.PROXY, connect, proxyAuthExchange, clientContext);
431409

0 commit comments

Comments
 (0)