View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.eclipse.aether.transport.jetty;
20  
21  import javax.net.ssl.*;
22  
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.net.URI;
26  import java.net.URISyntaxException;
27  import java.nio.file.Files;
28  import java.nio.file.Path;
29  import java.nio.file.StandardCopyOption;
30  import java.security.cert.X509Certificate;
31  import java.util.Collections;
32  import java.util.HashMap;
33  import java.util.Map;
34  import java.util.concurrent.ExecutionException;
35  import java.util.concurrent.TimeUnit;
36  import java.util.concurrent.atomic.AtomicBoolean;
37  import java.util.concurrent.atomic.AtomicReference;
38  import java.util.function.Function;
39  import java.util.regex.Matcher;
40  
41  import org.eclipse.aether.ConfigurationProperties;
42  import org.eclipse.aether.RepositorySystemSession;
43  import org.eclipse.aether.repository.AuthenticationContext;
44  import org.eclipse.aether.repository.RemoteRepository;
45  import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
46  import org.eclipse.aether.spi.connector.transport.GetTask;
47  import org.eclipse.aether.spi.connector.transport.PeekTask;
48  import org.eclipse.aether.spi.connector.transport.PutTask;
49  import org.eclipse.aether.spi.connector.transport.TransportTask;
50  import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
51  import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
52  import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
53  import org.eclipse.aether.spi.io.PathProcessor;
54  import org.eclipse.aether.transfer.NoTransporterException;
55  import org.eclipse.aether.transfer.TransferCancelledException;
56  import org.eclipse.aether.util.ConfigUtils;
57  import org.eclipse.aether.util.FileUtils;
58  import org.eclipse.jetty.client.HttpClient;
59  import org.eclipse.jetty.client.HttpProxy;
60  import org.eclipse.jetty.client.api.Authentication;
61  import org.eclipse.jetty.client.api.Request;
62  import org.eclipse.jetty.client.api.Response;
63  import org.eclipse.jetty.client.dynamic.HttpClientTransportDynamic;
64  import org.eclipse.jetty.client.http.HttpClientConnectionFactory;
65  import org.eclipse.jetty.client.util.BasicAuthentication;
66  import org.eclipse.jetty.client.util.InputStreamResponseListener;
67  import org.eclipse.jetty.http.HttpHeader;
68  import org.eclipse.jetty.http2.client.HTTP2Client;
69  import org.eclipse.jetty.http2.client.http.ClientConnectionFactoryOverHTTP2;
70  import org.eclipse.jetty.io.ClientConnector;
71  import org.eclipse.jetty.util.ssl.SslContextFactory;
72  import org.slf4j.Logger;
73  import org.slf4j.LoggerFactory;
74  
75  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.*;
76  
77  /**
78   * A transporter for HTTP/HTTPS.
79   *
80   * @since 2.0.0
81   */
82  final class JettyTransporter extends AbstractTransporter implements HttpTransporter {
83      private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
84  
85      private final ChecksumExtractor checksumExtractor;
86  
87      private final PathProcessor pathProcessor;
88  
89      private final URI baseUri;
90  
91      private final HttpClient client;
92  
93      private final int requestTimeout;
94  
95      private final Map<String, String> headers;
96  
97      private final boolean preemptiveAuth;
98  
99      private final boolean preemptivePutAuth;
100 
101     private final BasicAuthentication.BasicResult basicServerAuthenticationResult;
102 
103     private final BasicAuthentication.BasicResult basicProxyAuthenticationResult;
104 
105     JettyTransporter(
106             RepositorySystemSession session,
107             RemoteRepository repository,
108             ChecksumExtractor checksumExtractor,
109             PathProcessor pathProcessor)
110             throws NoTransporterException {
111         this.checksumExtractor = checksumExtractor;
112         this.pathProcessor = pathProcessor;
113         try {
114             URI uri = new URI(repository.getUrl()).parseServerAuthority();
115             if (uri.isOpaque()) {
116                 throw new URISyntaxException(repository.getUrl(), "URL must not be opaque");
117             }
118             if (uri.getRawFragment() != null || uri.getRawQuery() != null) {
119                 throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query");
120             }
121             String path = uri.getPath();
122             if (path == null) {
123                 path = "/";
124             }
125             if (!path.startsWith("/")) {
126                 path = "/" + path;
127             }
128             if (!path.endsWith("/")) {
129                 path = path + "/";
130             }
131             this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path);
132         } catch (URISyntaxException e) {
133             throw new NoTransporterException(repository, e.getMessage(), e);
134         }
135 
136         HashMap<String, String> headers = new HashMap<>();
137         String userAgent = ConfigUtils.getString(
138                 session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT);
139         if (userAgent != null) {
140             headers.put(USER_AGENT, userAgent);
141         }
142         @SuppressWarnings("unchecked")
143         Map<Object, Object> configuredHeaders = (Map<Object, Object>) ConfigUtils.getMap(
144                 session,
145                 Collections.emptyMap(),
146                 ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(),
147                 ConfigurationProperties.HTTP_HEADERS);
148         if (configuredHeaders != null) {
149             configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null));
150         }
151 
152         this.headers = headers;
153 
154         this.requestTimeout = ConfigUtils.getInteger(
155                 session,
156                 ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT,
157                 ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(),
158                 ConfigurationProperties.REQUEST_TIMEOUT);
159         this.preemptiveAuth = ConfigUtils.getBoolean(
160                 session,
161                 ConfigurationProperties.DEFAULT_HTTP_PREEMPTIVE_AUTH,
162                 ConfigurationProperties.HTTP_PREEMPTIVE_AUTH + "." + repository.getId(),
163                 ConfigurationProperties.HTTP_PREEMPTIVE_AUTH);
164         this.preemptivePutAuth = ConfigUtils.getBoolean(
165                 session,
166                 ConfigurationProperties.DEFAULT_HTTP_PREEMPTIVE_PUT_AUTH,
167                 ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH + "." + repository.getId(),
168                 ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH);
169 
170         this.client = getOrCreateClient(session, repository);
171 
172         final String instanceKey = JETTY_INSTANCE_KEY_PREFIX + repository.getId();
173         this.basicServerAuthenticationResult =
174                 (BasicAuthentication.BasicResult) session.getData().get(instanceKey + ".serverAuth");
175         this.basicProxyAuthenticationResult =
176                 (BasicAuthentication.BasicResult) session.getData().get(instanceKey + ".proxyAuth");
177     }
178 
179     private URI resolve(TransportTask task) {
180         return baseUri.resolve(task.getLocation());
181     }
182 
183     @Override
184     public int classify(Throwable error) {
185         if (error instanceof HttpTransporterException
186                 && ((HttpTransporterException) error).getStatusCode() == NOT_FOUND) {
187             return ERROR_NOT_FOUND;
188         }
189         return ERROR_OTHER;
190     }
191 
192     @Override
193     protected void implPeek(PeekTask task) throws Exception {
194         Request request = client.newRequest(resolve(task))
195                 .timeout(requestTimeout, TimeUnit.MILLISECONDS)
196                 .method("HEAD");
197         request.headers(m -> headers.forEach(m::add));
198         if (preemptiveAuth) {
199             if (basicServerAuthenticationResult != null) {
200                 basicServerAuthenticationResult.apply(request);
201             }
202             if (basicProxyAuthenticationResult != null) {
203                 basicProxyAuthenticationResult.apply(request);
204             }
205         }
206         Response response = request.send();
207         if (response.getStatus() >= MULTIPLE_CHOICES) {
208             throw new HttpTransporterException(response.getStatus());
209         }
210     }
211 
212     @Override
213     protected void implGet(GetTask task) throws Exception {
214         boolean resume = task.getResumeOffset() > 0L && task.getDataPath() != null;
215         Response response;
216         InputStreamResponseListener listener;
217 
218         while (true) {
219             Request request = client.newRequest(resolve(task))
220                     .timeout(requestTimeout, TimeUnit.MILLISECONDS)
221                     .method("GET");
222             request.headers(m -> headers.forEach(m::add));
223             if (preemptiveAuth) {
224                 if (basicServerAuthenticationResult != null) {
225                     basicServerAuthenticationResult.apply(request);
226                 }
227                 if (basicProxyAuthenticationResult != null) {
228                     basicProxyAuthenticationResult.apply(request);
229                 }
230             }
231 
232             if (resume) {
233                 long resumeOffset = task.getResumeOffset();
234                 long lastModified =
235                         Files.getLastModifiedTime(task.getDataPath()).toMillis();
236                 request.headers(h -> {
237                     h.add(RANGE, "bytes=" + resumeOffset + '-');
238                     h.addDateField(IF_UNMODIFIED_SINCE, lastModified - MODIFICATION_THRESHOLD);
239                     h.remove(HttpHeader.ACCEPT_ENCODING);
240                     h.add(ACCEPT_ENCODING, "identity");
241                 });
242             }
243 
244             listener = new InputStreamResponseListener();
245             request.send(listener);
246             try {
247                 response = listener.get(requestTimeout, TimeUnit.MILLISECONDS);
248             } catch (ExecutionException e) {
249                 Throwable t = e.getCause();
250                 if (t instanceof Exception) {
251                     throw (Exception) t;
252                 } else {
253                     throw new RuntimeException(t);
254                 }
255             }
256             if (response.getStatus() >= MULTIPLE_CHOICES) {
257                 if (resume && response.getStatus() == PRECONDITION_FAILED) {
258                     resume = false;
259                     continue;
260                 }
261                 throw new HttpTransporterException(response.getStatus());
262             }
263             break;
264         }
265 
266         long offset = 0L, length = response.getHeaders().getLongField(CONTENT_LENGTH);
267         if (resume) {
268             String range = response.getHeaders().get(CONTENT_RANGE);
269             if (range != null) {
270                 Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
271                 if (!m.matches()) {
272                     throw new IOException("Invalid Content-Range header for partial download: " + range);
273                 }
274                 offset = Long.parseLong(m.group(1));
275                 length = Long.parseLong(m.group(2)) + 1L;
276                 if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
277                     throw new IOException("Invalid Content-Range header for partial download from offset "
278                             + task.getResumeOffset() + ": " + range);
279                 }
280             }
281         }
282 
283         final boolean downloadResumed = offset > 0L;
284         final Path dataFile = task.getDataPath();
285         if (dataFile == null) {
286             try (InputStream is = listener.getInputStream()) {
287                 utilGet(task, is, true, length, downloadResumed);
288             }
289         } else {
290             try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile)) {
291                 task.setDataPath(tempFile.getPath(), downloadResumed);
292                 if (downloadResumed && Files.isRegularFile(dataFile)) {
293                     try (InputStream inputStream = Files.newInputStream(dataFile)) {
294                         Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
295                     }
296                 }
297                 try (InputStream is = listener.getInputStream()) {
298                     utilGet(task, is, true, length, downloadResumed);
299                 }
300                 tempFile.move();
301             } finally {
302                 task.setDataPath(dataFile);
303             }
304         }
305         if (task.getDataPath() != null && response.getHeaders().getDateField(LAST_MODIFIED) != -1) {
306             long lastModified =
307                     response.getHeaders().getDateField(LAST_MODIFIED); // note: Wagon also does first not last
308             if (lastModified != -1) {
309                 pathProcessor.setLastModified(task.getDataPath(), lastModified);
310             }
311         }
312         Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
313         if (checksums != null && !checksums.isEmpty()) {
314             checksums.forEach(task::setChecksum);
315         }
316     }
317 
318     private static Function<String, String> headerGetter(Response response) {
319         return s -> response.getHeaders().get(s);
320     }
321 
322     @Override
323     protected void implPut(PutTask task) throws Exception {
324         Request request = client.newRequest(resolve(task)).method("PUT").timeout(requestTimeout, TimeUnit.MILLISECONDS);
325         request.headers(m -> headers.forEach(m::add));
326         if (preemptiveAuth || preemptivePutAuth) {
327             if (basicServerAuthenticationResult != null) {
328                 basicServerAuthenticationResult.apply(request);
329             }
330             if (basicProxyAuthenticationResult != null) {
331                 basicProxyAuthenticationResult.apply(request);
332             }
333         }
334         request.body(new PutTaskRequestContent(task));
335         AtomicBoolean started = new AtomicBoolean(false);
336         Response response;
337         try {
338             response = request.onRequestCommit(r -> {
339                         if (task.getDataLength() == 0) {
340                             if (started.compareAndSet(false, true)) {
341                                 try {
342                                     task.getListener().transportStarted(0, task.getDataLength());
343                                 } catch (TransferCancelledException e) {
344                                     r.abort(e);
345                                 }
346                             }
347                         }
348                     })
349                     .onRequestContent((r, b) -> {
350                         if (started.compareAndSet(false, true)) {
351                             try {
352                                 task.getListener().transportStarted(0, task.getDataLength());
353                             } catch (TransferCancelledException e) {
354                                 r.abort(e);
355                                 return;
356                             }
357                         }
358                         try {
359                             task.getListener().transportProgressed(b);
360                         } catch (TransferCancelledException e) {
361                             r.abort(e);
362                         }
363                     })
364                     .send();
365         } catch (ExecutionException e) {
366             Throwable t = e.getCause();
367             if (t instanceof IOException) {
368                 IOException ioex = (IOException) t;
369                 if (ioex.getCause() instanceof TransferCancelledException) {
370                     throw (TransferCancelledException) ioex.getCause();
371                 } else {
372                     throw ioex;
373                 }
374             } else if (t instanceof Exception) {
375                 throw (Exception) t;
376             } else {
377                 throw new RuntimeException(t);
378             }
379         }
380         if (response.getStatus() >= MULTIPLE_CHOICES) {
381             throw new HttpTransporterException(response.getStatus());
382         }
383     }
384 
385     @Override
386     protected void implClose() {
387         // noop
388     }
389 
390     /**
391      * Visible for testing.
392      */
393     static final String JETTY_INSTANCE_KEY_PREFIX = JettyTransporterFactory.class.getName() + ".jetty.";
394 
395     static final Logger LOGGER = LoggerFactory.getLogger(JettyTransporter.class);
396 
397     @SuppressWarnings("checkstyle:methodlength")
398     private HttpClient getOrCreateClient(RepositorySystemSession session, RemoteRepository repository)
399             throws NoTransporterException {
400 
401         final String instanceKey = JETTY_INSTANCE_KEY_PREFIX + repository.getId();
402 
403         final String httpsSecurityMode = ConfigUtils.getString(
404                 session,
405                 ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT,
406                 ConfigurationProperties.HTTPS_SECURITY_MODE + "." + repository.getId(),
407                 ConfigurationProperties.HTTPS_SECURITY_MODE);
408 
409         if (!ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT.equals(httpsSecurityMode)
410                 && !ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode)) {
411             throw new IllegalArgumentException("Unsupported '" + httpsSecurityMode + "' HTTPS security mode.");
412         }
413         final boolean insecure = ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode);
414 
415         try {
416             AtomicReference<BasicAuthentication.BasicResult> serverAuth = new AtomicReference<>(null);
417             AtomicReference<BasicAuthentication.BasicResult> proxyAuth = new AtomicReference<>(null);
418             HttpClient client = (HttpClient) session.getData().computeIfAbsent(instanceKey, () -> {
419                 SSLContext sslContext = null;
420                 BasicAuthentication basicAuthentication = null;
421                 try {
422                     try (AuthenticationContext repoAuthContext =
423                             AuthenticationContext.forRepository(session, repository)) {
424                         if (repoAuthContext != null) {
425                             sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
426 
427                             String username = repoAuthContext.get(AuthenticationContext.USERNAME);
428                             String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
429 
430                             URI uri = URI.create(repository.getUrl());
431                             basicAuthentication =
432                                     new BasicAuthentication(uri, Authentication.ANY_REALM, username, password);
433                             if (preemptiveAuth || preemptivePutAuth) {
434                                 serverAuth.set(new BasicAuthentication.BasicResult(
435                                         uri, HttpHeader.AUTHORIZATION, username, password));
436                             }
437                         }
438                     }
439 
440                     if (sslContext == null) {
441                         if (insecure) {
442                             sslContext = SSLContext.getInstance("TLS");
443                             X509TrustManager tm = new X509TrustManager() {
444                                 @Override
445                                 public void checkClientTrusted(X509Certificate[] chain, String authType) {}
446 
447                                 @Override
448                                 public void checkServerTrusted(X509Certificate[] chain, String authType) {}
449 
450                                 @Override
451                                 public X509Certificate[] getAcceptedIssuers() {
452                                     return new X509Certificate[0];
453                                 }
454                             };
455                             sslContext.init(null, new X509TrustManager[] {tm}, null);
456                         } else {
457                             sslContext = SSLContext.getDefault();
458                         }
459                     }
460 
461                     int connectTimeout = ConfigUtils.getInteger(
462                             session,
463                             ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT,
464                             ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(),
465                             ConfigurationProperties.CONNECT_TIMEOUT);
466 
467                     SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
468                     sslContextFactory.setSslContext(sslContext);
469                     if (insecure) {
470                         sslContextFactory.setEndpointIdentificationAlgorithm(null);
471                         sslContextFactory.setHostnameVerifier((name, context) -> true);
472                     }
473 
474                     ClientConnector clientConnector = new ClientConnector();
475                     clientConnector.setSslContextFactory(sslContextFactory);
476 
477                     HTTP2Client http2Client = new HTTP2Client(clientConnector);
478                     ClientConnectionFactoryOverHTTP2.HTTP2 http2 =
479                             new ClientConnectionFactoryOverHTTP2.HTTP2(http2Client);
480 
481                     HttpClientTransportDynamic transport;
482                     if ("https".equalsIgnoreCase(repository.getProtocol())) {
483                         transport = new HttpClientTransportDynamic(
484                                 clientConnector, http2, HttpClientConnectionFactory.HTTP11); // HTTPS, prefer H2
485                     } else {
486                         transport = new HttpClientTransportDynamic(
487                                 clientConnector,
488                                 HttpClientConnectionFactory.HTTP11,
489                                 http2); // plaintext HTTP, H2 cannot be used
490                     }
491 
492                     HttpClient httpClient = new HttpClient(transport);
493                     httpClient.setConnectTimeout(connectTimeout);
494                     httpClient.setFollowRedirects(true);
495                     httpClient.setMaxRedirects(2);
496 
497                     httpClient.setUserAgentField(null); // we manage it
498 
499                     if (basicAuthentication != null) {
500                         httpClient.getAuthenticationStore().addAuthentication(basicAuthentication);
501                     }
502 
503                     if (repository.getProxy() != null) {
504                         HttpProxy proxy = new HttpProxy(
505                                 repository.getProxy().getHost(),
506                                 repository.getProxy().getPort());
507 
508                         httpClient.getProxyConfiguration().addProxy(proxy);
509                         try (AuthenticationContext proxyAuthContext =
510                                 AuthenticationContext.forProxy(session, repository)) {
511                             if (proxyAuthContext != null) {
512                                 String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
513                                 String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
514 
515                                 BasicAuthentication proxyAuthentication = new BasicAuthentication(
516                                         proxy.getURI(), Authentication.ANY_REALM, username, password);
517 
518                                 httpClient.getAuthenticationStore().addAuthentication(proxyAuthentication);
519                                 if (preemptiveAuth || preemptivePutAuth) {
520                                     proxyAuth.set(new BasicAuthentication.BasicResult(
521                                             proxy.getURI(), HttpHeader.PROXY_AUTHORIZATION, username, password));
522                                 }
523                             }
524                         }
525                     }
526                     if (!session.addOnSessionEndedHandler(() -> {
527                         try {
528                             httpClient.stop();
529                         } catch (Exception e) {
530                             throw new RuntimeException(e);
531                         }
532                     })) {
533                         LOGGER.warn(
534                                 "Using Resolver 2 feature without Resolver 2 session handling, you may leak resources.");
535                     }
536                     httpClient.start();
537                     return httpClient;
538                 } catch (Exception e) {
539                     throw new WrapperEx(e);
540                 }
541             });
542             if (serverAuth.get() != null) {
543                 session.getData().set(instanceKey + ".serverAuth", serverAuth.get());
544             }
545             if (proxyAuth.get() != null) {
546                 session.getData().set(instanceKey + ".proxyAuth", proxyAuth.get());
547             }
548             return client;
549         } catch (WrapperEx e) {
550             throw new NoTransporterException(repository, e.getCause());
551         }
552     }
553 
554     private static final class WrapperEx extends RuntimeException {
555         private WrapperEx(Throwable cause) {
556             super(cause);
557         }
558     }
559 }