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.jdk;
20  
21  import javax.net.ssl.SSLContext;
22  import javax.net.ssl.SSLEngine;
23  import javax.net.ssl.SSLParameters;
24  import javax.net.ssl.X509ExtendedTrustManager;
25  import javax.net.ssl.X509TrustManager;
26  
27  import java.io.BufferedInputStream;
28  import java.io.IOException;
29  import java.io.InputStream;
30  import java.lang.reflect.InvocationTargetException;
31  import java.lang.reflect.Method;
32  import java.net.Authenticator;
33  import java.net.ConnectException;
34  import java.net.InetAddress;
35  import java.net.InetSocketAddress;
36  import java.net.PasswordAuthentication;
37  import java.net.ProxySelector;
38  import java.net.Socket;
39  import java.net.URI;
40  import java.net.URISyntaxException;
41  import java.net.UnknownHostException;
42  import java.net.http.HttpClient;
43  import java.net.http.HttpRequest;
44  import java.net.http.HttpResponse;
45  import java.nio.file.Files;
46  import java.nio.file.Path;
47  import java.nio.file.StandardCopyOption;
48  import java.security.cert.X509Certificate;
49  import java.time.Duration;
50  import java.time.Instant;
51  import java.time.ZoneId;
52  import java.time.ZonedDateTime;
53  import java.time.format.DateTimeFormatter;
54  import java.time.format.DateTimeParseException;
55  import java.util.Collections;
56  import java.util.HashMap;
57  import java.util.Locale;
58  import java.util.Map;
59  import java.util.concurrent.Semaphore;
60  import java.util.function.Function;
61  import java.util.function.Supplier;
62  import java.util.regex.Matcher;
63  
64  import org.eclipse.aether.ConfigurationProperties;
65  import org.eclipse.aether.RepositorySystemSession;
66  import org.eclipse.aether.repository.AuthenticationContext;
67  import org.eclipse.aether.repository.RemoteRepository;
68  import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
69  import org.eclipse.aether.spi.connector.transport.GetTask;
70  import org.eclipse.aether.spi.connector.transport.PeekTask;
71  import org.eclipse.aether.spi.connector.transport.PutTask;
72  import org.eclipse.aether.spi.connector.transport.TransportTask;
73  import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
74  import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
75  import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
76  import org.eclipse.aether.spi.io.PathProcessor;
77  import org.eclipse.aether.transfer.NoTransporterException;
78  import org.eclipse.aether.util.ConfigUtils;
79  import org.eclipse.aether.util.FileUtils;
80  import org.slf4j.Logger;
81  import org.slf4j.LoggerFactory;
82  
83  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.ACCEPT_ENCODING;
84  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CACHE_CONTROL;
85  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_LENGTH;
86  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE;
87  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.CONTENT_RANGE_PATTERN;
88  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.IF_UNMODIFIED_SINCE;
89  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.LAST_MODIFIED;
90  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.MULTIPLE_CHOICES;
91  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.NOT_FOUND;
92  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.PRECONDITION_FAILED;
93  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.RANGE;
94  import static org.eclipse.aether.spi.connector.transport.http.HttpConstants.USER_AGENT;
95  import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_HTTP_VERSION;
96  import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.CONFIG_PROP_MAX_CONCURRENT_REQUESTS;
97  import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_HTTP_VERSION;
98  import static org.eclipse.aether.transport.jdk.JdkTransporterConfigurationKeys.DEFAULT_MAX_CONCURRENT_REQUESTS;
99  
100 /**
101  * JDK Transport using {@link HttpClient}.
102  * <p>
103  * Known issues:
104  * <ul>
105  *     <li>Does not support {@link ConfigurationProperties#REQUEST_TIMEOUT}, see <a href="https://bugs.openjdk.org/browse/JDK-8258397">JDK-8258397</a></li>
106  * </ul>
107  * <p>
108  * Related: <a href="https://dev.to/kdrakon/httpclient-can-t-connect-to-a-tls-proxy-118a">No TLS proxy supported</a>.
109  *
110  * @since 2.0.0
111  */
112 final class JdkTransporter extends AbstractTransporter implements HttpTransporter {
113     private static final Logger LOGGER = LoggerFactory.getLogger(JdkTransporter.class);
114 
115     private static final DateTimeFormatter RFC7231 = DateTimeFormatter.ofPattern(
116                     "EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH)
117             .withZone(ZoneId.of("GMT"));
118 
119     private static final long MODIFICATION_THRESHOLD = 60L * 1000L;
120 
121     private final ChecksumExtractor checksumExtractor;
122 
123     private final PathProcessor pathProcessor;
124 
125     private final URI baseUri;
126 
127     private final HttpClient client;
128 
129     private final Map<String, String> headers;
130 
131     private final int connectTimeout;
132 
133     private final int requestTimeout;
134 
135     private final Boolean expectContinue;
136 
137     private final Semaphore maxConcurrentRequests;
138 
139     JdkTransporter(
140             RepositorySystemSession session,
141             RemoteRepository repository,
142             int javaVersion,
143             ChecksumExtractor checksumExtractor,
144             PathProcessor pathProcessor)
145             throws NoTransporterException {
146         this.checksumExtractor = checksumExtractor;
147         this.pathProcessor = pathProcessor;
148         try {
149             URI uri = new URI(repository.getUrl()).parseServerAuthority();
150             if (uri.isOpaque()) {
151                 throw new URISyntaxException(repository.getUrl(), "URL must not be opaque");
152             }
153             if (uri.getRawFragment() != null || uri.getRawQuery() != null) {
154                 throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query");
155             }
156             String path = uri.getPath();
157             if (path == null) {
158                 path = "/";
159             }
160             if (!path.startsWith("/")) {
161                 path = "/" + path;
162             }
163             if (!path.endsWith("/")) {
164                 path = path + "/";
165             }
166             this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path);
167         } catch (URISyntaxException e) {
168             throw new NoTransporterException(repository, e.getMessage(), e);
169         }
170 
171         HashMap<String, String> headers = new HashMap<>();
172         String userAgent = ConfigUtils.getString(
173                 session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT);
174         if (userAgent != null) {
175             headers.put(USER_AGENT, userAgent);
176         }
177         @SuppressWarnings("unchecked")
178         Map<Object, Object> configuredHeaders = (Map<Object, Object>) ConfigUtils.getMap(
179                 session,
180                 Collections.emptyMap(),
181                 ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(),
182                 ConfigurationProperties.HTTP_HEADERS);
183         if (configuredHeaders != null) {
184             configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null));
185         }
186         headers.put(CACHE_CONTROL, "no-cache, no-store");
187 
188         this.connectTimeout = ConfigUtils.getInteger(
189                 session,
190                 ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT,
191                 ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(),
192                 ConfigurationProperties.CONNECT_TIMEOUT);
193         this.requestTimeout = ConfigUtils.getInteger(
194                 session,
195                 ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT,
196                 ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(),
197                 ConfigurationProperties.REQUEST_TIMEOUT);
198         String expectContinueConf = ConfigUtils.getString(
199                 session,
200                 null,
201                 ConfigurationProperties.HTTP_EXPECT_CONTINUE + "." + repository.getId(),
202                 ConfigurationProperties.HTTP_EXPECT_CONTINUE);
203         if (javaVersion > 19) {
204             this.expectContinue = expectContinueConf == null ? null : Boolean.parseBoolean(expectContinueConf);
205         } else {
206             this.expectContinue = null;
207             if (expectContinueConf != null) {
208                 LOGGER.warn(
209                         "Configuration for Expect-Continue set but is ignored on Java versions below 20 (current java version is {}) due https://bugs.openjdk.org/browse/JDK-8286171",
210                         javaVersion);
211             }
212         }
213         final String httpsSecurityMode = ConfigUtils.getString(
214                 session,
215                 ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT,
216                 ConfigurationProperties.HTTPS_SECURITY_MODE + "." + repository.getId(),
217                 ConfigurationProperties.HTTPS_SECURITY_MODE);
218 
219         if (!ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT.equals(httpsSecurityMode)
220                 && !ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode)) {
221             throw new IllegalArgumentException("Unsupported '" + httpsSecurityMode + "' HTTPS security mode.");
222         }
223         final boolean insecure = ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE.equals(httpsSecurityMode);
224 
225         this.maxConcurrentRequests = new Semaphore(ConfigUtils.getInteger(
226                 session,
227                 DEFAULT_MAX_CONCURRENT_REQUESTS,
228                 CONFIG_PROP_MAX_CONCURRENT_REQUESTS + "." + repository.getId(),
229                 CONFIG_PROP_MAX_CONCURRENT_REQUESTS));
230 
231         this.headers = headers;
232         this.client = createClient(session, repository, insecure);
233     }
234 
235     private URI resolve(TransportTask task) {
236         return baseUri.resolve(task.getLocation());
237     }
238 
239     private ConnectException enhance(ConnectException connectException) {
240         ConnectException result = new ConnectException("Connection to " + baseUri.toASCIIString() + " refused");
241         result.initCause(connectException);
242         return result;
243     }
244 
245     @Override
246     public int classify(Throwable error) {
247         if (error instanceof HttpTransporterException
248                 && ((HttpTransporterException) error).getStatusCode() == NOT_FOUND) {
249             return ERROR_NOT_FOUND;
250         }
251         return ERROR_OTHER;
252     }
253 
254     @Override
255     protected void implPeek(PeekTask task) throws Exception {
256         HttpRequest.Builder request =
257                 HttpRequest.newBuilder().uri(resolve(task)).method("HEAD", HttpRequest.BodyPublishers.noBody());
258         headers.forEach(request::setHeader);
259         try {
260             HttpResponse<Void> response = send(request.build(), HttpResponse.BodyHandlers.discarding());
261             if (response.statusCode() >= MULTIPLE_CHOICES) {
262                 throw new HttpTransporterException(response.statusCode());
263             }
264         } catch (ConnectException e) {
265             throw enhance(e);
266         }
267     }
268 
269     @Override
270     protected void implGet(GetTask task) throws Exception {
271         boolean resume = task.getResumeOffset() > 0L && task.getDataPath() != null;
272         HttpResponse<InputStream> response = null;
273 
274         try {
275             while (true) {
276                 HttpRequest.Builder request =
277                         HttpRequest.newBuilder().uri(resolve(task)).GET();
278                 headers.forEach(request::setHeader);
279 
280                 if (resume) {
281                     long resumeOffset = task.getResumeOffset();
282                     long lastModified = pathProcessor.lastModified(task.getDataPath(), 0L);
283                     request.header(RANGE, "bytes=" + resumeOffset + '-');
284                     request.header(
285                             IF_UNMODIFIED_SINCE,
286                             RFC7231.format(Instant.ofEpochMilli(lastModified - MODIFICATION_THRESHOLD)));
287                     request.header(ACCEPT_ENCODING, "identity");
288                 }
289 
290                 try {
291                     response = send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
292                     if (response.statusCode() >= MULTIPLE_CHOICES) {
293                         if (resume && response.statusCode() == PRECONDITION_FAILED) {
294                             closeBody(response);
295                             resume = false;
296                             continue;
297                         }
298                         try {
299                             JdkRFC9457Reporter.INSTANCE.generateException(response, (statusCode, reasonPhrase) -> {
300                                 throw new HttpTransporterException(statusCode);
301                             });
302                         } finally {
303                             closeBody(response);
304                         }
305                     }
306                 } catch (ConnectException e) {
307                     closeBody(response);
308                     throw enhance(e);
309                 }
310                 break;
311             }
312 
313             long offset = 0L,
314                     length = response.headers().firstValueAsLong(CONTENT_LENGTH).orElse(-1L);
315             if (resume) {
316                 String range = response.headers().firstValue(CONTENT_RANGE).orElse(null);
317                 if (range != null) {
318                     Matcher m = CONTENT_RANGE_PATTERN.matcher(range);
319                     if (!m.matches()) {
320                         throw new IOException("Invalid Content-Range header for partial download: " + range);
321                     }
322                     offset = Long.parseLong(m.group(1));
323                     length = Long.parseLong(m.group(2)) + 1L;
324                     if (offset < 0L || offset >= length || (offset > 0L && offset != task.getResumeOffset())) {
325                         throw new IOException("Invalid Content-Range header for partial download from offset "
326                                 + task.getResumeOffset() + ": " + range);
327                     }
328                 }
329             }
330 
331             final boolean downloadResumed = offset > 0L;
332             final Path dataFile = task.getDataPath();
333             if (dataFile == null) {
334                 try (InputStream is = response.body()) {
335                     utilGet(task, is, true, length, downloadResumed);
336                 }
337             } else {
338                 try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile)) {
339                     task.setDataPath(tempFile.getPath(), downloadResumed);
340                     if (downloadResumed && Files.isRegularFile(dataFile)) {
341                         try (InputStream inputStream = new BufferedInputStream(Files.newInputStream(dataFile))) {
342                             Files.copy(inputStream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
343                         }
344                     }
345                     try (InputStream is = response.body()) {
346                         utilGet(task, is, true, length, downloadResumed);
347                     }
348                     tempFile.move();
349                 } finally {
350                     task.setDataPath(dataFile);
351                 }
352             }
353             if (task.getDataPath() != null) {
354                 String lastModifiedHeader = response.headers()
355                         .firstValue(LAST_MODIFIED)
356                         .orElse(null); // note: Wagon also does first not last
357                 if (lastModifiedHeader != null) {
358                     try {
359                         pathProcessor.setLastModified(
360                                 task.getDataPath(),
361                                 ZonedDateTime.parse(lastModifiedHeader, RFC7231)
362                                         .toInstant()
363                                         .toEpochMilli());
364                     } catch (DateTimeParseException e) {
365                         // fall through
366                     }
367                 }
368             }
369             Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
370             if (checksums != null && !checksums.isEmpty()) {
371                 checksums.forEach(task::setChecksum);
372             }
373         } finally {
374             closeBody(response);
375         }
376     }
377 
378     private static Function<String, String> headerGetter(HttpResponse<?> response) {
379         return s -> response.headers().firstValue(s).orElse(null);
380     }
381 
382     private void closeBody(HttpResponse<InputStream> streamHttpResponse) throws IOException {
383         if (streamHttpResponse != null) {
384             InputStream body = streamHttpResponse.body();
385             if (body != null) {
386                 body.close();
387             }
388         }
389     }
390 
391     @Override
392     protected void implPut(PutTask task) throws Exception {
393         HttpRequest.Builder request = HttpRequest.newBuilder().uri(resolve(task));
394         if (expectContinue != null) {
395             request = request.expectContinue(expectContinue);
396         }
397         headers.forEach(request::setHeader);
398         try (FileUtils.TempFile tempFile = FileUtils.newTempFile()) {
399             utilPut(task, Files.newOutputStream(tempFile.getPath()), true);
400             request.PUT(HttpRequest.BodyPublishers.ofFile(tempFile.getPath()));
401 
402             try {
403                 HttpResponse<Void> response = send(request.build(), HttpResponse.BodyHandlers.discarding());
404                 if (response.statusCode() >= MULTIPLE_CHOICES) {
405                     throw new HttpTransporterException(response.statusCode());
406                 }
407             } catch (ConnectException e) {
408                 throw enhance(e);
409             }
410         }
411     }
412 
413     private <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
414             throws Exception {
415         maxConcurrentRequests.acquire();
416         try {
417             return client.send(request, responseBodyHandler);
418         } finally {
419             maxConcurrentRequests.release();
420         }
421     }
422 
423     @Override
424     protected void implClose() {
425         if (client != null) {
426             JdkTransporterCloser.closer(client).run();
427         }
428     }
429 
430     private HttpClient createClient(RepositorySystemSession session, RemoteRepository repository, boolean insecure)
431             throws RuntimeException {
432 
433         HashMap<Authenticator.RequestorType, PasswordAuthentication> authentications = new HashMap<>();
434         SSLContext sslContext = null;
435         try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
436             if (repoAuthContext != null) {
437                 sslContext = repoAuthContext.get(AuthenticationContext.SSL_CONTEXT, SSLContext.class);
438 
439                 String username = repoAuthContext.get(AuthenticationContext.USERNAME);
440                 String password = repoAuthContext.get(AuthenticationContext.PASSWORD);
441 
442                 authentications.put(
443                         Authenticator.RequestorType.SERVER,
444                         new PasswordAuthentication(username, password.toCharArray()));
445             }
446         }
447 
448         if (sslContext == null) {
449             try {
450                 if (insecure) {
451                     sslContext = SSLContext.getInstance("TLS");
452                     X509ExtendedTrustManager tm = new X509ExtendedTrustManager() {
453                         @Override
454                         public void checkClientTrusted(X509Certificate[] chain, String authType) {}
455 
456                         @Override
457                         public void checkServerTrusted(X509Certificate[] chain, String authType) {}
458 
459                         @Override
460                         public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {}
461 
462                         @Override
463                         public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) {}
464 
465                         @Override
466                         public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
467 
468                         @Override
469                         public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
470 
471                         @Override
472                         public X509Certificate[] getAcceptedIssuers() {
473                             return null;
474                         }
475                     };
476                     sslContext.init(null, new X509TrustManager[] {tm}, null);
477                 } else {
478                     sslContext = SSLContext.getDefault();
479                 }
480             } catch (Exception e) {
481                 if (e instanceof RuntimeException) {
482                     throw (RuntimeException) e;
483                 } else {
484                     throw new IllegalStateException("SSL Context setup failure", e);
485                 }
486             }
487         }
488 
489         HttpClient.Builder builder = HttpClient.newBuilder()
490                 .version(HttpClient.Version.valueOf(ConfigUtils.getString(
491                         session,
492                         DEFAULT_HTTP_VERSION,
493                         CONFIG_PROP_HTTP_VERSION + "." + repository.getId(),
494                         CONFIG_PROP_HTTP_VERSION)))
495                 .followRedirects(HttpClient.Redirect.NORMAL)
496                 .connectTimeout(Duration.ofMillis(connectTimeout))
497                 .sslContext(sslContext);
498 
499         if (insecure) {
500             SSLParameters sslParameters = sslContext.getDefaultSSLParameters();
501             sslParameters.setEndpointIdentificationAlgorithm(null);
502             builder.sslParameters(sslParameters);
503         }
504 
505         setLocalAddress(builder, () -> getHttpLocalAddress(session, repository));
506 
507         if (repository.getProxy() != null) {
508             ProxySelector proxy = ProxySelector.of(new InetSocketAddress(
509                     repository.getProxy().getHost(), repository.getProxy().getPort()));
510 
511             builder.proxy(proxy);
512             try (AuthenticationContext proxyAuthContext = AuthenticationContext.forProxy(session, repository)) {
513                 if (proxyAuthContext != null) {
514                     String username = proxyAuthContext.get(AuthenticationContext.USERNAME);
515                     String password = proxyAuthContext.get(AuthenticationContext.PASSWORD);
516 
517                     authentications.put(
518                             Authenticator.RequestorType.PROXY,
519                             new PasswordAuthentication(username, password.toCharArray()));
520                 }
521             }
522         }
523 
524         if (!authentications.isEmpty()) {
525             builder.authenticator(new Authenticator() {
526                 @Override
527                 protected PasswordAuthentication getPasswordAuthentication() {
528                     return authentications.get(getRequestorType());
529                 }
530             });
531         }
532 
533         return builder.build();
534     }
535 
536     private static InetAddress getHttpLocalAddress(RepositorySystemSession session, RemoteRepository repository) {
537         String bindAddress = ConfigUtils.getString(
538                 session,
539                 null,
540                 ConfigurationProperties.HTTP_LOCAL_ADDRESS + "." + repository.getId(),
541                 ConfigurationProperties.HTTP_LOCAL_ADDRESS);
542         if (bindAddress == null) {
543             return null;
544         }
545         try {
546             return InetAddress.getByName(bindAddress);
547         } catch (UnknownHostException uhe) {
548             throw new IllegalArgumentException(
549                     "Given bind address (" + bindAddress + ") cannot be resolved for remote repository " + repository,
550                     uhe);
551         }
552     }
553 
554     private static void setLocalAddress(HttpClient.Builder builder, Supplier<InetAddress> addressSupplier) {
555         try {
556             final InetAddress address = addressSupplier.get();
557             if (address == null) {
558                 return;
559             }
560 
561             final Method mtd = builder.getClass().getDeclaredMethod("localAddress", InetAddress.class);
562             if (!mtd.canAccess(builder)) {
563                 mtd.setAccessible(true);
564             }
565             mtd.invoke(builder, address);
566         } catch (final NoSuchMethodException nsme) {
567             // skip, not yet in the API
568         } catch (InvocationTargetException e) {
569             throw new IllegalStateException(e.getTargetException());
570         } catch (IllegalAccessException e) {
571             throw new IllegalStateException(e);
572         }
573     }
574 }