[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]

[or-cvs] [metrics-web/master 2/2] Add new page to search the relay descriptor archive.



Author: Karsten Loesing <karsten.loesing@xxxxxxx>
Date: Thu, 2 Sep 2010 17:30:45 +0200
Subject: Add new page to search the relay descriptor archive.
Commit: c015351564d593a570a7b56bf9dafe7d8df013a8

---
 .../torproject/ernie/web/RelaySearchServlet.java   |  554 ++++++++++++++++++++
 war/WEB-INF/templates/banner.tpl.jsp               |    1 +
 war/WEB-INF/templates/index.tpl.jsp                |    5 +
 war/WEB-INF/templates/status.tpl.jsp               |    4 +-
 war/WEB-INF/web.xml                                |    8 +
 5 files changed, 571 insertions(+), 1 deletions(-)
 create mode 100644 src/org/torproject/ernie/web/RelaySearchServlet.java

diff --git a/src/org/torproject/ernie/web/RelaySearchServlet.java b/src/org/torproject/ernie/web/RelaySearchServlet.java
new file mode 100644
index 0000000..265ee81
--- /dev/null
+++ b/src/org/torproject/ernie/web/RelaySearchServlet.java
@@ -0,0 +1,554 @@
+package org.torproject.ernie.web;
+
+import javax.servlet.*;
+import javax.servlet.http.*;
+import java.io.*;
+import java.math.*;
+import java.text.*;
+import java.util.*;
+import java.util.regex.*;
+
+import org.apache.commons.codec.*;
+import org.apache.commons.codec.binary.*;
+
+/**
+ * Web page that allows users to search for relays in the descriptor
+ * archives.
+ *
+ * Possible improvements:
+ * - Make nickname search case-insensitive
+ * - Instead of searching last 30 days, add date, month, or even year
+ *   parameter
+ * - Make CONSENSUS_DIRECTORY configurable
+ *
+ * Possible search terms for testing:
+ * - gabelmoo
+ * - gabelmoo F2044413DAC2E02E3D6BCF4735A19BCA1DE97281
+ * - gabelmoo 80.190.246
+ * - gabelmoo F2044413DAC2E02E3D6BCF4735A19BCA1DE97281 80.190.246
+ * - 5898549205 dc737cc9dca16af6 79.212.74.45
+ * - 5898549205 dc737cc9dca16af6
+ * - 80.190.246.100
+ * - F2044413DAC2E02E3D6BCF4735A19BCA1DE97281
+ * - F2044413DAC2E02E3D6BCF4735A19BCA1DE97281 80.190.246
+ * - 58985492
+ * - 58985492 79.212.74.45
+ */
+public class RelaySearchServlet extends HttpServlet {
+
+  private static Pattern alphaNumDotSpacePattern =
+      Pattern.compile("[A-Za-z0-9\\. ]+");
+
+  private static Pattern numPattern = Pattern.compile("[0-9]+");
+
+  private static Pattern hexPattern = Pattern.compile("[A-Fa-f0-9]+");
+
+  private static Pattern alphaNumPattern =
+      Pattern.compile("[A-Za-z0-9]+");
+
+  private void writeHeader(PrintWriter out) throws IOException {
+    out.println("<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 "
+          + "Transitional//EN\"\n"
+        + "\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\";>\n"
+        + "<html xmlns=\"http://www.w3.org/1999/xhtml\";>\n"
+        + "  <head>\n"
+        + "    <meta content=\"text/html; charset=ISO-8859-1\"\n"
+        + "          http-equiv=\"content-type\" />\n"
+        + "    <title>Relay Search</title>\n"
+        + "    <meta http-equiv=Content-Type content=\"text/html; "
+          + "charset=iso-8859-1\">\n"
+        + "    <link href=\"http://www.torproject.org/";
+          + "stylesheet-ltr.css\" type=text/css rel=stylesheet>\n"
+        + "    <link href=\"http://www.torproject.org/favicon.ico\"; "
+          + "type=image/x-icon rel=\"shortcut icon\">\n"
+        + "  </head>\n"
+        + "  <body>\n"
+        + "    <div class=\"center\">\n"
+        + "      <table class=\"banner\" border=\"0\" cellpadding=\"0\" "
+          + "cellspacing=\"0\" summary=\"\">\n"
+        + "        <tr>\n"
+        + "          <td class=\"banner-left\"><a "
+          + "href=\"https://www.torproject.org/\";><img "
+          + "src=\"http://www.torproject.org/images/top-left.png\"; "
+          + "alt=\"Click to go to home page\" width=\"193\" "
+          + "height=\"79\"></a></td>\n"
+        + "          <td class=\"banner-middle\">\n"
+        + "            <a href=\"/\">Home</a>\n"
+        + "            <a href=\"graphs.html\">Graphs</a>\n"
+        + "            <a href=\"research.html\">Research</a>\n"
+        + "            <a href=\"status.html\">Status</a>\n"
+        + "            <br/>\n"
+        + "            <font size=\"2\">\n"
+        + "              <a href=\"exonerator.html\">ExoneraTor</a>\n"
+        + "              <a class=\"current\">Relay Search</a>\n"
+        + "              <a href=\"consensus-health.html\">Consensus "
+          + "Health</a>\n"
+        + "              <a href=\"log.html\">Last Log</a>\n"
+        + "            </font>\n"
+        + "          </td>\n"
+        + "          <td class=\"banner-right\"></td>\n"
+        + "        </tr>\n"
+        + "      </table>\n"
+        + "      <div class=\"main-column\" style=\"margin:5; "
+          + "Padding:0;\">\n"
+        + "        <h2>Relay Search</h2>\n");
+  }
+
+  private void writeFooter(PrintWriter out) throws IOException {
+    out.println("        <br/>\n"
+        + "      </div>\n"
+        + "    </div>\n"
+        + "    <div class=\"bottom\" id=\"bottom\">\n"
+        + "      <p>This material is supported in part by the National "
+          + "Science Foundation under Grant No. CNS-0959138. Any "
+          + "opinions, finding, and conclusions or recommendations "
+          + "expressed in this material are those of the author(s) and "
+          + "do not necessarily reflect the views of the National "
+          + "Science Foundation.</p>\n"
+        + "      <p>\"Tor\" and the \"Onion Logo\" are <a "
+          + "href=\"https://www.torproject.org/trademark-faq.html.en\";>"
+          + "registered trademarks</a> of The Tor Project, Inc.</p>\n"
+        + "      <p>Data on this site is freely available under a <a "
+          + "href=\"http://creativecommons.org/publicdomain/zero/1.0/\";>"
+          + "CC0 no copyright declaration</a>: To the extent possible "
+          + "under law, the Tor Project has waived all copyright and "
+          + "related or neighboring rights in the data. Graphs are "
+          + "licensed under a <a "
+          + "href=\"http://creativecommons.org/licenses/by/3.0/us/\";>"
+          + "Creative Commons Attribution 3.0 United States "
+          + "License</a>.</p>\n"
+        + "    </div>\n"
+        + "  </body>\n"
+        + "</html>");
+    out.close();
+  }
+
+  public final String CONSENSUS_DIRECTORY =
+      "/srv/metrics.torproject.org/ernie/directory-archive/consensus";
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException,
+      ServletException {
+
+    /* Measure how long it takes to process this request. */
+    long started = System.currentTimeMillis();
+
+    /* Get print writer and start writing response. We're wrapping the
+     * PrintWriter, because we want it to auto-flush as soon as we have
+     * written a line. */
+    PrintWriter out = new PrintWriter(response.getWriter(), true);
+    writeHeader(out);
+
+    /* Check if we have a consensuses directory. */
+    File consensusDirectory = new File(CONSENSUS_DIRECTORY);
+    SortedSet<File> consensusDirectories = new TreeSet<File>();
+    if (consensusDirectory.exists() && consensusDirectory.isDirectory()) {
+      for (File yearFile : consensusDirectory.listFiles()) {
+        for (File monthFile : yearFile.listFiles()) {
+          consensusDirectories.add(monthFile);
+        }
+      }
+    }
+    if (consensusDirectories.isEmpty()) {
+      out.println("<p><font color=\"red\"><b>Warning: </b></font>This "
+          + "server doesn't have any relay lists available. If this "
+          + "problem persists, please "
+          + "<a href=\"mailto:tor-assistants@xxxxxxxxxxxxx\";>let us "
+          + "know</a>!</p>\n");
+      writeFooter(out);
+      return;
+    }
+
+    /* Read search parameter, if any. */
+    String searchParameter = request.getParameter("search");
+    if (searchParameter == null) {
+      searchParameter = "";
+    }
+
+    /* Write search form. */
+    out.print("        <p>Search for a relay in the relay descriptor "
+          + "archive by typing (part of) its <b>nickname</b>, "
+          + "<b>fingerprint</b>, or <b>IP address</b> in the following "
+          + "search field and clicking Search. The search will stop "
+          + "after 30 hits or parsing 1 month of descriptors. Note that "
+          + "the search can take up to 30 seconds.</p><br/>\n"
+        + "        <form action=\"relay-search.html\">\n"
+        + "          <table>\n"
+        + "            <tr>\n"
+        + "              <td><input type=\"text\" name=\"search\""
+          + (searchParameter.length() > 0 ? " value=\"" + searchParameter
+          + "\"" : "") + "/></td>\n"
+        + "              <td><input type=\"submit\" value=\"Search\"/>"
+          + "</td>\n"
+        + "            </tr>\n"
+        + "          </table>\n"
+        + "        </form>\n"
+        + "        <br/>\n");
+
+    /* No search parameter? We're done here. */
+    if (searchParameter.length() == 0) {
+      writeFooter(out);
+      return;
+    }
+
+    /* Parse search parameter to identify what nickname, fingerprint,
+     * and/or IP address to search for. A valid query contains no more
+     * than one identifier for each of the fields. As a special case,
+     * there are search terms consisting of 8 to 19 hex characters that
+     * can be either a nickname or a fingerprint. */
+    String searchNickname = "";
+    String searchFingerprint = "";
+    String searchIPAddress = "";
+    SortedSet<String> searchFingerprintOrNickname = new TreeSet<String>();
+    boolean validQuery = false;
+
+    /* Only parse search parameter if it contains nothing else than
+     * alphanumeric characters, dots, and spaces. */
+    if (alphaNumDotSpacePattern.matcher(searchParameter).matches()) {
+      SortedSet<String> searchTerms = new TreeSet<String>();
+      if (searchParameter.trim().contains(" ")) {
+        String[] split = searchParameter.trim().split(" ");
+        for (int i = 0; i < split.length; i++) {
+          if (split[i].length() > 0) {
+            searchTerms.add(split[i]);
+          }
+        }
+      } else {
+        searchTerms.add(searchParameter.trim());
+      }
+
+      /* Parse each search term separately. */
+      for (String searchTerm : searchTerms) {
+
+        /* If the search term contains a dot, it can only be an IP
+         * address. */
+        if (searchTerm.contains(".") && !searchTerm.startsWith(".")) {
+          String[] octets = searchTerm.split("\\.");
+          if (searchIPAddress.length() > 0 || octets.length < 2 ||
+              octets.length > 4) {
+            validQuery = false;
+            break;
+          }
+          boolean invalidOctet = false;
+          StringBuilder sb = new StringBuilder();
+          for (int i = 0; i < octets.length; i++) {
+            if (!numPattern.matcher(octets[i]).matches() ||
+                octets[i].length() > 3 ||
+                Integer.parseInt(octets[i]) > 255) {
+              invalidOctet = true;
+              break;
+            } else {
+              sb.append("." + Integer.parseInt(octets[i]));
+            }
+          }
+          if (invalidOctet) {
+            validQuery = false;
+            break;
+          }
+          if (octets.length < 4) {
+            sb.append(".");
+          }
+          searchIPAddress = sb.toString().substring(1);
+        }
+
+        /* If the search term contains between 8 and 19 hex characters, it
+         * could be either a nickname or a fingerprint. */
+        else if (searchTerm.length() >= 8 && searchTerm.length() <= 19 &&
+            hexPattern.matcher(searchTerm).matches()) {
+          searchFingerprintOrNickname.add(searchTerm);
+        }
+
+        /* If the search term contains between 20 and 40 hex characters,
+         * it must be a fingerprint. */
+        else if (searchTerm.length() >= 20 && searchTerm.length() <= 40 &&
+            hexPattern.matcher(searchTerm).matches()) {
+          if (searchFingerprint.length() > 0) {
+            validQuery = false;
+            break;
+          }
+          searchFingerprint = searchTerm;
+        }
+
+        /* If the search term contains up to 19 alphanumerical characters,
+         * it must be a nickname. */
+        else if (searchTerm.length() <= 19 &&
+            alphaNumPattern.matcher(searchTerm).matches()) {
+          if (searchNickname.length() > 0) {
+            validQuery = false;
+            break;
+          }
+          searchNickname = searchTerm;
+        }
+
+        /* We didn't recognize this search term. */
+        else {
+          validQuery = false;
+          break;
+        }
+
+        /* We recognized at least one search term, so that the query is
+         * potentially valid. */
+        validQuery = true;
+      }
+    }
+
+    /* We can only accept at most two search terms for nickname and
+     * fingerprint. */
+    int items = searchFingerprintOrNickname.size();
+    items += searchFingerprint.length() > 0 ? 1 : 0;
+    items += searchNickname.length() > 0 ? 1 : 0;
+    if (items > 2) {
+      validQuery = false;
+    }
+
+    /* If we have two candidates for fingerprint or nickname and we can
+     * recognize what one of them is, then we can conclude what the other
+     * one must be. */
+    else if (items == 2 && searchFingerprintOrNickname.size() == 1) {
+      if (searchFingerprint.length() == 0) {
+        searchFingerprint = searchFingerprintOrNickname.first();
+      } else {
+        searchNickname = searchFingerprintOrNickname.first();
+      }
+      searchFingerprintOrNickname.clear();
+    }
+
+    /* If the query is invalid, print out a general warning. */
+    if (!validQuery) {
+      out.write("        <p>Sorry, I didn't understand your query. "
+          + "Please provide a nickname (e.g., \"gabelmoo\"), at least "
+          + "the first 8 hex characters of a fingerprint (e.g., "
+          + "\"F2044413\"), or at least the first two octets of an IPv4 "
+          + "address in dotted-decimal notation (e.g., \"80.190\")."
+          + "</p>\n");
+      writeFooter(out);
+      return;
+    }
+
+    /* Print out what we're searching for. */
+    List<String> recognizedSearchTerms = new ArrayList<String>();
+    if (searchNickname.length() > 0) {
+      recognizedSearchTerms.add("nickname <b>" + searchNickname + "</b>");
+    }
+    if (searchFingerprint.length() > 0) {
+      recognizedSearchTerms.add("fingerprint <b>" + searchFingerprint
+          + "</b>");
+    }
+    for (String searchTerm : searchFingerprintOrNickname) {
+      recognizedSearchTerms.add("nickname or fingerprint <b>" + searchTerm
+          + "</b>");
+    }
+    if (searchIPAddress.length() > 0) {
+      recognizedSearchTerms.add("IP address <b>" + searchIPAddress
+          + "</b>");
+    }
+    out.write("        <p>Searching for relays with ");
+    if (recognizedSearchTerms.size() == 1) {
+      out.write(recognizedSearchTerms.get(0) + " ...</p>\n");
+    } else if (recognizedSearchTerms.size() == 2) {
+      out.write(recognizedSearchTerms.get(0) + " and "
+          + recognizedSearchTerms.get(1) + " ...</p>\n");
+    } else {
+      for (int i = 0; i < recognizedSearchTerms.size() - 1; i++) {
+        out.write(recognizedSearchTerms.get(i) + ", ");
+      }
+      out.write("and " + recognizedSearchTerms.get(
+          recognizedSearchTerms.size() - 1) + " ...</p>\n");
+    }
+    out.flush();
+
+    /* Compile a regular expression pattern to parse r lines more
+     * quickly. */
+    StringBuilder patternBuilder = new StringBuilder("r ");
+    if (searchNickname.length() > 0 || searchFingerprint.length() > 0) {
+      if (searchNickname.length() > 0) {
+        patternBuilder.append(searchNickname);
+      }
+      if (searchFingerprint.length() > 0) {
+        try {
+          patternBuilder.append(".*" + Base64.encodeBase64String(
+              Hex.decodeHex((searchFingerprint
+              + (searchFingerprint.length() % 2 == 1 ? "0" : "")).
+              toCharArray())).substring(0, searchFingerprint.length() *
+              2 / 3));
+        } catch (DecoderException e) {
+          /* We make sure this exception is never thrown by passing an
+           * even number of only hex characters to Hex.decodeHex(). */
+        }
+      }
+    } else if (searchFingerprintOrNickname.size() > 0) {
+      List<String> searchTermsCopy = new ArrayList<String>(
+          searchFingerprintOrNickname);
+      if (searchTermsCopy.size() < 2) {
+        searchTermsCopy.add("");
+      }
+      patternBuilder.append("(");
+      for (int i = 0; i < 2; i++) {
+        patternBuilder.append(searchTermsCopy.get(i));
+        String searchTerm = searchTermsCopy.get((i + 1) % 2);
+        if (searchTerm.length() > 0) {
+          try{
+            patternBuilder.append(".*" + Base64.encodeBase64String(
+                Hex.decodeHex((searchTerm + (searchTerm.length()
+                % 2 == 1 ? "0" : "")).toCharArray())).substring(0,
+                searchTerm.length() * 2 / 3));
+          } catch (DecoderException e) {
+            /* We make sure this exception is never thrown by passing an
+             * even number of only hex characters to Hex.decodeHex(). */
+          }
+        }
+        if (i == 0) {
+          patternBuilder.append("|");
+        }
+      }
+      patternBuilder.append(")");
+    }
+    if (searchIPAddress.length() > 0) {
+      patternBuilder.append(".* " + searchIPAddress.replaceAll("\\.",
+          "\\\\."));
+    }
+    patternBuilder.append(".*");
+    String pattern = patternBuilder.toString();
+    Pattern searchPattern = Pattern.compile(pattern);
+
+    /* While parsing, memorize the r lines of the last 24 parsed
+     * consensuses, so that we don't have to parse them again. */
+    Set<String> failedRLines = new HashSet<String>();
+    List<Set<String>> addedFailedRLines = new ArrayList<Set<String>>();
+
+    /* Parse consensus files from newest to oldest. Stop after either
+     * parsing 31 * 24 consensuses, finding 30 hits, or running out of
+     * consensuses. */
+    SortedSet<File> consensusDirsToParse = new TreeSet<File>();
+    consensusDirsToParse.addAll(consensusDirectories);
+    SortedSet<File> consensusesToParse = new TreeSet<File>();
+    int matches = 0, consensusesParsed = 0;
+    while (consensusesParsed < 31 * 24 && matches < 30 &&
+        !(consensusDirsToParse.isEmpty() &&
+        consensusesToParse.isEmpty())) {
+
+      /* Only put consensuses of one month in the queue at the same
+       * time. */
+      while (consensusesToParse.isEmpty() &&
+          !consensusDirsToParse.isEmpty()) {
+        Stack<File> parse = new Stack<File>();
+        File dir = consensusDirsToParse.last();
+        parse.add(dir);
+        consensusDirsToParse.remove(dir);
+        while (!parse.isEmpty()) {
+          File pop = parse.remove(0);
+          if (pop.isDirectory()) {
+            for (File file : pop.listFiles()) {
+              parse.add(file);
+            }
+          } else {
+            consensusesToParse.add(pop);
+          }
+        }
+      }
+      if (consensusesToParse.isEmpty()) {
+        break;
+      }
+
+      /* Parse consensus at the head of the queue. */
+      File consensus = consensusesToParse.last();
+      consensusesToParse.remove(consensus);
+      BufferedReader br = new BufferedReader(new FileReader(consensus));
+      String line = null, validAfterLine = null;
+      Set<String> currentlyAddedFailedRLines = new HashSet<String>();
+      addedFailedRLines.add(currentlyAddedFailedRLines);
+      while ((line = br.readLine()) != null) {
+        if (line.startsWith("r ")) {
+
+          /* If we already know this r line doesn't match our regular
+           * expression, ignore it. */
+          if (failedRLines.contains(line)) {
+
+          /* If we don't know this r line yet, but it doesn't match our
+           * regular expression, memorize it. */
+          } else if (!searchPattern.matcher(line).matches()) {
+            currentlyAddedFailedRLines.add(line);
+            failedRLines.add(line);
+
+          /* If this r line matches our regular expression, compare fields
+           * to be certain we want this relay. */
+          } else {
+            String[] parts = line.split(" ");
+            String nickname = parts[1];
+            String address = parts[6];
+            if (searchNickname.length() > 0 &&
+                !nickname.startsWith(searchNickname)) {
+              continue;
+            }
+            if (searchIPAddress.length() > 0 &&
+                !address.startsWith(searchIPAddress)) {
+              continue;
+            }
+            String fingerprint = String.format("%040x", new BigInteger(1,
+                Base64.decodeBase64(parts[2] + "=="))).toLowerCase();
+            if (searchFingerprint.length() > 0 && !fingerprint.startsWith(
+                searchFingerprint.toLowerCase())) {
+              continue;
+            }
+            boolean skip = false;
+            for (String searchTerm : searchFingerprintOrNickname) {
+              if (!nickname.startsWith(searchTerm) &&
+                  !fingerprint.startsWith(searchTerm.toLowerCase())) {
+                skip = true;
+                break;
+              }
+            }
+            if (skip) {
+              continue;
+            }
+
+            /* This r line matches the search criteria. If this is the
+             * first match in this consensus, print the valid-after
+             * line. */
+            if (validAfterLine != null) {
+              out.println("        <br/><tt>valid-after "
+                  + "<a href=\"consensus?valid-after="
+                  + validAfterLine.substring("valid-after ".length()).
+                  replaceAll(":", "-").replaceAll(" ", "-")
+                  + "\" target=\"_blank\">"
+                  + validAfterLine.substring("valid-after ".length())
+                  + "</a></tt><br/>");
+              validAfterLine = null;
+            }
+
+            /* And print the r line. */
+            String descriptor = String.format("%040x", new BigInteger(1,
+                Base64.decodeBase64(parts[3] + "==")));
+            out.println("    <tt>r " + parts[1] + " " + parts[2] + " "
+                + "<a href=\"serverdesc?desc-id=" + descriptor + "\" "
+                + "target=\"_blank\">" + parts[3] + "</a> " + parts[4]
+                + " " + parts[5] + " " + parts[6] + " " + parts[7]
+                + " " + parts[8] + "</tt><br/>");
+            matches++;
+          }
+        } else if (line.startsWith("valid-after ")) {
+          validAfterLine = line;
+        }
+      }
+      br.close();
+      consensusesParsed++;
+
+      /* Forget about failed r lines if they are 24 consensuses apart. */
+      while (addedFailedRLines.size() >= 24) {
+        Set<String> removeFailedRLines = addedFailedRLines.remove(0);
+        failedRLines.removeAll(removeFailedRLines);
+      }
+    }
+
+    /* Display total search time on the results page. */
+    long searchTime = System.currentTimeMillis() - started;
+    out.write("        <br/><p>Found " + matches + " relays in the last "
+        + consensusesParsed + " known consensuses in "
+        + String.format("%d.%03d", searchTime / 1000, searchTime % 1000)
+        + " seconds.</p>\n");
+
+    /* Finish writing response. */
+    writeFooter(out);
+  }
+}
+
diff --git a/war/WEB-INF/templates/banner.tpl.jsp b/war/WEB-INF/templates/banner.tpl.jsp
index 0bb94cc..bde314a 100644
--- a/war/WEB-INF/templates/banner.tpl.jsp
+++ b/war/WEB-INF/templates/banner.tpl.jsp
@@ -22,6 +22,7 @@
       <br/>
       <font size="2">
         <a href="/exonerator.html">ExoneraTor</a>
