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 requestTimeout;
107 
108     private final Map<String, String> headers;
109 
110     private final boolean preemptiveAuth;
111 
112     private final boolean preemptivePutAuth;
113 
114     private final boolean insecure;
115 
116     private final AtomicReference<BasicAuthentication.BasicResult> basicServerAuthenticationResult;
117 
118     private final AtomicReference<BasicAuthentication.BasicResult> basicProxyAuthenticationResult;
119 
120     JettyTransporter(
121             RepositorySystemSession session,
122             RemoteRepository repository,
123             ChecksumExtractor checksumExtractor,
124             PathProcessor pathProcessor)
125             throws NoTransporterException {
126         this.session = session;
127         this.repository = repository;
128         this.checksumExtractor = checksumExtractor;
129         this.pathProcessor = pathProcessor;
130         try {
131             URI uri = new URI(repository.getUrl()).parseServerAuthority();
132             if (uri.isOpaque()) {
133                 throw new URISyntaxException(repository.getUrl(), "URL must not be opaque");
134             }
135             if (uri.getRawFragment() != null || uri.getRawQuery() != null) {
136                 throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query");
137             }
138             String path = uri.getPath();
139             if (path == null) {
140                 path = "/";
141             }
142             if (!path.startsWith("/")) {
143                 path = "/" + path;
144             }
145             if (!path.endsWith("/")) {
146                 path = path + "/";
147             }
148             this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path);
149         } catch (URISyntaxException e) {
150             throw new NoTransporterException(repository, e.getMessage(), e);
151         }
152 
153         HashMap<String, String> headers = new HashMap<>();
154         String userAgent = ConfigUtils.getString(
155                 session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT);
156         if (userAgent != null) {
157             headers.put(USER_AGENT, userAgent);
158         }
159         @SuppressWarnings("unchecked")
160         Map<Object, Object> configuredHeaders = (Map<Object, Object>) ConfigUtils.getMap(
161                 session,
162                 Collections.emptyMap(),
163                 ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(),
164                 ConfigurationProperties.HTTP_HEADERS);
165         if (configuredHeaders != null) {
166             configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null));
167         }
168 
169         this.headers = headers;
170 
171         this.requestTimeout = ConfigUtils.getInteger(
172                 session,
173                 ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT,
174                 ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(),
175                 ConfigurationProperties.REQUEST_TIMEOUT);
176         this.preemptiveAuth = ConfigUtils.getBoolean(
177                 session,
178                 ConfigurationProperties.DEFAULT_HTTP_PREEMPTIVE_AUTH,
179                 ConfigurationProperties.HTTP_PREEMPTIVE_AUTH + "." + repository.getId(),
180                 ConfigurationProperties.HTTP_PREEMPTIVE_AUTH);
181         this.preemptivePutAuth = ConfigUtils.getBoolean(
182                 session,
183                 ConfigurationProperties.DEFAULT_HTTP_PREEMPTIVE_PUT_AUTH,
184                 ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH + "." + repository.getId(),
185                 ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH);
186         final String httpsSecurityMode = ConfigUtils.getString(
187                 session,
188                 ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT,
189                 ConfigurationProperties.HTTPS_SECURITY_MODE + "." + repository.getId(),
190                 ConfigurationProperties.HTTPS_SECURITY_MODE);
191 
192         if (!ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT.equals(httpsSecurityMode)
193                 && !ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode)) {
194             throw new IllegalArgumentException("Unsupported '" + httpsSecurityMode + "' HTTPS security mode.");
195         }
196         this.insecure = ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode);
197 
198         this.basicServerAuthenticationResult = new AtomicReference<>(null);
199         this.basicProxyAuthenticationResult = new AtomicReference<>(null);
200         try {
201             this.client = createClient();
202         } catch (Exception e) {
203             throw new NoTransporterException(repository, e);
204         }
205     }
206 
207     private void mayApplyPreemptiveAuth(Request request) {
208         if (basicServerAuthenticationResult.get() != null) {
209             basicServerAuthenticationResult.get().apply(request);
210         }
211         if (basicProxyAuthenticationResult.get() != null) {
212             basicProxyAuthenticationResult.get().apply(request);
213         }
214     }
215 
216     private URI resolve(TransportTask task) {
217         return baseUri.resolve(task.getLocation());
218     }
219 
220     @Override
221     public int classify(Throwable error) {
222         if (error instanceof HttpTransporterException
223                 && ((HttpTransporterException) error).getStatusCode() == NOT_FOUND) {
224             return ERROR_NOT_FOUND;
225         }
226         return ERROR_OTHER;
227     }
228 
229     @Override
230     protected void implPeek(PeekTask task) throws Exception {
231         Request request = client.newRequest(resolve(task))
232                 .timeout(requestTimeout, TimeUnit.MILLISECONDS)
233                 .method("HEAD");
234         request.headers(m -> headers.forEach(m::add));
235         if (preemptiveAuth) {
236             mayApplyPreemptiveAuth(request);
237         }
238         Response response = request.send();
239         if (response.getStatus() >= MULTIPLE_CHOICES) {
240             throw new HttpTransporterException(response.getStatus());
241         }
242     }
243 
244     @Override
245     protected void implGet(GetTask task) throws Exception {
246         boolean resume = task.getResumeOffset() > 0L && task.getDataPath() != null;
247         Response response;
248         InputStreamResponseListener listener;
249 
250         while (true) {
251             Request request = client.newRequest(resolve(task))
252                     .timeout(requestTimeout, TimeUnit.MILLISECONDS)
253                     .method("GET");
254             request.headers(m -> headers.forEach(m::add));
255             if (preemptiveAuth) {
256                 mayApplyPreemptiveAuth(request);
257             }
258 
259             if (resume) {
260                 long resumeOffset = task.getResumeOffset();
261                 long lastModified =
262                         Files.getLastModifiedTime(task.getDataPath()).toMillis();
263                 request.headers(h -> {
264                     h.add(RANGE, "bytes=" + resumeOffset + '-');
265                     h.addDateField(IF_UNMODIFIED_SINCE, lastModified - MODIFICATION_THRESHOLD);
266                     h.remove(HttpHeader.ACCEPT_ENCODING);
267                     h.add(ACCEPT_ENCODING, "identity");
268                 });
269             }
270 
271             listener = new InputStreamResponseListener();
272             request.send(listener);
273             try {
274                 response = listener.get(requestTimeout, TimeUnit.MILLISECONDS);
275             } catch (ExecutionException e) {
276                 Throwable t = e.getCause();
277                 if (t instanceof Exception) {
278                     throw (Exception) t;
279                 } else {
280                     throw new RuntimeException(t);
281                 }
282             }
283             if (response.getStatus() >= MULTIPLE_CHOICES) {
284                 if (resume && response.getStatus() == PRECONDITION_FAILED) {
285                     resume = false;
286                     continue;
287                 }
288                 JettyRFC9457Reporter.INSTANCE.generateException(listener, (statusCode, reasonPhrase) -> {
289                     throw new HttpTransporterException(statusCode);
290                 });
291             }
292             break;
293         }
294 
295         long offset = 0L, length = response.getHeaders().getLongField(CONTENT_LENGTH);
296         if (resume) {
297             String range = response.getHeaders().get(CONTENT_RANGE);
298             if (range != null) {
299                 Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
300                 if (!m.matches()) {
301                     throw new IOException("Invalid Content-Range header for partial download: " + range);
302                 }
303                 offset = Long.parseLong(m.group(1));
304                 length = Long.parseLong(m.group(2)) + 1L;
305                 if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
306                     throw new IOException("Invalid Content-Range header for partial download from offset "
307                             + task.getResumeOffset() + ": " + range);
308                 }
309             }
310         }
311 
312         final boolean downloadResumed = offset > 0L;
313         final Path dataFile = task.getDataPath();
314         if (dataFile == null) {
315             try (InputStream is = listener.getInputStream()) {
316                 utilGet(task, is, true, length, downloadResumed);
317             }
318         } else {
319             try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile)) {
320                 task.setDataPath(tempFile.getPath(), downloadResumed);
321                 if (downloadResumed && Files.isRegularFile(dataFile)) {
322                     try (InputStream inputStream = Files.newInputStream(dataFile)) {
323                         Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
324                     }
325                 }
326                 try (InputStream is = listener.getInputStream()) {
327                     utilGet(task, is, true, length, downloadResumed);
328                 }
329                 tempFile.move();
330             } finally {
331                 task.setDataPath(dataFile);
332             }
333         }
334         if (task.getDataPath() != null && response.getHeaders().getDateField(LAST_MODIFIED) != -1) {
335             long lastModified =
336                     response.getHeaders().getDateField(LAST_MODIFIED); // note: Wagon also does first not last
337             if (lastModified != -1) {
338                 pathProcessor.setLastModified(task.getDataPath(), lastModified);
339             }
340         }
341         Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
342         if (checksums != null && !checksums.isEmpty()) {
343             checksums.forEach(task::setChecksum);
344         }
345     }
346 
347     private static Function<String, String> headerGetter(Response response) {
348         return s -> response.getHeaders().get(s);
349     }
350 
351     @Override
352     protected void implPut(PutTask task) throws Exception {
353         Request request = client.newRequest(resolve(task)).method("PUT").timeout(requestTimeout, TimeUnit.MILLISECONDS);
354         request.headers(m -> headers.forEach(m::add));
355         if (preemptiveAuth || preemptivePutAuth) {
356             mayApplyPreemptiveAuth(request);
357         }
358         request.body(new PutTaskRequestContent(task));
359         AtomicBoolean started = new AtomicBoolean(false);
360         Response response;
361         try {
362             response = request.onRequestCommit(r -> {
363                         if (task.getDataLength() == 0) {
364                             if (started.compareAndSet(false, true)) {
365                                 try {
366                                     task.getListener().transportStarted(0, task.getDataLength());
367                                 } catch (TransferCancelledException e) {
368                                     r.abort(e);
369                                 }
370                             }
371                         }
372                     })
373                     .onRequestContent((r, b) -> {
374                         if (started.compareAndSet(false, true)) {
375                             try {
376                                 task.getListener().transportStarted(0, task.getDataLength());
377                             } catch (TransferCancelledException e) {
378                                 r.abort(e);
379                                 return;
380                             }
381                         }
382                         try {
383                             task.getListener().transportProgressed(b);
384                         } catch (TransferCancelledException e) {
385                             r.abort(e);
386                         }
387                     })
388                     .send();
389         } catch (ExecutionException e) {
390             Throwable t = e.getCause();
391             if (t instanceof IOException) {
392                 IOException ioex = (IOException) t;
393                 if (ioex.getCause() instanceof TransferCancelledException) {
394                     throw (TransferCancelledException) ioex.getCause();
395                 } else {
396                     throw ioex;
397                 }
398             } else if (t instanceof Exception) {
399                 throw (Exception) t;
400             } else {
401                 throw new RuntimeException(t);
402             }
403         }
404         if (response.getStatus() >= MULTIPLE_CHOICES) {
405             throw new HttpTransporterException(response.getStatus());
406         }
407     }
408 
409     @Override
410     protected void implClose() {
411         try {
412             this.client.stop();
413         } catch (Exception e) {
414             throw new RuntimeException(e);
415         }
416     }
417 
418     @SuppressWarnings("checkstyle:methodlength")
419     private HttpClient createClient() throws Exception {
420         BasicAuthentication.BasicResult serverAuth = null;
421         BasicAuthentication.BasicResult proxyAuth = null;
422         SSLContext sslContext = null;
423         BasicAuthentication basicAuthentication = null;
424         try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
425             if (repoAuthContext != null) {
426                 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
427 
428                 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
429                 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
430 
431                 URI uri = URI.create(repository.getUrl());
432                 basicAuthentication = new BasicAuthentication(uri, Authentication.ANY_REALM, username, password);
433                 if (preemptiveAuth || preemptivePutAuth) {
434                     serverAuth = new BasicAuthentication.BasicResult(uri, HttpHeader.AUTHORIZATION, username, password);
435                 }
436             }
437         }
438 
439         if (sslContext == null) {
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         }
459 
460         int connectTimeout = ConfigUtils.getInteger(
461                 session,
462                 ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT,
463                 ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(),
464                 ConfigurationProperties.CONNECT_TIMEOUT);
465 
466         SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
467         sslContextFactory.setSslContext(sslContext);
468         if (insecure) {
469             sslContextFactory.setEndpointIdentificationAlgorithm(null);
470             sslContextFactory.setHostnameVerifier((name, context) -> true);
471         }
472 
473         ClientConnector clientConnector = new ClientConnector();
474         clientConnector.setSslContextFactory(sslContextFactory);
475 
476         HTTP2Client http2Client = new HTTP2Client(clientConnector);
477         ClientConnectionFactoryOverHTTP2.HTTP2 http2 = 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, HttpClientConnectionFactory.HTTP11, http2); // plaintext HTTP, H2 cannot be used
486         }
487 
488         HttpClient httpClient = new HttpClient(transport);
489         httpClient.setConnectTimeout(connectTimeout);
490         httpClient.setFollowRedirects(ConfigUtils.getBoolean(
491                 session,
492                 JettyTransporterConfigurationKeys.DEFAULT_FOLLOW_REDIRECTS,
493                 JettyTransporterConfigurationKeys.CONFIG_PROP_FOLLOW_REDIRECTS));
494         httpClient.setMaxRedirects(ConfigUtils.getInteger(
495                 session,
496                 JettyTransporterConfigurationKeys.DEFAULT_MAX_REDIRECTS,
497                 JettyTransporterConfigurationKeys.CONFIG_PROP_MAX_REDIRECTS));
498 
499         httpClient.setUserAgentField(null); // we manage it
500 
501         if (basicAuthentication != null) {
502             httpClient.getAuthenticationStore().addAuthentication(basicAuthentication);
503         }
504 
505         if (repository.getProxy() != null) {
506             HttpProxy proxy = new HttpProxy(
507                     repository.getProxy().getHost(), repository.getProxy().getPort());
508 
509             httpClient.getProxyConfiguration().addProxy(proxy);
510             try (AuthenticationContext proxyAuthContext = 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 =
516                             new BasicAuthentication(proxy.getURI(), Authentication.ANY_REALM, username, password);
517 
518                     httpClient.getAuthenticationStore().addAuthentication(proxyAuthentication);
519                     if (preemptiveAuth || preemptivePutAuth) {
520                         proxyAuth = new BasicAuthentication.BasicResult(
521                                 proxy.getURI(), HttpHeader.PROXY_AUTHORIZATION, username, password);
522                     }
523                 }
524             }
525         }
526         if (serverAuth != null) {
527             this.basicServerAuthenticationResult.set(serverAuth);
528         }
529         if (proxyAuth != null) {
530             this.basicProxyAuthenticationResult.set(proxyAuth);
531         }
532 
533         httpClient.start();
534         return httpClient;
535     }
536 }