diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/async/AsyncExecRuntime.java b/httpclient5/src/main/java/org/apache/hc/client5/http/async/AsyncExecRuntime.java index 4fd510b8c3..d1757e4cc0 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/async/AsyncExecRuntime.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/async/AsyncExecRuntime.java @@ -175,6 +175,18 @@ Cancellable execute( */ void markConnectionNonReusable(); + /** + * Returns the route that has already been established by the connection pool, + * or {@code null} if route completion is not handled at the pool level. + * + * @return the established route, or {@code null}. + * + * @since 5.7 + */ + default HttpRoute getEstablishedRoute() { + return null; + } + /** * Forks this runtime for parallel execution. * 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..bd7a0422f9 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 @@ -46,7 +46,6 @@ import org.apache.hc.client5.http.auth.AuthenticationException; 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.auth.AuthCacheKeeper; import org.apache.hc.client5.http.impl.auth.AuthenticationHandler; import org.apache.hc.client5.http.impl.routing.BasicRouteDirector; @@ -250,6 +249,15 @@ public void cancelled() { public void completed(final AsyncExecRuntime execRuntime) { final HttpHost proxy = route.getProxyHost(); tracker.connectProxy(proxy, route.isSecure() && !route.isTunnelled()); + if (route.isTunnelled() && execRuntime.getEstablishedRoute() != null) { + if (LOG.isDebugEnabled()) { + LOG.debug("{} tunnel to target already established by connection pool", exchangeId); + } + tracker.tunnelTarget(false); + if (route.isLayered()) { + tracker.layerProtocol(route.isSecure()); + } + } if (LOG.isDebugEnabled()) { LOG.debug("{} connected to proxy", exchangeId); } @@ -519,31 +527,8 @@ private boolean needAuthentication( final HttpHost proxy, final HttpResponse response, final HttpClientContext context) throws AuthenticationException, MalformedChallengeException { - final RequestConfig config = context.getRequestConfigOrDefault(); - if (config.isAuthenticationEnabled()) { - final boolean proxyAuthRequested = authenticator.isChallenged(proxy, ChallengeType.PROXY, response, proxyAuthExchange, context); - final boolean proxyMutualAuthRequired = authenticator.isChallengeExpected(proxyAuthExchange); - - if (authCacheKeeper != null) { - if (proxyAuthRequested) { - authCacheKeeper.updateOnChallenge(proxy, null, proxyAuthExchange, context); - } else { - authCacheKeeper.updateOnNoChallenge(proxy, null, proxyAuthExchange, context); - } - } - - if (proxyAuthRequested || proxyMutualAuthRequired) { - final boolean updated = authenticator.handleResponse(proxy, ChallengeType.PROXY, response, - proxyAuthStrategy, proxyAuthExchange, context); - - if (authCacheKeeper != null) { - authCacheKeeper.updateOnResponse(proxy, null, proxyAuthExchange, context); - } - - return updated; - } - } - return false; + return authenticator.needProxyAuthentication( + proxyAuthExchange, proxy, response, proxyAuthStrategy, authCacheKeeper, context); } private void proceedConnected( diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncClientBuilder.java index 74648da764..13c4bd1974 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncClientBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncClientBuilder.java @@ -840,9 +840,12 @@ public CloseableHttpAsyncClient build() { new H2AsyncMainClientExec(httpProcessor), ChainElement.MAIN_TRANSPORT.name()); + final HttpProcessor proxyConnectHttpProcessor = + new DefaultHttpProcessor(new RequestTargetHost(), new RequestUserAgent(userAgentCopy)); + execChainDefinition.addFirst( new AsyncConnectExec( - new DefaultHttpProcessor(new RequestTargetHost(), new RequestUserAgent(userAgentCopy)), + proxyConnectHttpProcessor, proxyAuthStrategyCopy, schemePortResolver != null ? schemePortResolver : DefaultSchemePortResolver.INSTANCE, authCachingDisabled), @@ -971,7 +974,21 @@ public CloseableHttpAsyncClient build() { } final MultihomeConnectionInitiator connectionInitiator = new MultihomeConnectionInitiator(ioReactor, dnsResolver); - final InternalH2ConnPool connPool = new InternalH2ConnPool(connectionInitiator, host -> null, tlsStrategyCopy); + final H2RouteOperator routeOperator = new H2RouteOperator( + tlsStrategyCopy, + new H2TunnelProtocolStarter(h2Config, charCodingConfig), + proxyConnectHttpProcessor, + proxyAuthStrategyCopy, + schemePortResolver != null ? schemePortResolver : DefaultSchemePortResolver.INSTANCE, + authCachingDisabled, + authSchemeRegistryCopy, + credentialsProviderCopy, + defaultRequestConfig); + final InternalH2ConnPool connPool = new InternalH2ConnPool( + connectionInitiator, + host -> null, + tlsStrategyCopy, + routeOperator); connPool.setConnectionConfigResolver(connectionConfigResolver); List closeablesCopy = closeables != null ? new ArrayList<>(closeables) : null; diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2RouteOperator.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2RouteOperator.java new file mode 100644 index 0000000000..932ed84e7d --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2RouteOperator.java @@ -0,0 +1,257 @@ +/* + * ==================================================================== + * 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.async; + +import org.apache.hc.client5.http.AuthenticationStrategy; +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.SchemePortResolver; +import org.apache.hc.client5.http.auth.AuthExchange; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.client5.http.auth.AuthenticationException; +import org.apache.hc.client5.http.auth.ChallengeType; +import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.auth.MalformedChallengeException; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.auth.AuthCacheKeeper; +import org.apache.hc.client5.http.impl.auth.AuthenticationHandler; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.config.Lookup; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http2.nio.support.H2OverH2TunnelSupport; +import org.apache.hc.core5.http2.nio.support.TunnelRefusedException; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.IOEventHandlerFactory; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Completes an HTTP/2 route by establishing a CONNECT tunnel through the proxy + * and optionally upgrading to TLS. Handles proxy authentication with bounded retry. + * + * @since 5.7 + */ +@Internal +final class H2RouteOperator { + + private static final Logger LOG = LoggerFactory.getLogger(H2RouteOperator.class); + private static final int MAX_TUNNEL_AUTH_ATTEMPTS = 3; + + private final TlsStrategy tlsStrategy; + private final IOEventHandlerFactory tunnelProtocolStarter; + private final HttpProcessor proxyHttpProcessor; + private final AuthenticationStrategy proxyAuthStrategy; + private final AuthenticationHandler authenticator; + private final AuthCacheKeeper authCacheKeeper; + private final Lookup authSchemeRegistry; + private final CredentialsProvider credentialsProvider; + private final RequestConfig defaultRequestConfig; + + H2RouteOperator( + final TlsStrategy tlsStrategy, + final IOEventHandlerFactory tunnelProtocolStarter) { + this(tlsStrategy, tunnelProtocolStarter, null, null, null, true, null, null, null); + } + + H2RouteOperator( + final TlsStrategy tlsStrategy, + final IOEventHandlerFactory tunnelProtocolStarter, + final HttpProcessor proxyHttpProcessor, + final AuthenticationStrategy proxyAuthStrategy, + final SchemePortResolver schemePortResolver, + final boolean authCachingDisabled, + final Lookup authSchemeRegistry, + final CredentialsProvider credentialsProvider, + final RequestConfig defaultRequestConfig) { + this.tlsStrategy = tlsStrategy; + this.tunnelProtocolStarter = tunnelProtocolStarter; + this.proxyHttpProcessor = proxyHttpProcessor; + this.proxyAuthStrategy = proxyAuthStrategy; + this.authenticator = proxyHttpProcessor != null && proxyAuthStrategy != null + ? new AuthenticationHandler() : null; + this.authCacheKeeper = proxyHttpProcessor != null && proxyAuthStrategy != null + && !authCachingDisabled && schemePortResolver != null + ? new AuthCacheKeeper(schemePortResolver) + : null; + this.authSchemeRegistry = authSchemeRegistry; + this.credentialsProvider = credentialsProvider; + this.defaultRequestConfig = defaultRequestConfig != null ? defaultRequestConfig : RequestConfig.DEFAULT; + } + + void completeRoute( + final HttpRoute route, + final Timeout connectTimeout, + final IOSession ioSession, + final FutureCallback callback) { + if (!route.isTunnelled()) { + callback.completed(ioSession); + return; + } + if (route.getHopCount() > 2) { + callback.failed(new HttpException("Proxy chains are not supported for HTTP/2 CONNECT tunneling")); + return; + } + if (tunnelProtocolStarter == null) { + callback.failed(new IllegalStateException("HTTP/2 tunnel protocol starter not configured")); + return; + } + if (route.isLayered() && tlsStrategy == null) { + callback.failed(new IllegalStateException("TLS strategy not configured")); + return; + } + final NamedEndpoint targetEndpoint = route.getTargetName() != null + ? route.getTargetName() : route.getTargetHost(); + final HttpHost proxy = route.getProxyHost(); + if (LOG.isDebugEnabled()) { + LOG.debug("{} establishing H2 tunnel to {} via {}", ioSession.getId(), targetEndpoint, proxy); + } + if (proxy != null && proxyHttpProcessor != null && proxyAuthStrategy != null && authenticator != null) { + establishTunnelWithAuth(route, ioSession, targetEndpoint, proxy, connectTimeout, callback); + } else { + H2OverH2TunnelSupport.establish( + ioSession, + targetEndpoint, + connectTimeout, + route.isLayered(), + tlsStrategy, + tunnelProtocolStarter, + callback); + } + } + + private void establishTunnelWithAuth( + final HttpRoute route, + final IOSession ioSession, + final NamedEndpoint targetEndpoint, + final HttpHost proxy, + final Timeout connectTimeout, + final FutureCallback callback) { + final HttpClientContext tunnelContext = HttpClientContext.create(); + if (authSchemeRegistry != null) { + tunnelContext.setAuthSchemeRegistry(authSchemeRegistry); + } + if (credentialsProvider != null) { + tunnelContext.setCredentialsProvider(credentialsProvider); + } + tunnelContext.setRequestConfig(defaultRequestConfig); + + final AuthExchange proxyAuthExchange = tunnelContext.getAuthExchange(proxy); + if (authCacheKeeper != null) { + authCacheKeeper.loadPreemptively(proxy, null, proxyAuthExchange, tunnelContext); + } + establishTunnelWithAuthAttempt( + route, ioSession, targetEndpoint, proxy, connectTimeout, + callback, tunnelContext, proxyAuthExchange, 1); + } + + private void establishTunnelWithAuthAttempt( + final HttpRoute route, + final IOSession ioSession, + final NamedEndpoint targetEndpoint, + final HttpHost proxy, + final Timeout connectTimeout, + final FutureCallback callback, + final HttpClientContext tunnelContext, + final AuthExchange proxyAuthExchange, + final int attemptCount) { + H2OverH2TunnelSupport.establish( + ioSession, + targetEndpoint, + connectTimeout, + route.isLayered(), + tlsStrategy, + (request, entityDetails, context) -> { + proxyHttpProcessor.process(request, null, tunnelContext); + authenticator.addAuthResponse(proxy, ChallengeType.PROXY, request, proxyAuthExchange, tunnelContext); + }, + tunnelProtocolStarter, + new FutureCallback() { + + @Override + public void completed(final IOSession result) { + callback.completed(result); + } + + @Override + public void failed(final Exception ex) { + if (!(ex instanceof TunnelRefusedException)) { + callback.failed(ex); + return; + } + final TunnelRefusedException tunnelRefusedException = (TunnelRefusedException) ex; + final HttpResponse response = tunnelRefusedException.getResponse(); + if (response.getCode() != HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED + || attemptCount >= MAX_TUNNEL_AUTH_ATTEMPTS) { + callback.failed(ex); + return; + } + try { + proxyHttpProcessor.process(response, null, tunnelContext); + final boolean retry = needAuthentication( + proxyAuthExchange, proxy, response, tunnelContext); + if (retry) { + if (LOG.isDebugEnabled()) { + LOG.debug("{} tunnel auth challenge from {}; attempt {}/{}", + ioSession.getId(), proxy, attemptCount, MAX_TUNNEL_AUTH_ATTEMPTS); + } + establishTunnelWithAuthAttempt( + route, ioSession, targetEndpoint, proxy, connectTimeout, + callback, tunnelContext, proxyAuthExchange, attemptCount + 1); + } else { + callback.failed(ex); + } + } catch (final Exception ioEx) { + callback.failed(ioEx); + } + } + + @Override + public void cancelled() { + callback.cancelled(); + } + + }); + } + + private boolean needAuthentication( + final AuthExchange proxyAuthExchange, + final HttpHost proxy, + final HttpResponse response, + final HttpClientContext context) throws AuthenticationException, MalformedChallengeException { + return authenticator.needProxyAuthentication( + proxyAuthExchange, proxy, response, proxyAuthStrategy, authCacheKeeper, context); + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2TunnelProtocolStarter.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2TunnelProtocolStarter.java new file mode 100644 index 0000000000..cbad416933 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2TunnelProtocolStarter.java @@ -0,0 +1,74 @@ +/* + * ==================================================================== + * 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.async; + +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.protocol.HttpProcessorBuilder; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.http2.impl.nio.ClientH2PrefaceHandler; +import org.apache.hc.core5.http2.impl.nio.ClientH2StreamMultiplexerFactory; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.IOEventHandlerFactory; +import org.apache.hc.core5.reactor.ProtocolIOSession; + +/** + * Minimal {@link IOEventHandlerFactory} for starting HTTP/2 client protocol + * inside a CONNECT tunnel session. + *

+ * Unlike {@link H2AsyncClientProtocolStarter}, this factory does not + * install push consumer handling, frame/header logging listeners, or + * exception callbacks. Those concerns belong to the outer proxy + * connection, not the tunneled target connection. + *

+ * + * @since 5.7 + */ +final class H2TunnelProtocolStarter implements IOEventHandlerFactory { + + private final H2Config h2Config; + private final CharCodingConfig charCodingConfig; + + H2TunnelProtocolStarter( + final H2Config h2Config, + final CharCodingConfig charCodingConfig) { + this.h2Config = h2Config != null ? h2Config : H2Config.DEFAULT; + this.charCodingConfig = charCodingConfig != null ? charCodingConfig : CharCodingConfig.DEFAULT; + } + + @Override + public IOEventHandler createHandler(final ProtocolIOSession ioSession, final Object attachment) { + final ClientH2StreamMultiplexerFactory multiplexerFactory = new ClientH2StreamMultiplexerFactory( + HttpProcessorBuilder.create().build(), + null, + h2Config, + charCodingConfig, + null); + return new ClientH2PrefaceHandler(ioSession, multiplexerFactory, false, null); + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClients.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClients.java index 7afd03ccf0..a419c6360b 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClients.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClients.java @@ -276,12 +276,16 @@ public static MinimalHttpAsyncClient createMinimal(final AsyncClientConnectionMa private static MinimalH2AsyncClient createMinimalHttp2AsyncClientImpl( final IOEventHandlerFactory eventHandlerFactory, final AsyncPushConsumerRegistry pushConsumerRegistry, + final H2Config h2Config, + final CharCodingConfig charCodingConfig, final IOReactorConfig ioReactorConfig, final DnsResolver dnsResolver, final TlsStrategy tlsStrategy) { return new MinimalH2AsyncClient( eventHandlerFactory, pushConsumerRegistry, + h2Config, + charCodingConfig, ioReactorConfig, new DefaultThreadFactory("httpclient-main", true), new DefaultThreadFactory("httpclient-dispatch", true), @@ -307,6 +311,8 @@ public static MinimalH2AsyncClient createHttp2Minimal( CharCodingConfig.DEFAULT, LoggingExceptionCallback.INSTANCE), pushConsumerRegistry, + h2Config, + CharCodingConfig.DEFAULT, ioReactorConfig, dnsResolver, tlsStrategy); diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2AsyncClient.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2AsyncClient.java index 4d4c056124..9934c10fc0 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2AsyncClient.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2AsyncClient.java @@ -108,11 +108,7 @@ HttpRoute determineRoute( final HttpHost httpHost, final HttpRequest request, final HttpClientContext clientContext) throws HttpException { - final HttpRoute route = routePlanner.determineRoute(httpHost, request, clientContext); - if (route.isTunnelled()) { - throw new HttpException("HTTP/2 tunneling not supported"); - } - return route; + return routePlanner.determineRoute(httpHost, request, clientContext); } } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2AsyncExecRuntime.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2AsyncExecRuntime.java index 89ce8833cf..2d719b3f71 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2AsyncExecRuntime.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2AsyncExecRuntime.java @@ -188,6 +188,12 @@ Endpoint ensureValid() { return endpoint; } + @Override + public HttpRoute getEstablishedRoute() { + final Endpoint endpoint = sessionRef.get(); + return endpoint != null ? endpoint.route : null; + } + @Override public Cancellable connectEndpoint( final HttpClientContext context, diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2ConnPool.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2ConnPool.java index 97c6981ff7..46c15c1ae7 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2ConnPool.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalH2ConnPool.java @@ -51,6 +51,8 @@ import org.apache.hc.core5.reactor.ssl.TransportSecurityLayer; import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; class InternalH2ConnPool implements ModalCloseable { @@ -58,10 +60,23 @@ class InternalH2ConnPool implements ModalCloseable { private volatile Resolver connectionConfigResolver; - InternalH2ConnPool(final ConnectionInitiator connectionInitiator, - final Resolver addressResolver, - final TlsStrategy tlsStrategy) { - this.sessionPool = new SessionPool(connectionInitiator, addressResolver, tlsStrategy); + InternalH2ConnPool( + final ConnectionInitiator connectionInitiator, + final Resolver addressResolver, + final TlsStrategy tlsStrategy) { + this(connectionInitiator, addressResolver, tlsStrategy, null); + } + + InternalH2ConnPool( + final ConnectionInitiator connectionInitiator, + final Resolver addressResolver, + final TlsStrategy tlsStrategy, + final H2RouteOperator routeOperator) { + this.sessionPool = new SessionPool( + connectionInitiator, + addressResolver, + tlsStrategy, + routeOperator); } @Override @@ -74,9 +89,10 @@ public void close() { sessionPool.close(); } - private ConnectionConfig resolveConnectionConfig(final HttpHost httpHost) { + private ConnectionConfig resolveConnectionConfig(final HttpRoute route) { + final HttpHost firstHop = route.getProxyHost() != null ? route.getProxyHost() : route.getTargetHost(); final Resolver resolver = this.connectionConfigResolver; - final ConnectionConfig connectionConfig = resolver != null ? resolver.resolve(httpHost) : null; + final ConnectionConfig connectionConfig = resolver != null ? resolver.resolve(firstHop) : null; return connectionConfig != null ? connectionConfig : ConnectionConfig.DEFAULT; } @@ -84,7 +100,7 @@ public Future getSession( final HttpRoute route, final Timeout connectTimeout, final FutureCallback callback) { - final ConnectionConfig connectionConfig = resolveConnectionConfig(route.getTargetHost()); + final ConnectionConfig connectionConfig = resolveConnectionConfig(route); return sessionPool.getSession( route, connectTimeout != null ? connectTimeout : connectionConfig.getConnectTimeout(), @@ -118,32 +134,41 @@ public void setValidateAfterInactivity(final TimeValue timeValue) { sessionPool.validateAfterInactivity = timeValue; } - static class SessionPool extends AbstractIOSessionPool { + private static final Logger LOG = LoggerFactory.getLogger(InternalH2ConnPool.class); + private final ConnectionInitiator connectionInitiator; private final Resolver addressResolver; private final TlsStrategy tlsStrategy; + private final H2RouteOperator routeOperator; private volatile TimeValue validateAfterInactivity = TimeValue.NEG_ONE_MILLISECOND; - SessionPool(final ConnectionInitiator connectionInitiator, - final Resolver addressResolver, - final TlsStrategy tlsStrategy) { + SessionPool( + final ConnectionInitiator connectionInitiator, + final Resolver addressResolver, + final TlsStrategy tlsStrategy, + final H2RouteOperator routeOperator) { this.connectionInitiator = connectionInitiator; this.addressResolver = addressResolver; this.tlsStrategy = tlsStrategy; + this.routeOperator = routeOperator; } @Override - protected Future connectSession(final HttpRoute route, - final Timeout connectTimeout, - final FutureCallback callback) { + protected Future connectSession( + final HttpRoute route, + final Timeout connectTimeout, + final FutureCallback callback) { + final HttpHost proxy = route.getProxyHost(); final HttpHost target = route.getTargetHost(); + final HttpHost firstHop = proxy != null ? proxy : target; + final NamedEndpoint firstHopName = proxy == null && route.getTargetName() != null ? route.getTargetName() : firstHop; final InetSocketAddress localAddress = route.getLocalSocketAddress(); - final InetSocketAddress remoteAddress = addressResolver.resolve(target); + final InetSocketAddress remoteAddress = addressResolver.resolve(firstHop); return connectionInitiator.connect( - target, + firstHopName, remoteAddress, localAddress, connectTimeout, @@ -153,34 +178,46 @@ protected Future connectSession(final HttpRoute route, @Override public void completed(final IOSession ioSession) { if (tlsStrategy != null - && URIScheme.HTTPS.same(target.getSchemeName()) + && URIScheme.HTTPS.same(firstHop.getSchemeName()) && ioSession instanceof TransportSecurityLayer) { - final NamedEndpoint tlsName = route.getTargetName() != null ? route.getTargetName() : target; tlsStrategy.upgrade( (TransportSecurityLayer) ioSession, - tlsName, + firstHopName, null, connectTimeout, new CallbackContribution(callback) { @Override public void completed(final TransportSecurityLayer transportSecurityLayer) { - callback.completed(ioSession); + completeRoute(route, connectTimeout, ioSession, callback); } }); ioSession.setSocketTimeout(connectTimeout); } else { - callback.completed(ioSession); + completeRoute(route, connectTimeout, ioSession, callback); } } }); } + private void completeRoute( + final HttpRoute route, + final Timeout connectTimeout, + final IOSession ioSession, + final FutureCallback callback) { + if (routeOperator != null) { + routeOperator.completeRoute(route, connectTimeout, ioSession, callback); + } else { + callback.completed(ioSession); + } + } + @Override - protected void validateSession(final IOSession ioSession, - final Callback callback) { + protected void validateSession( + final IOSession ioSession, + final Callback callback) { if (ioSession.isOpen()) { final TimeValue timeValue = validateAfterInactivity; if (TimeValue.isNonNegative(timeValue)) { @@ -202,8 +239,9 @@ protected void validateSession(final IOSession ioSession, } @Override - protected void closeSession(final IOSession ioSession, - final CloseMode closeMode) { + protected void closeSession( + final IOSession ioSession, + final CloseMode closeMode) { if (closeMode == CloseMode.GRACEFUL) { ioSession.enqueue(ShutdownCommand.GRACEFUL, Command.Priority.NORMAL); } else { diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/MinimalH2AsyncClient.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/MinimalH2AsyncClient.java index da5a47410f..de3a45ff4a 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/MinimalH2AsyncClient.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/MinimalH2AsyncClient.java @@ -54,6 +54,7 @@ import org.apache.hc.core5.http.HttpException; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.URIScheme; import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; import org.apache.hc.core5.http.nio.AsyncPushConsumer; import org.apache.hc.core5.http.nio.CapacityChannel; @@ -61,9 +62,11 @@ import org.apache.hc.core5.http.nio.HandlerFactory; import org.apache.hc.core5.http.nio.RequestChannel; import org.apache.hc.core5.http.nio.command.RequestExecutionCommand; +import org.apache.hc.core5.http.config.CharCodingConfig; import org.apache.hc.core5.http.nio.command.ShutdownCommand; import org.apache.hc.core5.http.nio.ssl.TlsStrategy; import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http2.config.H2Config; import org.apache.hc.core5.io.CloseMode; import org.apache.hc.core5.reactor.Command; import org.apache.hc.core5.reactor.ConnectionInitiator; @@ -79,8 +82,7 @@ /** * Minimal implementation of HTTP/2 only {@link CloseableHttpAsyncClient}. This client * is optimized for HTTP/2 multiplexing message transport and does not support advanced - * HTTP protocol functionality such as request execution via a proxy, state management, - * authentication and request redirects. + * HTTP protocol functionality such as state management, authentication and request redirects. *

* Concurrent message exchanges with the same connection route executed by * this client will get automatically multiplexed over a single physical HTTP/2 @@ -99,6 +101,8 @@ public final class MinimalH2AsyncClient extends AbstractMinimalHttpAsyncClientBa MinimalH2AsyncClient( final IOEventHandlerFactory eventHandlerFactory, final AsyncPushConsumerRegistry pushConsumerRegistry, + final H2Config h2Config, + final CharCodingConfig charCodingConfig, final IOReactorConfig reactorConfig, final ThreadFactory threadFactory, final ThreadFactory workerThreadFactory, @@ -115,7 +119,13 @@ public final class MinimalH2AsyncClient extends AbstractMinimalHttpAsyncClientBa pushConsumerRegistry, threadFactory); this.connectionInitiator = new MultihomeConnectionInitiator(getConnectionInitiator(), dnsResolver); - this.connPool = new InternalH2ConnPool(this.connectionInitiator, object -> null, tlsStrategy); + this.connPool = new InternalH2ConnPool( + this.connectionInitiator, + object -> null, + tlsStrategy, + new H2RouteOperator( + tlsStrategy, + new H2TunnelProtocolStarter(h2Config, charCodingConfig))); } @Override @@ -143,8 +153,14 @@ public Cancellable execute( @SuppressWarnings("deprecation") final Timeout connectTimeout = requestConfig.getConnectTimeout(); final HttpHost target = new HttpHost(request.getScheme(), request.getAuthority()); + final HttpHost proxy = requestConfig.getProxy(); + final HttpRoute route = proxy != null ? new HttpRoute( + target, + null, + proxy, + URIScheme.HTTPS.same(target.getSchemeName())) : new HttpRoute(target); - final Future sessionFuture = connPool.getSession(new HttpRoute(target), connectTimeout, + final Future sessionFuture = connPool.getSession(route, connectTimeout, new FutureCallback() { @Override diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java index fc7e4a22dd..fee973d15e 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java @@ -43,6 +43,7 @@ import org.apache.hc.client5.http.auth.ChallengeType; import org.apache.hc.client5.http.auth.CredentialsProvider; import org.apache.hc.client5.http.auth.MalformedChallengeException; +import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.core5.annotation.Contract; import org.apache.hc.core5.annotation.Internal; @@ -362,6 +363,47 @@ public boolean handleResponse( return false; } + /** + * Determines whether proxy authentication is needed for the given response, + * updating the {@link AuthExchange} and auth cache state as appropriate. + * + * @since 5.7 + */ + public boolean needProxyAuthentication( + final AuthExchange proxyAuthExchange, + final HttpHost proxy, + final HttpResponse response, + final AuthenticationStrategy proxyAuthStrategy, + final AuthCacheKeeper authCacheKeeper, + final HttpClientContext context) throws AuthenticationException, MalformedChallengeException { + final RequestConfig config = context.getRequestConfigOrDefault(); + if (config.isAuthenticationEnabled()) { + final boolean proxyAuthRequested = isChallenged( + proxy, ChallengeType.PROXY, response, proxyAuthExchange, context); + final boolean proxyMutualAuthRequired = isChallengeExpected(proxyAuthExchange); + + if (authCacheKeeper != null) { + if (proxyAuthRequested) { + authCacheKeeper.updateOnChallenge(proxy, null, proxyAuthExchange, context); + } else { + authCacheKeeper.updateOnNoChallenge(proxy, null, proxyAuthExchange, context); + } + } + + if (proxyAuthRequested || proxyMutualAuthRequired) { + final boolean updated = handleResponse( + proxy, ChallengeType.PROXY, response, proxyAuthStrategy, proxyAuthExchange, context); + + if (authCacheKeeper != null) { + authCacheKeeper.updateOnResponse(proxy, null, proxyAuthExchange, context); + } + + return updated; + } + } + return false; + } + /** * Generates a response to the authentication challenge based on the actual {@link AuthExchange} state * and adds it to the given {@link HttpRequest} message . diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientH2ViaH2ProxyTunnel.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientH2ViaH2ProxyTunnel.java new file mode 100644 index 0000000000..e32b599728 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientH2ViaH2ProxyTunnel.java @@ -0,0 +1,150 @@ +/* + * ==================================================================== + * 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.File; +import java.util.concurrent.CountDownLatch; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.async.methods.SimpleRequestProducer; +import org.apache.hc.client5.http.async.methods.SimpleResponseConsumer; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.H2AsyncClientBuilder; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.routing.HttpRoutePlanner; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http2.ssl.H2ClientTlsStrategy; +import org.apache.hc.core5.http.message.StatusLine; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.ssl.SSLContexts; + +/** + * Full example of pure HTTP/2 client execution through an HTTP/2 proxy tunnel. + * + *

+ * Requirements: + *

+ *
    + *
  • Proxy endpoint speaks HTTP/2.
  • + *
  • Proxy supports CONNECT for the requested target.
  • + *
  • Target endpoint supports HTTP/2.
  • + *
+ * + *

+ * This example configures a tunneled and layered route: + * {@code client -> (h2) proxy -> CONNECT tunnel -> TLS -> (h2) target}. + *

+ */ +public class AsyncClientH2ViaH2ProxyTunnel { + + private static TlsStrategy createTlsStrategy() throws Exception { + final String trustStore = System.getProperty("h2.truststore"); + if (trustStore == null || trustStore.isEmpty()) { + return new H2ClientTlsStrategy(); + } + final String trustStorePassword = System.getProperty("h2.truststore.password", "changeit"); + final SSLContext sslContext = SSLContexts.custom() + .loadTrustMaterial(new File(trustStore), trustStorePassword.toCharArray()) + .build(); + return new H2ClientTlsStrategy(sslContext); + } + + public static void main(final String[] args) throws Exception { + final String proxyScheme = System.getProperty("h2.proxy.scheme", "http"); + final String proxyHost = System.getProperty("h2.proxy.host", "localhost"); + final int proxyPort = Integer.parseInt(System.getProperty("h2.proxy.port", "8080")); + final String targetScheme = System.getProperty("h2.target.scheme", "https"); + final String targetHost = System.getProperty("h2.target.host", "origin"); + final int targetPort = Integer.parseInt(System.getProperty("h2.target.port", "9443")); + final String[] requestUris = System.getProperty("h2.paths", "/").split(","); + + final HttpHost proxy = new HttpHost(proxyScheme, proxyHost, proxyPort); + final HttpHost target = new HttpHost(targetScheme, targetHost, targetPort); + + final HttpRoutePlanner routePlanner = (final HttpHost routeTarget, final org.apache.hc.core5.http.protocol.HttpContext context) -> + new HttpRoute(routeTarget, null, proxy, URIScheme.HTTPS.same(routeTarget.getSchemeName())); + final TlsStrategy tlsStrategy = createTlsStrategy(); + + try (CloseableHttpAsyncClient client = H2AsyncClientBuilder.create() + .setRoutePlanner(routePlanner) + .setTlsStrategy(tlsStrategy) + .build()) { + + client.start(); + + final CountDownLatch latch = new CountDownLatch(requestUris.length); + + for (final String requestUri : requestUris) { + final String normalizedRequestUri = requestUri.trim(); + final SimpleHttpRequest request = SimpleRequestBuilder.get() + .setHttpHost(target) + .setPath(normalizedRequestUri) + .build(); + final HttpClientContext clientContext = HttpClientContext.create(); + + client.execute( + SimpleRequestProducer.create(request), + SimpleResponseConsumer.create(), + clientContext, + new FutureCallback() { + + @Override + public void completed(final SimpleHttpResponse response) { + latch.countDown(); + System.out.println(request + " -> " + new StatusLine(response)); + System.out.println("Protocol: " + clientContext.getProtocolVersion()); + System.out.println(response.getBodyText()); + } + + @Override + public void failed(final Exception ex) { + latch.countDown(); + System.out.println(request + " -> " + ex); + } + + @Override + public void cancelled() { + latch.countDown(); + System.out.println(request + " cancelled"); + } + + }); + } + + latch.await(); + client.close(CloseMode.GRACEFUL); + } + } +} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestH2TunnelProtocolStarter.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestH2TunnelProtocolStarter.java new file mode 100644 index 0000000000..c73a54da0b --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestH2TunnelProtocolStarter.java @@ -0,0 +1,83 @@ +/* + * ==================================================================== + * 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.async; + +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.http2.impl.nio.ClientH2PrefaceHandler; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class TestH2TunnelProtocolStarter { + + @Test + void testCreatesMinimalH2HandlerWithoutPushOrLogging() { + final H2TunnelProtocolStarter starter = new H2TunnelProtocolStarter( + H2Config.DEFAULT, CharCodingConfig.DEFAULT); + final ProtocolIOSession ioSession = Mockito.mock(ProtocolIOSession.class); + Mockito.when(ioSession.getId()).thenReturn("test-tunnel"); + + final IOEventHandler handler = starter.createHandler(ioSession, null); + + Assertions.assertNotNull(handler); + Assertions.assertInstanceOf(ClientH2PrefaceHandler.class, handler); + } + + @Test + void testDefaultsWhenNullConfig() { + final H2TunnelProtocolStarter starter = new H2TunnelProtocolStarter(null, null); + final ProtocolIOSession ioSession = Mockito.mock(ProtocolIOSession.class); + Mockito.when(ioSession.getId()).thenReturn("test-tunnel-null"); + + final IOEventHandler handler = starter.createHandler(ioSession, null); + + Assertions.assertNotNull(handler); + Assertions.assertInstanceOf(ClientH2PrefaceHandler.class, handler); + } + + @Test + void testCustomH2ConfigIsRespected() { + final H2Config customConfig = H2Config.custom() + .setMaxFrameSize(32768) + .setInitialWindowSize(128 * 1024) + .setPushEnabled(false) + .build(); + final H2TunnelProtocolStarter starter = new H2TunnelProtocolStarter( + customConfig, CharCodingConfig.DEFAULT); + final ProtocolIOSession ioSession = Mockito.mock(ProtocolIOSession.class); + Mockito.when(ioSession.getId()).thenReturn("test-tunnel-custom"); + + final IOEventHandler handler = starter.createHandler(ioSession, null); + + Assertions.assertNotNull(handler); + Assertions.assertInstanceOf(ClientH2PrefaceHandler.class, handler); + } + +} diff --git a/pom.xml b/pom.xml index 084cf1cd8d..425554bdf9 100644 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,7 @@ 1.8 1.8 - 5.4.1 + 5.5-alpha1-SNAPSHOT 2.25.3 1.20.0 2.5.2