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.http.HttpServletRequest; 022import javax.servlet.http.HttpServletResponse; 023 024import java.io.File; 025import java.io.FileInputStream; 026import java.io.FileOutputStream; 027import java.io.IOException; 028import java.io.OutputStream; 029import java.nio.charset.StandardCharsets; 030import java.util.ArrayList; 031import java.util.Base64; 032import java.util.Collections; 033import java.util.Enumeration; 034import java.util.List; 035import java.util.Map; 036import java.util.TreeMap; 037import java.util.concurrent.atomic.AtomicInteger; 038import java.util.regex.Matcher; 039import java.util.regex.Pattern; 040 041import org.eclipse.aether.internal.impl.checksum.Sha1ChecksumAlgorithmFactory; 042import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmHelper; 043import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; 044import org.eclipse.jetty.http.HttpHeader; 045import org.eclipse.jetty.http.HttpMethod; 046import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; 047import org.eclipse.jetty.server.HttpConfiguration; 048import org.eclipse.jetty.server.HttpConnectionFactory; 049import org.eclipse.jetty.server.Request; 050import org.eclipse.jetty.server.Response; 051import org.eclipse.jetty.server.SecureRequestCustomizer; 052import org.eclipse.jetty.server.Server; 053import org.eclipse.jetty.server.ServerConnector; 054import org.eclipse.jetty.server.SslConnectionFactory; 055import org.eclipse.jetty.server.handler.AbstractHandler; 056import org.eclipse.jetty.server.handler.HandlerList; 057import org.eclipse.jetty.util.IO; 058import org.eclipse.jetty.util.URIUtil; 059import org.eclipse.jetty.util.ssl.SslContextFactory; 060import org.slf4j.Logger; 061import org.slf4j.LoggerFactory; 062 063public class HttpServer { 064 065 public static class LogEntry { 066 067 private final String method; 068 069 private final String path; 070 071 private final Map<String, String> headers; 072 073 public LogEntry(String method, String path, Map<String, String> headers) { 074 this.method = method; 075 this.path = path; 076 this.headers = headers; 077 } 078 079 public String getMethod() { 080 return method; 081 } 082 083 public String getPath() { 084 return path; 085 } 086 087 public Map<String, String> getHeaders() { 088 return headers; 089 } 090 091 @Override 092 public String toString() { 093 return method + " " + path; 094 } 095 } 096 097 public enum ExpectContinue { 098 FAIL, 099 PROPER, 100 BROKEN 101 } 102 103 public enum ChecksumHeader { 104 NEXUS, 105 XCHECKSUM 106 } 107 108 private static final Logger LOGGER = LoggerFactory.getLogger(HttpServer.class); 109 110 private File repoDir; 111 112 private boolean rangeSupport = true; 113 114 private boolean webDav; 115 116 private ExpectContinue expectContinue = ExpectContinue.PROPER; 117 118 private ChecksumHeader checksumHeader; 119 120 private Server server; 121 122 private ServerConnector httpConnector; 123 124 private ServerConnector httpsConnector; 125 126 private String username; 127 128 private String password; 129 130 private String proxyUsername; 131 132 private String proxyPassword; 133 134 private final AtomicInteger connectionsToClose = new AtomicInteger(0); 135 136 private final AtomicInteger serverErrorsBeforeWorks = new AtomicInteger(0); 137 138 private final List<LogEntry> logEntries = Collections.synchronizedList(new ArrayList<>()); 139 140 public String getHost() { 141 return "localhost"; 142 } 143 144 public int getHttpPort() { 145 return httpConnector != null ? httpConnector.getLocalPort() : -1; 146 } 147 148 public int getHttpsPort() { 149 return httpsConnector != null ? httpsConnector.getLocalPort() : -1; 150 } 151 152 public String getHttpUrl() { 153 return "http://" + getHost() + ":" + getHttpPort(); 154 } 155 156 public String getHttpsUrl() { 157 return "https://" + getHost() + ":" + getHttpsPort(); 158 } 159 160 public HttpServer addSslConnector() { 161 return addSslConnector(true, true); 162 } 163 164 public HttpServer addSelfSignedSslConnector() { 165 return addSslConnector(false, true); 166 } 167 168 public HttpServer addSelfSignedSslConnectorHttp2Only() { 169 return addSslConnector(false, false); 170 } 171 172 private HttpServer addSslConnector(boolean needClientAuth, boolean needHttp11) { 173 if (httpsConnector == null) { 174 SslContextFactory.Server ssl = new SslContextFactory.Server(); 175 ssl.setNeedClientAuth(needClientAuth); 176 if (!needClientAuth) { 177 ssl.setKeyStorePath(HttpTransporterTest.KEY_STORE_SELF_SIGNED_PATH 178 .toAbsolutePath() 179 .toString()); 180 ssl.setKeyStorePassword("server-pwd"); 181 ssl.setSniRequired(false); 182 } else { 183 ssl.setKeyStorePath( 184 HttpTransporterTest.KEY_STORE_PATH.toAbsolutePath().toString()); 185 ssl.setKeyStorePassword("server-pwd"); 186 ssl.setTrustStorePath( 187 HttpTransporterTest.TRUST_STORE_PATH.toAbsolutePath().toString()); 188 ssl.setTrustStorePassword("client-pwd"); 189 ssl.setSniRequired(false); 190 } 191 192 HttpConfiguration httpsConfig = new HttpConfiguration(); 193 SecureRequestCustomizer customizer = new SecureRequestCustomizer(); 194 customizer.setSniHostCheck(false); 195 httpsConfig.addCustomizer(customizer); 196 197 HttpConnectionFactory http1 = null; 198 if (needHttp11) { 199 http1 = new HttpConnectionFactory(httpsConfig); 200 } 201 202 HTTP2ServerConnectionFactory http2 = new HTTP2ServerConnectionFactory(httpsConfig); 203 204 ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory(); 205 alpn.setDefaultProtocol(http1 != null ? http1.getProtocol() : http2.getProtocol()); 206 207 SslConnectionFactory tls = new SslConnectionFactory(ssl, alpn.getProtocol()); 208 if (http1 != null) { 209 httpsConnector = new ServerConnector(server, tls, alpn, http2, http1); 210 } else { 211 httpsConnector = new ServerConnector(server, tls, alpn, http2); 212 } 213 server.addConnector(httpsConnector); 214 try { 215 httpsConnector.start(); 216 } catch (Exception e) { 217 throw new IllegalStateException(e); 218 } 219 } 220 return this; 221 } 222 223 public List<LogEntry> getLogEntries() { 224 return logEntries; 225 } 226 227 public HttpServer setRepoDir(File repoDir) { 228 this.repoDir = repoDir; 229 return this; 230 } 231 232 public HttpServer setRangeSupport(boolean rangeSupport) { 233 this.rangeSupport = rangeSupport; 234 return this; 235 } 236 237 public HttpServer setWebDav(boolean webDav) { 238 this.webDav = webDav; 239 return this; 240 } 241 242 public HttpServer setExpectSupport(ExpectContinue expectContinue) { 243 this.expectContinue = expectContinue; 244 return this; 245 } 246 247 public HttpServer setChecksumHeader(ChecksumHeader checksumHeader) { 248 this.checksumHeader = checksumHeader; 249 return this; 250 } 251 252 public HttpServer setAuthentication(String username, String password) { 253 this.username = username; 254 this.password = password; 255 return this; 256 } 257 258 public HttpServer setProxyAuthentication(String username, String password) { 259 proxyUsername = username; 260 proxyPassword = password; 261 return this; 262 } 263 264 public HttpServer setConnectionsToClose(int connectionsToClose) { 265 this.connectionsToClose.set(connectionsToClose); 266 return this; 267 } 268 269 public HttpServer setServerErrorsBeforeWorks(int serverErrorsBeforeWorks) { 270 this.serverErrorsBeforeWorks.set(serverErrorsBeforeWorks); 271 return this; 272 } 273 274 public HttpServer start() throws Exception { 275 if (server != null) { 276 return this; 277 } 278 279 HandlerList handlers = new HandlerList(); 280 handlers.addHandler(new ConnectionClosingHandler()); 281 handlers.addHandler(new ServerErrorHandler()); 282 handlers.addHandler(new LogHandler()); 283 handlers.addHandler(new ProxyAuthHandler()); 284 handlers.addHandler(new AuthHandler()); 285 handlers.addHandler(new RedirectHandler()); 286 handlers.addHandler(new RepoHandler()); 287 288 server = new Server(); 289 httpConnector = new ServerConnector(server); 290 server.addConnector(httpConnector); 291 server.setHandler(handlers); 292 server.start(); 293 294 return this; 295 } 296 297 public void stop() throws Exception { 298 if (server != null) { 299 server.stop(); 300 server = null; 301 httpConnector = null; 302 httpsConnector = null; 303 } 304 } 305 306 private class ConnectionClosingHandler extends AbstractHandler { 307 @Override 308 public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) { 309 if (connectionsToClose.getAndDecrement() > 0) { 310 Response jettyResponse = (Response) response; 311 jettyResponse.getHttpChannel().getConnection().close(); 312 } 313 } 314 } 315 316 private class ServerErrorHandler extends AbstractHandler { 317 @Override 318 public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) 319 throws IOException { 320 if (serverErrorsBeforeWorks.getAndDecrement() > 0) { 321 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); 322 writeResponseBodyMessage(response, "Oops, come back later!"); 323 } 324 } 325 } 326 327 private class LogHandler extends AbstractHandler { 328 @Override 329 public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) { 330 LOGGER.info( 331 "{} {}{}", 332 req.getMethod(), 333 req.getRequestURL(), 334 req.getQueryString() != null ? "?" + req.getQueryString() : ""); 335 336 Map<String, String> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); 337 for (Enumeration<String> en = req.getHeaderNames(); en.hasMoreElements(); ) { 338 String name = en.nextElement(); 339 StringBuilder buffer = new StringBuilder(128); 340 for (Enumeration<String> ien = req.getHeaders(name); ien.hasMoreElements(); ) { 341 if (buffer.length() > 0) { 342 buffer.append(", "); 343 } 344 buffer.append(ien.nextElement()); 345 } 346 headers.put(name, buffer.toString()); 347 } 348 logEntries.add(new LogEntry(req.getMethod(), req.getPathInfo(), Collections.unmodifiableMap(headers))); 349 } 350 } 351 352 private static final Pattern SIMPLE_RANGE = Pattern.compile("bytes=([0-9])+-"); 353 354 private class RepoHandler extends AbstractHandler { 355 @Override 356 public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) 357 throws IOException { 358 String path = req.getPathInfo().substring(1); 359 360 if (!path.startsWith("repo/")) { 361 return; 362 } 363 req.setHandled(true); 364 365 if (ExpectContinue.FAIL.equals(expectContinue) && request.getHeader(HttpHeader.EXPECT.asString()) != null) { 366 response.setStatus(HttpServletResponse.SC_EXPECTATION_FAILED); 367 writeResponseBodyMessage(response, "Expectation was set to fail"); 368 return; 369 } 370 371 File file = new File(repoDir, path.substring(5)); 372 if (HttpMethod.GET.is(req.getMethod()) || HttpMethod.HEAD.is(req.getMethod())) { 373 if (!file.isFile() || path.endsWith(URIUtil.SLASH)) { 374 response.setStatus(HttpServletResponse.SC_NOT_FOUND); 375 writeResponseBodyMessage(response, "Not found"); 376 return; 377 } 378 long ifUnmodifiedSince = request.getDateHeader(HttpHeader.IF_UNMODIFIED_SINCE.asString()); 379 if (ifUnmodifiedSince != -1L && file.lastModified() > ifUnmodifiedSince) { 380 response.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED); 381 writeResponseBodyMessage(response, "Precondition failed"); 382 return; 383 } 384 long offset = 0L; 385 String range = request.getHeader(HttpHeader.RANGE.asString()); 386 if (range != null && rangeSupport) { 387 Matcher m = SIMPLE_RANGE.matcher(range); 388 if (m.matches()) { 389 offset = Long.parseLong(m.group(1)); 390 if (offset >= file.length()) { 391 response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); 392 writeResponseBodyMessage(response, "Range not satisfiable"); 393 return; 394 } 395 } 396 String encoding = request.getHeader(HttpHeader.ACCEPT_ENCODING.asString()); 397 if ((encoding != null && !"identity".equals(encoding)) || ifUnmodifiedSince == -1L) { 398 response.setStatus(HttpServletResponse.SC_BAD_REQUEST); 399 return; 400 } 401 } 402 response.setStatus((offset > 0L) ? HttpServletResponse.SC_PARTIAL_CONTENT : HttpServletResponse.SC_OK); 403 response.setDateHeader(HttpHeader.LAST_MODIFIED.asString(), file.lastModified()); 404 response.setHeader(HttpHeader.CONTENT_LENGTH.asString(), Long.toString(file.length() - offset)); 405 if (offset > 0L) { 406 response.setHeader( 407 HttpHeader.CONTENT_RANGE.asString(), 408 "bytes " + offset + "-" + (file.length() - 1L) + "/" + file.length()); 409 } 410 if (checksumHeader != null) { 411 Map<String, String> checksums = ChecksumAlgorithmHelper.calculate( 412 file, Collections.singletonList(new Sha1ChecksumAlgorithmFactory())); 413 if (checksumHeader == ChecksumHeader.NEXUS) { 414 response.setHeader(HttpHeader.ETAG.asString(), "{SHA1{" + checksums.get("SHA-1") + "}}"); 415 } else if (checksumHeader == ChecksumHeader.XCHECKSUM) { 416 response.setHeader("x-checksum-sha1", checksums.get(Sha1ChecksumAlgorithmFactory.NAME)); 417 } 418 } 419 if (HttpMethod.HEAD.is(req.getMethod())) { 420 return; 421 } 422 FileInputStream is = null; 423 try { 424 is = new FileInputStream(file); 425 if (offset > 0L) { 426 long skipped = is.skip(offset); 427 while (skipped < offset && is.read() >= 0) { 428 skipped++; 429 } 430 } 431 IO.copy(is, response.getOutputStream()); 432 is.close(); 433 is = null; 434 } finally { 435 try { 436 if (is != null) { 437 is.close(); 438 } 439 } catch (final IOException e) { 440 // Suppressed due to an exception already thrown in the try block. 441 } 442 } 443 } else if (HttpMethod.PUT.is(req.getMethod())) { 444 if (!webDav) { 445 file.getParentFile().mkdirs(); 446 } 447 if (file.getParentFile().exists()) { 448 try { 449 FileOutputStream os = null; 450 try { 451 os = new FileOutputStream(file); 452 IO.copy(request.getInputStream(), os); 453 os.close(); 454 os = null; 455 } finally { 456 try { 457 if (os != null) { 458 os.close(); 459 } 460 } catch (final IOException e) { 461 // Suppressed due to an exception already thrown in the try block. 462 } 463 } 464 } catch (IOException e) { 465 file.delete(); 466 throw e; 467 } 468 response.setStatus(HttpServletResponse.SC_NO_CONTENT); 469 } else { 470 response.setStatus(HttpServletResponse.SC_FORBIDDEN); 471 } 472 } else if (HttpMethod.OPTIONS.is(req.getMethod())) { 473 if (webDav) { 474 response.setHeader("DAV", "1,2"); 475 } 476 response.setHeader(HttpHeader.ALLOW.asString(), "GET, PUT, HEAD, OPTIONS"); 477 response.setStatus(HttpServletResponse.SC_OK); 478 } else if (webDav && "MKCOL".equals(req.getMethod())) { 479 if (file.exists()) { 480 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED); 481 } else if (file.mkdir()) { 482 response.setStatus(HttpServletResponse.SC_CREATED); 483 } else { 484 response.setStatus(HttpServletResponse.SC_CONFLICT); 485 } 486 } else { 487 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED); 488 } 489 } 490 } 491 492 private void writeResponseBodyMessage(HttpServletResponse response, String message) throws IOException { 493 try (OutputStream outputStream = response.getOutputStream()) { 494 outputStream.write(message.getBytes(StandardCharsets.UTF_8)); 495 } 496 } 497 498 private class RedirectHandler extends AbstractHandler { 499 @Override 500 public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) { 501 String path = req.getPathInfo(); 502 if (!path.startsWith("/redirect/")) { 503 return; 504 } 505 req.setHandled(true); 506 StringBuilder location = new StringBuilder(128); 507 String scheme = req.getParameter("scheme"); 508 location.append(scheme != null ? scheme : req.getScheme()); 509 location.append("://"); 510 location.append(req.getServerName()); 511 location.append(":"); 512 if ("http".equalsIgnoreCase(scheme)) { 513 location.append(getHttpPort()); 514 } else if ("https".equalsIgnoreCase(scheme)) { 515 location.append(getHttpsPort()); 516 } else { 517 location.append(req.getServerPort()); 518 } 519 location.append("/repo").append(path.substring(9)); 520 response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY); 521 response.setHeader(HttpHeader.LOCATION.asString(), location.toString()); 522 } 523 } 524 525 private class AuthHandler extends AbstractHandler { 526 @Override 527 public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) 528 throws IOException { 529 if (ExpectContinue.BROKEN.equals(expectContinue) 530 && "100-continue".equalsIgnoreCase(request.getHeader(HttpHeader.EXPECT.asString()))) { 531 request.getInputStream(); 532 } 533 534 if (username != null && password != null) { 535 if (checkBasicAuth(request.getHeader(HttpHeader.AUTHORIZATION.asString()), username, password)) { 536 return; 537 } 538 req.setHandled(true); 539 response.setHeader(HttpHeader.WWW_AUTHENTICATE.asString(), "basic realm=\"Test-Realm\""); 540 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); 541 } 542 } 543 } 544 545 private class ProxyAuthHandler extends AbstractHandler { 546 @Override 547 public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) { 548 if (proxyUsername != null && proxyPassword != null) { 549 if (checkBasicAuth( 550 request.getHeader(HttpHeader.PROXY_AUTHORIZATION.asString()), proxyUsername, proxyPassword)) { 551 return; 552 } 553 req.setHandled(true); 554 response.setHeader(HttpHeader.PROXY_AUTHENTICATE.asString(), "basic realm=\"Test-Realm\""); 555 response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED); 556 } 557 } 558 } 559 560 static boolean checkBasicAuth(String credentials, String username, String password) { 561 if (credentials != null) { 562 int space = credentials.indexOf(' '); 563 if (space > 0) { 564 String method = credentials.substring(0, space); 565 if ("basic".equalsIgnoreCase(method)) { 566 credentials = credentials.substring(space + 1); 567 credentials = new String(Base64.getDecoder().decode(credentials), StandardCharsets.ISO_8859_1); 568 int i = credentials.indexOf(':'); 569 if (i > 0) { 570 String user = credentials.substring(0, i); 571 String pass = credentials.substring(i + 1); 572 if (username.equals(user) && password.equals(pass)) { 573 return true; 574 } 575 } 576 } 577 } 578 } 579 return false; 580 } 581}