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.internal.test.util.http;
20  
21  import javax.servlet.ServletException;
22  import javax.servlet.http.HttpServletRequest;
23  import javax.servlet.http.HttpServletResponse;
24  
25  import java.io.File;
26  import java.io.FileInputStream;
27  import java.io.FileOutputStream;
28  import java.io.IOException;
29  import java.io.OutputStream;
30  import java.net.URI;
31  import java.nio.charset.StandardCharsets;
32  import java.util.ArrayList;
33  import java.util.Base64;
34  import java.util.Collections;
35  import java.util.Enumeration;
36  import java.util.List;
37  import java.util.Map;
38  import java.util.TreeMap;
39  import java.util.concurrent.atomic.AtomicInteger;
40  import java.util.regex.Matcher;
41  import java.util.regex.Pattern;
42  
43  import com.google.gson.Gson;
44  import org.eclipse.aether.internal.impl.checksum.Sha1ChecksumAlgorithmFactory;
45  import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmHelper;
46  import org.eclipse.aether.spi.connector.transport.http.RFC9457.RFC9457Payload;
47  import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory;
48  import org.eclipse.jetty.http.HttpHeader;
49  import org.eclipse.jetty.http.HttpMethod;
50  import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
51  import org.eclipse.jetty.server.HttpConfiguration;
52  import org.eclipse.jetty.server.HttpConnectionFactory;
53  import org.eclipse.jetty.server.Request;
54  import org.eclipse.jetty.server.Response;
55  import org.eclipse.jetty.server.SecureRequestCustomizer;
56  import org.eclipse.jetty.server.Server;
57  import org.eclipse.jetty.server.ServerConnector;
58  import org.eclipse.jetty.server.SslConnectionFactory;
59  import org.eclipse.jetty.server.handler.AbstractHandler;
60  import org.eclipse.jetty.server.handler.HandlerList;
61  import org.eclipse.jetty.util.IO;
62  import org.eclipse.jetty.util.URIUtil;
63  import org.eclipse.jetty.util.ssl.SslContextFactory;
64  import org.slf4j.Logger;
65  import org.slf4j.LoggerFactory;
66  
67  public class HttpServer {
68  
69      public static class LogEntry {
70  
71          private final String method;
72  
73          private final String path;
74  
75          private final Map<String, String> headers;
76  
77          public LogEntry(String method, String path, Map<String, String> headers) {
78              this.method = method;
79              this.path = path;
80              this.headers = headers;
81          }
82  
83          public String getMethod() {
84              return method;
85          }
86  
87          public String getPath() {
88              return path;
89          }
90  
91          public Map<String, String> getHeaders() {
92              return headers;
93          }
94  
95          @Override
96          public String toString() {
97              return method + " " + path;
98          }
99      }
100 
101     public enum ExpectContinue {
102         FAIL,
103         PROPER,
104         BROKEN
105     }
106 
107     public enum ChecksumHeader {
108         NEXUS,
109         XCHECKSUM
110     }
111 
112     private static final Logger LOGGER = LoggerFactory.getLogger(HttpServer.class);
113 
114     private File repoDir;
115 
116     private boolean rangeSupport = true;
117 
118     private boolean webDav;
119 
120     private ExpectContinue expectContinue = ExpectContinue.PROPER;
121 
122     private ChecksumHeader checksumHeader;
123 
124     private Server server;
125 
126     private ServerConnector httpConnector;
127 
128     private ServerConnector httpsConnector;
129 
130     private String username;
131 
132     private String password;
133 
134     private String proxyUsername;
135 
136     private String proxyPassword;
137 
138     private final AtomicInteger connectionsToClose = new AtomicInteger(0);
139 
140     private final AtomicInteger serverErrorsBeforeWorks = new AtomicInteger(0);
141 
142     private final List<LogEntry> logEntries = Collections.synchronizedList(new ArrayList<>());
143 
144     public String getHost() {
145         return "localhost";
146     }
147 
148     public int getHttpPort() {
149         return httpConnector != null ? httpConnector.getLocalPort() : -1;
150     }
151 
152     public int getHttpsPort() {
153         return httpsConnector != null ? httpsConnector.getLocalPort() : -1;
154     }
155 
156     public String getHttpUrl() {
157         return "http://" + getHost() + ":" + getHttpPort();
158     }
159 
160     public String getHttpsUrl() {
161         return "https://" + getHost() + ":" + getHttpsPort();
162     }
163 
164     public HttpServer addSslConnector() {
165         return addSslConnector(true, true);
166     }
167 
168     public HttpServer addSelfSignedSslConnector() {
169         return addSslConnector(false, true);
170     }
171 
172     public HttpServer addSelfSignedSslConnectorHttp2Only() {
173         return addSslConnector(false, false);
174     }
175 
176     private HttpServer addSslConnector(boolean needClientAuth, boolean needHttp11) {
177         if (httpsConnector == null) {
178             SslContextFactory.Server ssl = new SslContextFactory.Server();
179             ssl.setNeedClientAuth(needClientAuth);
180             if (!needClientAuth) {
181                 ssl.setKeyStorePath(HttpTransporterTest.KEY_STORE_SELF_SIGNED_PATH
182                         .toAbsolutePath()
183                         .toString());
184                 ssl.setKeyStorePassword("server-pwd");
185                 ssl.setSniRequired(false);
186             } else {
187                 ssl.setKeyStorePath(
188                         HttpTransporterTest.KEY_STORE_PATH.toAbsolutePath().toString());
189                 ssl.setKeyStorePassword("server-pwd");
190                 ssl.setTrustStorePath(
191                         HttpTransporterTest.TRUST_STORE_PATH.toAbsolutePath().toString());
192                 ssl.setTrustStorePassword("client-pwd");
193                 ssl.setSniRequired(false);
194             }
195 
196             HttpConfiguration httpsConfig = new HttpConfiguration();
197             SecureRequestCustomizer customizer = new SecureRequestCustomizer();
198             customizer.setSniHostCheck(false);
199             httpsConfig.addCustomizer(customizer);
200 
201             HttpConnectionFactory http1 = null;
202             if (needHttp11) {
203                 http1 = new HttpConnectionFactory(httpsConfig);
204             }
205 
206             HTTP2ServerConnectionFactory http2 = new HTTP2ServerConnectionFactory(httpsConfig);
207 
208             ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory();
209             alpn.setDefaultProtocol(http1 != null ? http1.getProtocol() : http2.getProtocol());
210 
211             SslConnectionFactory tls = new SslConnectionFactory(ssl, alpn.getProtocol());
212             if (http1 != null) {
213                 httpsConnector = new ServerConnector(server, tls, alpn, http2, http1);
214             } else {
215                 httpsConnector = new ServerConnector(server, tls, alpn, http2);
216             }
217             server.addConnector(httpsConnector);
218             try {
219                 httpsConnector.start();
220             } catch (Exception e) {
221                 throw new IllegalStateException(e);
222             }
223         }
224         return this;
225     }
226 
227     public List<LogEntry> getLogEntries() {
228         return logEntries;
229     }
230 
231     public HttpServer setRepoDir(File repoDir) {
232         this.repoDir = repoDir;
233         return this;
234     }
235 
236     public HttpServer setRangeSupport(boolean rangeSupport) {
237         this.rangeSupport = rangeSupport;
238         return this;
239     }
240 
241     public HttpServer setWebDav(boolean webDav) {
242         this.webDav = webDav;
243         return this;
244     }
245 
246     public HttpServer setExpectSupport(ExpectContinue expectContinue) {
247         this.expectContinue = expectContinue;
248         return this;
249     }
250 
251     public HttpServer setChecksumHeader(ChecksumHeader checksumHeader) {
252         this.checksumHeader = checksumHeader;
253         return this;
254     }
255 
256     public HttpServer setAuthentication(String username, String password) {
257         this.username = username;
258         this.password = password;
259         return this;
260     }
261 
262     public HttpServer setProxyAuthentication(String username, String password) {
263         proxyUsername = username;
264         proxyPassword = password;
265         return this;
266     }
267 
268     public HttpServer setConnectionsToClose(int connectionsToClose) {
269         this.connectionsToClose.set(connectionsToClose);
270         return this;
271     }
272 
273     public HttpServer setServerErrorsBeforeWorks(int serverErrorsBeforeWorks) {
274         this.serverErrorsBeforeWorks.set(serverErrorsBeforeWorks);
275         return this;
276     }
277 
278     public HttpServer start() throws Exception {
279         if (server != null) {
280             return this;
281         }
282 
283         HandlerList handlers = new HandlerList();
284         handlers.addHandler(new ConnectionClosingHandler());
285         handlers.addHandler(new ServerErrorHandler());
286         handlers.addHandler(new LogHandler());
287         handlers.addHandler(new ProxyAuthHandler());
288         handlers.addHandler(new AuthHandler());
289         handlers.addHandler(new RedirectHandler());
290         handlers.addHandler(new RepoHandler());
291         handlers.addHandler(new RFC9457Handler());
292 
293         server = new Server();
294         httpConnector = new ServerConnector(server);
295         server.addConnector(httpConnector);
296         server.setHandler(handlers);
297         server.start();
298 
299         return this;
300     }
301 
302     public void stop() throws Exception {
303         if (server != null) {
304             server.stop();
305             server = null;
306             httpConnector = null;
307             httpsConnector = null;
308         }
309     }
310 
311     private class ConnectionClosingHandler extends AbstractHandler {
312         @Override
313         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) {
314             if (connectionsToClose.getAndDecrement() > 0) {
315                 Response jettyResponse = (Response) response;
316                 jettyResponse.getHttpChannel().getConnection().close();
317             }
318         }
319     }
320 
321     private class ServerErrorHandler extends AbstractHandler {
322         @Override
323         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response)
324                 throws IOException {
325             if (serverErrorsBeforeWorks.getAndDecrement() > 0) {
326                 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
327                 writeResponseBodyMessage(response, "Oops, come back later!");
328             }
329         }
330     }
331 
332     private class LogHandler extends AbstractHandler {
333         @Override
334         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) {
335             LOGGER.info(
336                     "{} {}{}",
337                     req.getMethod(),
338                     req.getRequestURL(),
339                     req.getQueryString() != null ? "?" + req.getQueryString() : "");
340 
341             Map<String, String> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
342             for (Enumeration<String> en = req.getHeaderNames(); en.hasMoreElements(); ) {
343                 String name = en.nextElement();
344                 StringBuilder buffer = new StringBuilder(128);
345                 for (Enumeration<String> ien = req.getHeaders(name); ien.hasMoreElements(); ) {
346                     if (buffer.length() > 0) {
347                         buffer.append(", ");
348                     }
349                     buffer.append(ien.nextElement());
350                 }
351                 headers.put(name, buffer.toString());
352             }
353             logEntries.add(new LogEntry(req.getMethod(), req.getPathInfo(), Collections.unmodifiableMap(headers)));
354         }
355     }
356 
357     private static final Pattern SIMPLE_RANGE = Pattern.compile("bytes=([0-9])+-");
358 
359     private class RepoHandler extends AbstractHandler {
360         @Override
361         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response)
362                 throws IOException {
363             String path = req.getPathInfo().substring(1);
364 
365             if (!path.startsWith("repo/")) {
366                 return;
367             }
368             req.setHandled(true);
369 
370             if (ExpectContinue.FAIL.equals(expectContinue) && request.getHeader(HttpHeader.EXPECT.asString()) != null) {
371                 response.setStatus(HttpServletResponse.SC_EXPECTATION_FAILED);
372                 writeResponseBodyMessage(response, "Expectation was set to fail");
373                 return;
374             }
375 
376             File file = new File(repoDir, path.substring(5));
377             if (HttpMethod.GET.is(req.getMethod()) || HttpMethod.HEAD.is(req.getMethod())) {
378                 if (!file.isFile() || path.endsWith(URIUtil.SLASH)) {
379                     response.setStatus(HttpServletResponse.SC_NOT_FOUND);
380                     writeResponseBodyMessage(response, "Not found");
381                     return;
382                 }
383                 long ifUnmodifiedSince = request.getDateHeader(HttpHeader.IF_UNMODIFIED_SINCE.asString());
384                 if (ifUnmodifiedSince != -1L && file.lastModified() > ifUnmodifiedSince) {
385                     response.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
386                     writeResponseBodyMessage(response, "Precondition failed");
387                     return;
388                 }
389                 long offset = 0L;
390                 String range = request.getHeader(HttpHeader.RANGE.asString());
391                 if (range != null && rangeSupport) {
392                     Matcher m = SIMPLE_RANGE.matcher(range);
393                     if (m.matches()) {
394                         offset = Long.parseLong(m.group(1));
395                         if (offset >= file.length()) {
396                             response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
397                             writeResponseBodyMessage(response, "Range not satisfiable");
398                             return;
399                         }
400                     }
401                     String encoding = request.getHeader(HttpHeader.ACCEPT_ENCODING.asString());
402                     if ((encoding != null && !"identity".equals(encoding)) || ifUnmodifiedSince == -1L) {
403                         response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
404                         return;
405                     }
406                 }
407                 response.setStatus((offset > 0L) ? HttpServletResponse.SC_PARTIAL_CONTENT : HttpServletResponse.SC_OK);
408                 response.setDateHeader(HttpHeader.LAST_MODIFIED.asString(), file.lastModified());
409                 response.setHeader(HttpHeader.CONTENT_LENGTH.asString(), Long.toString(file.length() - offset));
410                 if (offset > 0L) {
411                     response.setHeader(
412                             HttpHeader.CONTENT_RANGE.asString(),
413                             "bytes " + offset + "-" + (file.length() - 1L) + "/" + file.length());
414                 }
415                 if (checksumHeader != null) {
416                     Map<String, String> checksums = ChecksumAlgorithmHelper.calculate(
417                             file, Collections.singletonList(new Sha1ChecksumAlgorithmFactory()));
418                     if (checksumHeader == ChecksumHeader.NEXUS) {
419                         response.setHeader(HttpHeader.ETAG.asString(), "{SHA1{" + checksums.get("SHA-1") + "}}");
420                     } else if (checksumHeader == ChecksumHeader.XCHECKSUM) {
421                         response.setHeader("x-checksum-sha1", checksums.get(Sha1ChecksumAlgorithmFactory.NAME));
422                     }
423                 }
424                 if (HttpMethod.HEAD.is(req.getMethod())) {
425                     return;
426                 }
427                 FileInputStream is = null;
428                 try {
429                     is = new FileInputStream(file);
430                     if (offset > 0L) {
431                         long skipped = is.skip(offset);
432                         while (skipped < offset && is.read() >= 0) {
433                             skipped++;
434                         }
435                     }
436                     IO.copy(is, response.getOutputStream());
437                     is.close();
438                     is = null;
439                 } finally {
440                     try {
441                         if (is != null) {
442                             is.close();
443                         }
444                     } catch (final IOException e) {
445                         // Suppressed due to an exception already thrown in the try block.
446                     }
447                 }
448             } else if (HttpMethod.PUT.is(req.getMethod())) {
449                 if (!webDav) {
450                     file.getParentFile().mkdirs();
451                 }
452                 if (file.getParentFile().exists()) {
453                     try {
454                         FileOutputStream os = null;
455                         try {
456                             os = new FileOutputStream(file);
457                             IO.copy(request.getInputStream(), os);
458                             os.close();
459                             os = null;
460                         } finally {
461                             try {
462                                 if (os != null) {
463                                     os.close();
464                                 }
465                             } catch (final IOException e) {
466                                 // Suppressed due to an exception already thrown in the try block.
467                             }
468                         }
469                     } catch (IOException e) {
470                         file.delete();
471                         throw e;
472                     }
473                     response.setStatus(HttpServletResponse.SC_NO_CONTENT);
474                 } else {
475                     response.setStatus(HttpServletResponse.SC_FORBIDDEN);
476                 }
477             } else if (HttpMethod.OPTIONS.is(req.getMethod())) {
478                 if (webDav) {
479                     response.setHeader("DAV", "1,2");
480                 }
481                 response.setHeader(HttpHeader.ALLOW.asString(), "GET, PUT, HEAD, OPTIONS");
482                 response.setStatus(HttpServletResponse.SC_OK);
483             } else if (webDav && "MKCOL".equals(req.getMethod())) {
484                 if (file.exists()) {
485                     response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
486                 } else if (file.mkdir()) {
487                     response.setStatus(HttpServletResponse.SC_CREATED);
488                 } else {
489                     response.setStatus(HttpServletResponse.SC_CONFLICT);
490                 }
491             } else {
492                 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
493             }
494         }
495     }
496 
497     private void writeResponseBodyMessage(HttpServletResponse response, String message) throws IOException {
498         try (OutputStream outputStream = response.getOutputStream()) {
499             outputStream.write(message.getBytes(StandardCharsets.UTF_8));
500         }
501     }
502 
503     private class RFC9457Handler extends AbstractHandler {
504         @Override
505         public void handle(
506                 final String target,
507                 final Request req,
508                 final HttpServletRequest request,
509                 final HttpServletResponse response)
510                 throws IOException, ServletException {
511             String path = req.getPathInfo().substring(1);
512 
513             if (!path.startsWith("rfc9457/")) {
514                 return;
515             }
516             req.setHandled(true);
517 
518             if (HttpMethod.GET.is(req.getMethod())) {
519                 response.setStatus(HttpServletResponse.SC_FORBIDDEN);
520                 response.setHeader(HttpHeader.CONTENT_TYPE.asString(), "application/problem+json");
521                 RFC9457Payload rfc9457Payload;
522                 if (path.endsWith("missing_fields.txt")) {
523                     rfc9457Payload = new RFC9457Payload(null, null, null, null, null);
524                 } else {
525                     rfc9457Payload = new RFC9457Payload(
526                             URI.create("https://example.com/probs/out-of-credit"),
527                             HttpServletResponse.SC_FORBIDDEN,
528                             "You do not have enough credit.",
529                             "Your current balance is 30, but that costs 50.",
530                             URI.create("/account/12345/msgs/abc"));
531                 }
532                 writeResponseBodyMessage(response, buildRFC9457Message(rfc9457Payload));
533             }
534         }
535     }
536 
537     private String buildRFC9457Message(RFC9457Payload payload) {
538         return new Gson().toJson(payload, RFC9457Payload.class);
539     }
540 
541     private class RedirectHandler extends AbstractHandler {
542         @Override
543         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) {
544             String path = req.getPathInfo();
545             if (!path.startsWith("/redirect/")) {
546                 return;
547             }
548             req.setHandled(true);
549             StringBuilder location = new StringBuilder(128);
550             String scheme = req.getParameter("scheme");
551             location.append(scheme != null ? scheme : req.getScheme());
552             location.append("://");
553             location.append(req.getServerName());
554             location.append(":");
555             if ("http".equalsIgnoreCase(scheme)) {
556                 location.append(getHttpPort());
557             } else if ("https".equalsIgnoreCase(scheme)) {
558                 location.append(getHttpsPort());
559             } else {
560                 location.append(req.getServerPort());
561             }
562             location.append("/repo").append(path.substring(9));
563             response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
564             response.setHeader(HttpHeader.LOCATION.asString(), location.toString());
565         }
566     }
567 
568     private class AuthHandler extends AbstractHandler {
569         @Override
570         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response)
571                 throws IOException {
572             if (ExpectContinue.BROKEN.equals(expectContinue)
573                     && "100-continue".equalsIgnoreCase(request.getHeader(HttpHeader.EXPECT.asString()))) {
574                 request.getInputStream();
575             }
576 
577             if (username != null && password != null) {
578                 if (checkBasicAuth(request.getHeader(HttpHeader.AUTHORIZATION.asString()), username, password)) {
579                     return;
580                 }
581                 req.setHandled(true);
582                 response.setHeader(HttpHeader.WWW_AUTHENTICATE.asString(), "basic realm=\"Test-Realm\"");
583                 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
584             }
585         }
586     }
587 
588     private class ProxyAuthHandler extends AbstractHandler {
589         @Override
590         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) {
591             if (proxyUsername != null && proxyPassword != null) {
592                 if (checkBasicAuth(
593                         request.getHeader(HttpHeader.PROXY_AUTHORIZATION.asString()), proxyUsername, proxyPassword)) {
594                     return;
595                 }
596                 req.setHandled(true);
597                 response.setHeader(HttpHeader.PROXY_AUTHENTICATE.asString(), "basic realm=\"Test-Realm\"");
598                 response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
599             }
600         }
601     }
602 
603     static boolean checkBasicAuth(String credentials, String username, String password) {
604         if (credentials != null) {
605             int space = credentials.indexOf(' ');
606             if (space > 0) {
607                 String method = credentials.substring(0, space);
608                 if ("basic".equalsIgnoreCase(method)) {
609                     credentials = credentials.substring(space + 1);
610                     credentials = new String(Base64.getDecoder().decode(credentials), StandardCharsets.ISO_8859_1);
611                     int i = credentials.indexOf(':');
612                     if (i > 0) {
613                         String user = credentials.substring(0, i);
614                         String pass = credentials.substring(i + 1);
615                         if (username.equals(user) && password.equals(pass)) {
616                             return true;
617                         }
618                     }
619                 }
620             }
621         }
622         return false;
623     }
624 }