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}