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)