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}