001/* 002 * Licensed to the Apache Software Foundation (ASF) under one 003 * or more contributor license agreements. See the NOTICE file 004 * distributed with this work for additional information 005 * regarding copyright ownership. The ASF licenses this file 006 * to you under the Apache License, Version 2.0 (the 007 * "License"); you may not use this file except in compliance 008 * with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, 013 * software distributed under the License is distributed on an 014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 015 * KIND, either express or implied. See the License for the 016 * specific language governing permissions and limitations 017 * under the License. 018 */ 019package org.eclipse.aether.internal.test.util.http; 020 021import javax.servlet.ServletException; 022import javax.servlet.http.HttpServletRequest; 023import javax.servlet.http.HttpServletResponse; 024 025import java.io.File; 026import java.io.FileInputStream; 027import java.io.FileOutputStream; 028import java.io.IOException; 029import java.io.OutputStream; 030import java.net.URI; 031import java.nio.charset.StandardCharsets; 032import java.util.ArrayList; 033import java.util.Base64; 034import java.util.Collections; 035import java.util.Enumeration; 036import java.util.List; 037import java.util.Map; 038import java.util.TreeMap; 039import java.util.concurrent.atomic.AtomicInteger; 040import java.util.regex.Matcher; 041import java.util.regex.Pattern; 042 043import com.google.gson.Gson; 044import org.eclipse.aether.internal.impl.checksum.Sha1ChecksumAlgorithmFactory; 045import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmHelper; 046import org.eclipse.aether.spi.connector.transport.http.RFC9457.RFC9457Payload; 047import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; 048import org.eclipse.jetty.http.HttpHeader; 049import org.eclipse.jetty.http.HttpMethod; 050import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; 051import org.eclipse.jetty.server.HttpConfiguration; 052import org.eclipse.jetty.server.HttpConnectionFactory; 053import org.eclipse.jetty.server.Request; 054import org.eclipse.jetty.server.Response; 055import org.eclipse.jetty.server.SecureRequestCustomizer; 056import org.eclipse.jetty.server.Server; 057import org.eclipse.jetty.server.ServerConnector; 058import org.eclipse.jetty.server.SslConnectionFactory; 059import org.eclipse.jetty.server.handler.AbstractHandler; 060import org.eclipse.jetty.server.handler.HandlerList; 061import org.eclipse.jetty.util.IO; 062import org.eclipse.jetty.util.URIUtil; 063import org.eclipse.jetty.util.ssl.SslContextFactory; 064import org.slf4j.Logger; 065import org.slf4j.LoggerFactory; 066 067public class HttpServer { 068 069 public static class LogEntry { 070 071 private final String method; 072 073 private final String path; 074 075 private final Map<String, String> headers; 076 077 public LogEntry(String method, String path, Map<String, String> headers) { 078 this.method = method; 079 this.path = path; 080 this.headers = headers; 081 } 082 083 public String getMethod() { 084 return method; 085 } 086 087 public String getPath() { 088 return path; 089 } 090 091 public Map<String, String> getHeaders() { 092 return headers; 093 } 094 095 @Override 096 public String toString() { 097 return method + " " + path; 098 } 099 } 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}