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