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