[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