Add basic foundation of Service class It works, but has lots of TODOs and needs more fleshing out.
diff --git a/src/com/google/enterprise/adaptor/Application.java b/src/com/google/enterprise/adaptor/Application.java index 88665ea..797eb7f 100644 --- a/src/com/google/enterprise/adaptor/Application.java +++ b/src/com/google/enterprise/adaptor/Application.java
@@ -40,7 +40,7 @@ */ public final class Application { private static final String SLEEP_PATH = "/sleep"; - private static final String DEFAULT_CONFIG_FILE + static final String DEFAULT_CONFIG_FILE = "adaptor-config.properties"; private static final Logger log @@ -215,7 +215,7 @@ dashboardServer = null; } - private static void httpServerShutdown(HttpServer server, long time, + static void httpServerShutdown(HttpServer server, long time, TimeUnit unit) { // Workaround Java Bug 7105369. SleepHandler sleepHandler = new SleepHandler(100 /* millis */); @@ -280,7 +280,7 @@ }).start(); } - private static HttpServer createHttpServer(Config config) throws IOException { + static HttpServer createHttpServer(Config config) throws IOException { HttpServer server; if (!config.isServerSecure()) { server = HttpServer.create(); @@ -321,7 +321,7 @@ return server; } - private static HttpServer createDashboardHttpServer(Config config) + static HttpServer createDashboardHttpServer(Config config) throws IOException { boolean secure = config.isServerSecure(); HttpServer server; @@ -374,7 +374,6 @@ * @return unused command line arguments * @throws IllegalStateException when not all configuration keys have values */ - @VisibleForTesting static String[] autoConfig(Config config, String[] args, File configFile) { int i; for (i = 0; i < args.length; i++) {
diff --git a/src/com/google/enterprise/adaptor/ReverseProxyHandler.java b/src/com/google/enterprise/adaptor/ReverseProxyHandler.java index 13c57fc..360ba35 100644 --- a/src/com/google/enterprise/adaptor/ReverseProxyHandler.java +++ b/src/com/google/enterprise/adaptor/ReverseProxyHandler.java
@@ -67,6 +67,11 @@ throw new NullPointerException(); } this.destinationBase = destinationBase; + if (destinationBase.getScheme() == null || destinationBase.getHost() == null + || destinationBase.getPath() == null) { + throw new IllegalArgumentException( + "destinationBase must contain a scheme, host, and path"); + } } @Override @@ -206,6 +211,48 @@ } } + /** + * Compute the equivalent URI to this proxy provided a possible URI pointing + * to the destination server. + * + * <p>Redirects and similar require absolute URIs, so the destination server + * is forced to expose its hostname and IP. We could pass the original + * Host in the request (a la Apache's ProxyPreserveHost), but that is not + * sufficent because it can't specify the path. So instead, we rewrite + * such response headers when they match the destinationUri so they point to + * the proxy instead of the destination (a la Apache's ProxyPassReverse). + */ + private String computeReverseProxyDestination(HttpExchange ex, + String location) { + URI uri = null; + try { + uri = new URI(location); + } catch (URISyntaxException e) { + log.log(Level.INFO, "Could not parse value from header: " + location, e); + return location; + } + URI base = destinationBase; + // Check if the value starts with destinationBase. Use URI so that we + // can correctly take case into consideration as appropriate. + if (!(base.getScheme().equalsIgnoreCase(uri.getScheme()) + && base.getHost().equalsIgnoreCase(uri.getHost()) + && base.getPort() == uri.getPort() + && uri.getPath() != null + // This is correctly case-sensitive. + && uri.getPath().startsWith(base.getPath()))) { + return location; + } + String lastPartOfPath = uri.getPath().substring(base.getPath().length()); + URI requestUri = HttpExchanges.getRequestUri(ex); + try { + return new URI(requestUri.getScheme(), requestUri.getAuthority(), + ex.getHttpContext().getPath() + lastPartOfPath, uri.getQuery(), + uri.getFragment()).toASCIIString(); + } catch (URISyntaxException e) { + throw new AssertionError(e); + } + } + private void copyRequestHeaders(HttpExchange from, HttpURLConnection to) { Set<String> requestHopByHopHeaders = getHopByHopHeaders(from.getRequestHeaders().get("Connection")); @@ -239,6 +286,11 @@ if (responseHopByHopHeaders.contains(key)) { continue; } + if ("Location".equalsIgnoreCase(key) + || "Content-Location".equalsIgnoreCase(key) + || "URI".equalsIgnoreCase(key)) { + value = computeReverseProxyDestination(to, value); + } to.getResponseHeaders().add(key, value); } }
diff --git a/src/com/google/enterprise/adaptor/Service.java b/src/com/google/enterprise/adaptor/Service.java new file mode 100644 index 0000000..c371356 --- /dev/null +++ b/src/com/google/enterprise/adaptor/Service.java
@@ -0,0 +1,260 @@ +// Copyright 2014 Google Inc. All Rights Reserved. +// +// 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.enterprise.adaptor; + +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpServer; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.util.Arrays; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Provides environment for running multiple adaptors on the same port and + * having each instance be managed. + */ +// TODO(ejona): improve locking +// TODO(ejona): improve shutdown while starting +// TODO(ejona): improve handling of time limits +// TODO(ejona): improve state pre-condition checking +public final class Service { + private static final Logger log = Logger.getLogger(Service.class.getName()); + + private final Config config; + private final HttpServer server; + private final HttpServer dashboardServer; + private final ConcurrentMap<String, Instance> instances + = new ConcurrentHashMap<String, Instance>(); + private final Thread shutdownHook + = new Thread(new ShutdownHook(), "service-shutdown"); + private int index; + + private Service(Config config) throws IOException { + this.config = config; + this.server = Application.createHttpServer(config); + this.dashboardServer = Application.createDashboardHttpServer(config); + } + + public synchronized Instance createInstance(String name, File jar, + File workingDir) { + Instance instance = new Instance(name, jar, workingDir, index++); + if (instances.putIfAbsent(name, instance) != null) { + throw new IllegalArgumentException("Instance by name already present: " + + name); + } + instance.install(); + return instance; + } + + public synchronized void deleteInstance(String name, long time, + TimeUnit unit) { + Instance instance = instances.get(name); + if (instance == null) { + throw new IllegalArgumentException("No instance with name: " + name); + } + instance.stop(time, unit); + instance.uninstall(); + instances.remove(name, instance); + } + + public synchronized void start() throws IOException { + daemonInit(); + // The shutdown hook is purposefully not part of the daemon methods, + // because it should only be done when running from the command line. + Runtime.getRuntime().addShutdownHook(shutdownHook); + daemonStart(); + } + + synchronized void daemonInit() { + server.start(); + dashboardServer.start(); + } + + synchronized void daemonStart() { + for (Instance instance : instances.values()) { + instance.start(); + } + } + + public synchronized void stop(long time, TimeUnit unit) { + daemonStop(time, unit); + daemonDestroy(time, unit); + try { + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } catch (IllegalStateException ex) { + // Already executing hook. + } + } + + synchronized void daemonStop(long time, TimeUnit unit) { + for (Instance instance : instances.values()) { + instance.stop(time, unit); + } + } + + synchronized void daemonDestroy(long time, TimeUnit unit) { + Application.httpServerShutdown(server, time, unit); + Application.httpServerShutdown(dashboardServer, time, unit); + } + + static Service daemonMain(String[] args) throws IOException { + Config config = new Config(); + Application.autoConfig(config, args, + new File(Application.DEFAULT_CONFIG_FILE)); + return new Service(config); + } + + public static void main(String[] args) throws IOException { + Service service = daemonMain(args); + // TODO(ejona): decide if we want the JAR to be relative to the parent + // process or the child process (right now it is relative to the child). + service.createInstance("adaptor1", new File("../AdaptorTemplate.jar"), + new File("adaptor1")); + service.createInstance("adaptor2", new File("../AdaptorTemplate.jar"), + new File("adaptor2")); + service.start(); + } + + /** + * Maybe use this as an interface to allow disabling/enabling and + * starting/stopping of particular instances? + */ + public final class Instance { + private final String name; + private final File jar; + private final File workingDir; + private final int index; + private Thread running; + private ShutdownWaiter waiter; + + private Instance(String name, File jar, File workingDir, int index) { + if (name == null) { + throw new NullPointerException(); + } + if (name.contains("/") || name.startsWith(".")) { + // Prevent '.', '..', and things containing '/' from being names. + throw new IllegalArgumentException( + "Name must not contain / or start with ."); + } + if (jar == null) { + throw new NullPointerException(); + } + if (index < 0 || index > 10000) { + throw new IllegalArgumentException("Index too large or small: " + + index); + } + this.name = name; + this.jar = jar; + this.workingDir = workingDir; + this.index = index; + } + + private void install() { + // TODO(ejona): add disable support, where we return Service Unavailable + // instead of Not Found. + } + + private void start() { + if (running != null) { + throw new IllegalStateException(); + } + final int port = config.getServerPort() + 2 * (index + 1); + final int dashboardPort = config.getServerDashboardPort() + + 2 * (index + 1); + final String scheme = config.isServerSecure() ? "https" : "http"; + waiter = new ShutdownWaiter(); + running = new Thread(new Runnable() { + @Override + public void run() { + try { + // TODO(ejona): Figure out how much configuration to share. + int ret = JavaExec.exec(jar, workingDir, Arrays.<String>asList( + "-Dserver.port=" + port, + "-Dserver.dashboardPort=" + dashboardPort, + // TODO(ejona): use same security for child + "-Dserver.secure=false", + // TODO(ejona): REMOVE THIS HACK. WE NEED TO DO PROPER SECURITY + // COMMUNICATION AND HANDLE X-Forwarded-For AND SIMILAR. THIS + // DISABLES ALL SECURITY. + "-Dserver.fullAccessHosts=127.0.0.1", + "-Dgsa.hostname=" + config.getGsaHostname(), + "-Dserver.reverseProxyProtocol=" + scheme, + "-Dserver.reverseProxyPort=" + config.getServerPort())); + if (ret != 0) { + log.log(Level.WARNING, "Error response code from child: " + ret); + } + } catch (IOException ex) { + // TODO(ejona): add more info as to which one failed. + log.log(Level.WARNING, "IOException in subprocess", ex); + } catch (InterruptedException ex) { + log.log(Level.WARNING, "Forced shutdown of child", ex); + } + } + }); + running.start(); + + HttpContext context = server.createContext( + "/" + name + "/", new ReverseProxyHandler( + URI.create("http://127.0.0.1:" + port + "/"))); + context.getFilters().add(waiter.filter()); + + // TODO(ejona): When you end up visiting the dashboard, it redirects you + // to its port. It would be nice to fix RedirectHandler to deal with that, + // although it will require additional config parameters. + HttpContext dashboardContext = dashboardServer.createContext( + "/" + name + "/", new ReverseProxyHandler( + URI.create("http://127.0.0.1:" + dashboardPort + "/"))); + dashboardContext.getFilters().add(waiter.filter()); + } + + private void stop(long time, TimeUnit unit) { + if (running == null) { + throw new IllegalStateException(); + } + server.removeContext("/" + name); + dashboardServer.removeContext("/" + name); + try { + waiter.shutdown(time, unit); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + // TODO(ejona): Send graceful shutdown request and join with running + // thread, only interrupting if it times out. + running.interrupt(); + try { + running.join(unit.toMillis(time)); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + running = null; + } + + private void uninstall() { + } + } + + private class ShutdownHook implements Runnable { + @Override + public void run() { + stop(3, TimeUnit.SECONDS); + } + } +}
diff --git a/test/com/google/enterprise/adaptor/ReverseProxyHandlerTest.java b/test/com/google/enterprise/adaptor/ReverseProxyHandlerTest.java index a68a256..5ca5581 100644 --- a/test/com/google/enterprise/adaptor/ReverseProxyHandlerTest.java +++ b/test/com/google/enterprise/adaptor/ReverseProxyHandlerTest.java
@@ -276,6 +276,29 @@ assertArrayEquals(response, ex.getResponseBytes()); } + @Test + public void testReverseRedirect() throws IOException { + byte[] response = "test response".getBytes(charset); + Headers goldenResponseHeaders = new Headers(); + goldenResponseHeaders.add("Date", MockHttpExchange.HEADER_DATE_VALUE); + goldenResponseHeaders.add("Location", "http://example.com/proxy/page"); + + Map<String, List<String>> responseHeaders + = new HashMap<String, List<String>>(); + responseHeaders.put("Location", + Arrays.asList("http://localhost:" + port + "/page")); + MockHttpHandler mockHandler + = new MockHttpHandler(307, response, responseHeaders); + server.createContext("/get", mockHandler); + MockHttpExchange ex = new MockHttpExchange("GET", "example.com", + "/proxy/get", context); + handler.handle(ex); + + assertEquals(307, ex.getResponseCode()); + assertHeadersEquals(goldenResponseHeaders, ex.getResponseHeaders()); + assertArrayEquals(response, ex.getResponseBytes()); + } + private static void assertHeadersEquals(Headers golden, Headers header) { if (!Objects.equal(golden, header)) { fail("expected:" + new TreeMap<String, List<String>>(golden)