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}