/**
* Copyright (C) 2009-2013 FoundationDB, LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.foundationdb.http;
import com.foundationdb.server.error.AkibanInternalException;
import com.foundationdb.server.service.Service;
import com.foundationdb.server.service.config.ConfigurationService;
import com.foundationdb.server.service.monitor.MonitorService;
import com.foundationdb.server.service.monitor.ServerMonitor;
import com.foundationdb.server.service.security.SecurityService;
import com.foundationdb.server.service.session.Session;
import com.foundationdb.server.service.session.SessionService;
import com.foundationdb.sql.server.ServerSessionMonitor;
import com.google.inject.Inject;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.io.AsyncEndPoint;
import org.eclipse.jetty.io.Connection;
import org.eclipse.jetty.io.nio.AsyncConnection;
import org.eclipse.jetty.io.nio.SslConnection;
import org.eclipse.jetty.plus.jaas.JAASLoginService;
import org.eclipse.jetty.security.Authenticator;
import org.eclipse.jetty.security.ConstraintMapping;
import org.eclipse.jetty.security.ConstraintSecurityHandler;
import org.eclipse.jetty.security.LoginService;
import org.eclipse.jetty.security.SpnegoLoginService;
import org.eclipse.jetty.security.authentication.BasicAuthenticator;
import org.eclipse.jetty.security.authentication.DigestAuthenticator;
import org.eclipse.jetty.security.authentication.SpnegoAuthenticator;
import org.eclipse.jetty.server.AsyncHttpConnection;
import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.server.nio.SelectChannelConnector;
import org.eclipse.jetty.server.ssl.SslSelectChannelConnector;
import org.eclipse.jetty.servlet.ServletMapping;
import org.eclipse.jetty.servlets.CrossOriginFilter;
import org.eclipse.jetty.util.security.Constraint;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.FilterRegistration;
import javax.servlet.ServletException;
import java.net.MalformedURLException;
import java.nio.channels.SocketChannel;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static com.foundationdb.http.SecurityServiceLoginService.CredentialType;
public final class HttpConductorImpl implements HttpConductor, Service {
private static final Logger logger = LoggerFactory.getLogger(HttpConductorImpl.class);
private static final String CONFIG_REALM = "fdbsql.security.realm"; // See also SecurityServiceImpl
private static final String CONFIG_HTTP_PREFIX = "fdbsql.http.";
private static final String CONFIG_HOST_PROPERTY = CONFIG_HTTP_PREFIX + "host";
private static final String CONFIG_PORT_PROPERTY = CONFIG_HTTP_PREFIX + "port";
private static final String CONFIG_SSL_PROPERTY = CONFIG_HTTP_PREFIX + "ssl";
private static final String CONFIG_LOGIN_PROPERTY = CONFIG_HTTP_PREFIX + "login";
private static final String CONFIG_LOGIN_CACHE_SECONDS = CONFIG_HTTP_PREFIX + "login_cache_seconds";
private static final String CONFIG_XORIGIN_PREFIX = CONFIG_HTTP_PREFIX + "cross_origin.";
private static final String CONFIG_XORIGIN_ENABLED = CONFIG_XORIGIN_PREFIX + "enabled";
private static final String CONFIG_XORIGIN_ORIGINS = CONFIG_XORIGIN_PREFIX + "allowed_origins";
private static final String CONFIG_XORIGIN_METHODS = CONFIG_XORIGIN_PREFIX + "allowed_methods";
private static final String CONFIG_XORIGIN_HEADERS = CONFIG_XORIGIN_PREFIX + "allowed_headers";
private static final String CONFIG_XORIGIN_MAX_AGE = CONFIG_XORIGIN_PREFIX + "preflight_max_age";
private static final String CONFIG_XORIGIN_CREDENTIALS = CONFIG_XORIGIN_PREFIX + "allow_credentials";
private static final String CONFIG_CSRF_PREFIX = CONFIG_HTTP_PREFIX + "csrf_protection.";
private static final String CONFIG_CSRF_TYPE = CONFIG_CSRF_PREFIX + "type";
private static final String CONFIG_CSRF_ALLOWED_REFERERS = CONFIG_CSRF_PREFIX + "allowed_referers";
private static final String CONFIG_COMMON_PREFIX = "fdbsql.sql.";
private static final String CONFIG_COMMON_JAAS_PREFIX = CONFIG_COMMON_PREFIX + "jaas.";
private static final String CONFIG_JAAS_PREFIX = CONFIG_HTTP_PREFIX + "jaas.";
private static final String CONFIG_COMMON_SPNEGO_PREFIX = CONFIG_COMMON_PREFIX + "spnego.";
private static final String CONFIG_SPNEGO_PREFIX = CONFIG_HTTP_PREFIX + "spnego.";
private static final String REST_ROLE = "rest-user";
public static final String SERVER_TYPE = "REST";
private final ConfigurationService configurationService;
private final SecurityService securityService;
private final MonitorService monitorService;
private final SessionService sessionService;
private final Object lock = new Object();
private ServletContextHandler rootContextHandler;
private Server server;
private Set<String> registeredPaths;
private volatile int port = -1;
// Need reference to prevent GC and setting loss
private final java.util.logging.Logger jerseyLogging;
@Inject
public HttpConductorImpl(ConfigurationService configurationService,
SecurityService securityService,
MonitorService monitor,
SessionService session) {
this.configurationService = configurationService;
this.securityService = securityService;
this.monitorService = monitor;
this.sessionService = session;
jerseyLogging = java.util.logging.Logger.getLogger("com.sun.jersey");
jerseyLogging.setLevel(java.util.logging.Level.OFF);
}
@Override
public void registerHandler(ServletHolder servlet, String path) {
String contextBase = getContextPathPrefix(path);
synchronized(lock) {
if(!registeredPaths.add(contextBase)) {
throw new IllegalPathRequest("context already reserved: " + contextBase);
}
try {
rootContextHandler.addServlet(servlet, path);
if(!servlet.isStarted()) {
servlet.start();
}
} catch (Exception e) {
throw new HttpConductorException(e);
}
}
}
@Override
public void unregisterHandler(ServletHolder servlet) {
synchronized(lock) {
ServletHandler servletHandler = rootContextHandler.getServletHandler();
ServletHolder[] curServlets = servletHandler.getServlets();
List<ServletHolder> newServlets = new ArrayList<>();
newServlets.addAll(Arrays.asList(curServlets));
if(!newServlets.remove(servlet)) {
throw new IllegalArgumentException("Servlet not registered");
}
List<ServletMapping> newMappings = new ArrayList<>();
newMappings.addAll(Arrays.asList(servletHandler.getServletMappings()));
for(Iterator<ServletMapping> it = newMappings.iterator(); it.hasNext(); ) {
ServletMapping m = it.next();
if(servlet.getName().equals(m.getServletName())) {
for(String path : m.getPathSpecs()) {
registeredPaths.remove(path);
}
it.remove();
break;
}
}
servletHandler.setServlets(newServlets.toArray(new ServletHolder[newServlets.size()]));
servletHandler.setServletMappings(newMappings.toArray(new ServletMapping[newMappings.size()]));
if(!servlet.isStopped()) {
try {
servlet.stop();
}
catch(Exception e) {
throw new HttpConductorException(e);
}
}
}
}
@Override
public int getPort() {
return port;
}
private static enum CsrfProtectionType {
NONE,
REFERER
}
private static enum AuthenticationType {
NONE(null, null),
BASIC(CredentialType.BASIC, BasicAuthenticator.class),
DIGEST(CredentialType.DIGEST, DigestAuthenticator.class),
SPNEGO(null, SpnegoAuthenticatorEx.class);
public CredentialType getCredentialType() {
return credentialType;
}
public Authenticator createAuthenticator() throws IllegalAccessException, InstantiationException {
return authenticatorClass.newInstance();
}
private AuthenticationType(CredentialType credentialType, Class<? extends Authenticator> authenticatorClass) {
this.credentialType = credentialType;
this.authenticatorClass = authenticatorClass;
}
private final CredentialType credentialType;
private final Class<? extends Authenticator> authenticatorClass;
}
private AuthenticationType safeParseAuthentication(String propName) {
String propValue = configurationService.getProperty(propName);
try {
return AuthenticationType.valueOf(propValue.toUpperCase());
} catch(IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid " + propName + " property: " + propValue);
}
}
private CsrfProtectionType safeParseCsrfType(String propName) {
String propValue = configurationService.getProperty(propName);
try {
return CsrfProtectionType.valueOf(propValue.toUpperCase());
} catch(IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid " + propName + " property: " + propValue);
}
}
private int safeParseInt(String propName) {
String propValue = configurationService.getProperty(propName);
try {
return Integer.parseInt(propValue);
}
catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid " + propName + " property: " + propValue);
}
}
@Override
public void start() {
String sslProperty = configurationService.getProperty(CONFIG_SSL_PROPERTY);
int portLocal = safeParseInt(CONFIG_PORT_PROPERTY);
String hostLocal = configurationService.getProperty(CONFIG_HOST_PROPERTY);
int loginCacheSeconds = safeParseInt(CONFIG_LOGIN_CACHE_SECONDS);
AuthenticationType login = safeParseAuthentication(CONFIG_LOGIN_PROPERTY);
boolean sslOn = Boolean.parseBoolean(sslProperty);
boolean crossOriginOn = Boolean.parseBoolean(configurationService.getProperty(CONFIG_XORIGIN_ENABLED));
logger.info("Starting {} server listening on {}:{} with authentication {} and CORS {}",
new Object[] { sslOn ? "HTTPS" : "HTTP", hostLocal, portLocal, login, crossOriginOn ? "on" : "off"});
Server localServer = new Server();
SelectChannelConnector connector;
if(!sslOn) {
connector = new SelectChannelConnectorExtended();
} else {
// Share keystore configuration with PSQL.
SslContextFactory sslFactory = new SslContextFactory();
sslFactory.setKeyStorePath(System.getProperty("javax.net.ssl.keyStore"));
sslFactory.setKeyStorePassword(System.getProperty("javax.net.ssl.keyStorePassword"));
connector = new SslSelectChannelConnectorExtended(sslFactory);
}
connector.setHost(hostLocal);
connector.setPort(portLocal);
connector.setThreadPool(new QueuedThreadPool(200));
connector.setAcceptors(4);
connector.setMaxIdleTime(300000);
connector.setAcceptQueueSize(12000);
connector.setLowResourcesConnections(25000);
connector.setStatsOn(true);
localServer.setConnectors(new Connector[]{connector});
monitorService.registerServerMonitor(new ConnectionMonitor(connector));
ServletContextHandler localRootContextHandler = new ServletContextHandler();
localRootContextHandler.setContextPath("/");
localServer.addBean(new JsonErrorHandler());
try {
if (login != AuthenticationType.NONE) {
Authenticator authenticator = login.createAuthenticator();
Constraint constraint = new Constraint(authenticator.getAuthMethod(), REST_ROLE);
constraint.setAuthenticate(true);
ConstraintMapping cm = new ConstraintMapping();
cm.setPathSpec("/*");
cm.setConstraint(constraint);
String realm = configurationService.getProperty(CONFIG_REALM);
ConstraintSecurityHandler sh =
crossOriginOn ? new CrossOriginConstraintSecurityHandler() : new ConstraintSecurityHandler();
sh.setAuthenticator(authenticator);
sh.setConstraintMappings(Collections.singletonList(cm));
sh.setRealmName(realm);
LoginService loginService;
if (login == AuthenticationType.SPNEGO) {
Properties spnegoProps = configurationService.deriveProperties(CONFIG_COMMON_SPNEGO_PREFIX);
spnegoProps.putAll(configurationService.deriveProperties(CONFIG_SPNEGO_PREFIX));
File propFile = File.createTempFile("spnego", ".properties");
try (FileOutputStream ostr = new FileOutputStream(propFile)) {
spnegoProps.store(ostr, "SPNEGO config subset");
}
catch (IOException ex) {
throw new AkibanInternalException("Error writing temp file", ex);
}
String spnegoConfig;
try {
spnegoConfig = propFile.toURI().toURL().toString();
}
catch (MalformedURLException ex) {
throw new AkibanInternalException("Error getting temp URL", ex);
}
SpnegoLoginService spnegoLoginService = new SpnegoLoginService(realm, spnegoConfig);
loginService = new HybridLoginService(spnegoLoginService, securityService);
}
else {
Properties jaasProps = configurationService.deriveProperties(CONFIG_COMMON_JAAS_PREFIX);
jaasProps.putAll(configurationService.deriveProperties(CONFIG_JAAS_PREFIX));
if (jaasProps.getProperty("configName") != null) {
JAASLoginService jaasLoginService = new JAASLoginService(realm);
jaasLoginService.setLoginModuleName(jaasProps.getProperty("configName"));
if (jaasProps.getProperty("roleClasses") != null) {
jaasLoginService.setRoleClassNames(jaasProps.getProperty("roleClasses").split(",\\s+"));
loginService = jaasLoginService;
}
else {
loginService = new HybridLoginService(jaasLoginService, securityService);
}
}
else {
loginService = new SecurityServiceLoginService(securityService, login.getCredentialType(), loginCacheSeconds, realm);
}
}
sh.setLoginService(loginService);
localRootContextHandler.setSecurityHandler(sh);
}
if (crossOriginOn) {
addCrossOriginFilter(localRootContextHandler);
}
addCsrfFilter(localRootContextHandler);
localServer.setHandler(localRootContextHandler);
localServer.start();
}
catch (Exception e) {
logger.error("failed to start HTTP server", e);
throw new HttpConductorException(e);
}
synchronized (lock) {
this.server = localServer;
this.rootContextHandler = localRootContextHandler;
this.registeredPaths = new HashSet<>();
this.port = portLocal;
}
}
@Override
public void stop() {
Server localServer;
monitorService.deregisterServerMonitor(monitorService.getServerMonitors().get(SERVER_TYPE));
synchronized (lock) {
localServer = server;
server = null;
registeredPaths = null;
port = -1;
}
try {
localServer.stop();
}
catch (Exception e) {
logger.error("failed to stop HTTP server", e);
throw new HttpConductorException(e);
}
}
@Override
public void crash() {
stop();
}
private void addCsrfFilter(ContextHandler handler) throws ServletException {
CsrfProtectionType type = safeParseCsrfType(CONFIG_CSRF_TYPE);
switch (type) {
case NONE:
break;
case REFERER:
FilterRegistration reg = handler.getServletContext().addFilter("CSRFFilter", CsrfProtectionRefererFilter.class);
reg.addMappingForServletNames(null, false, "*");
reg.setInitParameter(CsrfProtectionRefererFilter.ALLOWED_REFERERS_PARAM,
configurationService.getProperty(CONFIG_CSRF_ALLOWED_REFERERS));
break;
default:
throw new IllegalArgumentException("Invalid " + CONFIG_CSRF_TYPE + " property: " + type);
}
}
private void addCrossOriginFilter(ContextHandler handler) throws ServletException {
FilterRegistration reg = handler.getServletContext().addFilter("CrossOriginFilter", CrossOriginFilter.class);
reg.addMappingForServletNames(null, false, "*");
reg.setInitParameter(CrossOriginFilter.ALLOWED_ORIGINS_PARAM,
configurationService.getProperty(CONFIG_XORIGIN_ORIGINS));
reg.setInitParameter(CrossOriginFilter.ALLOWED_METHODS_PARAM,
configurationService.getProperty(CONFIG_XORIGIN_METHODS));
reg.setInitParameter(CrossOriginFilter.ALLOWED_HEADERS_PARAM,
configurationService.getProperty(CONFIG_XORIGIN_HEADERS));
reg.setInitParameter(CrossOriginFilter.PREFLIGHT_MAX_AGE_PARAM,
configurationService.getProperty(CONFIG_XORIGIN_MAX_AGE));
reg.setInitParameter(CrossOriginFilter.ALLOW_CREDENTIALS_PARAM,
configurationService.getProperty(CONFIG_XORIGIN_CREDENTIALS));
}
static String getContextPathPrefix(String contextPath) {
if (!contextPath.startsWith("/"))
throw new IllegalPathRequest("registered paths must start with '/'");
int contextBaseEnd = contextPath.indexOf("/", 1);
if (contextBaseEnd < 0)
contextBaseEnd = contextPath.length();
String result = contextPath.substring(1, contextBaseEnd);
if (result.contains("*"))
throw new IllegalPathRequest("can't ask for a glob within the first URL segment");
return result;
}
private class ConnectionMonitor implements ServerMonitor {
private final SelectChannelConnector connector;
private final AtomicLong _statsStartedAt = new AtomicLong(System.currentTimeMillis());
public ConnectionMonitor(SelectChannelConnector connector) {
this.connector = connector;
}
@Override
public String getServerType() {
return SERVER_TYPE;
}
@Override
public int getLocalPort() {
return connector.getPort();
}
@Override
public String getLocalHost() {
return connector.getHost();
}
@Override
public long getStartTimeMillis() {
return _statsStartedAt.get();
}
@Override
public int getSessionCount() {
return connector.getConnections();
}
}
private class SelectChannelConnectorExtended extends SelectChannelConnector {
private Session session;
@Override
protected AsyncConnection newConnection(SocketChannel channel,final AsyncEndPoint endpoint)
{
AsyncHttpConnection conn = (AsyncHttpConnection)super.newConnection(channel, endpoint);
ServerSessionMonitor sessionMonitor = new ServerSessionMonitor(SERVER_TYPE,
monitorService.allocateSessionId());
conn.setAssociatedObject(sessionMonitor);
this.session = sessionService.createSession();
monitorService.registerSessionMonitor(sessionMonitor, session);
return conn;
}
@Override
protected void connectionClosed(Connection connection) {
if (connection instanceof AsyncHttpConnection) {
AsyncHttpConnection conn = (AsyncHttpConnection)connection;
ServerSessionMonitor monitor = (ServerSessionMonitor)conn.getAssociatedObject();
if (monitor != null) {
monitorService.deregisterSessionMonitor(monitor, session);
conn.setAssociatedObject(null);
}
}
super.connectionClosed(connection);
}
}
private class SslSelectChannelConnectorExtended extends SslSelectChannelConnector {
private Session session;
public SslSelectChannelConnectorExtended(SslContextFactory sslFactory) {
super(sslFactory);
}
@Override
protected AsyncConnection newConnection(SocketChannel channel,final AsyncEndPoint endpoint)
{
AsyncHttpConnection conn = (AsyncHttpConnection)((SslConnection)super.newConnection(channel, endpoint)).getSslEndPoint().getConnection();
ServerSessionMonitor sessionMonitor = new ServerSessionMonitor(SERVER_TYPE,
monitorService.allocateSessionId());
conn.setAssociatedObject(sessionMonitor);
this.session = sessionService.createSession();
monitorService.registerSessionMonitor(sessionMonitor, session);
return conn;
}
@Override
protected void connectionClosed (Connection connection) {
if (connection instanceof SslConnection) {
AsyncHttpConnection conn = (AsyncHttpConnection)((SslConnection) connection).getSslEndPoint().getConnection();
ServerSessionMonitor monitor = (ServerSessionMonitor)conn.getAssociatedObject();
if (monitor != null) {
monitorService.deregisterSessionMonitor(monitor, session);
conn.setAssociatedObject(null);
}
}
super.connectionClosed(connection);
}
}
}