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