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         try {
208             this.client = createClient();
209         } catch (Exception e) {
210             throw new NoTransporterException(repository, e);
211         }
212     }
213 
214     private void mayApplyPreemptiveAuth(Request request) {
215         if (basicServerAuthenticationResult.get() != null) {
216             basicServerAuthenticationResult.get().apply(request);
217         }
218         if (basicProxyAuthenticationResult.get() != null) {
219             basicProxyAuthenticationResult.get().apply(request);
220         }
221     }
222 
223     private URI resolve(TransportTask task) {
224         return baseUri.resolve(task.getLocation());
225     }
226 
227     @Override
228     public int classify(Throwable error) {
229         if (error instanceof HttpTransporterException
230                 && ((HttpTransporterException) error).getStatusCode() == NOT_FOUND) {
231             return ERROR_NOT_FOUND;
232         }
233         return ERROR_OTHER;
234     }
235 
236     @Override
237     protected void implPeek(PeekTask task) throws Exception {
238         Request request = client.newRequest(resolve(task)).method("HEAD");
239         request.headers(m -> headers.forEach(m::add));
240         if (preemptiveAuth) {
241             mayApplyPreemptiveAuth(request);
242         }
243         Response response = request.send();
244         if (response.getStatus() >= MULTIPLE_CHOICES) {
245             throw new HttpTransporterException(response.getStatus());
246         }
247     }
248 
249     @Override
250     protected void implGet(GetTask task) throws Exception {
251         boolean resume = task.getResumeOffset() > 0L && task.getDataPath() != null;
252         Response response;
253         InputStreamResponseListener listener;
254 
255         while (true) {
256             Request request = client.newRequest(resolve(task)).method("GET");
257             request.headers(m -> headers.forEach(m::add));
258             if (preemptiveAuth) {
259                 mayApplyPreemptiveAuth(request);
260             }
261 
262             if (resume) {
263                 long resumeOffset = task.getResumeOffset();
264                 long lastModified =
265                         Files.getLastModifiedTime(task.getDataPath()).toMillis();
266                 request.headers(h -> {
267                     h.add(RANGE, "bytes=" + resumeOffset + '-');
268                     h.addDateField(IF_UNMODIFIED_SINCE, lastModified - MODIFICATION_THRESHOLD);
269                     h.remove(HttpHeader.ACCEPT_ENCODING);
270                     h.add(ACCEPT_ENCODING, "identity");
271                 });
272             }
273 
274             listener = new InputStreamResponseListener();
275             request.send(listener);
276             try {
277                 response = listener.get(requestTimeout, TimeUnit.MILLISECONDS);
278             } catch (ExecutionException e) {
279                 Throwable t = e.getCause();
280                 if (t instanceof Exception) {
281                     throw (Exception) t;
282                 } else {
283                     throw new RuntimeException(t);
284                 }
285             }
286             if (response.getStatus() >= MULTIPLE_CHOICES) {
287                 if (resume && response.getStatus() == PRECONDITION_FAILED) {
288                     resume = false;
289                     continue;
290                 }
291                 JettyRFC9457Reporter.INSTANCE.generateException(listener, (statusCode, reasonPhrase) -> {
292                     throw new HttpTransporterException(statusCode);
293                 });
294             }
295             break;
296         }
297 
298         long offset = 0L, length = response.getHeaders().getLongField(CONTENT_LENGTH);
299         if (resume) {
300             String range = response.getHeaders().get(CONTENT_RANGE);
301             if (range != null) {
302                 Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
303                 if (!m.matches()) {
304                     throw new IOException("Invalid Content-Range header for partial download: " + range);
305                 }
306                 offset = Long.parseLong(m.group(1));
307                 length = Long.parseLong(m.group(2)) + 1L;
308                 if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
309                     throw new IOException("Invalid Content-Range header for partial download from offset "
310                             + task.getResumeOffset() + ": " + range);
311                 }
312             }
313         }
314 
315         final boolean downloadResumed = offset > 0L;
316         final Path dataFile = task.getDataPath();
317         if (dataFile == null) {
318             try (InputStream is = listener.getInputStream()) {
319                 utilGet(task, is, true, length, downloadResumed);
320             }
321         } else {
322             try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile)) {
323                 task.setDataPath(tempFile.getPath(), downloadResumed);
324                 if (downloadResumed && Files.isRegularFile(dataFile)) {
325                     try (InputStream inputStream = Files.newInputStream(dataFile)) {
326                         Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
327                     }
328                 }
329                 try (InputStream is = listener.getInputStream()) {
330                     utilGet(task, is, true, length, downloadResumed);
331                 }
332                 tempFile.move();
333             } finally {
334                 task.setDataPath(dataFile);
335             }
336         }
337         if (task.getDataPath() != null && response.getHeaders().getDateField(LAST_MODIFIED) != -1) {
338             long lastModified =
339                     response.getHeaders().getDateField(LAST_MODIFIED); // note: Wagon also does first not last
340             if (lastModified != -1) {
341                 pathProcessor.setLastModified(task.getDataPath(), lastModified);
342             }
343         }
344         Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
345         if (checksums != null && !checksums.isEmpty()) {
346             checksums.forEach(task::setChecksum);
347         }
348     }
349 
350     private static Function<String, String> headerGetter(Response response) {
351         return s -> response.getHeaders().get(s);
352     }
353 
354     @Override
355     protected void implPut(PutTask task) throws Exception {
356         Request request = client.newRequest(resolve(task)).method("PUT");
357         request.headers(m -> headers.forEach(m::add));
358         if (preemptiveAuth || preemptivePutAuth) {
359             mayApplyPreemptiveAuth(request);
360         }
361         request.body(new PutTaskRequestContent(task));
362         AtomicBoolean started = new AtomicBoolean(false);
363         Response response;
364         try {
365             response = request.onRequestCommit(r -> {
366                         if (task.getDataLength() == 0) {
367                             if (started.compareAndSet(false, true)) {
368                                 try {
369                                     task.getListener().transportStarted(0, task.getDataLength());
370                                 } catch (TransferCancelledException e) {
371                                     r.abort(e);
372                                 }
373                             }
374                         }
375                     })
376                     .onRequestContent((r, b) -> {
377                         if (started.compareAndSet(false, true)) {
378                             try {
379                                 task.getListener().transportStarted(0, task.getDataLength());
380                             } catch (TransferCancelledException e) {
381                                 r.abort(e);
382                                 return;
383                             }
384                         }
385                         try {
386                             task.getListener().transportProgressed(b);
387                         } catch (TransferCancelledException e) {
388                             r.abort(e);
389                         }
390                     })
391                     .send();
392         } catch (ExecutionException e) {
393             Throwable t = e.getCause();
394             if (t instanceof IOException) {
395                 IOException ioex = (IOException) t;
396                 if (ioex.getCause() instanceof TransferCancelledException) {
397                     throw (TransferCancelledException) ioex.getCause();
398                 } else {
399                     throw ioex;
400                 }
401             } else if (t instanceof Exception) {
402                 throw (Exception) t;
403             } else {
404                 throw new RuntimeException(t);
405             }
406         }
407         if (response.getStatus() >= MULTIPLE_CHOICES) {
408             throw new HttpTransporterException(response.getStatus());
409         }
410     }
411 
412     @Override
413     protected void implClose() {
414         try {
415             this.client.stop();
416         } catch (Exception e) {
417             throw new RuntimeException(e);
418         }
419     }
420 
421     @SuppressWarnings("checkstyle:methodlength")
422     private HttpClient createClient() throws Exception {
423         BasicAuthentication.BasicResult serverAuth = null;
424         BasicAuthentication.BasicResult proxyAuth = null;
425         SSLContext sslContext = null;
426         BasicAuthentication basicAuthentication = null;
427         try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
428             if (repoAuthContext != null) {
429                 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
430 
431                 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
432                 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
433 
434                 URI uri = URI.create(repository.getUrl());
435                 basicAuthentication = new BasicAuthentication(uri, Authentication.ANY_REALM, username, password);
436                 if (preemptiveAuth || preemptivePutAuth) {
437                     serverAuth = new BasicAuthentication.BasicResult(uri, HttpHeader.AUTHORIZATION, username, password);
438                 }
439             }
440         }
441 
442         if (sslContext == null) {
443             if (insecure) {
444                 sslContext = SSLContext.getInstance("TLS");
445                 X509TrustManager tm = new X509TrustManager() {
446                     @Override
447                     public void checkClientTrusted(X509Certificate[] chain, String authType) {}
448 
449                     @Override
450                     public void checkServerTrusted(X509Certificate[] chain, String authType) {}
451 
452                     @Override
453                     public X509Certificate[] getAcceptedIssuers() {
454                         return new X509Certificate[0];
455                     }
456                 };
457                 sslContext.init(null, new X509TrustManager[] {tm}, null);
458             } else {
459                 sslContext = SSLContext.getDefault();
460             }
461         }
462 
463         SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
464         sslContextFactory.setSslContext(sslContext);
465         if (insecure) {
466             sslContextFactory.setEndpointIdentificationAlgorithm(null);
467             sslContextFactory.setHostnameVerifier((name, context) -> true);
468         }
469 
470         ClientConnector clientConnector = new ClientConnector();
471         clientConnector.setSslContextFactory(sslContextFactory);
472 
473         HTTP2Client http2Client = new HTTP2Client(clientConnector);
474         ClientConnectionFactoryOverHTTP2.HTTP2 http2 = new ClientConnectionFactoryOverHTTP2.HTTP2(http2Client);
475 
476         HttpClientTransportDynamic transport;
477         if ("https".equalsIgnoreCase(repository.getProtocol())) {
478             transport = new HttpClientTransportDynamic(
479                     clientConnector, http2, HttpClientConnectionFactory.HTTP11); // HTTPS, prefer H2
480         } else {
481             transport = new HttpClientTransportDynamic(
482                     clientConnector, HttpClientConnectionFactory.HTTP11, http2); // plaintext HTTP, H2 cannot be used
483         }
484 
485         HttpClient httpClient = new HttpClient(transport);
486         httpClient.setConnectTimeout(connectTimeout);
487         httpClient.setIdleTimeout(requestTimeout);
488         httpClient.setFollowRedirects(ConfigUtils.getBoolean(
489                 session,
490                 JettyTransporterConfigurationKeys.DEFAULT_FOLLOW_REDIRECTS,
491                 JettyTransporterConfigurationKeys.CONFIG_PROP_FOLLOW_REDIRECTS));
492         httpClient.setMaxRedirects(ConfigUtils.getInteger(
493                 session,
494                 JettyTransporterConfigurationKeys.DEFAULT_MAX_REDIRECTS,
495                 JettyTransporterConfigurationKeys.CONFIG_PROP_MAX_REDIRECTS));
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(), repository.getProxy().getPort());
506 
507             httpClient.getProxyConfiguration().addProxy(proxy);
508             try (AuthenticationContext proxyAuthContext = 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 =
514                             new BasicAuthentication(proxy.getURI(), Authentication.ANY_REALM, username, password);
515 
516                     httpClient.getAuthenticationStore().addAuthentication(proxyAuthentication);
517                     if (preemptiveAuth || preemptivePutAuth) {
518                         proxyAuth = new BasicAuthentication.BasicResult(
519                                 proxy.getURI(), HttpHeader.PROXY_AUTHORIZATION, username, password);
520                     }
521                 }
522             }
523         }
524         if (serverAuth != null) {
525             this.basicServerAuthenticationResult.set(serverAuth);
526         }
527         if (proxyAuth != null) {
528             this.basicProxyAuthenticationResult.set(proxyAuth);
529         }
530 
531         httpClient.start();
532         return httpClient;
533     }
534 }