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