// 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.gerrit.launcher.GerritLauncher; import com.google.gerrit.lifecycle.LifecycleListener; import com.google.gerrit.reviewdb.AuthType; import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.config.ConfigUtil; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.config.SitePaths; import com.google.inject.Inject; import com.google.inject.Injector; import com.google.inject.Provider; import com.google.inject.Singleton; import com.google.inject.servlet.GuiceFilter; import com.google.inject.servlet.GuiceServletContextListener; import org.eclipse.jetty.io.EndPoint; import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Server; 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.nio.SelectChannelConnector; import org.eclipse.jetty.server.ssl.SslSelectChannelConnector; import org.eclipse.jetty.servlet.DefaultServlet; import org.eclipse.jetty.servlet.FilterMapping; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.util.resource.Resource; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.eclipse.jetty.util.thread.ThreadPool; import org.eclipse.jgit.lib.Config; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; import java.util.Enumeration; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @Singleton public class JettyServer { static class Lifecycle implements LifecycleListener { private final JettyServer server; @Inject Lifecycle(final JettyServer server) { this.server = server; } @Override public void start() { try { server.httpd.start(); } 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 Provider<CurrentUser> userProvider) throws MalformedURLException, IOException { this.site = site; httpd = new Server(); httpd.setConnectors(listen(cfg)); httpd.setThreadPool(threadPool(cfg)); Handler app = makeContext(env, cfg); if (cfg.getBoolean("httpd", "requestlog", !reverseProxy)) { RequestLogHandler handler = new RequestLogHandler(); handler.setRequestLog(new HttpLog(site, userProvider)); handler.setHandler(app); app = handler; } httpd.setHandler(app); httpd.setStopAtShutdown(false); httpd.setSendDateHeader(true); httpd.setSendServerVersion(false); httpd.setGracefulShutdown((int) MILLISECONDS.convert(1, SECONDS)); } private Connector[] listen(final 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 = true; final Connector[] connectors = new Connector[listenUrls.length]; for (int idx = 0; idx < listenUrls.length; idx++) { final URI u = listenUrls[idx]; final int defaultPort; final SelectChannelConnector c; 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())) { reverseProxy = false; defaultPort = 80; c = new SelectChannelConnector(); } else if ("https".equals(u.getScheme())) { final SslSelectChannelConnector ssl = new SslSelectChannelConnector(); final File keystore = getFile(cfg, "sslkeystore", "etc/keystore"); String password = cfg.getString("httpd", null, "sslkeypassword"); if (password == null) { password = "gerrit"; } ssl.setKeystore(keystore.getAbsolutePath()); ssl.setTruststore(keystore.getAbsolutePath()); ssl.setKeyPassword(password); ssl.setTrustPassword(password); if (AuthType.CLIENT_SSL_CERT_LDAP.equals(authType)) { ssl.setNeedClientAuth(true); } reverseProxy = false; defaultPort = 443; c = ssl; } else if ("proxy-http".equals(u.getScheme())) { defaultPort = 8080; c = new SelectChannelConnector(); c.setForwarded(true); } else if ("proxy-https".equals(u.getScheme())) { defaultPort = 8080; c = new SelectChannelConnector() { @Override public void customize(EndPoint endpoint, Request request) throws IOException { request.setScheme("https"); super.customize(endpoint, request); } }; c.setForwarded(true); } 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.setRequestHeaderSize(requestHeaderSize); c.setAcceptors(acceptors); c.setReuseAddress(reuseAddress); c.setStatsOn(false); connectors[idx] = c; } return connectors; } private 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) { final QueuedThreadPool pool = new QueuedThreadPool(); pool.setName("HTTP"); pool.setMinThreads(cfg.getInt("httpd", null, "minthreads", 5)); pool.setMaxThreads(cfg.getInt("httpd", null, "maxthreads", 25)); pool.setMaxQueued(cfg.getInt("httpd", null, "maxqueued", 50)); return pool; } private Handler makeContext(final JettyEnv env, final Config cfg) throws MalformedURLException, IOException { final Set<String> paths = new HashSet<String>(); 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<ContextHandler>(); for (String path : paths) { all.add(makeContext(path, env)); } 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) throws MalformedURLException, IOException { final ServletContextHandler app = new ServletContextHandler(); // 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()); // 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. // app.addFilter(GuiceFilter.class, "/*", FilterMapping.DEFAULT); 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() throws IOException { if (baseResource == null) { try { baseResource = unpackWar(); } catch (FileNotFoundException err) { if (err.getMessage() == GerritLauncher.NOT_ARCHIVED) { baseResource = useDeveloperBuild(); } else { throw err; } } } return baseResource; } private Resource unpackWar() throws IOException { final File srcwar = GerritLauncher.getDistributionArchive(); // 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 { dstwar = dstwar.getCanonicalFile(); } catch (IOException e) { dstwar = dstwar.getAbsoluteFile(); } 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(); } return Resource.newResource(dstwar.toURI()); } private void mkdir(final 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() throws IOException { // Find ourselves in the CLASSPATH. We should be a loose class file. // URL u = getClass().getResource(getClass().getSimpleName() + ".class"); if (u == null) { throw new FileNotFoundException("Cannot find web application root"); } if (!"file".equals(u.getProtocol())) { throw new FileNotFoundException("Cannot find web root from " + u); } // Pop up to the top level classes folder that contains us. // File dir = new File(u.getPath()); String myName = getClass().getName(); for (;;) { int dot = myName.lastIndexOf('.'); if (dot < 0) { dir = dir.getParentFile(); break; } myName = myName.substring(0, dot); dir = dir.getParentFile(); } // We should be in a Maven style output, that is $jar/target/classes. // if (!dir.getName().equals("classes")) { throw new FileNotFoundException("Cannot find web root from " + u); } dir = dir.getParentFile(); // pop classes if (!dir.getName().equals("target")) { throw new FileNotFoundException("Cannot find web root from " + u); } dir = dir.getParentFile(); // pop target dir = dir.getParentFile(); // pop the module we are in // Drop down into gerrit-gwtui to find the WAR assets we need. // dir = new File(new File(dir, "gerrit-gwtui"), "target"); final File[] entries = dir.listFiles(); if (entries == null) { throw new FileNotFoundException("No " + dir); } for (File e : entries) { if (e.isDirectory() /* must be a directory */ && e.getName().startsWith("gerrit-gwtui-") && new File(e, "gerrit/gerrit.nocache.js").isFile()) { return Resource.newResource(e.toURI()); } } throw new FileNotFoundException("No " + dir + "/gerrit-gwtui-*"); } }