// 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 java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.SECONDS; import com.google.common.base.Charsets; import com.google.common.base.Objects; import com.google.common.base.Strings; import com.google.common.escape.Escaper; import com.google.common.html.HtmlEscapers; import com.google.common.io.ByteStreams; import com.google.gerrit.extensions.events.LifecycleListener; import com.google.gerrit.launcher.GerritLauncher; import com.google.gerrit.pgm.http.jetty.HttpLog.HttpLogFactory; import com.google.gerrit.reviewdb.client.AuthType; import com.google.gerrit.server.config.ConfigUtil; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.config.SitePaths; import com.google.gerrit.server.util.TimeUtil; import com.google.gwtexpui.linker.server.UserAgentRule; import com.google.gwtexpui.server.CacheHeaders; 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 org.eclipse.jetty.http.HttpScheme; 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.Request; 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.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.resource.Resource; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.eclipse.jetty.util.thread.ThreadPool; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.util.RawParseUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InterruptedIOException; import java.io.PrintWriter; import java.lang.management.ManagementFactory; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.EnumSet; import java.util.Enumeration; import java.util.HashSet; import java.util.List; import java.util.Properties; import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import javax.servlet.DispatcherType; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @Singleton public class JettyServer { private static final Logger log = LoggerFactory.getLogger(JettyServer.class); static class Lifecycle implements LifecycleListener { private final JettyServer server; private final Config cfg; @Inject Lifecycle(final JettyServer server, @GerritServerConfig final 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); } } } private final SitePaths site; private final Server httpd; private boolean reverseProxy; /** Location on disk where our WAR file was unpacked to. */ private Resource baseResource; @Inject JettyServer(@GerritServerConfig final Config cfg, final SitePaths site, final JettyEnv env, final HttpLogFactory httpLogFactory) throws MalformedURLException, IOException { this.site = site; httpd = new Server(threadPool(cfg)); httpd.setConnectors(listen(httpd, cfg)); Handler app = makeContext(env, cfg); 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); } httpd.setHandler(app); httpd.setStopAtShutdown(false); } 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 = ConfigUtil.getEnum(cfg, "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 ssl = new SslContextFactory(); final File keystore = getFile(cfg, "sslkeystore", "etc/keystore"); String password = cfg.getString("httpd", null, "sslkeypassword"); if (password == null) { password = "gerrit"; } ssl.setKeyStorePath(keystore.getAbsolutePath()); ssl.setTrustStorePath(keystore.getAbsolutePath()); ssl.setKeyStorePassword(password); ssl.setTrustStorePassword(password); if (AuthType.CLIENT_SSL_CERT_LDAP.equals(authType)) { ssl.setNeedClientAuth(true); File crl = getFile(cfg, "sslcrl", "etc/crl.pem"); if (crl.exists()) { ssl.setCrlPath(crl.getAbsolutePath()); 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(new HttpConfiguration.Customizer() { @Override public void customize(Connector connector, HttpConfiguration channelConfig, Request 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.setReuseAddress(reuseAddress); 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(final URI[] listenUrls) { for (URI u : listenUrls) { if ("http".equals(u.getScheme()) || "https".equals(u.getScheme())) { return false; } } return true; } static URI[] listenURLs(final 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 File getFile(final Config cfg, final String name, final String def) { String path = cfg.getString("httpd", null, name); if (path == null || path.length() == 0) { path = def; } return site.resolve(path); } private ThreadPool threadPool(Config cfg) { int maxThreads = cfg.getInt("httpd", null, "maxthreads", 25); int minThreads = cfg.getInt("httpd", null, "minthreads", 5); int maxQueued = cfg.getInt("httpd", null, "maxqueued", 50); 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<Runnable>( minThreads, // capacity, minThreads, // growBy, maxCapacity // maxCapacity )); pool.setName("HTTP"); return pool; } private Handler makeContext(final JettyEnv env, final Config cfg) throws MalformedURLException, IOException { 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)); } 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); } else { // 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, final JettyEnv env, final Config cfg) throws MalformedURLException, IOException { 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(new SessionHandler()); app.setErrorHandler(new HiddenErrorHandler()); // This is the path we are accessed by clients within our domain. // app.setContextPath(contextPath); // Serve static resources directly from our JAR. This way we don't // need to unpack them into yet another temporary directory prior to // serving to clients. // app.setBaseResource(getBaseResource(app)); // HTTP front-end filter 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 filterClassName = cfg.getString("httpd", null, "filterClass"); if (filterClassName != null) { try { @SuppressWarnings("unchecked") Class<? extends Filter> filterClass = (Class<? extends Filter>) Class.forName(filterClassName); Filter filter = env.webInjector.getInstance(filterClass); app.addFilter(new FilterHolder(filter), "/*", EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC)); } catch (Throwable e) { String errorMessage = "Unable to instantiate front-end HTTP Filter " + filterClassName; log.error(errorMessage, e); throw new IllegalArgumentException(errorMessage, 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; } private Resource getBaseResource(ServletContextHandler app) throws IOException { if (baseResource == null) { try { baseResource = unpackWar(GerritLauncher.getDistributionArchive()); } catch (FileNotFoundException err) { if (GerritLauncher.NOT_ARCHIVED.equals(err.getMessage())) { baseResource = useDeveloperBuild(app); } else { throw err; } } } return baseResource; } private static Resource unpackWar(File srcwar) throws IOException { File dstwar = makeWarTempDir(); unpack(srcwar, dstwar); return Resource.newResource(dstwar.toURI()); } private static File makeWarTempDir() throws IOException { // Obtain our local temporary directory, but it comes back as a file // so we have to switch it to be a directory post creation. // File dstwar = GerritLauncher.createTempFile("gerrit_", "war"); if (!dstwar.delete() || !dstwar.mkdir()) { throw new IOException("Cannot mkdir " + dstwar.getAbsolutePath()); } // Jetty normally refuses to serve out of a symlinked directory, as // a security feature. Try to resolve out any symlinks in the path. // try { return dstwar.getCanonicalFile(); } catch (IOException e) { return dstwar.getAbsoluteFile(); } } private static void unpack(File srcwar, File dstwar) throws IOException { final ZipFile zf = new ZipFile(srcwar); try { final Enumeration<? extends ZipEntry> e = zf.entries(); while (e.hasMoreElements()) { final ZipEntry ze = e.nextElement(); final String name = ze.getName(); if (ze.isDirectory()) continue; if (name.startsWith("WEB-INF/")) continue; if (name.startsWith("META-INF/")) continue; if (name.startsWith("com/google/gerrit/launcher/")) continue; if (name.equals("Main.class")) continue; final File rawtmp = new File(dstwar, name); mkdir(rawtmp.getParentFile()); rawtmp.deleteOnExit(); final FileOutputStream rawout = new FileOutputStream(rawtmp); try { final InputStream in = zf.getInputStream(ze); try { final byte[] buf = new byte[4096]; int n; while ((n = in.read(buf, 0, buf.length)) > 0) { rawout.write(buf, 0, n); } } finally { in.close(); } } finally { rawout.close(); } } } finally { zf.close(); } } private static void mkdir(File dir) throws IOException { if (!dir.isDirectory()) { mkdir(dir.getParentFile()); if (!dir.mkdir()) throw new IOException("Cannot mkdir " + dir.getAbsolutePath()); dir.deleteOnExit(); } } private Resource useDeveloperBuild(ServletContextHandler app) throws IOException { final File dir = GerritLauncher.getDeveloperBuckOut(); final File gen = new File(dir, "gen"); final File root = dir.getParentFile(); final File dstwar = makeWarTempDir(); File ui = new File(dstwar, "gerrit_ui"); File p = new File(ui, "permutations"); mkdir(ui); p.createNewFile(); p.deleteOnExit(); app.addFilter(new FilterHolder(new Filter() { private final UserAgentRule rule = new UserAgentRule(); private String lastTarget; private long lastTime; @Override public void doFilter(ServletRequest request, ServletResponse res, FilterChain chain) throws IOException, ServletException { String pkg = "gerrit-gwtui"; String target = "ui_" + rule.select((HttpServletRequest) request); String rule = "//" + pkg + ":" + target; // TODO(davido): instead of assuming specific Buck's internal // target directory for gwt_binary() artifacts, ask Buck for // the location of user agent permutation GWT zip, e. g.: // $ buck targets --show_output //gerrit-gwtui:ui_safari \ // | awk '{print $2}' String child = String.format("%s/__gwt_binary_%s__", pkg, target); File zip = new File(new File(gen, child), target + ".zip"); synchronized (this) { try { build(root, gen, rule); } catch (BuildFailureException e) { displayFailure(rule, e.why, (HttpServletResponse) res); return; } if (!target.equals(lastTarget) || lastTime != zip.lastModified()) { lastTarget = target; lastTime = zip.lastModified(); unpack(zip, dstwar); } } chain.doFilter(request, res); } private void displayFailure(String rule, byte[] why, HttpServletResponse res) throws IOException { res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); res.setContentType("text/html"); res.setCharacterEncoding(Charsets.UTF_8.name()); CacheHeaders.setNotCacheable(res); Escaper html = HtmlEscapers.htmlEscaper(); PrintWriter w = res.getWriter(); w.write("<html><title>BUILD FAILED</title><body>"); w.format("<h1>%s FAILED</h1>", html.escape(rule)); w.write("<pre>"); w.write(html.escape(RawParseUtils.decode(why))); w.write("</pre>"); w.write("</body></html>"); w.close(); } @Override public void init(FilterConfig config) { } @Override public void destroy() { } }), "/", EnumSet.of(DispatcherType.REQUEST)); return Resource.newResource(dstwar.toURI()); } private static void build(File root, File gen, String target) throws IOException, BuildFailureException { log.info("buck build " + target); Properties properties = loadBuckProperties(gen); String buck = Objects.firstNonNull(properties.getProperty("buck"), "buck"); ProcessBuilder proc = new ProcessBuilder(buck, "build", target) .directory(root) .redirectErrorStream(true); if (properties.containsKey("PATH")) { proc.environment().put("PATH", properties.getProperty("PATH")); } long start = TimeUtil.nowMs(); Process rebuild = proc.start(); byte[] out; InputStream in = rebuild.getInputStream(); try { out = ByteStreams.toByteArray(in); } finally { rebuild.getOutputStream().close(); in.close(); } int status; try { status = rebuild.waitFor(); } catch (InterruptedException e) { throw new InterruptedIOException("interrupted waiting for " + buck); } if (status != 0) { throw new BuildFailureException(out); } long time = TimeUtil.nowMs() - start; log.info(String.format("UPDATED %s in %.3fs", target, time / 1000.0)); } private static Properties loadBuckProperties(File gen) throws FileNotFoundException, IOException { Properties properties = new Properties(); InputStream in = new FileInputStream( new File(new File(gen, "tools"), "buck.properties")); try { properties.load(in); } finally { in.close(); } return properties; } @SuppressWarnings("serial") private static class BuildFailureException extends Exception { final byte[] why; BuildFailureException(byte[] why) { this.why = why; } } }