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