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