001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.client.apachehttpclient45; 018 019import java.io.*; 020import java.net.*; 021import java.util.*; 022import java.util.concurrent.*; 023 024import org.apache.http.*; 025import org.apache.http.client.config.*; 026import org.apache.http.client.methods.*; 027import org.apache.http.client.protocol.*; 028import org.apache.http.config.*; 029import org.apache.http.conn.socket.*; 030import org.apache.http.conn.ssl.*; 031import org.apache.http.entity.*; 032import org.apache.http.impl.client.*; 033import org.apache.http.impl.conn.*; 034import org.apache.juneau.http.remote.*; 035import org.apache.juneau.rest.client.*; 036 037/** 038 * {@link HttpTransport} implementation backed by Apache HttpClient 4.5. 039 * 040 * <p> 041 * This transport is auto-discovered via {@link java.util.ServiceLoader} when 042 * {@code org.apache.httpcomponents:httpclient} is on the classpath. You can also instantiate it explicitly: 043 * 044 * <p class='bjava'> 045 * <jv>transport</jv> = ApacheHc45Transport.<jsm>builder</jsm>() 046 * .httpClient(HttpClients.createDefault()) 047 * .build(); 048 * 049 * <jv>client</jv> = RestClient.<jsm>builder</jsm>() 050 * .transport(<jv>transport</jv>) 051 * .build(); 052 * </p> 053 * 054 * <h5 class='section'>See Also:</h5><ul> 055 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/NextGenRestClient">Next-Generation REST Client</a> 056 * </ul> 057 * 058 * @since 9.2.1 059 */ 060@SuppressWarnings({ 061 "resource" // httpClient is owned by this transport and closed in close() 062}) 063public final class ApacheHc45Transport implements HttpTransport { 064 065 private final CloseableHttpClient httpClient; 066 private final CloseableHttpClient pinnedHttpClient; 067 private final BasicCookieStore cookieStore; 068 // One further pinned client (own connection pool) per distinct non-empty per-client allow-list, created on first use, 069 // so a connection opened under one allow-list can never be handed to a request with another. 070 private final Map<RemoteUrlPolicy.AllowedRanges,CloseableHttpClient> rangedPinnedHttpClients = new ConcurrentHashMap<>(); 071 072 ApacheHc45Transport(ApacheHc45TransportBuilder builder) { 073 if (builder.httpClient != null) { 074 // A caller-supplied client cannot be given pin-on-connect, so supportsUrlPolicy() reports false for it. 075 this.httpClient = builder.httpClient; 076 this.pinnedHttpClient = null; 077 this.cookieStore = null; 078 } else { 079 // Two clients, one per connection pool, sharing a cookie store so that cookies behave as on one client. 080 this.cookieStore = new BasicCookieStore(); 081 this.httpClient = createDefaultHttpClient(cookieStore); 082 this.pinnedHttpClient = createPinnedHttpClient(cookieStore, PolicyPinningDnsResolver.INSTANCE); 083 } 084 } 085 086 // The client for ordinary (non-@Remote) requests: system DNS and HttpClient's own redirect handling. 087 private static CloseableHttpClient createDefaultHttpClient(BasicCookieStore cookieStore) { 088 return HttpClients.custom() 089 .setDefaultCookieStore(cookieStore) 090 .setRedirectStrategy(new UserInfoRefusingRedirectStrategy()) 091 .build(); 092 } 093 094 // A redirect Location with user info would make HttpClient replace the client's stored credentials for the target's scope 095 // with the user info's, so it is refused (the policy path refuses it too, in PolicyEnforcedRedirects). 096 private static final class UserInfoRefusingRedirectStrategy extends DefaultRedirectStrategy { 097 @Override /* DefaultRedirectStrategy */ 098 public URI getLocationURI(HttpRequest request, HttpResponse response, org.apache.http.protocol.HttpContext context) throws org.apache.http.ProtocolException { 099 var uri = super.getLocationURI(request, response, context); 100 if (uri.getRawUserInfo() != null) 101 throw new org.apache.http.ProtocolException("Redirect Location carries user info, which is refused: " + uri.getHost()); 102 return uri; 103 } 104 } 105 106 // The client for SSRF-guard-active @Remote requests. Its connection manager is its own and resolves every host 107 // through PolicyPinningDnsResolver, so every connection in its pool was opened through the pin; a guarded request 108 // can never be handed an idle connection that an unguarded request opened. Redirect handling is disabled so that 109 // PolicyEnforcedRedirects can revalidate every hop. 110 private static CloseableHttpClient createPinnedHttpClient(BasicCookieStore cookieStore, PolicyPinningDnsResolver resolver) { 111 var registry = RegistryBuilder.<ConnectionSocketFactory>create() 112 .register("http", PlainConnectionSocketFactory.getSocketFactory()) 113 .register("https", SSLConnectionSocketFactory.getSocketFactory()) 114 .build(); 115 return HttpClients.custom() 116 .setConnectionManager(new PoolingHttpClientConnectionManager(registry, resolver)) 117 .setDefaultCookieStore(cookieStore) 118 .disableRedirectHandling() 119 .build(); 120 } 121 122 /** 123 * Returns a new builder for this transport. 124 * 125 * @return A new builder. Never <jk>null</jk>. 126 */ 127 public static ApacheHc45TransportBuilder builder() { 128 return new ApacheHc45TransportBuilder(); 129 } 130 131 /** 132 * Returns a new instance backed by a default {@link CloseableHttpClient}. 133 * 134 * @return A new instance. Never <jk>null</jk>. 135 */ 136 public static ApacheHc45Transport create() { 137 return builder().build(); 138 } 139 140 @Override /* HttpTransport */ 141 public TransportResponse execute(TransportRequest request) throws TransportException { 142 if (request.isSsrfGuardActive()) 143 return executeWithPolicy(request); 144 try { 145 return sendOnce(request, httpClient, Hc45RedirectCredentialGuard.newGuardingContext()); 146 } catch (StaleConnectionException e) { 147 // A pooled keep-alive connection was torn down by the server before any response was received. 148 // Replay once on a fresh connection, but only when it is provably safe (idempotent + repeatable body). 149 if (! request.isSafeToReplay()) 150 throw e.asTransportException(); 151 try { 152 return sendOnce(request, httpClient, Hc45RedirectCredentialGuard.newGuardingContext()); 153 } catch (StaleConnectionException e2) { 154 throw e2.asTransportException(); 155 } 156 } 157 } 158 159 @Override /* HttpTransport */ 160 public boolean supportsUrlPolicy() { 161 return pinnedHttpClient != null; 162 } 163 164 // Juneau-controlled redirect revalidation for a guarded request; every hop goes through sendOncePinned. 165 private TransportResponse executeWithPolicy(TransportRequest request) throws TransportException { 166 // One guarding context for the whole chain: each hop is a separate execute, and the guard must remember the first origin. 167 var context = Hc45RedirectCredentialGuard.newGuardingContext(); 168 return PolicyEnforcedRedirects.execute(request, hop -> sendOncePinned(hop, context)); 169 } 170 171 // Performs one pin-on-connect hop on the pinned client. A caller-supplied client has no pinned client, so its hops 172 // are sent unpinned on that client; RestRequest never routes a guarded request there (supportsUrlPolicy() is false). 173 // PolicyPinningDnsResolver refuses a host whose every address is denied with an IllegalArgumentException, which 174 // HttpClient propagates unchanged; it is reported as the documented TransportException. 175 private TransportResponse sendOncePinned(TransportRequest request, HttpClientContext context) throws TransportException { 176 try { 177 return sendOnce(request, pinnedClientFor(request), context); 178 } catch (StaleConnectionException e) { 179 throw e.asTransportException(); 180 } catch (IllegalArgumentException e) { 181 throw new TransportException("HTTP transport error: " + e.getMessage(), e); 182 } 183 } 184 185 // The pinned client for the request's per-client allow-list: the shared one when it allows nothing, else a dedicated 186 // one (own pool, own resolver) per distinct allow-list. A caller-supplied client has none, and is used as is. 187 private CloseableHttpClient pinnedClientFor(TransportRequest request) { 188 if (pinnedHttpClient == null) 189 return httpClient; 190 var ranges = request.getAllowedAddressRanges(); 191 if (ranges.isEmpty()) 192 return pinnedHttpClient; 193 return rangedPinnedHttpClients.computeIfAbsent(ranges, r -> createPinnedHttpClient(cookieStore, new PolicyPinningDnsResolver(r))); 194 } 195 196 // Performs a single HTTP exchange. Throws StaleConnectionException (a retryable signal) when the failure is a 197 // pre-response stale-connection failure; throws TransportException for every other failure. Every request is sent with a 198 // context that applies the redirect credential guard, so transport-built and caller-supplied clients behave the same. A legacy AbstractHttpClient is refused by the builder because 199 // it never sets the request again before it is written, so the wire-stage strip could not run for it. 200 private static TransportResponse sendOnce(TransportRequest request, CloseableHttpClient client, HttpClientContext context) throws TransportException, StaleConnectionException { 201 var hcRequest = buildHcRequest(request, client); 202 CloseableHttpResponse hcResponse; 203 try { 204 hcResponse = client.execute(hcRequest, context); 205 } catch (IOException e) { 206 // httpClient.execute() only returns once response headers have been read, so any IOException here 207 // occurred before any response bytes were received. 208 if (isStaleConnectionFailure(e)) 209 throw new StaleConnectionException(e); 210 throw new TransportException("HTTP transport error: " + e.getMessage(), e); 211 } 212 try { 213 return buildTransportResponse(hcResponse); 214 } catch (TransportException | RuntimeException e) { 215 // If response wiring fails the closeCallback never runs, so close here to release the leased connection. 216 closeQuietly(hcResponse); 217 throw e; 218 } 219 } 220 221 // A failure is a stale-connection (pre-response) failure when the server closed the pooled connection before 222 // sending any response. Apache HttpClient 4.5 surfaces this as a NoHttpResponseException ("failed to respond") 223 // or a connection-reset SocketException. HttpClient 4.5's default retry handler does not retry these for 224 // entity-enclosing idempotent methods (e.g. PUT) or for POST, so the transport applies its own idempotency-safe 225 // retry here. 226 private static boolean isStaleConnectionFailure(IOException e) { 227 return e instanceof NoHttpResponseException 228 || (e instanceof SocketException && e.getMessage() != null && e.getMessage().toLowerCase(Locale.ROOT).contains("reset")); 229 } 230 231 private static void closeQuietly(CloseableHttpResponse hcResponse) { 232 try { 233 hcResponse.close(); 234 } catch (IOException e) { 235 // Best-effort cleanup on an already-failing path; nothing more can be done. 236 } 237 } 238 239 @Override /* Closeable */ 240 public void close() throws IOException { 241 try { 242 httpClient.close(); 243 } finally { 244 try { 245 if (pinnedHttpClient != null) 246 pinnedHttpClient.close(); 247 } finally { 248 for (var c : rangedPinnedHttpClients.values()) 249 c.close(); 250 } 251 } 252 } 253 254 // ----------------------------------------------------------------------------------------------------------------- 255 // Internal helpers 256 // ----------------------------------------------------------------------------------------------------------------- 257 258 // The effective default config to layer the per-request timeouts on: the client's default if any, else the 259 // HttpClient defaults -- so client-level settings (redirects, cookie spec, ...) survive. 260 private static RequestConfig baseConfig(CloseableHttpClient client) { 261 if (client instanceof Configurable c && c.getConfig() != null) 262 return c.getConfig(); 263 return RequestConfig.DEFAULT; 264 } 265 266 private static HttpUriRequest buildHcRequest(TransportRequest request, CloseableHttpClient client) { 267 var builder = RequestBuilder.create(request.getMethod()).setUri(request.getUri()); 268 for (var h : request.getHeaders()) 269 builder.addHeader(h.name(), h.value()); 270 var timeout = request.getTimeout(); 271 if (timeout != null) { 272 // Per-request socket (read) and connect timeouts; overrides the client's default RequestConfig for this call only. 273 var ms = (int)Math.min(Integer.MAX_VALUE, Math.max(1, timeout.toMillis())); 274 builder.setConfig(RequestConfig.copy(baseConfig(client)).setSocketTimeout(ms).setConnectTimeout(ms).build()); 275 } 276 var body = request.getBody(); 277 if (body != null) 278 builder.setEntity(new TransportBodyEntity(body)); 279 return builder.build(); 280 } 281 282 private static TransportResponse buildTransportResponse(CloseableHttpResponse hcResponse) throws TransportException { 283 var statusLine = hcResponse.getStatusLine(); 284 var builder = TransportResponse.builder() 285 .statusCode(statusLine.getStatusCode()) 286 .reasonPhrase(statusLine.getReasonPhrase()) 287 .closeCallback(hcResponse); 288 for (var h : hcResponse.getAllHeaders()) 289 builder.header(h.getName(), h.getValue()); 290 var entity = hcResponse.getEntity(); 291 if (entity != null) { 292 try { 293 builder.body(entity.getContent()); 294 } catch (IOException e) { 295 throw new TransportException("Failed to read response body: " + e.getMessage(), e); 296 } 297 } 298 return builder.build(); 299 } 300 301 // ----------------------------------------------------------------------------------------------------------------- 302 // TransportBodyEntity — bridges TransportBody to Apache HttpEntity 303 // ----------------------------------------------------------------------------------------------------------------- 304 305 // Internal signal that a pre-response stale-connection failure occurred and may be retried once. Carries the 306 // original cause so the caller can build the user-visible TransportException if the retry is not attempted or 307 // also fails. 308 private static final class StaleConnectionException extends Exception { 309 private static final long serialVersionUID = 1L; 310 311 StaleConnectionException(Throwable cause) { 312 super(cause); 313 } 314 315 TransportException asTransportException() { 316 return new TransportException("HTTP transport error: " + getCause().getMessage(), getCause()); 317 } 318 } 319 320 /** 321 * Bridges a {@link TransportBody} to Apache HttpClient's {@link AbstractHttpEntity}. 322 * 323 * <p>Package-protected (rather than {@code private}) so {@code ApacheHc45Transport_TransportBodyEntity_Test} 324 * (same package) can construct it directly and assert its {@code getContent()} contract. 325 */ 326 static final class TransportBodyEntity extends AbstractHttpEntity { 327 328 private final TransportBody body; 329 330 TransportBodyEntity(TransportBody body) { 331 this.body = body; 332 var ct = body.getContentType(); 333 if (ct != null) 334 setContentType(ct); 335 } 336 337 @Override /* HttpEntity */ 338 public boolean isRepeatable() { 339 return body.isRepeatable(); 340 } 341 342 @Override /* HttpEntity */ 343 public long getContentLength() { 344 return body.getContentLength(); 345 } 346 347 @Override /* HttpEntity */ 348 public InputStream getContent() throws UnsupportedOperationException { 349 throw new UnsupportedOperationException("Use writeTo(OutputStream) instead"); 350 } 351 352 @Override /* HttpEntity */ 353 public void writeTo(OutputStream out) throws IOException { 354 body.writeTo(out); 355 } 356 357 @Override /* HttpEntity */ 358 public boolean isStreaming() { 359 return !body.isRepeatable(); 360 } 361 } 362}