| // Copyright (C) 2009 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.google.gerrit.pgm.http.jetty; |
| |
| import static com.google.gerrit.httpd.CacheBasedWebSession.MAX_AGE_MINUTES; |
| import static java.util.concurrent.TimeUnit.MILLISECONDS; |
| import static java.util.concurrent.TimeUnit.MINUTES; |
| import static java.util.concurrent.TimeUnit.SECONDS; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Strings; |
| import com.google.gerrit.extensions.client.AuthType; |
| import com.google.gerrit.extensions.events.LifecycleListener; |
| import com.google.gerrit.pgm.http.jetty.HttpLog.HttpLogFactory; |
| import com.google.gerrit.server.config.GerritServerConfig; |
| import com.google.gerrit.server.config.SitePaths; |
| import com.google.gerrit.server.config.ThreadSettingsConfig; |
| import com.google.inject.Inject; |
| import com.google.inject.Injector; |
| import com.google.inject.Singleton; |
| import com.google.inject.servlet.GuiceFilter; |
| import com.google.inject.servlet.GuiceServletContextListener; |
| import java.lang.management.ManagementFactory; |
| import java.net.URI; |
| import java.net.URISyntaxException; |
| import java.nio.file.Files; |
| import java.nio.file.Path; |
| import java.util.ArrayList; |
| import java.util.EnumSet; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.atomic.AtomicLong; |
| import javax.servlet.DispatcherType; |
| import javax.servlet.Filter; |
| import javax.servlet.http.HttpSessionEvent; |
| import javax.servlet.http.HttpSessionListener; |
| import org.eclipse.jetty.http.HttpScheme; |
| import org.eclipse.jetty.io.ConnectionStatistics; |
| import org.eclipse.jetty.jmx.MBeanContainer; |
| import org.eclipse.jetty.server.Connector; |
| import org.eclipse.jetty.server.ForwardedRequestCustomizer; |
| import org.eclipse.jetty.server.Handler; |
| import org.eclipse.jetty.server.HttpConfiguration; |
| import org.eclipse.jetty.server.HttpConnectionFactory; |
| import org.eclipse.jetty.server.SecureRequestCustomizer; |
| import org.eclipse.jetty.server.Server; |
| import org.eclipse.jetty.server.ServerConnector; |
| import org.eclipse.jetty.server.SslConnectionFactory; |
| import org.eclipse.jetty.server.handler.ContextHandler; |
| import org.eclipse.jetty.server.handler.ContextHandlerCollection; |
| import org.eclipse.jetty.server.handler.RequestLogHandler; |
| import org.eclipse.jetty.server.handler.StatisticsHandler; |
| import org.eclipse.jetty.server.session.SessionHandler; |
| import org.eclipse.jetty.servlet.DefaultServlet; |
| import org.eclipse.jetty.servlet.FilterHolder; |
| import org.eclipse.jetty.servlet.ServletContextHandler; |
| import org.eclipse.jetty.servlet.ServletHolder; |
| import org.eclipse.jetty.util.BlockingArrayQueue; |
| import org.eclipse.jetty.util.log.Log; |
| import org.eclipse.jetty.util.ssl.SslContextFactory; |
| import org.eclipse.jetty.util.thread.QueuedThreadPool; |
| import org.eclipse.jgit.lib.Config; |
| |
| @Singleton |
| public class JettyServer { |
| static class Lifecycle implements LifecycleListener { |
| private final JettyServer server; |
| private final Config cfg; |
| |
| @Inject |
| Lifecycle(JettyServer server, @GerritServerConfig Config cfg) { |
| this.server = server; |
| this.cfg = cfg; |
| } |
| |
| @Override |
| public void start() { |
| try { |
| String origUrl = cfg.getString("httpd", null, "listenUrl"); |
| boolean rewrite = !Strings.isNullOrEmpty(origUrl) && origUrl.endsWith(":0/"); |
| server.httpd.start(); |
| if (rewrite) { |
| Connector con = server.httpd.getConnectors()[0]; |
| if (con instanceof ServerConnector) { |
| @SuppressWarnings("resource") |
| ServerConnector serverCon = (ServerConnector) con; |
| String host = serverCon.getHost(); |
| int port = serverCon.getLocalPort(); |
| String url = String.format("http://%s:%d", host, port); |
| cfg.setString("gerrit", null, "canonicalWebUrl", url); |
| cfg.setString("httpd", null, "listenUrl", url); |
| } |
| } |
| } catch (Exception e) { |
| throw new IllegalStateException("Cannot start HTTP daemon", e); |
| } |
| } |
| |
| @Override |
| public void stop() { |
| try { |
| server.httpd.stop(); |
| server.httpd.join(); |
| } catch (Exception e) { |
| throw new IllegalStateException("Cannot stop HTTP daemon", e); |
| } |
| } |
| } |
| |
| static class Metrics { |
| private final QueuedThreadPool threadPool; |
| private ConnectionStatistics connStats; |
| |
| Metrics(QueuedThreadPool threadPool, ConnectionStatistics connStats) { |
| this.threadPool = threadPool; |
| this.connStats = connStats; |
| } |
| |
| public int getIdleThreads() { |
| return threadPool.getIdleThreads(); |
| } |
| |
| public int getBusyThreads() { |
| return threadPool.getBusyThreads(); |
| } |
| |
| public int getReservedThreads() { |
| return threadPool.getReservedThreads(); |
| } |
| |
| public int getMinThreads() { |
| return threadPool.getMinThreads(); |
| } |
| |
| public int getMaxThreads() { |
| return threadPool.getMaxThreads(); |
| } |
| |
| public int getThreads() { |
| return threadPool.getThreads(); |
| } |
| |
| public int getQueueSize() { |
| return threadPool.getQueueSize(); |
| } |
| |
| public boolean isLowOnThreads() { |
| return threadPool.isLowOnThreads(); |
| } |
| |
| public long getConnections() { |
| return connStats.getConnections(); |
| } |
| |
| public long getConnectionsTotal() { |
| return connStats.getConnectionsTotal(); |
| } |
| |
| public long getConnectionDurationMax() { |
| return connStats.getConnectionDurationMax(); |
| } |
| |
| public double getConnectionDurationMean() { |
| return connStats.getConnectionDurationMean(); |
| } |
| |
| public double getConnectionDurationStdDev() { |
| return connStats.getConnectionDurationStdDev(); |
| } |
| |
| public long getReceivedMessages() { |
| return connStats.getReceivedMessages(); |
| } |
| |
| public long getSentMessages() { |
| return connStats.getSentMessages(); |
| } |
| |
| public long getReceivedBytes() { |
| return connStats.getReceivedBytes(); |
| } |
| |
| public long getSentBytes() { |
| return connStats.getSentBytes(); |
| } |
| } |
| |
| private final SitePaths site; |
| private final Server httpd; |
| private final Metrics metrics; |
| private boolean reverseProxy; |
| private ConnectionStatistics connStats; |
| private final SessionHandler sessionHandler; |
| private final AtomicLong sessionsCounter; |
| |
| @Inject |
| JettyServer( |
| @GerritServerConfig Config cfg, |
| ThreadSettingsConfig threadSettingsConfig, |
| SitePaths site, |
| JettyEnv env, |
| HttpLogFactory httpLogFactory) { |
| this.site = site; |
| |
| QueuedThreadPool pool = threadPool(cfg, threadSettingsConfig); |
| httpd = new Server(pool); |
| httpd.setConnectors(listen(httpd, cfg)); |
| connStats = new ConnectionStatistics(); |
| for (Connector connector : httpd.getConnectors()) { |
| connector.addBean(connStats); |
| } |
| metrics = new Metrics(pool, connStats); |
| sessionHandler = new SessionHandler(); |
| sessionsCounter = new AtomicLong(); |
| |
| /* Code used for testing purposes for making assertions |
| * on the number of active HTTP sessions. |
| */ |
| sessionHandler.addEventListener( |
| new HttpSessionListener() { |
| |
| @Override |
| public void sessionDestroyed(HttpSessionEvent se) { |
| sessionsCounter.decrementAndGet(); |
| } |
| |
| @Override |
| public void sessionCreated(HttpSessionEvent se) { |
| sessionsCounter.incrementAndGet(); |
| } |
| }); |
| |
| sessionHandler.setMaxInactiveInterval( |
| (int) |
| cfg.getTimeUnit( |
| "cache", |
| "web_sessions", |
| "maxAge", |
| SECONDS.convert(MAX_AGE_MINUTES, MINUTES), |
| SECONDS)); |
| |
| Handler app = makeContext(env, cfg, sessionHandler); |
| if (cfg.getBoolean("httpd", "requestLog", !reverseProxy)) { |
| RequestLogHandler handler = new RequestLogHandler(); |
| handler.setRequestLog(httpLogFactory.get()); |
| handler.setHandler(app); |
| app = handler; |
| } |
| if (cfg.getBoolean("httpd", "registerMBeans", false)) { |
| MBeanContainer mbean = new MBeanContainer(ManagementFactory.getPlatformMBeanServer()); |
| httpd.addEventListener(mbean); |
| httpd.addBean(Log.getRootLogger()); |
| httpd.addBean(mbean); |
| } |
| |
| long gracefulStopTimeout = |
| cfg.getTimeUnit("httpd", null, "gracefulStopTimeout", 0L, TimeUnit.MILLISECONDS); |
| if (gracefulStopTimeout > 0) { |
| StatisticsHandler statsHandler = new StatisticsHandler(); |
| statsHandler.setHandler(app); |
| app = statsHandler; |
| httpd.setStopTimeout(gracefulStopTimeout); |
| } |
| |
| httpd.setHandler(app); |
| httpd.setStopAtShutdown(false); |
| } |
| |
| @VisibleForTesting |
| public long numActiveSessions() { |
| return sessionsCounter.longValue(); |
| } |
| |
| Metrics getMetrics() { |
| return metrics; |
| } |
| |
| private Connector[] listen(Server server, Config cfg) { |
| // OpenID and certain web-based single-sign-on products can cause |
| // some very long headers, especially in the Referer header. We |
| // need to use a larger default header size to ensure we have |
| // the space required. |
| // |
| final int requestHeaderSize = cfg.getInt("httpd", "requestheadersize", 16386); |
| final URI[] listenUrls = listenURLs(cfg); |
| final boolean reuseAddress = cfg.getBoolean("httpd", "reuseaddress", true); |
| final int acceptors = cfg.getInt("httpd", "acceptorThreads", 2); |
| final AuthType authType = cfg.getEnum("auth", null, "type", AuthType.OPENID); |
| |
| reverseProxy = isReverseProxied(listenUrls); |
| final Connector[] connectors = new Connector[listenUrls.length]; |
| for (int idx = 0; idx < listenUrls.length; idx++) { |
| final URI u = listenUrls[idx]; |
| final int defaultPort; |
| final ServerConnector c; |
| HttpConfiguration config = defaultConfig(requestHeaderSize); |
| |
| if (AuthType.CLIENT_SSL_CERT_LDAP.equals(authType) && !"https".equals(u.getScheme())) { |
| throw new IllegalArgumentException( |
| "Protocol '" |
| + u.getScheme() |
| + "' " |
| + " not supported in httpd.listenurl '" |
| + u |
| + "' when auth.type = '" |
| + AuthType.CLIENT_SSL_CERT_LDAP.name() |
| + "'; only 'https' is supported"); |
| } |
| |
| if ("http".equals(u.getScheme())) { |
| defaultPort = 80; |
| c = newServerConnector(server, acceptors, config); |
| |
| } else if ("https".equals(u.getScheme())) { |
| SslContextFactory.Server ssl = new SslContextFactory.Server(); |
| final Path keystore = getFile(cfg, "sslkeystore", "etc/keystore"); |
| String password = cfg.getString("httpd", null, "sslkeypassword"); |
| if (password == null) { |
| password = "gerrit"; |
| } |
| ssl.setKeyStorePath(keystore.toAbsolutePath().toString()); |
| ssl.setTrustStorePath(keystore.toAbsolutePath().toString()); |
| ssl.setKeyStorePassword(password); |
| ssl.setTrustStorePassword(password); |
| |
| if (AuthType.CLIENT_SSL_CERT_LDAP.equals(authType)) { |
| ssl.setNeedClientAuth(true); |
| |
| Path crl = getFile(cfg, "sslCrl", "etc/crl.pem"); |
| if (Files.exists(crl)) { |
| ssl.setCrlPath(crl.toAbsolutePath().toString()); |
| ssl.setValidatePeerCerts(true); |
| } |
| } |
| |
| defaultPort = 443; |
| |
| config.addCustomizer(new SecureRequestCustomizer()); |
| c = |
| new ServerConnector( |
| server, |
| null, |
| null, |
| null, |
| 0, |
| acceptors, |
| new SslConnectionFactory(ssl, "http/1.1"), |
| new HttpConnectionFactory(config)); |
| |
| } else if ("proxy-http".equals(u.getScheme())) { |
| defaultPort = 8080; |
| config.addCustomizer(new ForwardedRequestCustomizer()); |
| c = newServerConnector(server, acceptors, config); |
| |
| } else if ("proxy-https".equals(u.getScheme())) { |
| defaultPort = 8080; |
| config.addCustomizer(new ForwardedRequestCustomizer()); |
| config.addCustomizer( |
| (connector, channelConfig, request) -> { |
| request.setScheme(HttpScheme.HTTPS.asString()); |
| request.setSecure(true); |
| }); |
| c = newServerConnector(server, acceptors, config); |
| |
| } else { |
| throw new IllegalArgumentException( |
| "Protocol '" |
| + u.getScheme() |
| + "' " |
| + " not supported in httpd.listenurl '" |
| + u |
| + "';" |
| + " only 'http', 'https', 'proxy-http, 'proxy-https'" |
| + " are supported"); |
| } |
| |
| try { |
| if (u.getHost() == null |
| && (u.getAuthority().equals("*") // |
| || u.getAuthority().startsWith("*:"))) { |
| // Bind to all local addresses. Port wasn't parsed right by URI |
| // due to the illegal host of "*" so replace with a legal name |
| // and parse the URI. |
| // |
| final URI r = new URI(u.toString().replace('*', 'A')).parseServerAuthority(); |
| c.setHost(null); |
| c.setPort(0 < r.getPort() ? r.getPort() : defaultPort); |
| } else { |
| final URI r = u.parseServerAuthority(); |
| c.setHost(r.getHost()); |
| c.setPort(0 <= r.getPort() ? r.getPort() : defaultPort); |
| } |
| } catch (URISyntaxException e) { |
| throw new IllegalArgumentException("Invalid httpd.listenurl " + u, e); |
| } |
| c.setInheritChannel(cfg.getBoolean("httpd", "inheritChannel", false)); |
| c.setReuseAddress(reuseAddress); |
| c.setIdleTimeout(cfg.getTimeUnit("httpd", null, "idleTimeout", 30000L, MILLISECONDS)); |
| connectors[idx] = c; |
| } |
| return connectors; |
| } |
| |
| private static ServerConnector newServerConnector( |
| Server server, int acceptors, HttpConfiguration config) { |
| return new ServerConnector( |
| server, null, null, null, 0, acceptors, new HttpConnectionFactory(config)); |
| } |
| |
| private HttpConfiguration defaultConfig(int requestHeaderSize) { |
| HttpConfiguration config = new HttpConfiguration(); |
| config.setRequestHeaderSize(requestHeaderSize); |
| config.setSendServerVersion(false); |
| config.setSendDateHeader(true); |
| return config; |
| } |
| |
| static boolean isReverseProxied(URI[] listenUrls) { |
| for (URI u : listenUrls) { |
| if ("http".equals(u.getScheme()) || "https".equals(u.getScheme())) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| static URI[] listenURLs(Config cfg) { |
| String[] urls = cfg.getStringList("httpd", null, "listenurl"); |
| if (urls.length == 0) { |
| urls = new String[] {"http://*:8080/"}; |
| } |
| |
| final URI[] r = new URI[urls.length]; |
| for (int i = 0; i < r.length; i++) { |
| final String s = urls[i]; |
| try { |
| r[i] = new URI(s); |
| } catch (URISyntaxException e) { |
| throw new IllegalArgumentException("Invalid httpd.listenurl " + s, e); |
| } |
| } |
| return r; |
| } |
| |
| private Path getFile(Config cfg, String name, String def) { |
| String path = cfg.getString("httpd", null, name); |
| if (path == null || path.length() == 0) { |
| path = def; |
| } |
| return site.resolve(path); |
| } |
| |
| private QueuedThreadPool threadPool(Config cfg, ThreadSettingsConfig threadSettingsConfig) { |
| int maxThreads = threadSettingsConfig.getHttpdMaxThreads(); |
| int minThreads = cfg.getInt("httpd", null, "minthreads", 5); |
| int maxQueued = cfg.getInt("httpd", null, "maxqueued", 200); |
| int idleTimeout = (int) MILLISECONDS.convert(60, SECONDS); |
| int maxCapacity = maxQueued == 0 ? Integer.MAX_VALUE : Math.max(minThreads, maxQueued); |
| QueuedThreadPool pool = |
| new QueuedThreadPool( |
| maxThreads, |
| minThreads, |
| idleTimeout, |
| new BlockingArrayQueue<>( |
| minThreads, // capacity, |
| minThreads, // growBy, |
| maxCapacity // maxCapacity |
| )); |
| pool.setName("HTTP"); |
| return pool; |
| } |
| |
| private Handler makeContext(JettyEnv env, Config cfg, SessionHandler sessionHandler) { |
| final Set<String> paths = new HashSet<>(); |
| for (URI u : listenURLs(cfg)) { |
| String p = u.getPath(); |
| if (p == null || p.isEmpty()) { |
| p = "/"; |
| } |
| while (1 < p.length() && p.endsWith("/")) { |
| p = p.substring(0, p.length() - 1); |
| } |
| paths.add(p); |
| } |
| |
| final List<ContextHandler> all = new ArrayList<>(); |
| for (String path : paths) { |
| all.add(makeContext(path, env, cfg, sessionHandler)); |
| } |
| |
| if (all.size() == 1) { |
| // If we only have one context path in our web space, return it |
| // without any wrapping so Jetty has less work to do per-request. |
| // |
| return all.get(0); |
| } |
| // We have more than one path served out of this container so |
| // combine them in a handler which supports dispatching to the |
| // individual contexts. |
| // |
| final ContextHandlerCollection r = new ContextHandlerCollection(); |
| r.setHandlers(all.toArray(new Handler[0])); |
| return r; |
| } |
| |
| private ContextHandler makeContext( |
| final String contextPath, JettyEnv env, Config cfg, SessionHandler sessionHandler) { |
| final ServletContextHandler app = new ServletContextHandler(); |
| |
| // This enables the use of sessions in Jetty, feature available |
| // for Gerrit plug-ins to enable user-level sessions. |
| // |
| app.setSessionHandler(sessionHandler); |
| app.setErrorHandler(new HiddenErrorHandler()); |
| |
| // This is the path we are accessed by clients within our domain. |
| // |
| app.setContextPath(contextPath); |
| |
| // HTTP front-end filters to be used as surrogate of Apache HTTP |
| // reverse-proxy filtering. |
| // It is meant to be used as simpler tiny deployment of custom-made |
| // security enforcement (Security tokens, IP-based security filtering, others) |
| String[] filterClassNames = cfg.getStringList("httpd", null, "filterClass"); |
| for (String filterClassName : filterClassNames) { |
| try { |
| @SuppressWarnings("unchecked") |
| Class<? extends Filter> filterClass = |
| (Class<? extends Filter>) Class.forName(filterClassName); |
| Filter filter = env.webInjector.getInstance(filterClass); |
| |
| Map<String, String> initParams = new HashMap<>(); |
| Set<String> initParamKeys = cfg.getNames("filterClass", filterClassName, true); |
| initParamKeys.forEach( |
| paramKey -> { |
| String paramValue = cfg.getString("filterClass", filterClassName, paramKey); |
| initParams.put(paramKey, paramValue); |
| }); |
| |
| FilterHolder filterHolder = new FilterHolder(filter); |
| if (initParams.size() > 0) { |
| filterHolder.setInitParameters(initParams); |
| } |
| app.addFilter(filterHolder, "/*", EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC)); |
| } catch (Exception e) { |
| throw new IllegalArgumentException( |
| "Unable to instantiate front-end HTTP Filter " + filterClassName, e); |
| } |
| } |
| |
| // Perform the same binding as our web.xml would do, but instead |
| // of using the listener to create the injector pass the one we |
| // already have built. |
| // |
| GuiceFilter filter = env.webInjector.getInstance(GuiceFilter.class); |
| app.addFilter( |
| new FilterHolder(filter), "/*", EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC)); |
| app.addEventListener( |
| new GuiceServletContextListener() { |
| @Override |
| protected Injector getInjector() { |
| return env.webInjector; |
| } |
| }); |
| |
| // Jetty requires at least one servlet be bound before it will |
| // bother running the filter above. Since the filter has all |
| // of our URLs except the static resources, the only servlet |
| // we need to bind is the default static resource servlet from |
| // the Jetty container. |
| // |
| final ServletHolder ds = app.addServlet(DefaultServlet.class, "/"); |
| ds.setInitParameter("dirAllowed", "false"); |
| ds.setInitParameter("redirectWelcome", "false"); |
| ds.setInitParameter("useFileMappedBuffer", "false"); |
| ds.setInitParameter("gzip", "true"); |
| |
| app.setWelcomeFiles(new String[0]); |
| return app; |
| } |
| } |