blob: 05d5ac466fc7accd5407cadf96aeadc269ff4007 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2008-2010 Sonatype, Inc.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Sonatype, Inc. - initial API and implementation
*******************************************************************************/
package org.eclipse.m2e.tests.common;
import static org.junit.Assert.fail;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpVersion;
import org.eclipse.jetty.security.ConstraintMapping;
import org.eclipse.jetty.security.ConstraintSecurityHandler;
import org.eclipse.jetty.security.HashLoginService;
import org.eclipse.jetty.security.SecurityHandler;
import org.eclipse.jetty.security.authentication.BasicAuthenticator;
import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.HttpConnectionFactory;
import org.eclipse.jetty.server.NetworkConnector;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.SslConnectionFactory;
import org.eclipse.jetty.server.handler.AbstractHandler;
import org.eclipse.jetty.server.handler.DefaultHandler;
import org.eclipse.jetty.server.handler.HandlerList;
import org.eclipse.jetty.util.IO;
import org.eclipse.jetty.util.URIUtil;
import org.eclipse.jetty.util.security.Constraint;
import org.eclipse.jetty.util.ssl.SslContextFactory;
/**
* A helper for the tests to start an embedded HTTP server powered by Jetty. Create an instance of this class, use its
* mutators to configure the server and finally call {@link #start()}.
*
* @author Benjamin Bentmann
*/
@SuppressWarnings("synthetic-access")
public class HttpServer {
private Server server;
private int httpPort;
private int httpsPort = -1;
private String keyStoreLocation = "resources/ssl/keystore";
private String keyStorePassword;
private String trustStoreLocation;
private String trustStorePassword;
private boolean needClientAuth;
private String proxyUsername;
private String proxyPassword;
private boolean redirectToHttps;
private long latency;
private Map<String, String> userPasswords = new HashMap<String, String>();
private Map<String, String[]> userRoles = new HashMap<String, String[]>();
private Map<String, String[]> securedRealms = new HashMap<String, String[]>();
private Map<String, File> resourceDirs = new TreeMap<String, File>(Collections.reverseOrder());
private Map<String, String[]> resourceFilters = new HashMap<String, String[]>();
private Map<String, String> filterTokens = new HashMap<String, String>();
private Collection<String> recordedPatterns = new HashSet<String>();
private List<String> recordedRequests = new ArrayList<String>();
private Map<String, Map<String, String>> recordedHeaders = new HashMap<String, Map<String, String>>();
private String storePassword;
protected Connector newHttpConnector() {
HttpConfiguration config = new HttpConfiguration();
config.setSecurePort(httpsPort);
ServerConnector connector = new ServerConnector(server, new HttpConnectionFactory(config));
connector.setPort(httpPort);
return connector;
}
protected Connector newHttpsConnector() {
SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
sslContextFactory.setKeyManagerPassword(storePassword);
sslContextFactory.setKeyStorePath(new File(keyStoreLocation).getAbsolutePath());
sslContextFactory.setKeyStorePassword(keyStorePassword);
if(trustStoreLocation != null && !trustStoreLocation.equals("")) {
sslContextFactory.setTrustStorePath(new File(trustStoreLocation).getAbsolutePath());
}
if(trustStorePassword != null && !trustStoreLocation.equals("")) {
sslContextFactory.setTrustStorePassword(trustStorePassword);
}
sslContextFactory.setNeedClientAuth(needClientAuth);
ServerConnector connector = new ServerConnector(server,
new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString()));
connector.setPort(httpsPort);
return connector;
}
/**
* Sets the port to use for HTTP connections.
*
* @param httpPort The port to use, may be {@code 0} to pick a random port (default), if negative the HTTP connector
* will be disabled.
* @return This server, never {@code null}.
*/
public HttpServer setHttpPort(int httpPort) {
this.httpPort = httpPort;
return this;
}
/**
* Gets the port number of the server's HTTP connector.
*
* @return The port number of the server's HTTP connector.
*/
public int getHttpPort() {
if(httpPort >= 0 && server != null && server.isRunning()) {
return ((NetworkConnector) server.getConnectors()[0]).getLocalPort();
}
return httpPort;
}
/**
* Gets the base URL to the server's HTTP connector, e.g. {@code "http://localhost:8080"}.
*
* @return The base URL without trailing slash to the server's HTTP connector, never {@code null}.
*/
public String getHttpUrl() {
return "http://localhost:" + getHttpPort();
}
/**
* Sets the port to use for HTTPS connections.
*
* @param httpPort The port to use, may be {@code 0} to pick a random port, if negative the HTTPS connector will be
* disabled (default).
* @return This server, never {@code null}.
*/
public HttpServer setHttpsPort(int httpsPort) {
this.httpsPort = httpsPort;
return this;
}
/**
* Gets the port number of the server's HTTPS connector.
*
* @return The port number of the server's HTTPS connector.
*/
public int getHttpsPort() {
if(httpsPort >= 0 && server != null && server.isRunning()) {
return ((NetworkConnector) server.getConnectors()[(httpPort < 0) ? 0 : 1]).getLocalPort();
}
return httpsPort;
}
/**
* Gets the base URL to the server's HTTPS connector, e.g. {@code "https://localhost:8080"}.
*
* @return The base URL without trailing slash to the server's HTTPS connector, never {@code null}.
*/
public String getHttpsUrl() {
return "https://localhost:" + getHttpsPort();
}
/**
* Sets the keystore to use for the server certificate on the SSL connector. Also sets the storePassword value to be
* password, if it has not been set previously.
*
* @param path The path to the keystore to use for the server certificate, may be {@code null}.
* @param password The password for the keystore, may be {@code null}.
* @return This server, never {@code null}.
*/
public HttpServer setKeyStore(String path, String password) {
keyStoreLocation = path;
keyStorePassword = password;
if(storePassword == null) {
storePassword = keyStorePassword;
}
return this;
}
/**
* Sets the truststore to use for validating client credentials via the SSL connector.
*
* @param path The path to the truststore to use for the trusted client certificates, may be {@code null}.
* @param password The password for the truststore, may be {@code null}.
* @return This server, never {@code null}.
*/
public HttpServer setTrustStore(String path, String password) {
trustStoreLocation = path;
trustStorePassword = password;
return this;
}
/**
* Sets the password to use for the SSL connector store.
*
* @param password The password for the store, may be {@code null}.
* @return This server, never {@code null}.
*/
public HttpServer setStorePassword(String password) {
this.storePassword = password;
return this;
}
/**
* Enables/disables client-side certificate authentication.
*
* @param needClientAuth Whether the server should reject clients whose certificate can't be verified via the
* truststore.
* @return This server, never {@code null}.
*/
public HttpServer setNeedClientAuth(boolean needClientAuth) {
this.needClientAuth = needClientAuth;
return this;
}
/**
* Sets the credentials to use for proxy authentication. If either username or password is {@code null}, no proxy
* authentication is required.
*
* @param username The username, may be {@code null}.
* @param password The password, may be {@code null}.
* @return This server, never {@code null}.
*/
public HttpServer setProxyAuth(String username, String password) {
this.proxyUsername = username;
this.proxyPassword = password;
return this;
}
protected Handler newProxyHandler() {
return new AbstractHandler() {
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) {
String auth = request.getHeader("Proxy-Authorization");
if(auth != null) {
auth = auth.substring(auth.indexOf(' ') + 1).trim();
auth = new String(Base64.getDecoder().decode(auth));
}
}
};
}
/**
* Enforces redirection from HTTP to HTTPS.
*
* @param redirectToHttps {@code true} to redirect any HTTP requests to HTTPS, {@code false} to handle HTTP normally.
* @return This server, never {@code null}.
*/
public HttpServer setRedirectToHttps(boolean redirectToHttps) {
this.redirectToHttps = redirectToHttps;
return this;
}
protected Handler newSslRedirectHandler() {
return new AbstractHandler() {
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) {
int httpsPort = getHttpsPort();
if(!((Request) request).isHandled() && request.getServerPort() != httpsPort) {
String url = "https://" + request.getServerName() + ":" + httpsPort + request.getRequestURI();
response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
response.setHeader("Location", url);
((Request) request).setHandled(true);
}
}
};
}
/**
* Registers a user.
*
* @param username The username, must not be {@code null}.
* @param password The password, must not be {@code null}.
* @param roles The roles of the user, may be empty or {@code null}.
* @return This server, never {@code null}.
*/
public HttpServer addUser(String username, String password, String... roles) {
userPasswords.put(username, password);
userRoles.put(username, (roles == null) ? new String[0] : roles);
return this;
}
/**
* Sets up a security realm.
*
* @param pathSpec The path to secure, e.g. {@code "/files/*"}, must not be {@code null}.
* @param roles The roles that have access to the realm, may be empty or {@code null}.
* @return This server, never {@code null}.
*/
public HttpServer addSecuredRealm(String pathSpec, String... roles) {
securedRealms.put(pathSpec, (roles == null) ? new String[0] : roles);
return this;
}
protected SecurityHandler newSecurityHandler() {
List<ConstraintMapping> mappings = new ArrayList<ConstraintMapping>();
for(String pathSpec : securedRealms.keySet()) {
String[] roles = securedRealms.get(pathSpec);
Constraint constraint = new Constraint();
constraint.setName(Constraint.__BASIC_AUTH);
constraint.setRoles(roles);
constraint.setAuthenticate(true);
ConstraintMapping constraintMapping = new ConstraintMapping();
constraintMapping.setConstraint(constraint);
constraintMapping.setPathSpec(pathSpec);
mappings.add(constraintMapping);
}
Properties p = new Properties();
for(String username : userPasswords.keySet()) {
String password = userPasswords.get(username);
String[] roles = userRoles.get(username);
StringBuilder entry = new StringBuilder(password);
for(String role : roles) {
entry.append(",");
entry.append(role);
}
p.put(username, entry.toString());
}
File propFile = new File("target/users.properties");
try (FileOutputStream in = new FileOutputStream(propFile)) {
p.store(in, null);
} catch(IOException ex) {
fail("Unable to create users properties file");
}
HashLoginService userRealm = new HashLoginService("TestRealm", "target/users.properties");
server.addBean(userRealm);
ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler();
securityHandler.setAuthenticator(new BasicAuthenticator());
securityHandler.setLoginService(userRealm);
securityHandler.setConstraintMappings(mappings);
return securityHandler;
}
/**
* Adds resources to the server. Resources can be filtered upon serving using the tokens set via
* {@link #setFilterToken(String, String)}. The directory mounted into the server via this method will also be used to
* store files sent via PUT. Upon requests, the server will try to match the context roots in reverse alphabetical
* order, thereby giving longer path prefix matches precedence.
*
* @param contextRoot The context root to make the resources accessible at, must not be {@code null}.
* @param baseDirectory The local base directory whose files should be served, must not be {@code null}.
* @param filteredExtensions A list of extensions for files to filter, e.g. {@code "xml, "properties"}, may be
* {@code null}.
* @return This server, never {@code null}.
*/
public HttpServer addResources(String contextRoot, String baseDirectory, String... filteredExtensions) {
contextRoot = normalizeContextRoot(contextRoot);
File basedir = new File(baseDirectory).getAbsoluteFile();
resourceDirs.put(contextRoot, basedir);
resourceFilters.put(contextRoot, (filteredExtensions == null) ? new String[0] : filteredExtensions);
return this;
}
/**
* Enables request recording for the specified URI patterns. Recorded requests can be retrieved via
* {@link #getRecordedRequests()}.
*
* @param patterns The regular expressions denoting URIs to monitor, e.g. {@code "/context/.*"}, must not be
* {@code null}.
* @return This server, never {@code null}.
*/
public HttpServer enableRecording(String... patterns) {
for(String pattern : patterns) {
recordedPatterns.add(pattern);
}
return this;
}
/**
* Gets the sequence of requests that have been issued against context roots for which
* {@link #enableRecording(String...)} was called. A request is encoded in the form {@code <METHOD> <URI>}, e.g.
* {@code GET /context/some.jar}.
*
* @return The sequence of requests since the server was started, can be empty but never {@code null}.
*/
public List<String> getRecordedRequests() {
return recordedRequests;
}
/**
* Gets the headers sent in the most recent request to the specified path.
*
* @param uri the path
* @return the http request headers
*/
public Map<String, String> getRecordedHeaders(String uri) {
return recordedHeaders.get(uri);
}
/**
* Sets a token to replace during resource filtering. Upon server start, the following tokens will be defined
* automatically: <code>@basedir@</code>, <code>@baseurl@</code>, <code>@baseuri@</code>, <code>@port.http@</code> and
* <code>@port.https@</code>.
*
* @param token The token to replace, e.g. <code>@basedir@</code>, must not be {@code null}.
* @param value The replacement text of the token, may be {@code null}.
* @return This server, never {@code null}.
*/
public HttpServer setFilterToken(String token, String value) {
if(value == null) {
filterTokens.remove(token);
} else {
filterTokens.put(token, value);
}
return this;
}
protected Handler newResourceHandler() {
return new ResHandler();
}
/**
* Sets the latency of the server.
*
* @param millis The latency in milliseconds, maybe negative for infinite delay.
* @return This server, never {@code null}.
*/
public HttpServer setLatency(long millis) {
this.latency = millis;
return this;
}
protected Handler newSleepHandler(final long millis) {
return new AbstractHandler() {
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) {
if(millis >= 0) {
try {
Thread.sleep(millis);
} catch(InterruptedException e) {
e.printStackTrace();
}
} else {
synchronized(this) {
try {
wait();
} catch(InterruptedException e) {
e.printStackTrace();
}
}
}
}
};
}
/**
* Starts the server. Trying to start an already running server has no effect.
*
* @throws Exception If the server could not be started.
* @return This server, never {@code null}.
*/
public HttpServer start() throws Exception {
if(server != null) {
return this;
}
recordedRequests.clear();
server = new Server();
List<Connector> connectors = new ArrayList<Connector>();
if(httpPort >= 0) {
connectors.add(newHttpConnector());
}
if(httpsPort >= 0 && keyStoreLocation != null) {
connectors.add(newHttpsConnector());
}
HandlerList handlerList = new HandlerList();
if(!recordedPatterns.isEmpty()) {
handlerList.addHandler(new RecordingHandler());
}
if(latency != 0) {
handlerList.addHandler(newSleepHandler(latency));
}
if(redirectToHttps) {
handlerList.addHandler(newSslRedirectHandler());
}
if(proxyUsername != null && proxyPassword != null) {
handlerList.addHandler(newProxyHandler());
}
SecurityHandler security = null;
if(!securedRealms.isEmpty()) {
security = newSecurityHandler();
handlerList.addHandler(security);
}
if(!resourceDirs.isEmpty()) {
if(security != null) {
security.setHandler(newResourceHandler());
} else {
handlerList.addHandler(newResourceHandler());
}
}
handlerList.addHandler(new DefaultHandler());
server.setHandler(handlerList);
server.setConnectors(connectors.toArray(new Connector[connectors.size()]));
server.start();
waitForConnectors();
addDefaultFilterTokens();
return this;
}
protected void waitForConnectors() throws Exception {
// for unknown reasons, the connectors occasionally don't start properly, this tries hard to ensure they are up
List<Connector> badConnectors = new ArrayList<Connector>(2);
for(int r = 10; r > 0; r-- ) {
// wait some seconds for the connectors to come up
for(int i = 200; i > 0; i-- ) {
badConnectors.clear();
for(Connector connector : server.getConnectors()) {
if(((NetworkConnector) connector).getLocalPort() < 0) {
badConnectors.add(connector);
}
}
if(badConnectors.isEmpty()) {
return;
}
try {
Thread.sleep(15);
} catch(InterruptedException e) {
return;
}
}
// restart the broken connectors and hope they make it this time
System.err.println("WARNING: " + badConnectors + " did not start properly, restarting");
for(Connector connector : badConnectors) {
connector.stop();
connector.start();
}
}
}
protected void addDefaultFilterTokens() {
if(!filterTokens.containsKey("@basedir@")) {
filterTokens.put("@basedir@", new File("").getAbsolutePath());
}
if(!filterTokens.containsKey("@baseurl@")) {
String baseurl = "file://" + new File("").toURI().getPath();
if(baseurl.endsWith("/")) {
baseurl = baseurl.substring(0, baseurl.length() - 1);
}
filterTokens.put("@baseurl@", baseurl);
}
if(!filterTokens.containsKey("@baseuri@")) {
String baseuri = "file://" + new File("").toURI().getRawPath();
if(baseuri.endsWith("/")) {
baseuri = baseuri.substring(0, baseuri.length() - 1);
}
filterTokens.put("@baseuri@", baseuri);
}
if(!filterTokens.containsKey("@port.http@")) {
filterTokens.put("@port.http@", Integer.toString(getHttpPort()));
}
if(!filterTokens.containsKey("@port.https@")) {
filterTokens.put("@port.https@", Integer.toString(getHttpsPort()));
}
}
/**
* Stops the server. Stopping an already stopped server has no effect.
*/
public void stop() {
if(server != null) {
try {
server.stop();
} catch(Exception e) {
e.printStackTrace();
}
server = null;
}
}
class ResHandler extends AbstractHandler {
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
throws IOException {
String uri = request.getRequestURI();
for(String contextRoot : resourceDirs.keySet()) {
String path = URIUtil.decodePath(trimContextRoot(uri, contextRoot));
if(path != null) {
File basedir = resourceDirs.get(contextRoot);
File file = new File(basedir, path);
if(HttpMethod.HEAD.equals(request.getMethod())) {
if(file.exists())
response.setStatus(HttpServletResponse.SC_OK);
else
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
((Request) request).setHandled(true);
return;
} else if(HttpMethod.PUT.equals(request.getMethod()) || HttpMethod.POST.equals(request.getMethod())) {
file.getParentFile().mkdirs();
FileOutputStream os = new FileOutputStream(file);
try {
IO.copy(request.getInputStream(), os);
} finally {
os.close();
}
response.setStatus(HttpServletResponse.SC_CREATED);
((Request) request).setHandled(true);
} else if(file.isFile()) {
FileInputStream is = new FileInputStream(file);
try {
String filterEncoding = getFilterEncoding(path, resourceFilters.get(contextRoot));
if(filterEncoding == null) {
IO.copy(is, response.getOutputStream());
} else {
String text = IO.toString(is, filterEncoding);
text = filter(text, filterTokens);
response.getOutputStream().write(text.getBytes(filterEncoding));
}
} finally {
is.close();
}
response.setStatus(HttpServletResponse.SC_OK);
((Request) request).setHandled(true);
}
break;
}
}
}
private String getExtension(String path) {
return path.substring(path.lastIndexOf('.') + 1);
}
private String getFilterEncoding(String path, String[] filteredExtensions) {
String ext = getExtension(path);
if(filteredExtensions != null) {
for(String filteredExtension : filteredExtensions) {
if(filteredExtension.startsWith(".")) {
filteredExtension = filteredExtension.substring(1);
}
if(filteredExtension.equalsIgnoreCase(ext)) {
return "properties".equalsIgnoreCase(ext) ? "ISO-8859-1" : "UTF-8";
}
}
}
return null;
}
private String filter(String str, Map<String, String> tokens) {
for(String token : tokens.keySet()) {
str = str.replace(token, tokens.get(token));
}
return str;
}
}
class RecordingHandler extends AbstractHandler {
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) {
String uri = request.getRequestURI();
for(String pattern : recordedPatterns) {
if(uri.matches(pattern)) {
String req = request.getMethod() + " " + uri;
recordedRequests.add(req);
Map<String, String> headers = new HashMap<String, String>();
recordedHeaders.put(uri, headers);
for(Enumeration<String> h = request.getHeaderNames(); h.hasMoreElements();) {
String headername = h.nextElement();
headers.put(headername, request.getHeader(headername));
}
}
}
}
}
private static String normalizeContextRoot(String contextRoot) {
if(contextRoot.endsWith("/")) {
contextRoot = contextRoot.substring(0, contextRoot.length() - 1);
}
if(!contextRoot.startsWith("/")) {
contextRoot = "/" + contextRoot;
}
return contextRoot;
}
private static String trimContextRoot(String uri, String contextRoot) {
if(uri.startsWith(contextRoot)) {
if(contextRoot.length() == 1) {
return uri.substring(1);
} else if(uri.length() > contextRoot.length() && uri.charAt(contextRoot.length()) == '/') {
return uri.substring(contextRoot.length() + 1);
}
}
return null;
}
public void resetRecording() {
recordedHeaders.clear();
recordedRequests.clear();
}
}