+        <a href="/relay-search.html">Relay Search</a>
         <a href="/consensus-health.html">Consensus Health</a>
         <a href="/log.html">Last Log</a>
       </font>
diff --git a/war/WEB-INF/templates/index.tpl.jsp b/war/WEB-INF/templates/index.tpl.jsp
index 6e9bcc5..53108cb 100644
--- a/war/WEB-INF/templates/index.tpl.jsp
+++ b/war/WEB-INF/templates/index.tpl.jsp
@@ -20,6 +20,11 @@
         <br/>
         <h3>News</h3>
         <ul>
+          <li>September 2, 2010: New <a href="relay-search.html">relay
+          search</a> feature available.</li>
+          <li>August 31, 2010: Named relays can now be found more easily
+          on the consensus-health page by using anchors, e.g.,
+          <a href="consensus-health.html#gabelmoo">https://metrics.torproject.org/consensus-health.html#gabelmoo</a> .</li>
           <li>August 16, 2010: There are now graphs for total
           <a href="new-users-graphs.html">new or returning</a> and
           <a href="recurring-users-graphs.html">recurring</a> directly
diff --git a/war/WEB-INF/templates/status.tpl.jsp b/war/WEB-INF/templates/status.tpl.jsp
index 1cbde54..9470edb 100644
--- a/war/WEB-INF/templates/status.tpl.jsp
+++ b/war/WEB-INF/templates/status.tpl.jsp
@@ -4,7 +4,9 @@
         used to analyze the Tor network status from a few years ago until
         an hour ago. There are currently two applications for this data:
         The <a href="exonerator.html">ExoneraTor</a> tells you whether
-        some IP address was a Tor relay at a given time, and the
+        some IP address was a Tor relay at a given time, the
+        <a href="relay-search.html">Relay Search</a> lets you search the
+        descriptor archive for a relay, and the
         <a href="consensus-health.html">Consensus Health</a> summarizes
         information about the latest network consensus voting process.
         The <a href="log.html">Last Log</a> of the metrics portal software
diff --git a/war/WEB-INF/web.xml b/war/WEB-INF/web.xml
index 53e87de..0193656 100644
--- a/war/WEB-INF/web.xml
+++ b/war/WEB-INF/web.xml
@@ -23,6 +23,14 @@
   </filter-mapping>
 
   <servlet>
+    <servlet-name>RelaySearch</servlet-name>
+    <servlet-class>org.torproject.ernie.web.RelaySearchServlet</servlet-class>
+  </servlet>
+  <servlet-mapping>
+    <servlet-name>RelaySearch</servlet-name>
+    <url-pattern>/relay-search.html</url-pattern>
+  </servlet-mapping>
+  <servlet>
     <servlet-name>ExoneraTor</servlet-name>
     <servlet-class>org.torproject.ernie.web.ExoneraTorServlet</servlet-class>
   </servlet>
-- 
1.7.1