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.HashMap;
33  import java.util.Map;
34  import java.util.concurrent.ExecutionException;
35  import java.util.concurrent.TimeUnit;
36  import java.util.concurrent.atomic.AtomicBoolean;
37  import java.util.concurrent.atomic.AtomicReference;
38  import java.util.function.Function;
39  import java.util.regex.Matcher;
40  
41  import org.eclipse.aether.ConfigurationProperties;
42  import org.eclipse.aether.RepositorySystemSession;
43  import org.eclipse.aether.repository.AuthenticationContext;
44  import org.eclipse.aether.repository.RemoteRepository;
45  import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
46  import org.eclipse.aether.spi.connector.transport.GetTask;
47  import org.eclipse.aether.spi.connector.transport.PeekTask;
48  import org.eclipse.aether.spi.connector.transport.PutTask;
49  import org.eclipse.aether.spi.connector.transport.TransportTask;
50  import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
51  import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
52  import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
53  import org.eclipse.aether.spi.io.PathProcessor;
54  import org.eclipse.aether.transfer.NoTransporterException;
55  import org.eclipse.aether.transfer.TransferCancelledException;
56  import org.eclipse.aether.util.ConfigUtils;
57  import org.eclipse.aether.util.connector.transport.http.HttpTransporterUtils;
58  import org.eclipse.jetty.client.Authentication;
59  import org.eclipse.jetty.client.BasicAuthentication;
60  import org.eclipse.jetty.client.HttpClient;
61  import org.eclipse.jetty.client.HttpProxy;
62  import org.eclipse.jetty.client.InputStreamResponseListener;
63  import org.eclipse.jetty.client.Request;
64  import org.eclipse.jetty.client.Response;
65  import org.eclipse.jetty.client.transport.HttpClientConnectionFactory;
66  import org.eclipse.jetty.client.transport.HttpClientTransportDynamic;
67  import org.eclipse.jetty.http.HttpHeader;
68  import org.eclipse.jetty.http2.client.HTTP2Client;
69  import org.eclipse.jetty.http2.client.transport.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.PRECONDITION_FAILED;
81  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.RANGE;
82  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.USER_AGENT;
83  
84  /**
85   * A transporter for HTTP/HTTPS.
86   *
87   * @since 2.0.0
88   */
89  final class JettyTransporter extends AbstractTransporter implements HttpTransporter {
90      private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
91  
92      private final RepositorySystemSession session;
93  
94      private final RemoteRepository repository;
95  
96      private final ChecksumExtractor checksumExtractor;
97  
98      private final PathProcessor pathProcessor;
99  
100     private final URI baseUri;
101 
102     private final HttpClient client;
103 
104     private final int connectTimeout;
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 = HttpTransporterUtils.getUserAgent(session, repository);
155         if (userAgent != null) {
156             headers.put(USER_AGENT, userAgent);
157         }
158         Map<String, String> configuredHeaders = HttpTransporterUtils.getHttpHeaders(session, repository);
159         if (configuredHeaders != null) {
160             headers.putAll(configuredHeaders);
161         }
162 
163         this.headers = headers;
164 
165         this.connectTimeout = HttpTransporterUtils.getHttpRequestTimeout(session, repository);
166         this.requestTimeout = HttpTransporterUtils.getHttpRequestTimeout(session, repository);
167         this.preemptiveAuth = HttpTransporterUtils.isHttpPreemptiveAuth(session, repository);
168         this.preemptivePutAuth = HttpTransporterUtils.isHttpPreemptivePutAuth(session, repository);
169         final String httpsSecurityMode = HttpTransporterUtils.getHttpsSecurityMode(session, repository);
170         this.insecure = ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode);
171 
172         this.basicServerAuthenticationResult = new AtomicReference<>(null);
173         this.basicProxyAuthenticationResult = new AtomicReference<>(null);
174         this.client = createClient();
175     }
176 
177     private void mayApplyPreemptiveAuth(Request request) {
178         if (basicServerAuthenticationResult.get() != null) {
179             basicServerAuthenticationResult.get().apply(request);
180         }
181         if (basicProxyAuthenticationResult.get() != null) {
182             basicProxyAuthenticationResult.get().apply(request);
183         }
184     }
185 
186     private URI resolve(TransportTask task) {
187         return baseUri.resolve(task.getLocation());
188     }
189 
190     @Override
191     protected void implPeek(PeekTask task) throws Exception {
192         Request request = client.newRequest(resolve(task)).method("HEAD");
193         request.headers(m -> headers.forEach(m::add));
194         if (preemptiveAuth) {
195             mayApplyPreemptiveAuth(request);
196         }
197         Response response = request.send();
198         if (response.getStatus() >= MULTIPLE_CHOICES) {
199             throw new HttpTransporterException(response.getStatus());
200         }
201     }
202 
203     @Override
204     protected void implGet(GetTask task) throws Exception {
205         boolean resume = task.getResumeOffset() > 0L && task.getDataPath() != null;
206         Response response;
207         InputStreamResponseListener listener;
208 
209         while (true) {
210             Request request = client.newRequest(resolve(task)).method("GET");
211             request.headers(m -> headers.forEach(m::add));
212             if (preemptiveAuth) {
213                 mayApplyPreemptiveAuth(request);
214             }
215             JettyRFC9457Reporter.INSTANCE.prepareRequest(request);
216             if (resume) {
217                 long resumeOffset = task.getResumeOffset();
218                 long lastModified =
219                         Files.getLastModifiedTime(task.getDataPath()).toMillis();
220                 request.headers(h -> {
221                     h.add(RANGE, "bytes=" + resumeOffset + '-');
222                     h.addDateField(IF_UNMODIFIED_SINCE, lastModified - MODIFICATION_THRESHOLD);
223                     h.remove(HttpHeader.ACCEPT_ENCODING);
224                     h.add(ACCEPT_ENCODING, "identity");
225                 });
226             }
227 
228             listener = new InputStreamResponseListener();
229             request.send(listener);
230             try {
231                 response = listener.get(requestTimeout, TimeUnit.MILLISECONDS);
232             } catch (ExecutionException e) {
233                 Throwable t = e.getCause();
234                 if (t instanceof Exception) {
235                     throw (Exception) t;
236                 } else {
237                     throw new RuntimeException(t);
238                 }
239             }
240             if (response.getStatus() >= MULTIPLE_CHOICES) {
241                 if (resume && response.getStatus() == PRECONDITION_FAILED) {
242                     resume = false;
243                     continue;
244                 }
245                 JettyRFC9457Reporter.INSTANCE.generateException(listener, (statusCode, reasonPhrase) -> {
246                     throw new HttpTransporterException(statusCode);
247                 });
248             }
249             break;
250         }
251 
252         long offset = 0L, length = response.getHeaders().getLongField(CONTENT_LENGTH);
253         if (resume) {
254             String range = response.getHeaders().get(CONTENT_RANGE);
255             if (range != null) {
256                 Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
257                 if (!m.matches()) {
258                     throw new IOException("Invalid Content-Range header for partial download: " + range);
259                 }
260                 offset = Long.parseLong(m.group(1));
261                 length = Long.parseLong(m.group(2)) + 1L;
262                 if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
263                     throw new IOException("Invalid Content-Range header for partial download from offset "
264                             + task.getResumeOffset() + ": " + range);
265                 }
266             }
267         }
268 
269         final boolean downloadResumed = offset > 0L;
270         final Path dataFile = task.getDataPath();
271         if (dataFile == null) {
272             try (InputStream is = listener.getInputStream()) {
273                 utilGet(task, is, true, length, downloadResumed);
274             }
275         } else {
276             try (PathProcessor.CollocatedTempFile tempFile = pathProcessor.newTempFile(dataFile)) {
277                 task.setDataPath(tempFile.getPath(), downloadResumed);
278                 if (downloadResumed && Files.isRegularFile(dataFile)) {
279                     try (InputStream inputStream = Files.newInputStream(dataFile)) {
280                         Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
281                     }
282                 }
283                 try (InputStream is = listener.getInputStream()) {
284                     utilGet(task, is, true, length, downloadResumed);
285                 }
286                 tempFile.move();
287             } finally {
288                 task.setDataPath(dataFile);
289             }
290         }
291         if (task.getDataPath() != null && response.getHeaders().getDateField(LAST_MODIFIED) != -1) {
292             long lastModified =
293                     response.getHeaders().getDateField(LAST_MODIFIED); // note: Wagon also does first not last
294             if (lastModified != -1) {
295                 pathProcessor.setLastModified(task.getDataPath(), lastModified);
296             }
297         }
298         Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
299         if (checksums != null && !checksums.isEmpty()) {
300             checksums.forEach(task::setChecksum);
301         }
302     }
303 
304     private static Function<String, String> headerGetter(Response response) {
305         return s -> response.getHeaders().get(s);
306     }
307 
308     @Override
309     protected void implPut(PutTask task) throws Exception {
310         Request request = client.newRequest(resolve(task)).method("PUT");
311         request.headers(m -> headers.forEach(m::add));
312         JettyRFC9457Reporter.INSTANCE.prepareRequest(request);
313         if (preemptiveAuth || preemptivePutAuth) {
314             mayApplyPreemptiveAuth(request);
315         }
316         request.body(PutTaskRequestContent.from(task));
317         AtomicBoolean started = new AtomicBoolean(false);
318         Response response;
319         InputStreamResponseListener listener = new InputStreamResponseListener();
320         try {
321             request.onRequestCommit(r -> {
322                         if (task.getDataLength() == 0) {
323                             if (started.compareAndSet(false, true)) {
324                                 try {
325                                     task.getListener().transportStarted(0, task.getDataLength());
326                                 } catch (TransferCancelledException e) {
327                                     r.abort(e);
328                                 }
329                             }
330                         }
331                     })
332                     .onRequestContent((r, b) -> {
333                         if (started.compareAndSet(false, true)) {
334                             try {
335                                 task.getListener().transportStarted(0, task.getDataLength());
336                             } catch (TransferCancelledException e) {
337                                 r.abort(e);
338                                 return;
339                             }
340                         }
341                         try {
342                             task.getListener().transportProgressed(b);
343                         } catch (TransferCancelledException e) {
344                             r.abort(e);
345                         }
346                     })
347                     .send(listener);
348             response = listener.get(requestTimeout, TimeUnit.MILLISECONDS);
349         } catch (ExecutionException e) {
350             Throwable t = e.getCause();
351             if (t instanceof IOException ioex) {
352                 if (ioex.getCause() instanceof TransferCancelledException) {
353                     throw (TransferCancelledException) ioex.getCause();
354                 } else {
355                     throw ioex;
356                 }
357             } else if (t instanceof Exception) {
358                 throw (Exception) t;
359             } else {
360                 throw new RuntimeException(t);
361             }
362         }
363 
364         if (response.getStatus() >= MULTIPLE_CHOICES) {
365             JettyRFC9457Reporter.INSTANCE.generateException(listener, (statusCode, reasonPhrase) -> {
366                 throw new HttpTransporterException(statusCode);
367             });
368             throw new HttpTransporterException(response.getStatus());
369         }
370     }
371 
372     @Override
373     protected void implClose() {
374         try {
375             this.client.stop();
376         } catch (Exception e) {
377             throw new RuntimeException(e);
378         }
379     }
380 
381     @SuppressWarnings("checkstyle:methodlength")
382     private HttpClient createClient() throws RuntimeException {
383         BasicAuthentication.BasicResult serverAuth = null;
384         BasicAuthentication.BasicResult proxyAuth = null;
385         SSLContext sslContext = null;
386         BasicAuthentication basicAuthentication = null;
387         try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
388             if (repoAuthContext != null) {
389                 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
390 
391                 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
392                 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
393 
394                 URI uri = URI.create(repository.getUrl());
395                 basicAuthentication = new BasicAuthentication(uri, Authentication.ANY_REALM, username, password);
396                 if (preemptiveAuth || preemptivePutAuth) {
397                     serverAuth = new BasicAuthentication.BasicResult(uri, HttpHeader.AUTHORIZATION, username, password);
398                 }
399             }
400         }
401 
402         if (sslContext == null) {
403             try {
404                 if (insecure) {
405                     sslContext = SSLContext.getInstance("TLS");
406                     X509TrustManager tm = new X509TrustManager() {
407                         @Override
408                         public void checkClientTrusted(X509Certificate[] chain, String authType) {}
409 
410                         @Override
411                         public void checkServerTrusted(X509Certificate[] chain, String authType) {}
412 
413                         @Override
414                         public X509Certificate[] getAcceptedIssuers() {
415                             return new X509Certificate[0];
416                         }
417                     };
418                     sslContext.init(null, new X509TrustManager[] {tm}, null);
419                 } else {
420                     sslContext = SSLContext.getDefault();
421                 }
422             } catch (Exception e) {
423                 if (e instanceof RuntimeException) {
424                     throw (RuntimeException) e;
425                 } else {
426                     throw new IllegalStateException("SSL Context setup failure", e);
427                 }
428             }
429         }
430 
431         SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
432         sslContextFactory.setSslContext(sslContext);
433         if (insecure) {
434             sslContextFactory.setEndpointIdentificationAlgorithm(null);
435             sslContextFactory.setHostnameVerifier((name, context) -> true);
436         }
437 
438         ClientConnector clientConnector = new ClientConnector();
439         clientConnector.setSslContextFactory(sslContextFactory);
440 
441         HTTP2Client http2Client = new HTTP2Client(clientConnector);
442         ClientConnectionFactoryOverHTTP2.HTTP2 http2 = new ClientConnectionFactoryOverHTTP2.HTTP2(http2Client);
443 
444         HttpClientTransportDynamic transport;
445         if ("https".equalsIgnoreCase(repository.getProtocol())) {
446             transport = new HttpClientTransportDynamic(
447                     clientConnector, http2, HttpClientConnectionFactory.HTTP11); // HTTPS, prefer H2
448         } else {
449             transport = new HttpClientTransportDynamic(
450                     clientConnector, HttpClientConnectionFactory.HTTP11, http2); // plaintext HTTP, H2 cannot be used
451         }
452 
453         HttpClient httpClient = new HttpClient(transport);
454         httpClient.setConnectTimeout(connectTimeout);
455         httpClient.setIdleTimeout(requestTimeout);
456         httpClient.setFollowRedirects(ConfigUtils.getBoolean(
457                 session,
458                 JettyTransporterConfigurationKeys.DEFAULT_FOLLOW_REDIRECTS,
459                 JettyTransporterConfigurationKeys.CONFIG_PROP_FOLLOW_REDIRECTS));
460         httpClient.setMaxRedirects(ConfigUtils.getInteger(
461                 session,
462                 JettyTransporterConfigurationKeys.DEFAULT_MAX_REDIRECTS,
463                 JettyTransporterConfigurationKeys.CONFIG_PROP_MAX_REDIRECTS));
464 
465         httpClient.setUserAgentField(null); // we manage it
466 
467         if (basicAuthentication != null) {
468             httpClient.getAuthenticationStore().addAuthentication(basicAuthentication);
469         }
470 
471         if (repository.getProxy() != null) {
472             HttpProxy proxy = new HttpProxy(
473                     repository.getProxy().getHost(), repository.getProxy().getPort());
474 
475             httpClient.getProxyConfiguration().addProxy(proxy);
476             try (AuthenticationContext proxyAuthContext = AuthenticationContext.forProxy(session, repository)) {
477                 if (proxyAuthContext != null) {
478                     String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
479                     String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
480 
481                     BasicAuthentication proxyAuthentication =
482                             new BasicAuthentication(proxy.getURI(), Authentication.ANY_REALM, username, password);
483 
484                     httpClient.getAuthenticationStore().addAuthentication(proxyAuthentication);
485                     if (preemptiveAuth || preemptivePutAuth) {
486                         proxyAuth = new BasicAuthentication.BasicResult(
487                                 proxy.getURI(), HttpHeader.PROXY_AUTHORIZATION, username, password);
488                     }
489                 }
490             }
491         }
492         if (serverAuth != null) {
493             this.basicServerAuthenticationResult.set(serverAuth);
494         }
495         if (proxyAuth != null) {
496             this.basicProxyAuthenticationResult.set(proxyAuth);
497         }
498 
499         try {
500             httpClient.start();
501             return httpClient;
502         } catch (Exception e) {
503             if (e instanceof RuntimeException) {
504                 throw (RuntimeException) e;
505             } else {
506                 throw new IllegalStateException("Jetty client start failure", e);
507             }
508         }
509     }
510 }