[tor-commits] [arm/master] Renaming 'src' to 'arm'

atagar at torproject.org atagar at torproject.org
Tue Sep 3 02:54:29 UTC 2013


commit 4c6488435aa008e3dfd9dcb5e1974761149c4b14
Author: Damian Johnson <atagar at torproject.org>
Date:   Mon Sep 2 10:46:46 2013 -0700

    Renaming 'src' to 'arm'
    
    Changing our 'src' directory to 'arm'. The old name is largely a holdover from
    when I was a java developer, in the python world it seems more common to name
    the source directory after your project.
    
    One gotcha is that this means we can't have an 'arm' executable at the base
    directory any longer since this would conflict with the directory. Pity.
    Changing that to 'run_arm' for now, I might change it again later.
---
 arm                                    |   17 -
 arm/__init__.py                        |    6 +
 arm/cli/__init__.py                    |    6 +
 arm/cli/configPanel.py                 |  614 +++++++++++++++
 arm/cli/connections/__init__.py        |    6 +
 arm/cli/connections/circEntry.py       |  196 +++++
 arm/cli/connections/connEntry.py       |  849 +++++++++++++++++++++
 arm/cli/connections/connPanel.py       |  587 +++++++++++++++
 arm/cli/connections/countPopup.py      |  102 +++
 arm/cli/connections/descriptorPopup.py |  229 ++++++
 arm/cli/connections/entries.py         |  171 +++++
 arm/cli/controller.py                  |  676 +++++++++++++++++
 arm/cli/graphing/__init__.py           |    6 +
 arm/cli/graphing/bandwidthStats.py     |  430 +++++++++++
 arm/cli/graphing/connStats.py          |   60 ++
 arm/cli/graphing/graphPanel.py         |  518 +++++++++++++
 arm/cli/graphing/resourceStats.py      |   53 ++
 arm/cli/headerPanel.py                 |  590 +++++++++++++++
 arm/cli/logPanel.py                    | 1270 ++++++++++++++++++++++++++++++++
 arm/cli/menu/__init__.py               |    6 +
 arm/cli/menu/actions.py                |  296 ++++++++
 arm/cli/menu/item.py                   |  201 +++++
 arm/cli/menu/menu.py                   |  164 +++++
 arm/cli/popups.py                      |  337 +++++++++
 arm/cli/torrcPanel.py                  |  311 ++++++++
 arm/prereq.py                          |  141 ++++
 arm/resources/arm.1                    |   74 ++
 arm/resources/tor-arm.desktop          |   12 +
 arm/resources/tor-arm.svg              | 1074 +++++++++++++++++++++++++++
 arm/resources/torConfigDesc.txt        | 1123 ++++++++++++++++++++++++++++
 arm/settings.cfg                       |  707 ++++++++++++++++++
 arm/starter.py                         |  465 ++++++++++++
 arm/uninstall                          |   16 +
 arm/util/__init__.py                   |    8 +
 arm/util/connections.py                |  783 ++++++++++++++++++++
 arm/util/hostnames.py                  |  394 ++++++++++
 arm/util/panel.py                      |  729 ++++++++++++++++++
 arm/util/sysTools.py                   |  326 ++++++++
 arm/util/textInput.py                  |  195 +++++
 arm/util/torConfig.py                  | 1041 ++++++++++++++++++++++++++
 arm/util/torTools.py                   | 1020 +++++++++++++++++++++++++
 arm/util/uiTools.py                    |  541 ++++++++++++++
 arm/version.py                         |    7 +
 run_arm                                |   17 +
 setup.py                               |   20 +-
 src/__init__.py                        |    6 -
 src/cli/__init__.py                    |    6 -
 src/cli/configPanel.py                 |  614 ---------------
 src/cli/connections/__init__.py        |    6 -
 src/cli/connections/circEntry.py       |  196 -----
 src/cli/connections/connEntry.py       |  849 ---------------------
 src/cli/connections/connPanel.py       |  587 ---------------
 src/cli/connections/countPopup.py      |  102 ---
 src/cli/connections/descriptorPopup.py |  229 ------
 src/cli/connections/entries.py         |  171 -----
 src/cli/controller.py                  |  676 -----------------
 src/cli/graphing/__init__.py           |    6 -
 src/cli/graphing/bandwidthStats.py     |  430 -----------
 src/cli/graphing/connStats.py          |   60 --
 src/cli/graphing/graphPanel.py         |  518 -------------
 src/cli/graphing/resourceStats.py      |   53 --
 src/cli/headerPanel.py                 |  590 ---------------
 src/cli/logPanel.py                    | 1270 --------------------------------
 src/cli/menu/__init__.py               |    6 -
 src/cli/menu/actions.py                |  296 --------
 src/cli/menu/item.py                   |  201 -----
 src/cli/menu/menu.py                   |  164 -----
 src/cli/popups.py                      |  337 ---------
 src/cli/torrcPanel.py                  |  311 --------
 src/prereq.py                          |  141 ----
 src/resources/arm.1                    |   74 --
 src/resources/tor-arm.desktop          |   12 -
 src/resources/tor-arm.svg              | 1074 ---------------------------
 src/resources/torConfigDesc.txt        | 1123 ----------------------------
 src/settings.cfg                       |  707 ------------------
 src/starter.py                         |  465 ------------
 src/uninstall                          |   16 -
 src/util/__init__.py                   |    8 -
 src/util/connections.py                |  783 --------------------
 src/util/hostnames.py                  |  394 ----------
 src/util/panel.py                      |  729 ------------------
 src/util/sysTools.py                   |  326 --------
 src/util/textInput.py                  |  195 -----
 src/util/torConfig.py                  | 1041 --------------------------
 src/util/torTools.py                   | 1020 -------------------------
 src/util/uiTools.py                    |  541 --------------
 src/version.py                         |    7 -
 87 files changed, 16367 insertions(+), 16367 deletions(-)

diff --git a/arm b/arm
deleted file mode 100755
index 0a23aaa..0000000
--- a/arm
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/bin/sh
-
-# Also looking in /bin/arm because of the UsrMove feature on Fedora...
-# https://trac.torproject.org/5973
-
-if [ "$0" = /usr/bin/arm ] || [ "$0" = /bin/arm ]; then
-  arm_base=/usr/share/arm/
-else
-  arm_base=$( dirname "$0" )/src/
-fi
-
-python "${arm_base}prereq.py" $*
-
-if [ $? = 0 ]; then
-  exec python -W ignore::DeprecationWarning "${arm_base}starter.py" $*
-fi
-
diff --git a/arm/__init__.py b/arm/__init__.py
new file mode 100644
index 0000000..461b0ce
--- /dev/null
+++ b/arm/__init__.py
@@ -0,0 +1,6 @@
+"""
+Scripts involved in validating user input, system state, and initializing arm.
+"""
+
+__all__ = ["starter", "prereq", "version"]
+
diff --git a/arm/cli/__init__.py b/arm/cli/__init__.py
new file mode 100644
index 0000000..052e06c
--- /dev/null
+++ b/arm/cli/__init__.py
@@ -0,0 +1,6 @@
+"""
+Panels, popups, and handlers comprising the arm user interface.
+"""
+
+__all__ = ["configPanel", "controller", "headerPanel", "logPanel", "popups", "torrcPanel"]
+
diff --git a/arm/cli/configPanel.py b/arm/cli/configPanel.py
new file mode 100644
index 0000000..f37c1d1
--- /dev/null
+++ b/arm/cli/configPanel.py
@@ -0,0 +1,614 @@
+"""
+Panel presenting the configuration state for tor or arm. Options can be edited
+and the resulting configuration files saved.
+"""
+
+import curses
+import threading
+
+import cli.controller
+import popups
+
+from util import panel, sysTools, torConfig, torTools, uiTools
+
+import stem.control
+
+from stem.util import conf, enum, str_tools
+
+# TODO: The arm use cases are incomplete since they currently can't be
+# modified, have their descriptions fetched, or even get a complete listing
+# of what's available.
+State = enum.Enum("TOR", "ARM") # state to be presented
+
+# mappings of option categories to the color for their entries
+CATEGORY_COLOR = {torConfig.Category.GENERAL: "green",
+                  torConfig.Category.CLIENT: "blue",
+                  torConfig.Category.RELAY: "yellow",
+                  torConfig.Category.DIRECTORY: "magenta",
+                  torConfig.Category.AUTHORITY: "red",
+                  torConfig.Category.HIDDEN_SERVICE: "cyan",
+                  torConfig.Category.TESTING: "white",
+                  torConfig.Category.UNKNOWN: "white"}
+
+# attributes of a ConfigEntry
+Field = enum.Enum("CATEGORY", "OPTION", "VALUE", "TYPE", "ARG_USAGE",
+                  "SUMMARY", "DESCRIPTION", "MAN_ENTRY", "IS_DEFAULT")
+
+FIELD_ATTR = {Field.CATEGORY: ("Category", "red"),
+              Field.OPTION: ("Option Name", "blue"),
+              Field.VALUE: ("Value", "cyan"),
+              Field.TYPE: ("Arg Type", "green"),
+              Field.ARG_USAGE: ("Arg Usage", "yellow"),
+              Field.SUMMARY: ("Summary", "green"),
+              Field.DESCRIPTION: ("Description", "white"),
+              Field.MAN_ENTRY: ("Man Page Entry", "blue"),
+              Field.IS_DEFAULT: ("Is Default", "magenta")}
+
+def conf_handler(key, value):
+  if key == "features.config.selectionDetails.height":
+    return max(0, value)
+  elif key == "features.config.state.colWidth.option":
+    return max(5, value)
+  elif key == "features.config.state.colWidth.value":
+    return max(5, value)
+  elif key == "features.config.order":
+    return conf.parse_enum_csv(key, value[0], Field, 3)
+
+CONFIG = conf.config_dict("arm", {
+  "features.config.order": [Field.MAN_ENTRY, Field.OPTION, Field.IS_DEFAULT],
+  "features.config.selectionDetails.height": 6,
+  "features.config.prepopulateEditValues": True,
+  "features.config.state.showPrivateOptions": False,
+  "features.config.state.showVirtualOptions": False,
+  "features.config.state.colWidth.option": 25,
+  "features.config.state.colWidth.value": 15,
+}, conf_handler)
+
+def getFieldFromLabel(fieldLabel):
+  """
+  Converts field labels back to their enumeration, raising a ValueError if it
+  doesn't exist.
+  """
+  
+  for entryEnum in FIELD_ATTR:
+    if fieldLabel == FIELD_ATTR[entryEnum][0]:
+      return entryEnum
+
+class ConfigEntry():
+  """
+  Configuration option in the panel.
+  """
+  
+  def __init__(self, option, type, isDefault):
+    self.fields = {}
+    self.fields[Field.OPTION] = option
+    self.fields[Field.TYPE] = type
+    self.fields[Field.IS_DEFAULT] = isDefault
+    
+    # Fetches extra infromation from external sources (the arm config and tor
+    # man page). These are None if unavailable for this config option.
+    summary = torConfig.getConfigSummary(option)
+    manEntry = torConfig.getConfigDescription(option)
+    
+    if manEntry:
+      self.fields[Field.MAN_ENTRY] = manEntry.index
+      self.fields[Field.CATEGORY] = manEntry.category
+      self.fields[Field.ARG_USAGE] = manEntry.argUsage
+      self.fields[Field.DESCRIPTION] = manEntry.description
+    else:
+      self.fields[Field.MAN_ENTRY] = 99999 # sorts non-man entries last
+      self.fields[Field.CATEGORY] = torConfig.Category.UNKNOWN
+      self.fields[Field.ARG_USAGE] = ""
+      self.fields[Field.DESCRIPTION] = ""
+    
+    # uses the full man page description if a summary is unavailable
+    self.fields[Field.SUMMARY] = summary if summary != None else self.fields[Field.DESCRIPTION]
+    
+    # cache of what's displayed for this configuration option
+    self.labelCache = None
+    self.labelCacheArgs = None
+  
+  def get(self, field):
+    """
+    Provides back the value in the given field.
+    
+    Arguments:
+      field - enum for the field to be provided back
+    """
+    
+    if field == Field.VALUE: return self._getValue()
+    else: return self.fields[field]
+  
+  def getAll(self, fields):
+    """
+    Provides back a list with the given field values.
+    
+    Arguments:
+      field - enums for the fields to be provided back
+    """
+    
+    return [self.get(field) for field in fields]
+  
+  def getLabel(self, optionWidth, valueWidth, summaryWidth):
+    """
+    Provides display string of the configuration entry with the given
+    constraints on the width of the contents.
+    
+    Arguments:
+      optionWidth  - width of the option column
+      valueWidth   - width of the value column
+      summaryWidth - width of the summary column
+    """
+    
+    # Fetching the display entries is very common so this caches the values.
+    # Doing this substantially drops cpu usage when scrolling (by around 40%).
+    
+    argSet = (optionWidth, valueWidth, summaryWidth)
+    if not self.labelCache or self.labelCacheArgs != argSet:
+      optionLabel = uiTools.cropStr(self.get(Field.OPTION), optionWidth)
+      valueLabel = uiTools.cropStr(self.get(Field.VALUE), valueWidth)
+      summaryLabel = uiTools.cropStr(self.get(Field.SUMMARY), summaryWidth, None)
+      lineTextLayout = "%%-%is %%-%is %%-%is" % (optionWidth, valueWidth, summaryWidth)
+      self.labelCache = lineTextLayout % (optionLabel, valueLabel, summaryLabel)
+      self.labelCacheArgs = argSet
+    
+    return self.labelCache
+  
+  def isUnset(self):
+    """
+    True if we have no value, false otherwise.
+    """
+    
+    confValue = torTools.getConn().getOption(self.get(Field.OPTION), [], True)
+    return not bool(confValue)
+  
+  def _getValue(self):
+    """
+    Provides the current value of the configuration entry, taking advantage of
+    the torTools caching to effectively query the accurate value. This uses the
+    value's type to provide a user friendly representation if able.
+    """
+    
+    confValue = ", ".join(torTools.getConn().getOption(self.get(Field.OPTION), [], True))
+    
+    # provides nicer values for recognized types
+    if not confValue: confValue = "<none>"
+    elif self.get(Field.TYPE) == "Boolean" and confValue in ("0", "1"):
+      confValue = "False" if confValue == "0" else "True"
+    elif self.get(Field.TYPE) == "DataSize" and confValue.isdigit():
+      confValue = str_tools.get_size_label(int(confValue))
+    elif self.get(Field.TYPE) == "TimeInterval" and confValue.isdigit():
+      confValue = str_tools.get_time_label(int(confValue), is_long = True)
+    
+    return confValue
+
+class ConfigPanel(panel.Panel):
+  """
+  Renders a listing of the tor or arm configuration state, allowing options to
+  be selected and edited.
+  """
+  
+  def __init__(self, stdscr, configType):
+    panel.Panel.__init__(self, stdscr, "configuration", 0)
+    
+    self.configType = configType
+    self.confContents = []
+    self.confImportantContents = []
+    self.scroller = uiTools.Scroller(True)
+    self.valsLock = threading.RLock()
+    
+    # shows all configuration options if true, otherwise only the ones with
+    # the 'important' flag are shown
+    self.showAll = False
+    
+    # initializes config contents if we're connected
+    conn = torTools.getConn()
+    conn.addStatusListener(self.resetListener)
+    if conn.isAlive(): self.resetListener(None, stem.control.State.INIT, None)
+  
+  def resetListener(self, controller, eventType, _):
+    # fetches configuration options if a new instance, otherewise keeps our
+    # current contents
+    
+    if eventType == stem.control.State.INIT:
+      self._loadConfigOptions()
+  
+  def _loadConfigOptions(self):
+    """
+    Fetches the configuration options available from tor or arm.
+    """
+    
+    self.confContents = []
+    self.confImportantContents = []
+    
+    if self.configType == State.TOR:
+      conn, configOptionLines = torTools.getConn(), []
+      customOptions = torConfig.getCustomOptions()
+      configOptionQuery = conn.getInfo("config/names", None)
+      
+      if configOptionQuery:
+        configOptionLines = configOptionQuery.strip().split("\n")
+      
+      for line in configOptionLines:
+        # lines are of the form "<option> <type>[ <documentation>]", like:
+        # UseEntryGuards Boolean
+        # documentation is aparently only in older versions (for instance,
+        # 0.2.1.25)
+        lineComp = line.strip().split(" ")
+        confOption, confType = lineComp[0], lineComp[1]
+        
+        # skips private and virtual entries if not configured to show them
+        if not CONFIG["features.config.state.showPrivateOptions"] and confOption.startswith("__"):
+          continue
+        elif not CONFIG["features.config.state.showVirtualOptions"] and confType == "Virtual":
+          continue
+        
+        self.confContents.append(ConfigEntry(confOption, confType, not confOption in customOptions))
+    elif self.configType == State.ARM:
+      # loaded via the conf utility
+      armConf = conf.get_config("arm")
+      for key in armConf.keys():
+        pass # TODO: implement
+    
+    # mirror listing with only the important configuration options
+    self.confImportantContents = []
+    for entry in self.confContents:
+      if torConfig.isImportant(entry.get(Field.OPTION)):
+        self.confImportantContents.append(entry)
+    
+    # if there aren't any important options then show everything
+    if not self.confImportantContents:
+      self.confImportantContents = self.confContents
+    
+    self.setSortOrder() # initial sorting of the contents
+  
+  def getSelection(self):
+    """
+    Provides the currently selected entry.
+    """
+    
+    return self.scroller.getCursorSelection(self._getConfigOptions())
+  
+  def setFiltering(self, isFiltered):
+    """
+    Sets if configuration options are filtered or not.
+    
+    Arguments:
+      isFiltered - if true then only relatively important options will be
+                   shown, otherwise everything is shown
+    """
+    
+    self.showAll = not isFiltered
+  
+  def setSortOrder(self, ordering = None):
+    """
+    Sets the configuration attributes we're sorting by and resorts the
+    contents.
+    
+    Arguments:
+      ordering - new ordering, if undefined then this resorts with the last
+                 set ordering
+    """
+    
+    self.valsLock.acquire()
+    if ordering: CONFIG["features.config.order"] = ordering
+    self.confContents.sort(key=lambda i: (i.getAll(CONFIG["features.config.order"])))
+    self.confImportantContents.sort(key=lambda i: (i.getAll(CONFIG["features.config.order"])))
+    self.valsLock.release()
+  
+  def showSortDialog(self):
+    """
+    Provides the sort dialog for our configuration options.
+    """
+    
+    # set ordering for config options
+    titleLabel = "Config Option Ordering:"
+    options = [FIELD_ATTR[field][0] for field in Field]
+    oldSelection = [FIELD_ATTR[field][0] for field in CONFIG["features.config.order"]]
+    optionColors = dict([FIELD_ATTR[field] for field in Field])
+    results = popups.showSortDialog(titleLabel, options, oldSelection, optionColors)
+    
+    if results:
+      # converts labels back to enums
+      resultEnums = [getFieldFromLabel(label) for label in results]
+      self.setSortOrder(resultEnums)
+  
+  def handleKey(self, key):
+    self.valsLock.acquire()
+    isKeystrokeConsumed = True
+    if uiTools.isScrollKey(key):
+      pageHeight = self.getPreferredSize()[0] - 1
+      detailPanelHeight = CONFIG["features.config.selectionDetails.height"]
+      if detailPanelHeight > 0 and detailPanelHeight + 2 <= pageHeight:
+        pageHeight -= (detailPanelHeight + 1)
+      
+      isChanged = self.scroller.handleKey(key, self._getConfigOptions(), pageHeight)
+      if isChanged: self.redraw(True)
+    elif uiTools.isSelectionKey(key) and self._getConfigOptions():
+      # Prompts the user to edit the selected configuration value. The
+      # interface is locked to prevent updates between setting the value
+      # and showing any errors.
+      
+      panel.CURSES_LOCK.acquire()
+      try:
+        selection = self.getSelection()
+        configOption = selection.get(Field.OPTION)
+        if selection.isUnset(): initialValue = ""
+        else: initialValue = selection.get(Field.VALUE)
+        
+        promptMsg = "%s Value (esc to cancel): " % configOption
+        isPrepopulated = CONFIG["features.config.prepopulateEditValues"]
+        newValue = popups.inputPrompt(promptMsg, initialValue if isPrepopulated else "")
+        
+        if newValue != None and newValue != initialValue:
+          try:
+            if selection.get(Field.TYPE) == "Boolean":
+              # if the value's a boolean then allow for 'true' and 'false' inputs
+              if newValue.lower() == "true": newValue = "1"
+              elif newValue.lower() == "false": newValue = "0"
+            elif selection.get(Field.TYPE) == "LineList":
+              # setOption accepts list inputs when there's multiple values
+              newValue = newValue.split(",")
+            
+            torTools.getConn().setOption(configOption, newValue)
+            
+            # forces the label to be remade with the new value
+            selection.labelCache = None
+            
+            # resets the isDefault flag
+            customOptions = torConfig.getCustomOptions()
+            selection.fields[Field.IS_DEFAULT] = not configOption in customOptions
+            
+            self.redraw(True)
+          except Exception, exc:
+            popups.showMsg("%s (press any key)" % exc)
+      finally:
+        panel.CURSES_LOCK.release()
+    elif key == ord('a') or key == ord('A'):
+      self.showAll = not self.showAll
+      self.redraw(True)
+    elif key == ord('s') or key == ord('S'):
+      self.showSortDialog()
+    elif key == ord('v') or key == ord('V'):
+      self.showWriteDialog()
+    else: isKeystrokeConsumed = False
+    
+    self.valsLock.release()
+    return isKeystrokeConsumed
+  
+  def showWriteDialog(self):
+    """
+    Provies an interface to confirm if the configuration is saved and, if so,
+    where.
+    """
+    
+    # display a popup for saving the current configuration
+    configLines = torConfig.getCustomOptions(True)
+    popup, width, height = popups.init(len(configLines) + 2)
+    if not popup: return
+    
+    try:
+      # displayed options (truncating the labels if there's limited room)
+      if width >= 30: selectionOptions = ("Save", "Save As...", "Cancel")
+      else: selectionOptions = ("Save", "Save As", "X")
+      
+      # checks if we can show options beside the last line of visible content
+      isOptionLineSeparate = False
+      lastIndex = min(height - 2, len(configLines) - 1)
+      
+      # if we don't have room to display the selection options and room to
+      # grow then display the selection options on its own line
+      if width < (30 + len(configLines[lastIndex])):
+        popup.setHeight(height + 1)
+        popup.redraw(True) # recreates the window instance
+        newHeight, _ = popup.getPreferredSize()
+        
+        if newHeight > height:
+          height = newHeight
+          isOptionLineSeparate = True
+      
+      key, selection = 0, 2
+      while not uiTools.isSelectionKey(key):
+        # if the popup has been resized then recreate it (needed for the
+        # proper border height)
+        newHeight, newWidth = popup.getPreferredSize()
+        if (height, width) != (newHeight, newWidth):
+          height, width = newHeight, newWidth
+          popup.redraw(True)
+        
+        # if there isn't room to display the popup then cancel it
+        if height <= 2:
+          selection = 2
+          break
+        
+        popup.win.erase()
+        popup.win.box()
+        popup.addstr(0, 0, "Configuration being saved:", curses.A_STANDOUT)
+        
+        visibleConfigLines = height - 3 if isOptionLineSeparate else height - 2
+        for i in range(visibleConfigLines):
+          line = uiTools.cropStr(configLines[i], width - 2)
+          
+          if " " in line:
+            option, arg = line.split(" ", 1)
+            popup.addstr(i + 1, 1, option, curses.A_BOLD | uiTools.getColor("green"))
+            popup.addstr(i + 1, len(option) + 2, arg, curses.A_BOLD | uiTools.getColor("cyan"))
+          else:
+            popup.addstr(i + 1, 1, line, curses.A_BOLD | uiTools.getColor("green"))
+        
+        # draws selection options (drawn right to left)
+        drawX = width - 1
+        for i in range(len(selectionOptions) - 1, -1, -1):
+          optionLabel = selectionOptions[i]
+          drawX -= (len(optionLabel) + 2)
+          
+          # if we've run out of room then drop the option (this will only
+          # occure on tiny displays)
+          if drawX < 1: break
+          
+          selectionFormat = curses.A_STANDOUT if i == selection else curses.A_NORMAL
+          popup.addstr(height - 2, drawX, "[")
+          popup.addstr(height - 2, drawX + 1, optionLabel, selectionFormat | curses.A_BOLD)
+          popup.addstr(height - 2, drawX + len(optionLabel) + 1, "]")
+          
+          drawX -= 1 # space gap between the options
+        
+        popup.win.refresh()
+        
+        key = cli.controller.getController().getScreen().getch()
+        if key == curses.KEY_LEFT: selection = max(0, selection - 1)
+        elif key == curses.KEY_RIGHT: selection = min(len(selectionOptions) - 1, selection + 1)
+      
+      if selection in (0, 1):
+        loadedTorrc, promptCanceled = torConfig.getTorrc(), False
+        try: configLocation = loadedTorrc.getConfigLocation()
+        except IOError: configLocation = ""
+        
+        if selection == 1:
+          # prompts user for a configuration location
+          configLocation = popups.inputPrompt("Save to (esc to cancel): ", configLocation)
+          if not configLocation: promptCanceled = True
+        
+        if not promptCanceled:
+          try:
+            torConfig.saveConf(configLocation, configLines)
+            msg = "Saved configuration to %s" % configLocation
+          except IOError, exc:
+            msg = "Unable to save configuration (%s)" % sysTools.getFileErrorMsg(exc)
+          
+          popups.showMsg(msg, 2)
+    finally: popups.finalize()
+  
+  def getHelp(self):
+    options = []
+    options.append(("up arrow", "scroll up a line", None))
+    options.append(("down arrow", "scroll down a line", None))
+    options.append(("page up", "scroll up a page", None))
+    options.append(("page down", "scroll down a page", None))
+    options.append(("enter", "edit configuration option", None))
+    options.append(("v", "save configuration", None))
+    options.append(("a", "toggle option filtering", None))
+    options.append(("s", "sort ordering", None))
+    return options
+  
+  def draw(self, width, height):
+    self.valsLock.acquire()
+    
+    # panel with details for the current selection
+    detailPanelHeight = CONFIG["features.config.selectionDetails.height"]
+    isScrollbarVisible = False
+    if detailPanelHeight == 0 or detailPanelHeight + 2 >= height:
+      # no detail panel
+      detailPanelHeight = 0
+      scrollLoc = self.scroller.getScrollLoc(self._getConfigOptions(), height - 1)
+      cursorSelection = self.getSelection()
+      isScrollbarVisible = len(self._getConfigOptions()) > height - 1
+    else:
+      # Shrink detail panel if there isn't sufficient room for the whole
+      # thing. The extra line is for the bottom border.
+      detailPanelHeight = min(height - 1, detailPanelHeight + 1)
+      scrollLoc = self.scroller.getScrollLoc(self._getConfigOptions(), height - 1 - detailPanelHeight)
+      cursorSelection = self.getSelection()
+      isScrollbarVisible = len(self._getConfigOptions()) > height - detailPanelHeight - 1
+      
+      if cursorSelection != None:
+        self._drawSelectionPanel(cursorSelection, width, detailPanelHeight, isScrollbarVisible)
+    
+    # draws the top label
+    if self.isTitleVisible():
+      configType = "Tor" if self.configType == State.TOR else "Arm"
+      hiddenMsg = "press 'a' to hide most options" if self.showAll else "press 'a' to show all options"
+      titleLabel = "%s Configuration (%s):" % (configType, hiddenMsg)
+      self.addstr(0, 0, titleLabel, curses.A_STANDOUT)
+    
+    # draws left-hand scroll bar if content's longer than the height
+    scrollOffset = 1
+    if isScrollbarVisible:
+      scrollOffset = 3
+      self.addScrollBar(scrollLoc, scrollLoc + height - detailPanelHeight - 1, len(self._getConfigOptions()), 1 + detailPanelHeight)
+    
+    optionWidth = CONFIG["features.config.state.colWidth.option"]
+    valueWidth = CONFIG["features.config.state.colWidth.value"]
+    descriptionWidth = max(0, width - scrollOffset - optionWidth - valueWidth - 2)
+    
+    # if the description column is overly long then use its space for the
+    # value instead
+    if descriptionWidth > 80:
+      valueWidth += descriptionWidth - 80
+      descriptionWidth = 80
+    
+    for lineNum in range(scrollLoc, len(self._getConfigOptions())):
+      entry = self._getConfigOptions()[lineNum]
+      drawLine = lineNum + detailPanelHeight + 1 - scrollLoc
+      
+      lineFormat = curses.A_NORMAL if entry.get(Field.IS_DEFAULT) else curses.A_BOLD
+      if entry.get(Field.CATEGORY): lineFormat |= uiTools.getColor(CATEGORY_COLOR[entry.get(Field.CATEGORY)])
+      if entry == cursorSelection: lineFormat |= curses.A_STANDOUT
+      
+      lineText = entry.getLabel(optionWidth, valueWidth, descriptionWidth)
+      self.addstr(drawLine, scrollOffset, lineText, lineFormat)
+      
+      if drawLine >= height: break
+    
+    self.valsLock.release()
+  
+  def _getConfigOptions(self):
+    return self.confContents if self.showAll else self.confImportantContents
+  
+  def _drawSelectionPanel(self, selection, width, detailPanelHeight, isScrollbarVisible):
+    """
+    Renders a panel for the selected configuration option.
+    """
+    
+    # This is a solid border unless the scrollbar is visible, in which case a
+    # 'T' pipe connects the border to the bar.
+    uiTools.drawBox(self, 0, 0, width, detailPanelHeight + 1)
+    if isScrollbarVisible: self.addch(detailPanelHeight, 1, curses.ACS_TTEE)
+    
+    selectionFormat = curses.A_BOLD | uiTools.getColor(CATEGORY_COLOR[selection.get(Field.CATEGORY)])
+    
+    # first entry:
+    # <option> (<category> Option)
+    optionLabel =" (%s Option)" % selection.get(Field.CATEGORY)
+    self.addstr(1, 2, selection.get(Field.OPTION) + optionLabel, selectionFormat)
+    
+    # second entry:
+    # Value: <value> ([default|custom], <type>, usage: <argument usage>)
+    if detailPanelHeight >= 3:
+      valueAttr = []
+      valueAttr.append("default" if selection.get(Field.IS_DEFAULT) else "custom")
+      valueAttr.append(selection.get(Field.TYPE))
+      valueAttr.append("usage: %s" % (selection.get(Field.ARG_USAGE)))
+      valueAttrLabel = ", ".join(valueAttr)
+      
+      valueLabelWidth = width - 12 - len(valueAttrLabel)
+      valueLabel = uiTools.cropStr(selection.get(Field.VALUE), valueLabelWidth)
+      
+      self.addstr(2, 2, "Value: %s (%s)" % (valueLabel, valueAttrLabel), selectionFormat)
+    
+    # remainder is filled with the man page description
+    descriptionHeight = max(0, detailPanelHeight - 3)
+    descriptionContent = "Description: " + selection.get(Field.DESCRIPTION)
+    
+    for i in range(descriptionHeight):
+      # checks if we're done writing the description
+      if not descriptionContent: break
+      
+      # there's a leading indent after the first line
+      if i > 0: descriptionContent = "  " + descriptionContent
+      
+      # we only want to work with content up until the next newline
+      if "\n" in descriptionContent:
+        lineContent, descriptionContent = descriptionContent.split("\n", 1)
+      else: lineContent, descriptionContent = descriptionContent, ""
+      
+      if i != descriptionHeight - 1:
+        # there's more lines to display
+        msg, remainder = uiTools.cropStr(lineContent, width - 3, 4, 4, uiTools.Ending.HYPHEN, True)
+        descriptionContent = remainder.strip() + descriptionContent
+      else:
+        # this is the last line, end it with an ellipse
+        msg = uiTools.cropStr(lineContent, width - 3, 4, 4)
+      
+      self.addstr(3 + i, 2, msg, selectionFormat)
+
diff --git a/arm/cli/connections/__init__.py b/arm/cli/connections/__init__.py
new file mode 100644
index 0000000..abd3410
--- /dev/null
+++ b/arm/cli/connections/__init__.py
@@ -0,0 +1,6 @@
+"""
+Connection panel related resources.
+"""
+
+__all__ = ["circEntry", "connEntry", "connPanel", "countPopup", "descriptorPopup", "entries"]
+
diff --git a/arm/cli/connections/circEntry.py b/arm/cli/connections/circEntry.py
new file mode 100644
index 0000000..25966df
--- /dev/null
+++ b/arm/cli/connections/circEntry.py
@@ -0,0 +1,196 @@
+"""
+Connection panel entries for client circuits. This includes a header entry
+followed by an entry for each hop in the circuit. For instance:
+
+89.188.20.246:42667    -->  217.172.182.26 (de)       General / Built     8.6m (CIRCUIT)
+|  85.8.28.4 (se)               98FBC3B2B93897A78CDD797EF549E6B62C9A8523    1 / Guard
+|  91.121.204.76 (fr)           546387D93F8D40CFF8842BB9D3A8EC477CEDA984    2 / Middle
++- 217.172.182.26 (de)          5CFA9EA136C0EA0AC096E5CEA7EB674F1207CF86    3 / Exit
+"""
+
+import curses
+
+from cli.connections import entries, connEntry
+from util import torTools, uiTools
+
+class CircEntry(connEntry.ConnectionEntry):
+  def __init__(self, circuitID, status, purpose, path):
+    connEntry.ConnectionEntry.__init__(self, "127.0.0.1", "0", "127.0.0.1", "0")
+    
+    self.circuitID = circuitID
+    self.status = status
+    
+    # drops to lowercase except the first letter
+    if len(purpose) >= 2:
+      purpose = purpose[0].upper() + purpose[1:].lower()
+    
+    self.lines = [CircHeaderLine(self.circuitID, purpose)]
+    
+    # Overwrites attributes of the initial line to make it more fitting as the
+    # header for our listing.
+    
+    self.lines[0].baseType = connEntry.Category.CIRCUIT
+    
+    self.update(status, path)
+  
+  def update(self, status, path):
+    """
+    Our status and path can change over time if the circuit is still in the
+    process of being built. Updates these attributes of our relay.
+    
+    Arguments:
+      status - new status of the circuit
+      path   - list of fingerprints for the series of relays involved in the
+               circuit
+    """
+    
+    self.status = status
+    self.lines = [self.lines[0]]
+    conn = torTools.getConn()
+    
+    if status == "BUILT" and not self.lines[0].isBuilt:
+      exitIp, exitORPort = conn.getRelayAddress(path[-1], ("192.168.0.1", "0"))
+      self.lines[0].setExit(exitIp, exitORPort, path[-1])
+    
+    for i in range(len(path)):
+      relayFingerprint = path[i]
+      relayIp, relayOrPort = conn.getRelayAddress(relayFingerprint, ("192.168.0.1", "0"))
+      
+      if i == len(path) - 1:
+        if status == "BUILT": placementType = "Exit"
+        else: placementType = "Extending"
+      elif i == 0: placementType = "Guard"
+      else: placementType = "Middle"
+      
+      placementLabel = "%i / %s" % (i + 1, placementType)
+      
+      self.lines.append(CircLine(relayIp, relayOrPort, relayFingerprint, placementLabel))
+    
+    self.lines[-1].isLast = True
+
+class CircHeaderLine(connEntry.ConnectionLine):
+  """
+  Initial line of a client entry. This has the same basic format as connection
+  lines except that its etc field has circuit attributes.
+  """
+  
+  def __init__(self, circuitID, purpose):
+    connEntry.ConnectionLine.__init__(self, "127.0.0.1", "0", "0.0.0.0", "0", False, False)
+    self.circuitID = circuitID
+    self.purpose = purpose
+    self.isBuilt = False
+  
+  def setExit(self, exitIpAddr, exitPort, exitFingerprint):
+    connEntry.ConnectionLine.__init__(self, "127.0.0.1", "0", exitIpAddr, exitPort, False, False)
+    self.isBuilt = True
+    self.foreign.fingerprintOverwrite = exitFingerprint
+  
+  def getType(self):
+    return connEntry.Category.CIRCUIT
+  
+  def getDestinationLabel(self, maxLength, includeLocale=False, includeHostname=False):
+    if not self.isBuilt: return "Building..."
+    return connEntry.ConnectionLine.getDestinationLabel(self, maxLength, includeLocale, includeHostname)
+  
+  def getEtcContent(self, width, listingType):
+    """
+    Attempts to provide all circuit related stats. Anything that can't be
+    shown completely (not enough room) is dropped.
+    """
+    
+    etcAttr = ["Purpose: %s" % self.purpose, "Circuit ID: %i" % self.circuitID]
+    
+    for i in range(len(etcAttr), -1, -1):
+      etcLabel = ", ".join(etcAttr[:i])
+      if len(etcLabel) <= width:
+        return ("%%-%is" % width) % etcLabel
+    
+    return ""
+  
+  def getDetails(self, width):
+    if not self.isBuilt:
+      detailFormat = curses.A_BOLD | uiTools.getColor(connEntry.CATEGORY_COLOR[self.getType()])
+      return [("Building Circuit...", detailFormat)]
+    else: return connEntry.ConnectionLine.getDetails(self, width)
+
+class CircLine(connEntry.ConnectionLine):
+  """
+  An individual hop in a circuit. This overwrites the displayed listing, but
+  otherwise makes use of the ConnectionLine attributes (for the detail display,
+  caching, etc).
+  """
+  
+  def __init__(self, fIpAddr, fPort, fFingerprint, placementLabel):
+    connEntry.ConnectionLine.__init__(self, "127.0.0.1", "0", fIpAddr, fPort)
+    self.foreign.fingerprintOverwrite = fFingerprint
+    self.placementLabel = placementLabel
+    self.includePort = False
+    
+    # determines the sort of left hand bracketing we use
+    self.isLast = False
+  
+  def getType(self):
+    return connEntry.Category.CIRCUIT
+  
+  def getListingPrefix(self):
+    if self.isLast: return (ord(' '), curses.ACS_LLCORNER, curses.ACS_HLINE, ord(' '))
+    else: return (ord(' '), curses.ACS_VLINE, ord(' '), ord(' '))
+  
+  def getListingEntry(self, width, currentTime, listingType):
+    """
+    Provides the [(msg, attr)...] listing for this relay in the circuilt
+    listing. Lines are composed of the following components:
+      <bracket> <dst> <etc> <placement label>
+    
+    The dst and etc entries largely match their ConnectionEntry counterparts.
+    
+    Arguments:
+      width       - maximum length of the line
+      currentTime - the current unix time (ignored)
+      listingType - primary attribute we're listing connections by
+    """
+    
+    return entries.ConnectionPanelLine.getListingEntry(self, width, currentTime, listingType)
+  
+  def _getListingEntry(self, width, currentTime, listingType):
+    lineFormat = uiTools.getColor(connEntry.CATEGORY_COLOR[self.getType()])
+    
+    # The required widths are the sum of the following:
+    # initial space (1 character)
+    # bracketing (3 characters)
+    # placementLabel (14 characters)
+    # gap between etc and placement label (5 characters)
+    
+    baselineSpace = 14 + 5
+    
+    dst, etc = "", ""
+    if listingType == entries.ListingType.IP_ADDRESS:
+      # TODO: include hostname when that's available
+      # dst width is derived as:
+      # src (21) + dst (26) + divider (7) + right gap (2) - bracket (3) = 53 char
+      dst = "%-53s" % self.getDestinationLabel(53, includeLocale = True)
+      
+      # fills the nickname into the empty space here
+      dst = "%s%-25s   " % (dst[:25], uiTools.cropStr(self.foreign.getNickname(), 25, 0))
+      
+      etc = self.getEtcContent(width - baselineSpace - len(dst), listingType)
+    elif listingType == entries.ListingType.HOSTNAME:
+      # min space for the hostname is 40 characters
+      etc = self.getEtcContent(width - baselineSpace - 40, listingType)
+      dstLayout = "%%-%is" % (width - baselineSpace - len(etc))
+      dst = dstLayout % self.foreign.getHostname(self.foreign.getIpAddr())
+    elif listingType == entries.ListingType.FINGERPRINT:
+      # dst width is derived as:
+      # src (9) + dst (40) + divider (7) + right gap (2) - bracket (3) = 55 char
+      dst = "%-55s" % self.foreign.getFingerprint()
+      etc = self.getEtcContent(width - baselineSpace - len(dst), listingType)
+    else:
+      # min space for the nickname is 56 characters
+      etc = self.getEtcContent(width - baselineSpace - 56, listingType)
+      dstLayout = "%%-%is" % (width - baselineSpace - len(etc))
+      dst = dstLayout % self.foreign.getNickname()
+    
+    return ((dst + etc, lineFormat),
+            (" " * (width - baselineSpace - len(dst) - len(etc) + 5), lineFormat),
+            ("%-14s" % self.placementLabel, lineFormat))
+
diff --git a/arm/cli/connections/connEntry.py b/arm/cli/connections/connEntry.py
new file mode 100644
index 0000000..5b2eda5
--- /dev/null
+++ b/arm/cli/connections/connEntry.py
@@ -0,0 +1,849 @@
+"""
+Connection panel entries related to actual connections to or from the system
+(ie, results seen by netstat, lsof, etc).
+"""
+
+import time
+import curses
+
+from util import connections, torTools, uiTools
+from cli.connections import entries
+
+from stem.util import conf, enum, str_tools
+
+# Connection Categories:
+#   Inbound      Relay connection, coming to us.
+#   Outbound     Relay connection, leaving us.
+#   Exit         Outbound relay connection leaving the Tor network.
+#   Hidden       Connections to a hidden service we're providing.
+#   Socks        Socks connections for applications using Tor.
+#   Circuit      Circuits our tor client has created.
+#   Directory    Fetching tor consensus information.
+#   Control      Tor controller (arm, vidalia, etc).
+
+Category = enum.Enum("INBOUND", "OUTBOUND", "EXIT", "HIDDEN", "SOCKS", "CIRCUIT", "DIRECTORY", "CONTROL")
+CATEGORY_COLOR = {Category.INBOUND: "green",      Category.OUTBOUND: "blue",
+                  Category.EXIT: "red",           Category.HIDDEN: "magenta",
+                  Category.SOCKS: "yellow",       Category.CIRCUIT: "cyan",
+                  Category.DIRECTORY: "magenta",  Category.CONTROL: "red"}
+
+# static data for listing format
+# <src>  -->  <dst>  <etc><padding>
+LABEL_FORMAT = "%s  -->  %s  %s%s"
+LABEL_MIN_PADDING = 2 # min space between listing label and following data
+
+# sort value for scrubbed ip addresses
+SCRUBBED_IP_VAL = 255 ** 4
+
+CONFIG = conf.config_dict("arm", {
+  "features.connection.markInitialConnections": True,
+  "features.connection.showIps": True,
+  "features.connection.showExitPort": True,
+  "features.connection.showColumn.fingerprint": True,
+  "features.connection.showColumn.nickname": True,
+  "features.connection.showColumn.destination": True,
+  "features.connection.showColumn.expandedIp": True,
+})
+
+class Endpoint:
+  """
+  Collection of attributes associated with a connection endpoint. This is a
+  thin wrapper for torUtil functions, making use of its caching for
+  performance.
+  """
+  
+  def __init__(self, ipAddr, port):
+    self.ipAddr = ipAddr
+    self.port = port
+    
+    # if true, we treat the port as an definitely not being an ORPort when
+    # searching for matching fingerprints (otherwise we use it to possably
+    # narrow results when unknown)
+    self.isNotORPort = True
+    
+    # if set then this overwrites fingerprint lookups
+    self.fingerprintOverwrite = None
+  
+  def getIpAddr(self):
+    """
+    Provides the IP address of the endpoint.
+    """
+    
+    return self.ipAddr
+  
+  def getPort(self):
+    """
+    Provides the port of the endpoint.
+    """
+    
+    return self.port
+  
+  def getHostname(self, default = None):
+    """
+    Provides the hostname associated with the relay's address. This is a
+    non-blocking call and returns None if the address either can't be resolved
+    or hasn't been resolved yet.
+    
+    Arguments:
+      default - return value if no hostname is available
+    """
+    
+    # TODO: skipping all hostname resolution to be safe for now
+    #try:
+    #  myHostname = hostnames.resolve(self.ipAddr)
+    #except:
+    #  # either a ValueError or IOError depending on the source of the lookup failure
+    #  myHostname = None
+    #
+    #if not myHostname: return default
+    #else: return myHostname
+    
+    return default
+  
+  def getLocale(self, default=None):
+    """
+    Provides the two letter country code for the IP address' locale.
+    
+    Arguments:
+      default - return value if no locale information is available
+    """
+    
+    conn = torTools.getConn()
+    return conn.getInfo("ip-to-country/%s" % self.ipAddr, default)
+  
+  def getFingerprint(self):
+    """
+    Provides the fingerprint of the relay, returning "UNKNOWN" if it can't be
+    determined.
+    """
+    
+    if self.fingerprintOverwrite:
+      return self.fingerprintOverwrite
+    
+    conn = torTools.getConn()
+    myFingerprint = conn.getRelayFingerprint(self.ipAddr)
+    
+    # If there were multiple matches and our port is likely the ORPort then
+    # try again with that to narrow the results.
+    if not myFingerprint and not self.isNotORPort:
+      myFingerprint = conn.getRelayFingerprint(self.ipAddr, self.port)
+    
+    if myFingerprint: return myFingerprint
+    else: return "UNKNOWN"
+  
+  def getNickname(self):
+    """
+    Provides the nickname of the relay, retuning "UNKNOWN" if it can't be
+    determined.
+    """
+    
+    myFingerprint = self.getFingerprint()
+    
+    if myFingerprint != "UNKNOWN":
+      conn = torTools.getConn()
+      myNickname = conn.getRelayNickname(myFingerprint)
+      
+      if myNickname: return myNickname
+      else: return "UNKNOWN"
+    else: return "UNKNOWN"
+
+class ConnectionEntry(entries.ConnectionPanelEntry):
+  """
+  Represents a connection being made to or from this system. These only
+  concern real connections so it includes the inbound, outbound, directory,
+  application, and controller categories.
+  """
+  
+  def __init__(self, lIpAddr, lPort, fIpAddr, fPort):
+    entries.ConnectionPanelEntry.__init__(self)
+    self.lines = [ConnectionLine(lIpAddr, lPort, fIpAddr, fPort)]
+  
+  def getSortValue(self, attr, listingType):
+    """
+    Provides the value of a single attribute used for sorting purposes.
+    """
+    
+    connLine = self.lines[0]
+    if attr == entries.SortAttr.IP_ADDRESS:
+      if connLine.isPrivate(): return SCRUBBED_IP_VAL # orders at the end
+      return connLine.sortIpAddr
+    elif attr == entries.SortAttr.PORT:
+      return connLine.sortPort
+    elif attr == entries.SortAttr.HOSTNAME:
+      if connLine.isPrivate(): return ""
+      return connLine.foreign.getHostname("")
+    elif attr == entries.SortAttr.FINGERPRINT:
+      return connLine.foreign.getFingerprint()
+    elif attr == entries.SortAttr.NICKNAME:
+      myNickname = connLine.foreign.getNickname()
+      if myNickname == "UNKNOWN": return "z" * 20 # orders at the end
+      else: return myNickname.lower()
+    elif attr == entries.SortAttr.CATEGORY:
+      return Category.index_of(connLine.getType())
+    elif attr == entries.SortAttr.UPTIME:
+      return connLine.startTime
+    elif attr == entries.SortAttr.COUNTRY:
+      if connections.isIpAddressPrivate(self.lines[0].foreign.getIpAddr()): return ""
+      else: return connLine.foreign.getLocale("")
+    else:
+      return entries.ConnectionPanelEntry.getSortValue(self, attr, listingType)
+
+class ConnectionLine(entries.ConnectionPanelLine):
+  """
+  Display component of the ConnectionEntry.
+  """
+  
+  def __init__(self, lIpAddr, lPort, fIpAddr, fPort, includePort=True, includeExpandedIpAddr=True):
+    entries.ConnectionPanelLine.__init__(self)
+    
+    self.local = Endpoint(lIpAddr, lPort)
+    self.foreign = Endpoint(fIpAddr, fPort)
+    self.startTime = time.time()
+    self.isInitialConnection = False
+    
+    # overwrite the local fingerprint with ours
+    conn = torTools.getConn()
+    self.local.fingerprintOverwrite = conn.getInfo("fingerprint", None)
+    
+    # True if the connection has matched the properties of a client/directory
+    # connection every time we've checked. The criteria we check is...
+    #   client    - first hop in an established circuit
+    #   directory - matches an established single-hop circuit (probably a
+    #               directory mirror)
+    
+    self._possibleClient = True
+    self._possibleDirectory = True
+    
+    # attributes for SOCKS, HIDDEN, and CONTROL connections
+    self.appName = None
+    self.appPid = None
+    self.isAppResolving = False
+    
+    myOrPort = conn.getOption("ORPort", None)
+    myDirPort = conn.getOption("DirPort", None)
+    mySocksPort = conn.getOption("SocksPort", "9050")
+    myCtlPort = conn.getOption("ControlPort", None)
+    myHiddenServicePorts = conn.getHiddenServicePorts()
+    
+    # the ORListenAddress can overwrite the ORPort
+    listenAddr = conn.getOption("ORListenAddress", None)
+    if listenAddr and ":" in listenAddr:
+      myOrPort = listenAddr[listenAddr.find(":") + 1:]
+    
+    if lPort in (myOrPort, myDirPort):
+      self.baseType = Category.INBOUND
+      self.local.isNotORPort = False
+    elif lPort == mySocksPort:
+      self.baseType = Category.SOCKS
+    elif fPort in myHiddenServicePorts:
+      self.baseType = Category.HIDDEN
+    elif lPort == myCtlPort:
+      self.baseType = Category.CONTROL
+    else:
+      self.baseType = Category.OUTBOUND
+      self.foreign.isNotORPort = False
+    
+    self.cachedType = None
+    
+    # includes the port or expanded ip address field when displaying listing
+    # information if true
+    self.includePort = includePort
+    self.includeExpandedIpAddr = includeExpandedIpAddr
+    
+    # cached immutable values used for sorting
+    self.sortIpAddr = connections.ipToInt(self.foreign.getIpAddr())
+    self.sortPort = int(self.foreign.getPort())
+  
+  def getListingEntry(self, width, currentTime, listingType):
+    """
+    Provides the tuple list for this connection's listing. Lines are composed
+    of the following components:
+      <src>  -->  <dst>     <etc>     <uptime> (<type>)
+    
+    ListingType.IP_ADDRESS:
+      src - <internal addr:port> --> <external addr:port>
+      dst - <destination addr:port>
+      etc - <fingerprint> <nickname>
+    
+    ListingType.HOSTNAME:
+      src - localhost:<port>
+      dst - <destination hostname:port>
+      etc - <destination addr:port> <fingerprint> <nickname>
+    
+    ListingType.FINGERPRINT:
+      src - localhost
+      dst - <destination fingerprint>
+      etc - <nickname> <destination addr:port>
+    
+    ListingType.NICKNAME:
+      src - <source nickname>
+      dst - <destination nickname>
+      etc - <fingerprint> <destination addr:port>
+    
+    Arguments:
+      width       - maximum length of the line
+      currentTime - unix timestamp for what the results should consider to be
+                    the current time
+      listingType - primary attribute we're listing connections by
+    """
+    
+    # fetch our (most likely cached) display entry for the listing
+    myListing = entries.ConnectionPanelLine.getListingEntry(self, width, currentTime, listingType)
+    
+    # fill in the current uptime and return the results
+    if CONFIG["features.connection.markInitialConnections"]:
+      timePrefix = "+" if self.isInitialConnection else " "
+    else: timePrefix = ""
+    
+    timeLabel = timePrefix + "%5s" % str_tools.get_time_label(currentTime - self.startTime, 1)
+    myListing[2] = (timeLabel, myListing[2][1])
+    
+    return myListing
+  
+  def isUnresolvedApp(self):
+    """
+    True if our display uses application information that hasn't yet been resolved.
+    """
+    
+    return self.appName == None and self.getType() in (Category.SOCKS, Category.HIDDEN, Category.CONTROL)
+  
+  def _getListingEntry(self, width, currentTime, listingType):
+    entryType = self.getType()
+    
+    # Lines are split into the following components in reverse:
+    # init gap - " "
+    # content  - "<src>  -->  <dst>     <etc>     "
+    # time     - "<uptime>"
+    # preType  - " ("
+    # category - "<type>"
+    # postType - ")   "
+    
+    lineFormat = uiTools.getColor(CATEGORY_COLOR[entryType])
+    timeWidth = 6 if CONFIG["features.connection.markInitialConnections"] else 5
+    
+    drawEntry = [(" ", lineFormat),
+                 (self._getListingContent(width - (12 + timeWidth) - 1, listingType), lineFormat),
+                 (" " * timeWidth, lineFormat),
+                 (" (", lineFormat),
+                 (entryType.upper(), lineFormat | curses.A_BOLD),
+                 (")" + " " * (9 - len(entryType)), lineFormat)]
+    return drawEntry
+  
+  def _getDetails(self, width):
+    """
+    Provides details on the connection, correlated against available consensus
+    data.
+    
+    Arguments:
+      width - available space to display in
+    """
+    
+    detailFormat = curses.A_BOLD | uiTools.getColor(CATEGORY_COLOR[self.getType()])
+    return [(line, detailFormat) for line in self._getDetailContent(width)]
+  
+  def resetDisplay(self):
+    entries.ConnectionPanelLine.resetDisplay(self)
+    self.cachedType = None
+  
+  def isPrivate(self):
+    """
+    Returns true if the endpoint is private, possibly belonging to a client
+    connection or exit traffic.
+    """
+    
+    if not CONFIG["features.connection.showIps"]: return True
+    
+    # This is used to scrub private information from the interface. Relaying
+    # etiquette (and wiretapping laws) say these are bad things to look at so
+    # DON'T CHANGE THIS UNLESS YOU HAVE A DAMN GOOD REASON!
+    
+    myType = self.getType()
+    
+    if myType == Category.INBOUND:
+      # if we're a guard or bridge and the connection doesn't belong to a
+      # known relay then it might be client traffic
+      
+      conn = torTools.getConn()
+      if "Guard" in conn.getMyFlags([]) or conn.getOption("BridgeRelay", None) == "1":
+        allMatches = conn.getRelayFingerprint(self.foreign.getIpAddr(), getAllMatches = True)
+        return allMatches == []
+    elif myType == Category.EXIT:
+      # DNS connections exiting us aren't private (since they're hitting our
+      # resolvers). Everything else, however, is.
+      
+      # TODO: Ideally this would also double check that it's a UDP connection
+      # (since DNS is the only UDP connections Tor will relay), however this
+      # will take a bit more work to propagate the information up from the
+      # connection resolver.
+      return self.foreign.getPort() != "53"
+    
+    # for everything else this isn't a concern
+    return False
+  
+  def getType(self):
+    """
+    Provides our best guess at the current type of the connection. This
+    depends on consensus results, our current client circuits, etc. Results
+    are cached until this entry's display is reset.
+    """
+    
+    # caches both to simplify the calls and to keep the type consistent until
+    # we want to reflect changes
+    if not self.cachedType:
+      if self.baseType == Category.OUTBOUND:
+        # Currently the only non-static categories are OUTBOUND vs...
+        # - EXIT since this depends on the current consensus
+        # - CIRCUIT if this is likely to belong to our guard usage
+        # - DIRECTORY if this is a single-hop circuit (directory mirror?)
+        # 
+        # The exitability, circuits, and fingerprints are all cached by the
+        # torTools util keeping this a quick lookup.
+        
+        conn = torTools.getConn()
+        destFingerprint = self.foreign.getFingerprint()
+        
+        if destFingerprint == "UNKNOWN":
+          # Not a known relay. This might be an exit connection.
+          
+          if conn.isExitingAllowed(self.foreign.getIpAddr(), self.foreign.getPort()):
+            self.cachedType = Category.EXIT
+        elif self._possibleClient or self._possibleDirectory:
+          # This belongs to a known relay. If we haven't eliminated ourselves as
+          # a possible client or directory connection then check if it still
+          # holds true.
+          
+          myCircuits = conn.getCircuits()
+          
+          if self._possibleClient:
+            # Checks that this belongs to the first hop in a circuit that's
+            # either unestablished or longer than a single hop (ie, anything but
+            # a built 1-hop connection since those are most likely a directory
+            # mirror).
+            
+            for _, status, _, path in myCircuits:
+              if path[0] == destFingerprint and (status != "BUILT" or len(path) > 1):
+                self.cachedType = Category.CIRCUIT # matched a probable guard connection
+            
+            # if we fell through, we can eliminate ourselves as a guard in the future
+            if not self.cachedType:
+              self._possibleClient = False
+          
+          if self._possibleDirectory:
+            # Checks if we match a built, single hop circuit.
+            
+            for _, status, _, path in myCircuits:
+              if path[0] == destFingerprint and status == "BUILT" and len(path) == 1:
+                self.cachedType = Category.DIRECTORY
+            
+            # if we fell through, eliminate ourselves as a directory connection
+            if not self.cachedType:
+              self._possibleDirectory = False
+      
+      if not self.cachedType:
+        self.cachedType = self.baseType
+    
+    return self.cachedType
+  
+  def getEtcContent(self, width, listingType):
+    """
+    Provides the optional content for the connection.
+    
+    Arguments:
+      width       - maximum length of the line
+      listingType - primary attribute we're listing connections by
+    """
+    
+    # for applications show the command/pid
+    if self.getType() in (Category.SOCKS, Category.HIDDEN, Category.CONTROL):
+      displayLabel = ""
+      
+      if self.appName:
+        if self.appPid: displayLabel = "%s (%s)" % (self.appName, self.appPid)
+        else: displayLabel = self.appName
+      elif self.isAppResolving:
+        displayLabel = "resolving..."
+      else: displayLabel = "UNKNOWN"
+      
+      if len(displayLabel) < width:
+        return ("%%-%is" % width) % displayLabel
+      else: return ""
+    
+    # for everything else display connection/consensus information
+    dstAddress = self.getDestinationLabel(26, includeLocale = True)
+    etc, usedSpace = "", 0
+    if listingType == entries.ListingType.IP_ADDRESS:
+      if width > usedSpace + 42 and CONFIG["features.connection.showColumn.fingerprint"]:
+        # show fingerprint (column width: 42 characters)
+        etc += "%-40s  " % self.foreign.getFingerprint()
+        usedSpace += 42
+      
+      if width > usedSpace + 10 and CONFIG["features.connection.showColumn.nickname"]:
+        # show nickname (column width: remainder)
+        nicknameSpace = width - usedSpace
+        nicknameLabel = uiTools.cropStr(self.foreign.getNickname(), nicknameSpace, 0)
+        etc += ("%%-%is  " % nicknameSpace) % nicknameLabel
+        usedSpace += nicknameSpace + 2
+    elif listingType == entries.ListingType.HOSTNAME:
+      if width > usedSpace + 28 and CONFIG["features.connection.showColumn.destination"]:
+        # show destination ip/port/locale (column width: 28 characters)
+        etc += "%-26s  " % dstAddress
+        usedSpace += 28
+      
+      if width > usedSpace + 42 and CONFIG["features.connection.showColumn.fingerprint"]:
+        # show fingerprint (column width: 42 characters)
+        etc += "%-40s  " % self.foreign.getFingerprint()
+        usedSpace += 42
+      
+      if width > usedSpace + 17 and CONFIG["features.connection.showColumn.nickname"]:
+        # show nickname (column width: min 17 characters, uses half of the remainder)
+        nicknameSpace = 15 + (width - (usedSpace + 17)) / 2
+        nicknameLabel = uiTools.cropStr(self.foreign.getNickname(), nicknameSpace, 0)
+        etc += ("%%-%is  " % nicknameSpace) % nicknameLabel
+        usedSpace += (nicknameSpace + 2)
+    elif listingType == entries.ListingType.FINGERPRINT:
+      if width > usedSpace + 17:
+        # show nickname (column width: min 17 characters, consumes any remaining space)
+        nicknameSpace = width - usedSpace - 2
+        
+        # if there's room then also show a column with the destination
+        # ip/port/locale (column width: 28 characters)
+        isIpLocaleIncluded = width > usedSpace + 45
+        isIpLocaleIncluded &= CONFIG["features.connection.showColumn.destination"]
+        if isIpLocaleIncluded: nicknameSpace -= 28
+        
+        if CONFIG["features.connection.showColumn.nickname"]:
+          nicknameLabel = uiTools.cropStr(self.foreign.getNickname(), nicknameSpace, 0)
+          etc += ("%%-%is  " % nicknameSpace) % nicknameLabel
+          usedSpace += nicknameSpace + 2
+        
+        if isIpLocaleIncluded:
+          etc += "%-26s  " % dstAddress
+          usedSpace += 28
+    else:
+      if width > usedSpace + 42 and CONFIG["features.connection.showColumn.fingerprint"]:
+        # show fingerprint (column width: 42 characters)
+        etc += "%-40s  " % self.foreign.getFingerprint()
+        usedSpace += 42
+      
+      if width > usedSpace + 28 and CONFIG["features.connection.showColumn.destination"]:
+        # show destination ip/port/locale (column width: 28 characters)
+        etc += "%-26s  " % dstAddress
+        usedSpace += 28
+    
+    return ("%%-%is" % width) % etc
+  
+  def _getListingContent(self, width, listingType):
+    """
+    Provides the source, destination, and extra info for our listing.
+    
+    Arguments:
+      width       - maximum length of the line
+      listingType - primary attribute we're listing connections by
+    """
+    
+    conn = torTools.getConn()
+    myType = self.getType()
+    dstAddress = self.getDestinationLabel(26, includeLocale = True)
+    
+    # The required widths are the sum of the following:
+    # - room for LABEL_FORMAT and LABEL_MIN_PADDING (11 characters)
+    # - base data for the listing
+    # - that extra field plus any previous
+    
+    usedSpace = len(LABEL_FORMAT % tuple([""] * 4)) + LABEL_MIN_PADDING
+    localPort = ":%s" % self.local.getPort() if self.includePort else ""
+    
+    src, dst, etc = "", "", ""
+    if listingType == entries.ListingType.IP_ADDRESS:
+      myExternalIpAddr = conn.getInfo("address", self.local.getIpAddr())
+      addrDiffer = myExternalIpAddr != self.local.getIpAddr()
+      
+      # Expanding doesn't make sense, if the connection isn't actually
+      # going through Tor's external IP address. As there isn't a known
+      # method for checking if it is, we're checking the type instead.
+      #
+      # This isn't entirely correct. It might be a better idea to check if
+      # the source and destination addresses are both private, but that might
+      # not be perfectly reliable either.
+      
+      isExpansionType = not myType in (Category.SOCKS, Category.HIDDEN, Category.CONTROL)
+      
+      if isExpansionType: srcAddress = myExternalIpAddr + localPort
+      else: srcAddress = self.local.getIpAddr() + localPort
+      
+      if myType in (Category.SOCKS, Category.CONTROL):
+        # Like inbound connections these need their source and destination to
+        # be swapped. However, this only applies when listing by IP or hostname
+        # (their fingerprint and nickname are both for us). Reversing the
+        # fields here to keep the same column alignments.
+        
+        src = "%-21s" % dstAddress
+        dst = "%-26s" % srcAddress
+      else:
+        src = "%-21s" % srcAddress # ip:port = max of 21 characters
+        dst = "%-26s" % dstAddress # ip:port (xx) = max of 26 characters
+      
+      usedSpace += len(src) + len(dst) # base data requires 47 characters
+      
+      # Showing the fingerprint (which has the width of 42) has priority over
+      # an expanded address field. Hence check if we either have space for
+      # both or wouldn't be showing the fingerprint regardless.
+      
+      isExpandedAddrVisible = width > usedSpace + 28
+      if isExpandedAddrVisible and CONFIG["features.connection.showColumn.fingerprint"]:
+        isExpandedAddrVisible = width < usedSpace + 42 or width > usedSpace + 70
+      
+      if addrDiffer and isExpansionType and isExpandedAddrVisible and self.includeExpandedIpAddr and CONFIG["features.connection.showColumn.expandedIp"]:
+        # include the internal address in the src (extra 28 characters)
+        internalAddress = self.local.getIpAddr() + localPort
+        
+        # If this is an inbound connection then reverse ordering so it's:
+        # <foreign> --> <external> --> <internal>
+        # when the src and dst are swapped later
+        
+        if myType == Category.INBOUND: src = "%-21s  -->  %s" % (src, internalAddress)
+        else: src = "%-21s  -->  %s" % (internalAddress, src)
+        
+        usedSpace += 28
+      
+      etc = self.getEtcContent(width - usedSpace, listingType)
+      usedSpace += len(etc)
+    elif listingType == entries.ListingType.HOSTNAME:
+      # 15 characters for source, and a min of 40 reserved for the destination
+      # TODO: when actually functional the src and dst need to be swapped for
+      # SOCKS and CONTROL connections
+      src = "localhost%-6s" % localPort
+      usedSpace += len(src)
+      minHostnameSpace = 40
+      
+      etc = self.getEtcContent(width - usedSpace - minHostnameSpace, listingType)
+      usedSpace += len(etc)
+      
+      hostnameSpace = width - usedSpace
+      usedSpace = width # prevents padding at the end
+      if self.isPrivate():
+        dst = ("%%-%is" % hostnameSpace) % "<scrubbed>"
+      else:
+        hostname = self.foreign.getHostname(self.foreign.getIpAddr())
+        portLabel = ":%-5s" % self.foreign.getPort() if self.includePort else ""
+        
+        # truncates long hostnames and sets dst to <hostname>:<port>
+        hostname = uiTools.cropStr(hostname, hostnameSpace, 0)
+        dst = ("%%-%is" % hostnameSpace) % (hostname + portLabel)
+    elif listingType == entries.ListingType.FINGERPRINT:
+      src = "localhost"
+      if myType == Category.CONTROL: dst = "localhost"
+      else: dst = self.foreign.getFingerprint()
+      dst = "%-40s" % dst
+      
+      usedSpace += len(src) + len(dst) # base data requires 49 characters
+      
+      etc = self.getEtcContent(width - usedSpace, listingType)
+      usedSpace += len(etc)
+    else:
+      # base data requires 50 min characters
+      src = self.local.getNickname()
+      if myType == Category.CONTROL: dst = self.local.getNickname()
+      else: dst = self.foreign.getNickname()
+      minBaseSpace = 50
+      
+      etc = self.getEtcContent(width - usedSpace - minBaseSpace, listingType)
+      usedSpace += len(etc)
+      
+      baseSpace = width - usedSpace
+      usedSpace = width # prevents padding at the end
+      
+      if len(src) + len(dst) > baseSpace:
+        src = uiTools.cropStr(src, baseSpace / 3)
+        dst = uiTools.cropStr(dst, baseSpace - len(src))
+      
+      # pads dst entry to its max space
+      dst = ("%%-%is" % (baseSpace - len(src))) % dst
+    
+    if myType == Category.INBOUND: src, dst = dst, src
+    padding = " " * (width - usedSpace + LABEL_MIN_PADDING)
+    return LABEL_FORMAT % (src, dst, etc, padding)
+  
+  def _getDetailContent(self, width):
+    """
+    Provides a list with detailed information for this connection.
+    
+    Arguments:
+      width - max length of lines
+    """
+    
+    lines = [""] * 7
+    lines[0] = "address: %s" % self.getDestinationLabel(width - 11)
+    lines[1] = "locale: %s" % ("??" if self.isPrivate() else self.foreign.getLocale("??"))
+    
+    # Remaining data concerns the consensus results, with three possible cases:
+    # - if there's a single match then display its details
+    # - if there's multiple potential relays then list all of the combinations
+    #   of ORPorts / Fingerprints
+    # - if no consensus data is available then say so (probably a client or
+    #   exit connection)
+    
+    fingerprint = self.foreign.getFingerprint()
+    conn = torTools.getConn()
+    
+    if fingerprint != "UNKNOWN":
+      # single match - display information available about it
+      nsEntry = conn.getConsensusEntry(fingerprint)
+      descEntry = conn.getDescriptorEntry(fingerprint)
+      
+      # append the fingerprint to the second line
+      lines[1] = "%-13sfingerprint: %s" % (lines[1], fingerprint)
+      
+      if nsEntry:
+        # example consensus entry:
+        # r murble R8sCM1ar1sS2GulQYFVmvN95xsk RJr6q+wkTFG+ng5v2bdCbVVFfA4 2011-02-21 00:25:32 195.43.157.85 443 0
+        # s Exit Fast Guard Named Running Stable Valid
+        # w Bandwidth=2540
+        # p accept 20-23,43,53,79-81,88,110,143,194,443
+        
+        nsLines = nsEntry.split("\n")
+        
+        firstLineComp = nsLines[0].split(" ")
+        if len(firstLineComp) >= 9:
+          _, nickname, _, _, pubDate, pubTime, _, orPort, dirPort = firstLineComp[:9]
+        else: nickname, pubDate, pubTime, orPort, dirPort = "", "", "", "", ""
+        
+        flags = "unknown"
+        if len(nsLines) >= 2 and nsLines[1].startswith("s "):
+          flags = nsLines[1][2:]
+        
+        exitPolicy = conn.getRelayExitPolicy(fingerprint)
+        
+        if exitPolicy: policyLabel = exitPolicy.summary()
+        else: policyLabel = "unknown"
+        
+        dirPortLabel = "" if dirPort == "0" else "dirport: %s" % dirPort
+        lines[2] = "nickname: %-25s orport: %-10s %s" % (nickname, orPort, dirPortLabel)
+        lines[3] = "published: %s %s" % (pubTime, pubDate)
+        lines[4] = "flags: %s" % flags.replace(" ", ", ")
+        lines[5] = "exit policy: %s" % policyLabel
+      
+      if descEntry:
+        torVersion, platform, contact = "", "", ""
+        
+        for descLine in descEntry.split("\n"):
+          if descLine.startswith("platform"):
+            # has the tor version and platform, ex:
+            # platform Tor 0.2.1.29 (r318f470bc5f2ad43) on Linux x86_64
+            
+            torVersion = descLine[13:descLine.find(" ", 13)]
+            platform = descLine[descLine.rfind(" on ") + 4:]
+          elif descLine.startswith("contact"):
+            contact = descLine[8:]
+            
+            # clears up some highly common obscuring
+            for alias in (" at ", " AT "): contact = contact.replace(alias, "@")
+            for alias in (" dot ", " DOT "): contact = contact.replace(alias, ".")
+            
+            break # contact lines come after the platform
+        
+        lines[3] = "%-35s os: %-14s version: %s" % (lines[3], platform, torVersion)
+        
+        # contact information is an optional field
+        if contact: lines[6] = "contact: %s" % contact
+    else:
+      allMatches = conn.getRelayFingerprint(self.foreign.getIpAddr(), getAllMatches = True)
+      
+      if allMatches:
+        # multiple matches
+        lines[2] = "Multiple matches, possible fingerprints are:"
+        
+        for i in range(len(allMatches)):
+          isLastLine = i == 3
+          
+          relayPort, relayFingerprint = allMatches[i]
+          lineText = "%i. or port: %-5s fingerprint: %s" % (i, relayPort, relayFingerprint)
+          
+          # if there's multiple lines remaining at the end then give a count
+          remainingRelays = len(allMatches) - i
+          if isLastLine and remainingRelays > 1:
+            lineText = "... %i more" % remainingRelays
+          
+          lines[3 + i] = lineText
+          
+          if isLastLine: break
+      else:
+        # no consensus entry for this ip address
+        lines[2] = "No consensus data found"
+    
+    # crops any lines that are too long
+    for i in range(len(lines)):
+      lines[i] = uiTools.cropStr(lines[i], width - 2)
+    
+    return lines
+  
+  def getDestinationLabel(self, maxLength, includeLocale=False, includeHostname=False):
+    """
+    Provides a short description of the destination. This is made up of two
+    components, the base <ip addr>:<port> and an extra piece of information in
+    parentheses. The IP address is scrubbed from private connections.
+    
+    Extra information is...
+    - the port's purpose for exit connections
+    - the locale and/or hostname if set to do so, the address isn't private,
+      and isn't on the local network
+    - nothing otherwise
+    
+    Arguments:
+      maxLength       - maximum length of the string returned
+      includeLocale   - possibly includes the locale
+      includeHostname - possibly includes the hostname
+    """
+    
+    # the port and port derived data can be hidden by config or without includePort
+    includePort = self.includePort and (CONFIG["features.connection.showExitPort"] or self.getType() != Category.EXIT)
+    
+    # destination of the connection
+    ipLabel = "<scrubbed>" if self.isPrivate() else self.foreign.getIpAddr()
+    portLabel = ":%s" % self.foreign.getPort() if includePort else ""
+    dstAddress = ipLabel + portLabel
+    
+    # Only append the extra info if there's at least a couple characters of
+    # space (this is what's needed for the country codes).
+    if len(dstAddress) + 5 <= maxLength:
+      spaceAvailable = maxLength - len(dstAddress) - 3
+      
+      if self.getType() == Category.EXIT and includePort:
+        purpose = connections.getPortUsage(self.foreign.getPort())
+        
+        if purpose:
+          # BitTorrent is a common protocol to truncate, so just use "Torrent"
+          # if there's not enough room.
+          if len(purpose) > spaceAvailable and purpose == "BitTorrent":
+            purpose = "Torrent"
+          
+          # crops with a hyphen if too long
+          purpose = uiTools.cropStr(purpose, spaceAvailable, endType = uiTools.Ending.HYPHEN)
+          
+          dstAddress += " (%s)" % purpose
+      elif not connections.isIpAddressPrivate(self.foreign.getIpAddr()):
+        extraInfo = []
+        conn = torTools.getConn()
+        
+        if includeLocale and not conn.isGeoipUnavailable():
+          foreignLocale = self.foreign.getLocale("??")
+          extraInfo.append(foreignLocale)
+          spaceAvailable -= len(foreignLocale) + 2
+        
+        if includeHostname:
+          dstHostname = self.foreign.getHostname()
+          
+          if dstHostname:
+            # determines the full space available, taking into account the ", "
+            # dividers if there's multiple pieces of extra data
+            
+            maxHostnameSpace = spaceAvailable - 2 * len(extraInfo)
+            dstHostname = uiTools.cropStr(dstHostname, maxHostnameSpace)
+            extraInfo.append(dstHostname)
+            spaceAvailable -= len(dstHostname)
+        
+        if extraInfo:
+          dstAddress += " (%s)" % ", ".join(extraInfo)
+    
+    return dstAddress[:maxLength]
+
diff --git a/arm/cli/connections/connPanel.py b/arm/cli/connections/connPanel.py
new file mode 100644
index 0000000..ec11944
--- /dev/null
+++ b/arm/cli/connections/connPanel.py
@@ -0,0 +1,587 @@
+"""
+Listing of the currently established connections tor has made.
+"""
+
+import re
+import time
+import curses
+import threading
+
+import cli.popups
+
+from cli.connections import countPopup, descriptorPopup, entries, connEntry, circEntry
+from util import connections, panel, torTools, uiTools
+
+from stem.control import State
+from stem.util import conf, enum
+
+# height of the detail panel content, not counting top and bottom border
+DETAILS_HEIGHT = 7
+
+# listing types
+Listing = enum.Enum(("IP_ADDRESS", "IP Address"), "HOSTNAME", "FINGERPRINT", "NICKNAME")
+
+def conf_handler(key, value):
+  if key == "features.connection.listingType":
+    return conf.parse_enum(key, value, Listing)
+  elif key == "features.connection.refreshRate":
+    return max(1, value)
+  elif key == "features.connection.order":
+    return conf.parse_enum_csv(key, value[0], entries.SortAttr, 3)
+
+CONFIG = conf.config_dict("arm", {
+  "features.connection.resolveApps": True,
+  "features.connection.listingType": Listing.IP_ADDRESS,
+  "features.connection.order": [
+    entries.SortAttr.CATEGORY,
+    entries.SortAttr.LISTING,
+    entries.SortAttr.UPTIME],
+  "features.connection.refreshRate": 5,
+  "features.connection.showIps": True,
+}, conf_handler)
+
+class ConnectionPanel(panel.Panel, threading.Thread):
+  """
+  Listing of connections tor is making, with information correlated against
+  the current consensus and other data sources.
+  """
+  
+  def __init__(self, stdscr):
+    panel.Panel.__init__(self, stdscr, "connections", 0)
+    threading.Thread.__init__(self)
+    self.setDaemon(True)
+    
+    # defaults our listing selection to fingerprints if ip address
+    # displaying is disabled
+    #
+    # TODO: This is a little sucky in that it won't work if showIps changes
+    # while we're running (... but arm doesn't allow for that atm)
+    
+    if not CONFIG["features.connection.showIps"] and CONFIG["features.connection.listingType"] == 0:
+      armConf = conf.get_config("arm")
+      armConf.set("features.connection.listingType", enumeration.keys()[Listing.index_of(Listing.FINGERPRINT)])
+    
+    self._scroller = uiTools.Scroller(True)
+    self._title = "Connections:" # title line of the panel
+    self._entries = []          # last fetched display entries
+    self._entryLines = []       # individual lines rendered from the entries listing
+    self._showDetails = False   # presents the details panel if true
+    
+    self._lastUpdate = -1       # time the content was last revised
+    self._isTorRunning = True   # indicates if tor is currently running or not
+    self._haltTime = None       # time when tor was stopped
+    self._halt = False          # terminates thread if true
+    self._cond = threading.Condition()  # used for pausing the thread
+    self.valsLock = threading.RLock()
+    
+    # Tracks exiting port and client country statistics
+    self._clientLocaleUsage = {}
+    self._exitPortUsage = {}
+    
+    # If we're a bridge and been running over a day then prepopulates with the
+    # last day's clients.
+    
+    conn = torTools.getConn()
+    bridgeClients = conn.getInfo("status/clients-seen", None)
+    
+    if bridgeClients:
+      # Response has a couple arguments...
+      # TimeStarted="2011-08-17 15:50:49" CountrySummary=us=16,de=8,uk=8
+      
+      countrySummary = None
+      for arg in bridgeClients.split():
+        if arg.startswith("CountrySummary="):
+          countrySummary = arg[15:]
+          break
+      
+      if countrySummary:
+        for entry in countrySummary.split(","):
+          if re.match("^..=[0-9]+$", entry):
+            locale, count = entry.split("=", 1)
+            self._clientLocaleUsage[locale] = int(count)
+    
+    # Last sampling received from the ConnectionResolver, used to detect when
+    # it changes.
+    self._lastResourceFetch = -1
+    
+    # resolver for the command/pid associated with SOCKS, HIDDEN, and CONTROL connections
+    self._appResolver = connections.AppResolver("arm")
+    
+    # rate limits appResolver queries to once per update
+    self.appResolveSinceUpdate = False
+    
+    # mark the initially exitsing connection uptimes as being estimates
+    for entry in self._entries:
+      if isinstance(entry, connEntry.ConnectionEntry):
+        entry.getLines()[0].isInitialConnection = True
+    
+    # listens for when tor stops so we know to stop reflecting changes
+    conn.addStatusListener(self.torStateListener)
+  
+  def torStateListener(self, controller, eventType, _):
+    """
+    Freezes the connection contents when Tor stops.
+    """
+    
+    self._isTorRunning = eventType in (State.INIT, State.RESET)
+    
+    if self._isTorRunning: self._haltTime = None
+    else: self._haltTime = time.time()
+    
+    self.redraw(True)
+  
+  def getPauseTime(self):
+    """
+    Provides the time Tor stopped if it isn't running. Otherwise this is the
+    time we were last paused.
+    """
+    
+    if self._haltTime: return self._haltTime
+    else: return panel.Panel.getPauseTime(self)
+  
+  def setSortOrder(self, ordering = None):
+    """
+    Sets the connection attributes we're sorting by and resorts the contents.
+    
+    Arguments:
+      ordering - new ordering, if undefined then this resorts with the last
+                 set ordering
+    """
+    
+    self.valsLock.acquire()
+    
+    if ordering:
+      armConf = conf.get_config("arm")
+      
+      ordering_keys = [entries.SortAttr.keys()[entries.SortAttr.index_of(v)] for v in ordering]
+      armConf.set("features.connection.order", ", ".join(ordering_keys))
+    
+    self._entries.sort(key=lambda i: (i.getSortValues(CONFIG["features.connection.order"], self.getListingType())))
+    
+    self._entryLines = []
+    for entry in self._entries:
+      self._entryLines += entry.getLines()
+    self.valsLock.release()
+  
+  def getListingType(self):
+    """
+    Provides the priority content we list connections by.
+    """
+    
+    return CONFIG["features.connection.listingType"]
+  
+  def setListingType(self, listingType):
+    """
+    Sets the priority information presented by the panel.
+    
+    Arguments:
+      listingType - Listing instance for the primary information to be shown
+    """
+    
+    if self.getListingType() == listingType: return
+    
+    self.valsLock.acquire()
+    
+    armConf = conf.get_config("arm")
+    armConf.set("features.connection.listingType", Listing.keys()[Listing.index_of(listingType)])
+    
+    # if we're sorting by the listing then we need to resort
+    if entries.SortAttr.LISTING in CONFIG["features.connection.order"]:
+      self.setSortOrder()
+    
+    self.valsLock.release()
+  
+  def isClientsAllowed(self):
+    """
+    True if client connections are permissable, false otherwise.
+    """
+    
+    conn = torTools.getConn()
+    return "Guard" in conn.getMyFlags([]) or conn.getOption("BridgeRelay", None) == "1"
+  
+  def isExitsAllowed(self):
+    """
+    True if exit connections are permissable, false otherwise.
+    """
+    
+    if not torTools.getConn().getOption("ORPort", None):
+      return False # no ORPort
+    
+    policy = torTools.getConn().getExitPolicy()
+    return policy and policy.is_exiting_allowed()
+  
+  def showSortDialog(self):
+    """
+    Provides the sort dialog for our connections.
+    """
+    
+    # set ordering for connection options
+    titleLabel = "Connection Ordering:"
+    options = list(entries.SortAttr)
+    oldSelection = CONFIG["features.connection.order"]
+    optionColors = dict([(attr, entries.SORT_COLORS[attr]) for attr in options])
+    results = cli.popups.showSortDialog(titleLabel, options, oldSelection, optionColors)
+    if results: self.setSortOrder(results)
+  
+  def handleKey(self, key):
+    self.valsLock.acquire()
+    
+    isKeystrokeConsumed = True
+    if uiTools.isScrollKey(key):
+      pageHeight = self.getPreferredSize()[0] - 1
+      if self._showDetails: pageHeight -= (DETAILS_HEIGHT + 1)
+      isChanged = self._scroller.handleKey(key, self._entryLines, pageHeight)
+      if isChanged: self.redraw(True)
+    elif uiTools.isSelectionKey(key):
+      self._showDetails = not self._showDetails
+      self.redraw(True)
+    elif key == ord('s') or key == ord('S'):
+      self.showSortDialog()
+    elif key == ord('u') or key == ord('U'):
+      # provides a menu to pick the connection resolver
+      title = "Resolver Util:"
+      options = ["auto"] + list(connections.Resolver)
+      connResolver = connections.getResolver("tor")
+      
+      currentOverwrite = connResolver.overwriteResolver
+      if currentOverwrite == None: oldSelection = 0
+      else: oldSelection = options.index(currentOverwrite)
+      
+      selection = cli.popups.showMenu(title, options, oldSelection)
+      
+      # applies new setting
+      if selection != -1:
+        selectedOption = options[selection] if selection != 0 else None
+        connResolver.overwriteResolver = selectedOption
+    elif key == ord('l') or key == ord('L'):
+      # provides a menu to pick the primary information we list connections by
+      title = "List By:"
+      options = list(entries.ListingType)
+      
+      # dropping the HOSTNAME listing type until we support displaying that content
+      options.remove(cli.connections.entries.ListingType.HOSTNAME)
+      
+      oldSelection = options.index(self.getListingType())
+      selection = cli.popups.showMenu(title, options, oldSelection)
+      
+      # applies new setting
+      if selection != -1: self.setListingType(options[selection])
+    elif key == ord('d') or key == ord('D'):
+      # presents popup for raw consensus data
+      descriptorPopup.showDescriptorPopup(self)
+    elif (key == ord('c') or key == ord('C')) and self.isClientsAllowed():
+      countPopup.showCountDialog(countPopup.CountType.CLIENT_LOCALE, self._clientLocaleUsage)
+    elif (key == ord('e') or key == ord('E')) and self.isExitsAllowed():
+      countPopup.showCountDialog(countPopup.CountType.EXIT_PORT, self._exitPortUsage)
+    else: isKeystrokeConsumed = False
+    
+    self.valsLock.release()
+    return isKeystrokeConsumed
+  
+  def run(self):
+    """
+    Keeps connections listing updated, checking for new entries at a set rate.
+    """
+    
+    lastDraw = time.time() - 1
+    
+    # Fetches out initial connection results. The wait is so this doesn't
+    # run during arm's interface initialization (otherwise there's a
+    # noticeable pause before the first redraw).
+    self._cond.acquire()
+    self._cond.wait(0.2)
+    self._cond.release()
+    self._update()            # populates initial entries
+    self._resolveApps(False)  # resolves initial applications
+    
+    while not self._halt:
+      currentTime = time.time()
+      
+      if self.isPaused() or not self._isTorRunning or currentTime - lastDraw < CONFIG["features.connection.refreshRate"]:
+        self._cond.acquire()
+        if not self._halt: self._cond.wait(0.2)
+        self._cond.release()
+      else:
+        # updates content if their's new results, otherwise just redraws
+        self._update()
+        self.redraw(True)
+        
+        # we may have missed multiple updates due to being paused, showing
+        # another panel, etc so lastDraw might need to jump multiple ticks
+        drawTicks = (time.time() - lastDraw) / CONFIG["features.connection.refreshRate"]
+        lastDraw += CONFIG["features.connection.refreshRate"] * drawTicks
+  
+  def getHelp(self):
+    resolverUtil = connections.getResolver("tor").overwriteResolver
+    if resolverUtil == None: resolverUtil = "auto"
+    
+    options = []
+    options.append(("up arrow", "scroll up a line", None))
+    options.append(("down arrow", "scroll down a line", None))
+    options.append(("page up", "scroll up a page", None))
+    options.append(("page down", "scroll down a page", None))
+    options.append(("enter", "show connection details", None))
+    options.append(("d", "raw consensus descriptor", None))
+    
+    if self.isClientsAllowed():
+      options.append(("c", "client locale usage summary", None))
+    
+    if self.isExitsAllowed():
+      options.append(("e", "exit port usage summary", None))
+    
+    options.append(("l", "listed identity", self.getListingType().lower()))
+    options.append(("s", "sort ordering", None))
+    options.append(("u", "resolving utility", resolverUtil))
+    return options
+  
+  def getSelection(self):
+    """
+    Provides the currently selected connection entry.
+    """
+    
+    return self._scroller.getCursorSelection(self._entryLines)
+  
+  def draw(self, width, height):
+    self.valsLock.acquire()
+    
+    # if we don't have any contents then refuse to show details
+    if not self._entries: self._showDetails = False
+    
+    # extra line when showing the detail panel is for the bottom border
+    detailPanelOffset = DETAILS_HEIGHT + 1 if self._showDetails else 0
+    isScrollbarVisible = len(self._entryLines) > height - detailPanelOffset - 1
+    
+    scrollLoc = self._scroller.getScrollLoc(self._entryLines, height - detailPanelOffset - 1)
+    cursorSelection = self.getSelection()
+    
+    # draws the detail panel if currently displaying it
+    if self._showDetails and cursorSelection:
+      # This is a solid border unless the scrollbar is visible, in which case a
+      # 'T' pipe connects the border to the bar.
+      uiTools.drawBox(self, 0, 0, width, DETAILS_HEIGHT + 2)
+      if isScrollbarVisible: self.addch(DETAILS_HEIGHT + 1, 1, curses.ACS_TTEE)
+      
+      drawEntries = cursorSelection.getDetails(width)
+      for i in range(min(len(drawEntries), DETAILS_HEIGHT)):
+        self.addstr(1 + i, 2, drawEntries[i][0], drawEntries[i][1])
+    
+    # title label with connection counts
+    if self.isTitleVisible():
+      title = "Connection Details:" if self._showDetails else self._title
+      self.addstr(0, 0, title, curses.A_STANDOUT)
+    
+    scrollOffset = 0
+    if isScrollbarVisible:
+      scrollOffset = 2
+      self.addScrollBar(scrollLoc, scrollLoc + height - detailPanelOffset - 1, len(self._entryLines), 1 + detailPanelOffset)
+    
+    if self.isPaused() or not self._isTorRunning:
+      currentTime = self.getPauseTime()
+    else: currentTime = time.time()
+    
+    for lineNum in range(scrollLoc, len(self._entryLines)):
+      entryLine = self._entryLines[lineNum]
+      
+      # if this is an unresolved SOCKS, HIDDEN, or CONTROL entry then queue up
+      # resolution for the applicaitions they belong to
+      if isinstance(entryLine, connEntry.ConnectionLine) and entryLine.isUnresolvedApp():
+        self._resolveApps()
+      
+      # hilighting if this is the selected line
+      extraFormat = curses.A_STANDOUT if entryLine == cursorSelection else curses.A_NORMAL
+      
+      drawLine = lineNum + detailPanelOffset + 1 - scrollLoc
+      
+      prefix = entryLine.getListingPrefix()
+      for i in range(len(prefix)):
+        self.addch(drawLine, scrollOffset + i, prefix[i])
+      
+      xOffset = scrollOffset + len(prefix)
+      drawEntry = entryLine.getListingEntry(width - scrollOffset - len(prefix), currentTime, self.getListingType())
+      
+      for msg, attr in drawEntry:
+        attr |= extraFormat
+        self.addstr(drawLine, xOffset, msg, attr)
+        xOffset += len(msg)
+      
+      if drawLine >= height: break
+    
+    self.valsLock.release()
+  
+  def stop(self):
+    """
+    Halts further resolutions and terminates the thread.
+    """
+    
+    self._cond.acquire()
+    self._halt = True
+    self._cond.notifyAll()
+    self._cond.release()
+  
+  def _update(self):
+    """
+    Fetches the newest resolved connections.
+    """
+    
+    self.appResolveSinceUpdate = False
+    
+    # if we don't have an initialized resolver then this is a no-op
+    if not connections.isResolverAlive("tor"): return
+    
+    connResolver = connections.getResolver("tor")
+    currentResolutionCount = connResolver.getResolutionCount()
+    
+    self.valsLock.acquire()
+    
+    newEntries = [] # the new results we'll display
+    
+    # Fetches new connections and client circuits...
+    # newConnections  [(local ip, local port, foreign ip, foreign port)...]
+    # newCircuits     {circuitID => (status, purpose, path)...}
+    
+    newConnections = connResolver.getConnections()
+    newCircuits = {}
+    
+    for circuitID, status, purpose, path in torTools.getConn().getCircuits():
+      # Skips established single-hop circuits (these are for directory
+      # fetches, not client circuits)
+      if not (status == "BUILT" and len(path) == 1):
+        newCircuits[circuitID] = (status, purpose, path)
+    
+    # Populates newEntries with any of our old entries that still exist.
+    # This is both for performance and to keep from resetting the uptime
+    # attributes. Note that CircEntries are a ConnectionEntry subclass so
+    # we need to check for them first.
+    
+    for oldEntry in self._entries:
+      if isinstance(oldEntry, circEntry.CircEntry):
+        newEntry = newCircuits.get(oldEntry.circuitID)
+        
+        if newEntry:
+          oldEntry.update(newEntry[0], newEntry[2])
+          newEntries.append(oldEntry)
+          del newCircuits[oldEntry.circuitID]
+      elif isinstance(oldEntry, connEntry.ConnectionEntry):
+        connLine = oldEntry.getLines()[0]
+        connAttr = (connLine.local.getIpAddr(), connLine.local.getPort(),
+                    connLine.foreign.getIpAddr(), connLine.foreign.getPort())
+        
+        if connAttr in newConnections:
+          newEntries.append(oldEntry)
+          newConnections.remove(connAttr)
+    
+    # Reset any display attributes for the entries we're keeping
+    for entry in newEntries: entry.resetDisplay()
+    
+    # Adds any new connection and circuit entries.
+    for lIp, lPort, fIp, fPort in newConnections:
+      newConnEntry = connEntry.ConnectionEntry(lIp, lPort, fIp, fPort)
+      newConnLine = newConnEntry.getLines()[0]
+      
+      if newConnLine.getType() != connEntry.Category.CIRCUIT:
+        newEntries.append(newConnEntry)
+        
+        # updates exit port and client locale usage information
+        if newConnLine.isPrivate():
+          if newConnLine.getType() == connEntry.Category.INBOUND:
+            # client connection, update locale information
+            clientLocale = newConnLine.foreign.getLocale()
+            
+            if clientLocale:
+              self._clientLocaleUsage[clientLocale] = self._clientLocaleUsage.get(clientLocale, 0) + 1
+          elif newConnLine.getType() == connEntry.Category.EXIT:
+            exitPort = newConnLine.foreign.getPort()
+            self._exitPortUsage[exitPort] = self._exitPortUsage.get(exitPort, 0) + 1
+    
+    for circuitID in newCircuits:
+      status, purpose, path = newCircuits[circuitID]
+      newEntries.append(circEntry.CircEntry(circuitID, status, purpose, path))
+    
+    # Counts the relays in each of the categories. This also flushes the
+    # type cache for all of the connections (in case its changed since last
+    # fetched).
+    
+    categoryTypes = list(connEntry.Category)
+    typeCounts = dict((type, 0) for type in categoryTypes)
+    for entry in newEntries:
+      if isinstance(entry, connEntry.ConnectionEntry):
+        typeCounts[entry.getLines()[0].getType()] += 1
+      elif isinstance(entry, circEntry.CircEntry):
+        typeCounts[connEntry.Category.CIRCUIT] += 1
+    
+    # makes labels for all the categories with connections (ie,
+    # "21 outbound", "1 control", etc)
+    countLabels = []
+    
+    for category in categoryTypes:
+      if typeCounts[category] > 0:
+        countLabels.append("%i %s" % (typeCounts[category], category.lower()))
+    
+    if countLabels: self._title = "Connections (%s):" % ", ".join(countLabels)
+    else: self._title = "Connections:"
+    
+    self._entries = newEntries
+    
+    self._entryLines = []
+    for entry in self._entries:
+      self._entryLines += entry.getLines()
+    
+    self.setSortOrder()
+    self._lastResourceFetch = currentResolutionCount
+    self.valsLock.release()
+  
+  def _resolveApps(self, flagQuery = True):
+    """
+    Triggers an asynchronous query for all unresolved SOCKS, HIDDEN, and
+    CONTROL entries.
+    
+    Arguments:
+      flagQuery - sets a flag to prevent further call from being respected
+                  until the next update if true
+    """
+    
+    if self.appResolveSinceUpdate or not CONFIG["features.connection.resolveApps"]: return
+    unresolvedLines = [l for l in self._entryLines if isinstance(l, connEntry.ConnectionLine) and l.isUnresolvedApp()]
+    
+    # get the ports used for unresolved applications
+    appPorts = []
+    
+    for line in unresolvedLines:
+      appConn = line.local if line.getType() == connEntry.Category.HIDDEN else line.foreign
+      appPorts.append(appConn.getPort())
+    
+    # Queue up resolution for the unresolved ports (skips if it's still working
+    # on the last query).
+    if appPorts and not self._appResolver.isResolving:
+      self._appResolver.resolve(appPorts)
+    
+    # Fetches results. If the query finishes quickly then this is what we just
+    # asked for, otherwise these belong to an earlier resolution.
+    #
+    # The application resolver might have given up querying (for instance, if
+    # the lsof lookups aren't working on this platform or lacks permissions).
+    # The isAppResolving flag lets the unresolved entries indicate if there's
+    # a lookup in progress for them or not.
+    
+    appResults = self._appResolver.getResults(0.2)
+    
+    for line in unresolvedLines:
+      isLocal = line.getType() == connEntry.Category.HIDDEN
+      linePort = line.local.getPort() if isLocal else line.foreign.getPort()
+      
+      if linePort in appResults:
+        # sets application attributes if there's a result with this as the
+        # inbound port
+        for inboundPort, outboundPort, cmd, pid in appResults[linePort]:
+          appPort = outboundPort if isLocal else inboundPort
+          
+          if linePort == appPort:
+            line.appName = cmd
+            line.appPid = pid
+            line.isAppResolving = False
+      else:
+        line.isAppResolving = self._appResolver.isResolving
+    
+    if flagQuery:
+      self.appResolveSinceUpdate = True
+
diff --git a/arm/cli/connections/countPopup.py b/arm/cli/connections/countPopup.py
new file mode 100644
index 0000000..d2818ed
--- /dev/null
+++ b/arm/cli/connections/countPopup.py
@@ -0,0 +1,102 @@
+"""
+Provides a dialog with client locale or exiting port counts.
+"""
+
+import curses
+import operator
+
+import cli.controller
+import cli.popups
+
+from util import connections, uiTools
+
+from stem.util import enum, log
+
+CountType = enum.Enum("CLIENT_LOCALE", "EXIT_PORT")
+EXIT_USAGE_WIDTH = 15
+
+def showCountDialog(countType, counts):
+  """
+  Provides a dialog with bar graphs and percentages for the given set of
+  counts. Pressing any key closes the dialog.
+  
+  Arguments:
+    countType - type of counts being presented
+    counts    - mapping of labels to counts
+  """
+  
+  isNoStats = not counts
+  noStatsMsg = "Usage stats aren't available yet, press any key..."
+  
+  if isNoStats:
+    popup, width, height = cli.popups.init(3, len(noStatsMsg) + 4)
+  else:
+    popup, width, height = cli.popups.init(4 + max(1, len(counts)), 80)
+  if not popup: return
+  
+  try:
+    control = cli.controller.getController()
+    
+    popup.win.box()
+    
+    # dialog title
+    if countType == CountType.CLIENT_LOCALE:
+      title = "Client Locales"
+    elif countType == CountType.EXIT_PORT:
+      title = "Exiting Port Usage"
+    else:
+      title = ""
+      log.warn("Unrecognized count type: %s" % countType)
+    
+    popup.addstr(0, 0, title, curses.A_STANDOUT)
+    
+    if isNoStats:
+      popup.addstr(1, 2, noStatsMsg, curses.A_BOLD | uiTools.getColor("cyan"))
+    else:
+      sortedCounts = sorted(counts.iteritems(), key=operator.itemgetter(1))
+      sortedCounts.reverse()
+      
+      # constructs string formatting for the max key and value display width
+      keyWidth, valWidth, valueTotal = 3, 1, 0
+      for k, v in sortedCounts:
+        keyWidth = max(keyWidth, len(k))
+        valWidth = max(valWidth, len(str(v)))
+        valueTotal += v
+      
+      # extra space since we're adding usage informaion
+      if countType == CountType.EXIT_PORT:
+        keyWidth += EXIT_USAGE_WIDTH
+      
+      labelFormat = "%%-%is %%%ii (%%%%%%-2i)" % (keyWidth, valWidth)
+      
+      for i in range(height - 4):
+        k, v = sortedCounts[i]
+        
+        # includes a port usage column
+        if countType == CountType.EXIT_PORT:
+          usage = connections.getPortUsage(k)
+          
+          if usage:
+            keyFormat = "%%-%is   %%s" % (keyWidth - EXIT_USAGE_WIDTH)
+            k = keyFormat % (k, usage[:EXIT_USAGE_WIDTH - 3])
+        
+        label = labelFormat % (k, v, v * 100 / valueTotal)
+        popup.addstr(i + 1, 2, label, curses.A_BOLD | uiTools.getColor("green"))
+        
+        # All labels have the same size since they're based on the max widths.
+        # If this changes then this'll need to be the max label width.
+        labelWidth = len(label)
+        
+        # draws simple bar graph for percentages
+        fillWidth = v * (width - 4 - labelWidth) / valueTotal
+        for j in range(fillWidth):
+          popup.addstr(i + 1, 3 + labelWidth + j, " ", curses.A_STANDOUT | uiTools.getColor("red"))
+      
+      popup.addstr(height - 2, 2, "Press any key...")
+    
+    popup.win.refresh()
+    
+    curses.cbreak()
+    control.getScreen().getch()
+  finally: cli.popups.finalize()
+
diff --git a/arm/cli/connections/descriptorPopup.py b/arm/cli/connections/descriptorPopup.py
new file mode 100644
index 0000000..eed213f
--- /dev/null
+++ b/arm/cli/connections/descriptorPopup.py
@@ -0,0 +1,229 @@
+"""
+Popup providing the raw descriptor and consensus information for a relay.
+"""
+
+import math
+import curses
+
+import cli.popups
+import cli.connections.connEntry
+
+from util import panel, torTools, uiTools
+
+# field keywords used to identify areas for coloring
+LINE_NUM_COLOR = "yellow"
+HEADER_COLOR = "cyan"
+HEADER_PREFIX = ["ns/id/", "desc/id/"]
+
+SIG_COLOR = "red"
+SIG_START_KEYS = ["-----BEGIN RSA PUBLIC KEY-----", "-----BEGIN SIGNATURE-----"]
+SIG_END_KEYS = ["-----END RSA PUBLIC KEY-----", "-----END SIGNATURE-----"]
+
+UNRESOLVED_MSG = "No consensus data available"
+ERROR_MSG = "Unable to retrieve data"
+
+def showDescriptorPopup(connPanel):
+  """
+  Presents consensus descriptor in popup window with the following controls:
+  Up, Down, Page Up, Page Down - scroll descriptor
+  Right, Left - next / previous connection
+  Enter, Space, d, D - close popup
+  
+  Arguments:
+    connPanel - connection panel providing the dialog
+  """
+  
+  # hides the title of the connection panel
+  connPanel.setTitleVisible(False)
+  connPanel.redraw(True)
+  
+  control = cli.controller.getController()
+  panel.CURSES_LOCK.acquire()
+  isDone = False
+  
+  try:
+    while not isDone:
+      selection = connPanel.getSelection()
+      if not selection: break
+      
+      fingerprint = selection.foreign.getFingerprint()
+      if fingerprint == "UNKNOWN": fingerprint = None
+      
+      displayText = getDisplayText(fingerprint)
+      displayColor = cli.connections.connEntry.CATEGORY_COLOR[selection.getType()]
+      showLineNumber = fingerprint != None
+      
+      # determines the maximum popup size the displayText can fill
+      pHeight, pWidth = getPreferredSize(displayText, connPanel.maxX, showLineNumber)
+      
+      popup, _, height = cli.popups.init(pHeight, pWidth)
+      if not popup: break
+      scroll, isChanged = 0, True
+      
+      try:
+        while not isDone:
+          if isChanged:
+            draw(popup, fingerprint, displayText, displayColor, scroll, showLineNumber)
+            isChanged = False
+          
+          key = control.getScreen().getch()
+          
+          if uiTools.isScrollKey(key):
+            # TODO: This is a bit buggy in that scrolling is by displayText
+            # lines rather than the displayed lines, causing issues when
+            # content wraps. The result is that we can't have a scrollbar and
+            # can't scroll to the bottom if there's a multi-line being
+            # displayed. However, trying to correct this introduces a big can
+            # of worms and after hours decided that this isn't worth the
+            # effort...
+            
+            newScroll = uiTools.getScrollPosition(key, scroll, height - 2, len(displayText))
+            
+            if scroll != newScroll:
+              scroll, isChanged = newScroll, True
+          elif uiTools.isSelectionKey(key) or key in (ord('d'), ord('D')):
+            isDone = True # closes popup
+          elif key in (curses.KEY_LEFT, curses.KEY_RIGHT):
+            # navigation - pass on to connPanel and recreate popup
+            connPanel.handleKey(curses.KEY_UP if key == curses.KEY_LEFT else curses.KEY_DOWN)
+            break
+      finally: cli.popups.finalize()
+  finally:
+    connPanel.setTitleVisible(True)
+    connPanel.redraw(True)
+    panel.CURSES_LOCK.release()
+
+def getDisplayText(fingerprint):
+  """
+  Provides the descriptor and consensus entry for a relay. This is a list of
+  lines to be displayed by the dialog.
+  """
+  
+  if not fingerprint: return [UNRESOLVED_MSG]
+  conn, description = torTools.getConn(), []
+  
+  description.append("ns/id/%s" % fingerprint)
+  consensusEntry = conn.getConsensusEntry(fingerprint)
+  
+  if consensusEntry: description += consensusEntry.split("\n")
+  else: description += [ERROR_MSG, ""]
+  
+  description.append("desc/id/%s" % fingerprint)
+  descriptorEntry = conn.getDescriptorEntry(fingerprint)
+  
+  if descriptorEntry: description += descriptorEntry.split("\n")
+  else: description += [ERROR_MSG]
+  
+  return description
+
+def getPreferredSize(text, maxWidth, showLineNumber):
+  """
+  Provides the (height, width) tuple for the preferred size of the given text.
+  """
+  
+  width, height = 0, len(text) + 2
+  lineNumWidth = int(math.log10(len(text))) + 1
+  for line in text:
+    # width includes content, line number field, and border
+    lineWidth = len(line) + 5
+    if showLineNumber: lineWidth += lineNumWidth
+    width = max(width, lineWidth)
+    
+    # tracks number of extra lines that will be taken due to text wrap
+    height += (lineWidth - 2) / maxWidth
+  
+  return (height, width)
+
+def draw(popup, fingerprint, displayText, displayColor, scroll, showLineNumber):
+  popup.win.erase()
+  popup.win.box()
+  xOffset = 2
+  
+  if fingerprint: title = "Consensus Descriptor (%s):" % fingerprint
+  else: title = "Consensus Descriptor:"
+  popup.addstr(0, 0, title, curses.A_STANDOUT)
+  
+  lineNumWidth = int(math.log10(len(displayText))) + 1
+  isEncryptionBlock = False   # flag indicating if we're currently displaying a key
+  
+  # checks if first line is in an encryption block
+  for i in range(0, scroll):
+    lineText = displayText[i].strip()
+    if lineText in SIG_START_KEYS: isEncryptionBlock = True
+    elif lineText in SIG_END_KEYS: isEncryptionBlock = False
+  
+  drawLine, pageHeight = 1, popup.maxY - 2
+  for i in range(scroll, scroll + pageHeight):
+    lineText = displayText[i].strip()
+    xOffset = 2
+    
+    if showLineNumber:
+      lineNumLabel = ("%%%ii" % lineNumWidth) % (i + 1)
+      lineNumFormat = curses.A_BOLD | uiTools.getColor(LINE_NUM_COLOR)
+      
+      popup.addstr(drawLine, xOffset, lineNumLabel, lineNumFormat)
+      xOffset += lineNumWidth + 1
+    
+    # Most consensus and descriptor lines are keyword/value pairs. Both are
+    # shown with the same color, but the keyword is bolded.
+    
+    keyword, value = lineText, ""
+    drawFormat = uiTools.getColor(displayColor)
+    
+    if lineText.startswith(HEADER_PREFIX[0]) or lineText.startswith(HEADER_PREFIX[1]):
+      keyword, value = lineText, ""
+      drawFormat = uiTools.getColor(HEADER_COLOR)
+    elif lineText == UNRESOLVED_MSG or lineText == ERROR_MSG:
+      keyword, value = lineText, ""
+    elif lineText in SIG_START_KEYS:
+      keyword, value = lineText, ""
+      isEncryptionBlock = True
+      drawFormat = uiTools.getColor(SIG_COLOR)
+    elif lineText in SIG_END_KEYS:
+      keyword, value = lineText, ""
+      isEncryptionBlock = False
+      drawFormat = uiTools.getColor(SIG_COLOR)
+    elif isEncryptionBlock:
+      keyword, value = "", lineText
+      drawFormat = uiTools.getColor(SIG_COLOR)
+    elif " " in lineText:
+      divIndex = lineText.find(" ")
+      keyword, value = lineText[:divIndex], lineText[divIndex:]
+    
+    displayQueue = [(keyword, drawFormat | curses.A_BOLD), (value, drawFormat)]
+    cursorLoc = xOffset
+    
+    while displayQueue:
+      msg, format = displayQueue.pop(0)
+      if not msg: continue
+      
+      maxMsgSize = popup.maxX - 1 - cursorLoc
+      if len(msg) >= maxMsgSize:
+        # needs to split up the line
+        msg, remainder = uiTools.cropStr(msg, maxMsgSize, None, endType = None, getRemainder = True)
+        
+        if xOffset == cursorLoc and msg == "":
+          # first word is longer than the line
+          msg = uiTools.cropStr(remainder, maxMsgSize)
+          
+          if " " in remainder:
+            remainder = remainder.split(" ", 1)[1]
+          else: remainder = ""
+        
+        popup.addstr(drawLine, cursorLoc, msg, format)
+        cursorLoc = xOffset
+        
+        if remainder:
+          displayQueue.insert(0, (remainder.strip(), format))
+          drawLine += 1
+      else:
+        popup.addstr(drawLine, cursorLoc, msg, format)
+        cursorLoc += len(msg)
+      
+      if drawLine > pageHeight: break
+    
+    drawLine += 1
+    if drawLine > pageHeight: break
+  
+  popup.win.refresh()
+
diff --git a/arm/cli/connections/entries.py b/arm/cli/connections/entries.py
new file mode 100644
index 0000000..d5085aa
--- /dev/null
+++ b/arm/cli/connections/entries.py
@@ -0,0 +1,171 @@
+"""
+Interface for entries in the connection panel. These consist of two parts: the
+entry itself (ie, Tor connection, client circuit, etc) and the lines it
+consists of in the listing.
+"""
+
+from stem.util import enum
+
+# attributes we can list entries by
+ListingType = enum.Enum(("IP_ADDRESS", "IP Address"), "HOSTNAME", "FINGERPRINT", "NICKNAME")
+
+SortAttr = enum.Enum("CATEGORY", "UPTIME", "LISTING", "IP_ADDRESS", "PORT",
+                     "HOSTNAME", "FINGERPRINT", "NICKNAME", "COUNTRY")
+
+SORT_COLORS = {SortAttr.CATEGORY: "red",      SortAttr.UPTIME: "yellow",
+               SortAttr.LISTING: "green",     SortAttr.IP_ADDRESS: "blue",
+               SortAttr.PORT: "blue",         SortAttr.HOSTNAME: "magenta",
+               SortAttr.FINGERPRINT: "cyan",  SortAttr.NICKNAME: "cyan",
+               SortAttr.COUNTRY: "blue"}
+
+# maximum number of ports a system can have
+PORT_COUNT = 65536
+
+class ConnectionPanelEntry:
+  """
+  Common parent for connection panel entries. This consists of a list of lines
+  in the panel listing. This caches results until the display indicates that
+  they should be flushed.
+  """
+  
+  def __init__(self):
+    self.lines = []
+    self.flushCache = True
+  
+  def getLines(self):
+    """
+    Provides the individual lines in the connection listing.
+    """
+    
+    if self.flushCache:
+      self.lines = self._getLines(self.lines)
+      self.flushCache = False
+    
+    return self.lines
+  
+  def _getLines(self, oldResults):
+    # implementation of getLines
+    
+    for line in oldResults:
+      line.resetDisplay()
+    
+    return oldResults
+  
+  def getSortValues(self, sortAttrs, listingType):
+    """
+    Provides the value used in comparisons to sort based on the given
+    attribute.
+    
+    Arguments:
+      sortAttrs   - list of SortAttr values for the field being sorted on
+      listingType - ListingType enumeration for the attribute we're listing
+                    entries by
+    """
+    
+    return [self.getSortValue(attr, listingType) for attr in sortAttrs]
+  
+  def getSortValue(self, attr, listingType):
+    """
+    Provides the value of a single attribute used for sorting purposes.
+    
+    Arguments:
+      attr        - list of SortAttr values for the field being sorted on
+      listingType - ListingType enumeration for the attribute we're listing
+                    entries by
+    """
+    
+    if attr == SortAttr.LISTING:
+      if listingType == ListingType.IP_ADDRESS:
+        # uses the IP address as the primary value, and port as secondary
+        sortValue = self.getSortValue(SortAttr.IP_ADDRESS, listingType) * PORT_COUNT
+        sortValue += self.getSortValue(SortAttr.PORT, listingType)
+        return sortValue
+      elif listingType == ListingType.HOSTNAME:
+        return self.getSortValue(SortAttr.HOSTNAME, listingType)
+      elif listingType == ListingType.FINGERPRINT:
+        return self.getSortValue(SortAttr.FINGERPRINT, listingType)
+      elif listingType == ListingType.NICKNAME:
+        return self.getSortValue(SortAttr.NICKNAME, listingType)
+    
+    return ""
+  
+  def resetDisplay(self):
+    """
+    Flushes cached display results.
+    """
+    
+    self.flushCache = True
+
+class ConnectionPanelLine:
+  """
+  Individual line in the connection panel listing.
+  """
+  
+  def __init__(self):
+    # cache for displayed information
+    self._listingCache = None
+    self._listingCacheArgs = (None, None)
+    
+    self._detailsCache = None
+    self._detailsCacheArgs = None
+    
+    self._descriptorCache = None
+    self._descriptorCacheArgs = None
+  
+  def getListingPrefix(self):
+    """
+    Provides a list of characters to be appended before the listing entry.
+    """
+    
+    return ()
+  
+  def getListingEntry(self, width, currentTime, listingType):
+    """
+    Provides a [(msg, attr)...] tuple list for contents to be displayed in the
+    connection panel listing.
+    
+    Arguments:
+      width       - available space to display in
+      currentTime - unix timestamp for what the results should consider to be
+                    the current time (this may be ignored due to caching)
+      listingType - ListingType enumeration for the highest priority content
+                    to be displayed
+    """
+    
+    if self._listingCacheArgs != (width, listingType):
+      self._listingCache = self._getListingEntry(width, currentTime, listingType)
+      self._listingCacheArgs = (width, listingType)
+    
+    return self._listingCache
+  
+  def _getListingEntry(self, width, currentTime, listingType):
+    # implementation of getListingEntry
+    return None
+  
+  def getDetails(self, width):
+    """
+    Provides a list of [(msg, attr)...] tuple listings with detailed
+    information for this connection.
+    
+    Arguments:
+      width - available space to display in
+    """
+    
+    if self._detailsCacheArgs != width:
+      self._detailsCache = self._getDetails(width)
+      self._detailsCacheArgs = width
+    
+    return self._detailsCache
+  
+  def _getDetails(self, width):
+    # implementation of getDetails
+    return []
+  
+  def resetDisplay(self):
+    """
+    Flushes cached display results.
+    """
+    
+    self._listingCacheArgs = (None, None)
+    self._detailsCacheArgs = None
+
diff --git a/arm/cli/controller.py b/arm/cli/controller.py
new file mode 100644
index 0000000..0ef07d8
--- /dev/null
+++ b/arm/cli/controller.py
@@ -0,0 +1,676 @@
+"""
+Main interface loop for arm, periodically redrawing the screen and issuing
+user input to the proper panels.
+"""
+
+import os
+import time
+import curses
+import sys
+import threading
+
+import cli.menu.menu
+import cli.popups
+import cli.headerPanel
+import cli.logPanel
+import cli.configPanel
+import cli.torrcPanel
+import cli.graphing.graphPanel
+import cli.graphing.bandwidthStats
+import cli.graphing.connStats
+import cli.graphing.resourceStats
+import cli.connections.connPanel
+
+from stem.control import State, Controller
+
+from util import connections, hostnames, panel, sysTools, torConfig, torTools
+
+from stem.util import conf, enum, log
+
+ARM_CONTROLLER = None
+
+def conf_handler(key, value):
+  if key == "features.redrawRate":
+    return max(1, value)
+  elif key == "features.refreshRate":
+    return max(0, value)
+
+CONFIG = conf.config_dict("arm", {
+  "startup.events": "N3",
+  "startup.dataDirectory": "~/.arm",
+  "startup.blindModeEnabled": False,
+  "features.panels.show.graph": True,
+  "features.panels.show.log": True,
+  "features.panels.show.connection": True,
+  "features.panels.show.config": True,
+  "features.panels.show.torrc": True,
+  "features.redrawRate": 5,
+  "features.refreshRate": 5,
+  "features.confirmQuit": True,
+  "features.graph.type": 1,
+  "features.graph.bw.prepopulate": True,
+}, conf_handler)
+
+GraphStat = enum.Enum("BANDWIDTH", "CONNECTIONS", "SYSTEM_RESOURCES")
+
+# maps 'features.graph.type' config values to the initial types
+GRAPH_INIT_STATS = {1: GraphStat.BANDWIDTH, 2: GraphStat.CONNECTIONS, 3: GraphStat.SYSTEM_RESOURCES}
+
+def getController():
+  """
+  Provides the arm controller instance.
+  """
+  
+  return ARM_CONTROLLER
+
+def initController(stdscr, startTime):
+  """
+  Spawns the controller, and related panels for it.
+  
+  Arguments:
+    stdscr - curses window
+  """
+  
+  global ARM_CONTROLLER
+  
+  # initializes the panels
+  stickyPanels = [cli.headerPanel.HeaderPanel(stdscr, startTime),
+                  LabelPanel(stdscr)]
+  pagePanels, firstPagePanels = [], []
+  
+  # first page: graph and log
+  if CONFIG["features.panels.show.graph"]:
+    firstPagePanels.append(cli.graphing.graphPanel.GraphPanel(stdscr))
+  
+  if CONFIG["features.panels.show.log"]:
+    expandedEvents = cli.logPanel.expandEvents(CONFIG["startup.events"])
+    firstPagePanels.append(cli.logPanel.LogPanel(stdscr, expandedEvents))
+  
+  if firstPagePanels: pagePanels.append(firstPagePanels)
+  
+  # second page: connections
+  if not CONFIG["startup.blindModeEnabled"] and CONFIG["features.panels.show.connection"]:
+    pagePanels.append([cli.connections.connPanel.ConnectionPanel(stdscr)])
+  
+  # third page: config
+  if CONFIG["features.panels.show.config"]:
+    pagePanels.append([cli.configPanel.ConfigPanel(stdscr, cli.configPanel.State.TOR)])
+  
+  # fourth page: torrc
+  if CONFIG["features.panels.show.torrc"]:
+    pagePanels.append([cli.torrcPanel.TorrcPanel(stdscr, cli.torrcPanel.Config.TORRC)])
+  
+  # initializes the controller
+  ARM_CONTROLLER = Controller(stdscr, stickyPanels, pagePanels)
+  
+  # additional configuration for the graph panel
+  graphPanel = ARM_CONTROLLER.getPanel("graph")
+  
+  if graphPanel:
+    # statistical monitors for graph
+    bwStats = cli.graphing.bandwidthStats.BandwidthStats()
+    graphPanel.addStats(GraphStat.BANDWIDTH, bwStats)
+    graphPanel.addStats(GraphStat.SYSTEM_RESOURCES, cli.graphing.resourceStats.ResourceStats())
+    if not CONFIG["startup.blindModeEnabled"]:
+      graphPanel.addStats(GraphStat.CONNECTIONS, cli.graphing.connStats.ConnStats())
+    
+    # sets graph based on config parameter
+    try:
+      initialStats = GRAPH_INIT_STATS.get(CONFIG["features.graph.type"])
+      graphPanel.setStats(initialStats)
+    except ValueError: pass # invalid stats, maybe connections when in blind mode
+    
+    # prepopulates bandwidth values from state file
+    if CONFIG["features.graph.bw.prepopulate"] and torTools.getConn().isAlive():
+      isSuccessful = bwStats.prepopulateFromState()
+      if isSuccessful: graphPanel.updateInterval = 4
+
+class LabelPanel(panel.Panel):
+  """
+  Panel that just displays a single line of text.
+  """
+  
+  def __init__(self, stdscr):
+    panel.Panel.__init__(self, stdscr, "msg", 0, height=1)
+    self.msgText = ""
+    self.msgAttr = curses.A_NORMAL
+  
+  def setMessage(self, msg, attr = None):
+    """
+    Sets the message being displayed by the panel.
+    
+    Arguments:
+      msg  - string to be displayed
+      attr - attribute for the label, normal text if undefined
+    """
+    
+    if attr == None: attr = curses.A_NORMAL
+    self.msgText = msg
+    self.msgAttr = attr
+  
+  def draw(self, width, height):
+    self.addstr(0, 0, self.msgText, self.msgAttr)
+
+class Controller:
+  """
+  Tracks the global state of the interface
+  """
+  
+  def __init__(self, stdscr, stickyPanels, pagePanels):
+    """
+    Creates a new controller instance. Panel lists are ordered as they appear,
+    top to bottom on the page.
+    
+    Arguments:
+      stdscr       - curses window
+      stickyPanels - panels shown at the top of each page
+      pagePanels   - list of pages, each being a list of the panels on it
+    """
+    
+    self._screen = stdscr
+    self._stickyPanels = stickyPanels
+    self._pagePanels = pagePanels
+    self._page = 0
+    self._isPaused = False
+    self._forceRedraw = False
+    self._isDone = False
+    self._lastDrawn = 0
+    self.setMsg() # initializes our control message
+  
+  def getScreen(self):
+    """
+    Provides our curses window.
+    """
+    
+    return self._screen
+  
+  def getPageCount(self):
+    """
+    Provides the number of pages the interface has. This may be zero if all
+    page panels have been disabled.
+    """
+    
+    return len(self._pagePanels)
+  
+  def getPage(self):
+    """
+    Provides the number belonging to this page. Page numbers start at zero.
+    """
+    
+    return self._page
+  
+  def setPage(self, pageNumber):
+    """
+    Sets the selected page, raising a ValueError if the page number is invalid.
+    
+    Arguments:
+      pageNumber - page number to be selected
+    """
+    
+    if pageNumber < 0 or pageNumber >= self.getPageCount():
+      raise ValueError("Invalid page number: %i" % pageNumber)
+    
+    if pageNumber != self._page:
+      self._page = pageNumber
+      self._forceRedraw = True
+      self.setMsg()
+  
+  def nextPage(self):
+    """
+    Increments the page number.
+    """
+    
+    self.setPage((self._page + 1) % len(self._pagePanels))
+  
+  def prevPage(self):
+    """
+    Decrements the page number.
+    """
+    
+    self.setPage((self._page - 1) % len(self._pagePanels))
+  
+  def isPaused(self):
+    """
+    True if the interface is paused, false otherwise.
+    """
+    
+    return self._isPaused
+  
+  def setPaused(self, isPause):
+    """
+    Sets the interface to be paused or unpaused.
+    """
+    
+    if isPause != self._isPaused:
+      self._isPaused = isPause
+      self._forceRedraw = True
+      self.setMsg()
+      
+      for panelImpl in self.getAllPanels():
+        panelImpl.setPaused(isPause)
+  
+  def getPanel(self, name):
+    """
+    Provides the panel with the given identifier. This returns None if no such
+    panel exists.
+    
+    Arguments:
+      name - name of the panel to be fetched
+    """
+    
+    for panelImpl in self.getAllPanels():
+      if panelImpl.getName() == name:
+        return panelImpl
+    
+    return None
+  
+  def getStickyPanels(self):
+    """
+    Provides the panels visibile at the top of every page.
+    """
+    
+    return list(self._stickyPanels)
+  
+  def getDisplayPanels(self, pageNumber = None, includeSticky = True):
+    """
+    Provides all panels belonging to a page and sticky content above it. This
+    is ordered they way they are presented (top to bottom) on the page.
+    
+    Arguments:
+      pageNumber    - page number of the panels to be returned, the current
+                      page if None
+      includeSticky - includes sticky panels in the results if true
+    """
+    
+    returnPage = self._page if pageNumber == None else pageNumber
+    
+    if self._pagePanels:
+      if includeSticky:
+        return self._stickyPanels + self._pagePanels[returnPage]
+      else: return list(self._pagePanels[returnPage])
+    else: return self._stickyPanels if includeSticky else []
+  
+  def getDaemonPanels(self):
+    """
+    Provides thread panels.
+    """
+    
+    threadPanels = []
+    for panelImpl in self.getAllPanels():
+      if isinstance(panelImpl, threading.Thread):
+        threadPanels.append(panelImpl)
+    
+    return threadPanels
+  
+  def getAllPanels(self):
+    """
+    Provides all panels in the interface.
+    """
+    
+    allPanels = list(self._stickyPanels)
+    
+    for page in self._pagePanels:
+      allPanels += list(page)
+    
+    return allPanels
+  
+  def redraw(self, force = True):
+    """
+    Redraws the displayed panel content.
+    
+    Arguments:
+      force - redraws reguardless of if it's needed if true, otherwise ignores
+              the request when there arne't changes to be displayed
+    """
+    
+    force |= self._forceRedraw
+    self._forceRedraw = False
+    
+    currentTime = time.time()
+    if CONFIG["features.refreshRate"] != 0:
+      if self._lastDrawn + CONFIG["features.refreshRate"] <= currentTime:
+        force = True
+    
+    displayPanels = self.getDisplayPanels()
+    
+    occupiedContent = 0
+    for panelImpl in displayPanels:
+      panelImpl.setTop(occupiedContent)
+      occupiedContent += panelImpl.getHeight()
+    
+    # apparently curses may cache display contents unless we explicitely
+    # request a redraw here...
+    # https://trac.torproject.org/projects/tor/ticket/2830#comment:9
+    if force: self._screen.clear()
+    
+    for panelImpl in displayPanels:
+      panelImpl.redraw(force)
+    
+    if force: self._lastDrawn = currentTime
+  
+  def requestRedraw(self):
+    """
+    Requests that all content is redrawn when the interface is next rendered.
+    """
+    
+    self._forceRedraw = True
+  
+  def getLastRedrawTime(self):
+    """
+    Provides the time when the content was last redrawn, zero if the content
+    has never been drawn.
+    """
+    
+    return self._lastDrawn
+  
+  def setMsg(self, msg = None, attr = None, redraw = False):
+    """
+    Sets the message displayed in the interfaces control panel. This uses our
+    default prompt if no arguments are provided.
+    
+    Arguments:
+      msg    - string to be displayed
+      attr   - attribute for the label, normal text if undefined
+      redraw - redraws right away if true, otherwise redraws when display
+               content is next normally drawn
+    """
+    
+    if msg == None:
+      msg = ""
+      
+      if attr == None:
+        if not self._isPaused:
+          msg = "page %i / %i - m: menu, p: pause, h: page help, q: quit" % (self._page + 1, len(self._pagePanels))
+          attr = curses.A_NORMAL
+        else:
+          msg = "Paused"
+          attr = curses.A_STANDOUT
+    
+    controlPanel = self.getPanel("msg")
+    controlPanel.setMessage(msg, attr)
+    
+    if redraw: controlPanel.redraw(True)
+    else: self._forceRedraw = True
+  
+  def getDataDirectory(self):
+    """
+    Provides the path where arm's resources are being placed. The path ends
+    with a slash and is created if it doesn't already exist.
+    """
+    
+    dataDir = os.path.expanduser(CONFIG["startup.dataDirectory"])
+    if not dataDir.endswith("/"): dataDir += "/"
+    if not os.path.exists(dataDir): os.makedirs(dataDir)
+    return dataDir
+  
+  def isDone(self):
+    """
+    True if arm should be terminated, false otherwise.
+    """
+    
+    return self._isDone
+  
+  def quit(self):
+    """
+    Terminates arm after the input is processed. Optionally if we're connected
+    to a arm generated tor instance then this may check if that should be shut
+    down too.
+    """
+    
+    self._isDone = True
+    
+    # check if the torrc has a "ARM_SHUTDOWN" comment flag, if so then shut
+    # down the instance
+    
+    isShutdownFlagPresent = False
+    torrcContents = torConfig.getTorrc().getContents()
+    
+    if torrcContents:
+      for line in torrcContents:
+        if "# ARM_SHUTDOWN" in line:
+          isShutdownFlagPresent = True
+          break
+    
+    if isShutdownFlagPresent:
+      try: torTools.getConn().shutdown()
+      except IOError, exc: cli.popups.showMsg(str(exc), 3, curses.A_BOLD)
+
+def shutdownDaemons():
+  """
+  Stops and joins on worker threads.
+  """
+  
+  # prevents further worker threads from being spawned
+  torTools.NO_SPAWN = True
+  
+  # stops panel daemons
+  control = getController()
+
+  if control:
+    for panelImpl in control.getDaemonPanels(): panelImpl.stop()
+    for panelImpl in control.getDaemonPanels(): panelImpl.join()
+  
+  # joins on stem threads
+  torTools.getConn().close()
+  
+  # joins on utility daemon threads - this might take a moment since the
+  # internal threadpools being joined might be sleeping
+  hostnames.stop()
+  resourceTrackers = sysTools.RESOURCE_TRACKERS.values()
+  resolver = connections.getResolver("tor") if connections.isResolverAlive("tor") else None
+  for tracker in resourceTrackers: tracker.stop()
+  if resolver: resolver.stop()  # sets halt flag (returning immediately)
+  for tracker in resourceTrackers: tracker.join()
+  if resolver: resolver.join()  # joins on halted resolver
+
+def heartbeatCheck(isUnresponsive):
+  """
+  Logs if its been ten seconds since the last BW event.
+  
+  Arguments:
+    isUnresponsive - flag for if we've indicated to be responsive or not
+  """
+  
+  conn = torTools.getConn()
+  lastHeartbeat = conn.controller.get_latest_heartbeat()
+  if conn.isAlive():
+    if not isUnresponsive and (time.time() - lastHeartbeat) >= 10:
+      isUnresponsive = True
+      log.notice("Relay unresponsive (last heartbeat: %s)" % time.ctime(lastHeartbeat))
+    elif isUnresponsive and (time.time() - lastHeartbeat) < 10:
+      # really shouldn't happen (meant Tor froze for a bit)
+      isUnresponsive = False
+      log.notice("Relay resumed")
+  
+  return isUnresponsive
+
+def connResetListener(controller, eventType, _):
+  """
+  Pauses connection resolution when tor's shut down, and resumes with the new
+  pid if started again.
+  """
+  
+  if connections.isResolverAlive("tor"):
+    resolver = connections.getResolver("tor")
+    resolver.setPaused(eventType == State.CLOSED)
+    
+    if eventType in (State.INIT, State.RESET):
+      # Reload the torrc contents. If the torrc panel is present then it will
+      # do this instead since it wants to do validation and redraw _after_ the
+      # new contents are loaded.
+      
+      if getController().getPanel("torrc") == None:
+        torConfig.getTorrc().load(True)
+      
+      try:
+        resolver.setPid(controller.get_pid())
+      except ValueError:
+        pass
+
+def startTorMonitor(startTime):
+  """
+  Initializes the interface and starts the main draw loop.
+  
+  Arguments:
+    startTime - unix time for when arm was started
+  """
+  
+  # attempts to fetch the tor pid, warning if unsuccessful (this is needed for
+  # checking its resource usage, among other things)
+  conn = torTools.getConn()
+  torPid = conn.controller.get_pid(None)
+  
+  if not torPid and conn.isAlive():
+    log.warn("Unable to determine Tor's pid. Some information, like its resource usage will be unavailable.")
+  
+  # adds events needed for arm functionality to the torTools REQ_EVENTS
+  # mapping (they're then included with any setControllerEvents call, and log
+  # a more helpful error if unavailable)
+  
+  torTools.REQ_EVENTS["BW"] = "bandwidth graph won't function"
+  
+  if not CONFIG["startup.blindModeEnabled"]:
+    # The DisableDebuggerAttachment will prevent our connection panel from really
+    # functioning. It'll have circuits, but little else. If this is the case then
+    # notify the user and tell them what they can do to fix it.
+    
+    if conn.getOption("DisableDebuggerAttachment", None) == "1":
+      log.notice("Tor is preventing system utilities like netstat and lsof from working. This means that arm can't provide you with connection information. You can change this by adding 'DisableDebuggerAttachment 0' to your torrc and restarting tor. For more information see...\nhttps://trac.torproject.org/3313")
+      connections.getResolver("tor").setPaused(True)
+    else:
+      torTools.REQ_EVENTS["CIRC"] = "may cause issues in identifying client connections"
+      
+      # Configures connection resoultions. This is paused/unpaused according to
+      # if Tor's connected or not.
+      conn.addStatusListener(connResetListener)
+      
+      if torPid:
+        # use the tor pid to help narrow connection results
+        torCmdName = sysTools.getProcessName(torPid, "tor")
+        connections.getResolver(torCmdName, torPid, "tor")
+      else:
+        # constructs singleton resolver and, if tor isn't connected, initizes
+        # it to be paused
+        connections.getResolver("tor").setPaused(not conn.isAlive())
+      
+      # hack to display a better (arm specific) notice if all resolvers fail
+      connections.RESOLVER_FINAL_FAILURE_MSG = "We were unable to use any of your system's resolvers to get tor's connections. This is fine, but means that the connections page will be empty. This is usually permissions related so if you would like to fix this then run arm with the same user as tor (ie, \"sudo -u <tor user> arm\")."
+  
+  # provides a notice about any event types tor supports but arm doesn't
+  missingEventTypes = cli.logPanel.getMissingEventTypes()
+  
+  if missingEventTypes:
+    pluralLabel = "s" if len(missingEventTypes) > 1 else ""
+    log.info("arm doesn't recognize the following event type%s: %s (log 'UNKNOWN' events to see them)" % (pluralLabel, ", ".join(missingEventTypes)))
+  
+  try:
+    curses.wrapper(drawTorMonitor, startTime)
+  except UnboundLocalError, exc:
+    if os.environ['TERM'] != 'xterm':
+      shutdownDaemons()
+      print 'Unknown $TERM: (%s)' % os.environ['TERM']
+      print 'Either update your terminfo database or run arm using "TERM=xterm arm".'
+      print
+    else:
+      raise exc
+  except KeyboardInterrupt:
+    # Skip printing stack trace in case of keyboard interrupt. The
+    # HALT_ACTIVITY attempts to prevent daemons from triggering a curses redraw
+    # (which would leave the user's terminal in a screwed up state). There is
+    # still a tiny timing issue here (after the exception but before the flag
+    # is set) but I've never seen it happen in practice.
+    
+    panel.HALT_ACTIVITY = True
+    shutdownDaemons()
+
+def drawTorMonitor(stdscr, startTime):
+  """
+  Main draw loop context.
+  
+  Arguments:
+    stdscr    - curses window
+    startTime - unix time for when arm was started
+  """
+  
+  initController(stdscr, startTime)
+  control = getController()
+  
+  # provides notice about any unused config keys
+  for key in conf.get_config("arm").unused_keys():
+    log.notice("Unused configuration entry: %s" % key)
+  
+  # tells daemon panels to start
+  for panelImpl in control.getDaemonPanels(): panelImpl.start()
+  
+  # allows for background transparency
+  try: curses.use_default_colors()
+  except curses.error: pass
+  
+  # makes the cursor invisible
+  try: curses.curs_set(0)
+  except curses.error: pass
+  
+  # logs the initialization time
+  log.info("arm started (initialization took %0.3f seconds)" % (time.time() - startTime))
+  
+  # main draw loop
+  overrideKey = None     # uses this rather than waiting on user input
+  isUnresponsive = False # flag for heartbeat responsiveness check
+  
+  while not control.isDone():
+    displayPanels = control.getDisplayPanels()
+    isUnresponsive = heartbeatCheck(isUnresponsive)
+    
+    # sets panel visability
+    for panelImpl in control.getAllPanels():
+      panelImpl.setVisible(panelImpl in displayPanels)
+    
+    # redraws the interface if it's needed
+    control.redraw(False)
+    stdscr.refresh()
+    
+    # wait for user keyboard input until timeout, unless an override was set
+    if overrideKey:
+      key, overrideKey = overrideKey, None
+    else:
+      curses.halfdelay(CONFIG["features.redrawRate"] * 10)
+      key = stdscr.getch()
+    
+    if key == curses.KEY_RIGHT:
+      control.nextPage()
+    elif key == curses.KEY_LEFT:
+      control.prevPage()
+    elif key == ord('p') or key == ord('P'):
+      control.setPaused(not control.isPaused())
+    elif key == ord('m') or key == ord('M'):
+      cli.menu.menu.showMenu()
+    elif key == ord('q') or key == ord('Q'):
+      # provides prompt to confirm that arm should exit
+      if CONFIG["features.confirmQuit"]:
+        msg = "Are you sure (q again to confirm)?"
+        confirmationKey = cli.popups.showMsg(msg, attr = curses.A_BOLD)
+        quitConfirmed = confirmationKey in (ord('q'), ord('Q'))
+      else: quitConfirmed = True
+      
+      if quitConfirmed: control.quit()
+    elif key == ord('x') or key == ord('X'):
+      # provides prompt to confirm that arm should issue a sighup
+      msg = "This will reset Tor's internal state. Are you sure (x again to confirm)?"
+      confirmationKey = cli.popups.showMsg(msg, attr = curses.A_BOLD)
+      
+      if confirmationKey in (ord('x'), ord('X')):
+        try: torTools.getConn().reload()
+        except IOError, exc:
+          log.error("Error detected when reloading tor: %s" % sysTools.getFileErrorMsg(exc))
+    elif key == ord('h') or key == ord('H'):
+      overrideKey = cli.popups.showHelpPopup()
+    elif key == ord('l') - 96:
+      # force redraw when ctrl+l is pressed
+      control.redraw(True)
+    else:
+      for panelImpl in displayPanels:
+        isKeystrokeConsumed = panelImpl.handleKey(key)
+        if isKeystrokeConsumed: break
+  
+  shutdownDaemons()
+
diff --git a/arm/cli/graphing/__init__.py b/arm/cli/graphing/__init__.py
new file mode 100644
index 0000000..2dddaa3
--- /dev/null
+++ b/arm/cli/graphing/__init__.py
@@ -0,0 +1,6 @@
+"""
+Graphing panel resources.
+"""
+
+__all__ = ["graphPanel", "bandwidthStats", "connStats", "resourceStats"]
+
diff --git a/arm/cli/graphing/bandwidthStats.py b/arm/cli/graphing/bandwidthStats.py
new file mode 100644
index 0000000..935e23d
--- /dev/null
+++ b/arm/cli/graphing/bandwidthStats.py
@@ -0,0 +1,430 @@
+"""
+Tracks bandwidth usage of the tor process, expanding to include accounting
+stats if they're set.
+"""
+
+import time
+import curses
+
+import cli.controller
+
+from cli.graphing import graphPanel
+from util import torTools, uiTools
+
+from stem.control import State
+from stem.util import conf, log, str_tools, system
+
+def conf_handler(key, value):
+  if key == "features.graph.bw.accounting.rate":
+    return max(1, value)
+
+CONFIG = conf.config_dict("arm", {
+  "features.graph.bw.transferInBytes": False,
+  "features.graph.bw.accounting.show": True,
+  "features.graph.bw.accounting.rate": 10,
+  "features.graph.bw.accounting.isTimeLong": False,
+}, conf_handler)
+
+DL_COLOR, UL_COLOR = "green", "cyan"
+
+# width at which panel abandons placing optional stats (avg and total) with
+# header in favor of replacing the x-axis label
+COLLAPSE_WIDTH = 135
+
+# valid keys for the accountingInfo mapping
+ACCOUNTING_ARGS = ("status", "resetTime", "read", "written", "readLimit", "writtenLimit")
+
+PREPOPULATE_SUCCESS_MSG = "Read the last day of bandwidth history from the state file"
+PREPOPULATE_FAILURE_MSG = "Unable to prepopulate bandwidth information (%s)"
+
+class BandwidthStats(graphPanel.GraphStats):
+  """
+  Uses tor BW events to generate bandwidth usage graph.
+  """
+  
+  def __init__(self, isPauseBuffer=False):
+    graphPanel.GraphStats.__init__(self)
+    
+    # stats prepopulated from tor's state file
+    self.prepopulatePrimaryTotal = 0
+    self.prepopulateSecondaryTotal = 0
+    self.prepopulateTicks = 0
+    
+    # accounting data (set by _updateAccountingInfo method)
+    self.accountingLastUpdated = 0
+    self.accountingInfo = dict([(arg, "") for arg in ACCOUNTING_ARGS])
+    
+    # listens for tor reload (sighup) events which can reset the bandwidth
+    # rate/burst and if tor's using accounting
+    conn = torTools.getConn()
+    self._titleStats, self.isAccounting = [], False
+    if not isPauseBuffer: self.resetListener(conn.getController(), State.INIT, None) # initializes values
+    conn.addStatusListener(self.resetListener)
+    
+    # Initialized the bandwidth totals to the values reported by Tor. This
+    # uses a controller options introduced in ticket 2345:
+    # https://trac.torproject.org/projects/tor/ticket/2345
+    # 
+    # further updates are still handled via BW events to avoid unnecessary
+    # GETINFO requests.
+    
+    self.initialPrimaryTotal = 0
+    self.initialSecondaryTotal = 0
+    
+    readTotal = conn.getInfo("traffic/read", None)
+    if readTotal and readTotal.isdigit():
+      self.initialPrimaryTotal = int(readTotal) / 1024 # Bytes -> KB
+    
+    writeTotal = conn.getInfo("traffic/written", None)
+    if writeTotal and writeTotal.isdigit():
+      self.initialSecondaryTotal = int(writeTotal) / 1024 # Bytes -> KB
+  
+  def clone(self, newCopy=None):
+    if not newCopy: newCopy = BandwidthStats(True)
+    newCopy.accountingLastUpdated = self.accountingLastUpdated
+    newCopy.accountingInfo = self.accountingInfo
+    
+    # attributes that would have been initialized from calling the resetListener
+    newCopy.isAccounting = self.isAccounting
+    newCopy._titleStats = self._titleStats
+    
+    return graphPanel.GraphStats.clone(self, newCopy)
+  
+  def resetListener(self, controller, eventType, _):
+    # updates title parameters and accounting status if they changed
+    self._titleStats = []     # force reset of title
+    self.new_desc_event(None) # updates title params
+    
+    if eventType in (State.INIT, State.RESET) and CONFIG["features.graph.bw.accounting.show"]:
+      isAccountingEnabled = controller.get_info('accounting/enabled', None) == '1'
+      
+      if isAccountingEnabled != self.isAccounting:
+        self.isAccounting = isAccountingEnabled
+        
+        # redraws the whole screen since our height changed
+        cli.controller.getController().redraw()
+    
+    # redraws to reflect changes (this especially noticeable when we have
+    # accounting and shut down since it then gives notice of the shutdown)
+    if self._graphPanel and self.isSelected: self._graphPanel.redraw(True)
+  
+  def prepopulateFromState(self):
+    """
+    Attempts to use tor's state file to prepopulate values for the 15 minute
+    interval via the BWHistoryReadValues/BWHistoryWriteValues values. This
+    returns True if successful and False otherwise.
+    """
+    
+    # checks that this is a relay (if ORPort is unset, then skip)
+    conn = torTools.getConn()
+    orPort = conn.getOption("ORPort", None)
+    if orPort == "0": return
+    
+    # gets the uptime (using the same parameters as the header panel to take
+    # advantage of caching)
+    # TODO: stem dropped system caching support so we'll need to think of
+    # something else
+    uptime = None
+    queryPid = conn.controller.get_pid(None)
+    if queryPid:
+      queryParam = ["%cpu", "rss", "%mem", "etime"]
+      queryCmd = "ps -p %s -o %s" % (queryPid, ",".join(queryParam))
+      psCall = system.call(queryCmd, None)
+      
+      if psCall and len(psCall) == 2:
+        stats = psCall[1].strip().split()
+        if len(stats) == 4: uptime = stats[3]
+    
+    # checks if tor has been running for at least a day, the reason being that
+    # the state tracks a day's worth of data and this should only prepopulate
+    # results associated with this tor instance
+    if not uptime or not "-" in uptime:
+      msg = PREPOPULATE_FAILURE_MSG % "insufficient uptime"
+      log.notice(msg)
+      return False
+    
+    # get the user's data directory (usually '~/.tor')
+    dataDir = conn.getOption("DataDirectory", None)
+    if not dataDir:
+      msg = PREPOPULATE_FAILURE_MSG % "data directory not found"
+      log.notice(msg)
+      return False
+    
+    # attempt to open the state file
+    try: stateFile = open("%s%s/state" % (conn.getPathPrefix(), dataDir), "r")
+    except IOError:
+      msg = PREPOPULATE_FAILURE_MSG % "unable to read the state file"
+      log.notice(msg)
+      return False
+    
+    # get the BWHistory entries (ordered oldest to newest) and number of
+    # intervals since last recorded
+    bwReadEntries, bwWriteEntries = None, None
+    missingReadEntries, missingWriteEntries = None, None
+    
+    # converts from gmt to local with respect to DST
+    tz_offset = time.altzone if time.localtime()[8] else time.timezone
+    
+    for line in stateFile:
+      line = line.strip()
+      
+      # According to the rep_hist_update_state() function the BWHistory*Ends
+      # correspond to the start of the following sampling period. Also, the
+      # most recent values of BWHistory*Values appear to be an incremental
+      # counter for the current sampling period. Hence, offsets are added to
+      # account for both.
+      
+      if line.startswith("BWHistoryReadValues"):
+        bwReadEntries = line[20:].split(",")
+        bwReadEntries = [int(entry) / 1024.0 / 900 for entry in bwReadEntries]
+        bwReadEntries.pop()
+      elif line.startswith("BWHistoryWriteValues"):
+        bwWriteEntries = line[21:].split(",")
+        bwWriteEntries = [int(entry) / 1024.0 / 900 for entry in bwWriteEntries]
+        bwWriteEntries.pop()
+      elif line.startswith("BWHistoryReadEnds"):
+        lastReadTime = time.mktime(time.strptime(line[18:], "%Y-%m-%d %H:%M:%S")) - tz_offset
+        lastReadTime -= 900
+        missingReadEntries = int((time.time() - lastReadTime) / 900)
+      elif line.startswith("BWHistoryWriteEnds"):
+        lastWriteTime = time.mktime(time.strptime(line[19:], "%Y-%m-%d %H:%M:%S")) - tz_offset
+        lastWriteTime -= 900
+        missingWriteEntries = int((time.time() - lastWriteTime) / 900)
+    
+    if not bwReadEntries or not bwWriteEntries or not lastReadTime or not lastWriteTime:
+      msg = PREPOPULATE_FAILURE_MSG % "bandwidth stats missing from state file"
+      log.notice(msg)
+      return False
+    
+    # fills missing entries with the last value
+    bwReadEntries += [bwReadEntries[-1]] * missingReadEntries
+    bwWriteEntries += [bwWriteEntries[-1]] * missingWriteEntries
+    
+    # crops starting entries so they're the same size
+    entryCount = min(len(bwReadEntries), len(bwWriteEntries), self.maxCol)
+    bwReadEntries = bwReadEntries[len(bwReadEntries) - entryCount:]
+    bwWriteEntries = bwWriteEntries[len(bwWriteEntries) - entryCount:]
+    
+    # gets index for 15-minute interval
+    intervalIndex = 0
+    for indexEntry in graphPanel.UPDATE_INTERVALS:
+      if indexEntry[1] == 900: break
+      else: intervalIndex += 1
+    
+    # fills the graphing parameters with state information
+    for i in range(entryCount):
+      readVal, writeVal = bwReadEntries[i], bwWriteEntries[i]
+      
+      self.lastPrimary, self.lastSecondary = readVal, writeVal
+      
+      self.prepopulatePrimaryTotal += readVal * 900
+      self.prepopulateSecondaryTotal += writeVal * 900
+      self.prepopulateTicks += 900
+      
+      self.primaryCounts[intervalIndex].insert(0, readVal)
+      self.secondaryCounts[intervalIndex].insert(0, writeVal)
+    
+    self.maxPrimary[intervalIndex] = max(self.primaryCounts)
+    self.maxSecondary[intervalIndex] = max(self.secondaryCounts)
+    del self.primaryCounts[intervalIndex][self.maxCol + 1:]
+    del self.secondaryCounts[intervalIndex][self.maxCol + 1:]
+    
+    msg = PREPOPULATE_SUCCESS_MSG
+    missingSec = time.time() - min(lastReadTime, lastWriteTime)
+    if missingSec: msg += " (%s is missing)" % str_tools.get_time_label(missingSec, 0, True)
+    log.notice(msg)
+    
+    return True
+  
+  def bandwidth_event(self, event):
+    if self.isAccounting and self.isNextTickRedraw():
+      if time.time() - self.accountingLastUpdated >= CONFIG["features.graph.bw.accounting.rate"]:
+        self._updateAccountingInfo()
+    
+    # scales units from B to KB for graphing
+    self._processEvent(event.read / 1024.0, event.written / 1024.0)
+  
+  def draw(self, panel, width, height):
+    # line of the graph's x-axis labeling
+    labelingLine = graphPanel.GraphStats.getContentHeight(self) + panel.graphHeight - 2
+    
+    # if display is narrow, overwrites x-axis labels with avg / total stats
+    if width <= COLLAPSE_WIDTH:
+      # clears line
+      panel.addstr(labelingLine, 0, " " * width)
+      graphCol = min((width - 10) / 2, self.maxCol)
+      
+      primaryFooter = "%s, %s" % (self._getAvgLabel(True), self._getTotalLabel(True))
+      secondaryFooter = "%s, %s" % (self._getAvgLabel(False), self._getTotalLabel(False))
+      
+      panel.addstr(labelingLine, 1, primaryFooter, uiTools.getColor(self.getColor(True)))
+      panel.addstr(labelingLine, graphCol + 6, secondaryFooter, uiTools.getColor(self.getColor(False)))
+    
+    # provides accounting stats if enabled
+    if self.isAccounting:
+      if torTools.getConn().isAlive():
+        status = self.accountingInfo["status"]
+        
+        hibernateColor = "green"
+        if status == "soft": hibernateColor = "yellow"
+        elif status == "hard": hibernateColor = "red"
+        elif status == "":
+          # failed to be queried
+          status, hibernateColor = "unknown", "red"
+        
+        panel.addstr(labelingLine + 2, 0, "Accounting (", curses.A_BOLD)
+        panel.addstr(labelingLine + 2, 12, status, curses.A_BOLD | uiTools.getColor(hibernateColor))
+        panel.addstr(labelingLine + 2, 12 + len(status), ")", curses.A_BOLD)
+        
+        resetTime = self.accountingInfo["resetTime"]
+        if not resetTime: resetTime = "unknown"
+        panel.addstr(labelingLine + 2, 35, "Time to reset: %s" % resetTime)
+        
+        used, total = self.accountingInfo["read"], self.accountingInfo["readLimit"]
+        if used and total:
+          panel.addstr(labelingLine + 3, 2, "%s / %s" % (used, total), uiTools.getColor(self.getColor(True)))
+        
+        used, total = self.accountingInfo["written"], self.accountingInfo["writtenLimit"]
+        if used and total:
+          panel.addstr(labelingLine + 3, 37, "%s / %s" % (used, total), uiTools.getColor(self.getColor(False)))
+      else:
+        panel.addstr(labelingLine + 2, 0, "Accounting:", curses.A_BOLD)
+        panel.addstr(labelingLine + 2, 12, "Connection Closed...")
+  
+  def getTitle(self, width):
+    stats = list(self._titleStats)
+    
+    while True:
+      if not stats: return "Bandwidth:"
+      else:
+        label = "Bandwidth (%s):" % ", ".join(stats)
+        
+        if len(label) > width: del stats[-1]
+        else: return label
+  
+  def getHeaderLabel(self, width, isPrimary):
+    graphType = "Download" if isPrimary else "Upload"
+    stats = [""]
+    
+    # if wide then avg and total are part of the header, otherwise they're on
+    # the x-axis
+    if width * 2 > COLLAPSE_WIDTH:
+      stats = [""] * 3
+      stats[1] = "- %s" % self._getAvgLabel(isPrimary)
+      stats[2] = ", %s" % self._getTotalLabel(isPrimary)
+    
+    stats[0] = "%-14s" % ("%s/sec" % str_tools.get_size_label((self.lastPrimary if isPrimary else self.lastSecondary) * 1024, 1, False, CONFIG["features.graph.bw.transferInBytes"]))
+    
+    # drops label's components if there's not enough space
+    labeling = graphType + " (" + "".join(stats).strip() + "):"
+    while len(labeling) >= width:
+      if len(stats) > 1:
+        del stats[-1]
+        labeling = graphType + " (" + "".join(stats).strip() + "):"
+      else:
+        labeling = graphType + ":"
+        break
+    
+    return labeling
+  
+  def getColor(self, isPrimary):
+    return DL_COLOR if isPrimary else UL_COLOR
+  
+  def getContentHeight(self):
+    baseHeight = graphPanel.GraphStats.getContentHeight(self)
+    return baseHeight + 3 if self.isAccounting else baseHeight
+  
+  def new_desc_event(self, event):
+    # updates self._titleStats with updated values
+    conn = torTools.getConn()
+    if not conn.isAlive(): return # keep old values
+    
+    myFingerprint = conn.getInfo("fingerprint", None)
+    if not self._titleStats or not myFingerprint or (event and myFingerprint in event.idlist):
+      stats = []
+      bwRate = conn.getMyBandwidthRate()
+      bwBurst = conn.getMyBandwidthBurst()
+      bwObserved = conn.getMyBandwidthObserved()
+      bwMeasured = conn.getMyBandwidthMeasured()
+      labelInBytes = CONFIG["features.graph.bw.transferInBytes"]
+      
+      if bwRate and bwBurst:
+        bwRateLabel = str_tools.get_size_label(bwRate, 1, False, labelInBytes)
+        bwBurstLabel = str_tools.get_size_label(bwBurst, 1, False, labelInBytes)
+        
+        # if both are using rounded values then strip off the ".0" decimal
+        if ".0" in bwRateLabel and ".0" in bwBurstLabel:
+          bwRateLabel = bwRateLabel.replace(".0", "")
+          bwBurstLabel = bwBurstLabel.replace(".0", "")
+        
+        stats.append("limit: %s/s" % bwRateLabel)
+        stats.append("burst: %s/s" % bwBurstLabel)
+      
+      # Provide the observed bandwidth either if the measured bandwidth isn't
+      # available or if the measured bandwidth is the observed (this happens
+      # if there isn't yet enough bandwidth measurements).
+      if bwObserved and (not bwMeasured or bwMeasured == bwObserved):
+        stats.append("observed: %s/s" % str_tools.get_size_label(bwObserved, 1, False, labelInBytes))
+      elif bwMeasured:
+        stats.append("measured: %s/s" % str_tools.get_size_label(bwMeasured, 1, False, labelInBytes))
+      
+      self._titleStats = stats
+  
+  def _getAvgLabel(self, isPrimary):
+    total = self.primaryTotal if isPrimary else self.secondaryTotal
+    total += self.prepopulatePrimaryTotal if isPrimary else self.prepopulateSecondaryTotal
+    return "avg: %s/sec" % str_tools.get_size_label((total / max(1, self.tick + self.prepopulateTicks)) * 1024, 1, False, CONFIG["features.graph.bw.transferInBytes"])
+  
+  def _getTotalLabel(self, isPrimary):
+    total = self.primaryTotal if isPrimary else self.secondaryTotal
+    total += self.initialPrimaryTotal if isPrimary else self.initialSecondaryTotal
+    return "total: %s" % str_tools.get_size_label(total * 1024, 1)
+  
+  def _updateAccountingInfo(self):
+    """
+    Updates mapping used for accounting info. This includes the following keys:
+    status, resetTime, read, written, readLimit, writtenLimit
+    
+    Any failed lookups result in a mapping to an empty string.
+    """
+    
+    conn = torTools.getConn()
+    queried = dict([(arg, "") for arg in ACCOUNTING_ARGS])
+    queried["status"] = conn.getInfo("accounting/hibernating", None)
+    
+    # provides a nicely formatted reset time
+    endInterval = conn.getInfo("accounting/interval-end", None)
+    if endInterval:
+      # converts from gmt to local with respect to DST
+      if time.localtime()[8]: tz_offset = time.altzone
+      else: tz_offset = time.timezone
+      
+      sec = time.mktime(time.strptime(endInterval, "%Y-%m-%d %H:%M:%S")) - time.time() - tz_offset
+      if CONFIG["features.graph.bw.accounting.isTimeLong"]:
+        queried["resetTime"] = ", ".join(str_tools.get_time_labels(sec, True))
+      else:
+        days = sec / 86400
+        sec %= 86400
+        hours = sec / 3600
+        sec %= 3600
+        minutes = sec / 60
+        sec %= 60
+        queried["resetTime"] = "%i:%02i:%02i:%02i" % (days, hours, minutes, sec)
+    
+    # number of bytes used and in total for the accounting period
+    used = conn.getInfo("accounting/bytes", None)
+    left = conn.getInfo("accounting/bytes-left", None)
+    
+    if used and left:
+      usedComp, leftComp = used.split(" "), left.split(" ")
+      read, written = int(usedComp[0]), int(usedComp[1])
+      readLeft, writtenLeft = int(leftComp[0]), int(leftComp[1])
+      
+      queried["read"] = str_tools.get_size_label(read)
+      queried["written"] = str_tools.get_size_label(written)
+      queried["readLimit"] = str_tools.get_size_label(read + readLeft)
+      queried["writtenLimit"] = str_tools.get_size_label(written + writtenLeft)
+    
+    self.accountingInfo = queried
+    self.accountingLastUpdated = time.time()
+
diff --git a/arm/cli/graphing/connStats.py b/arm/cli/graphing/connStats.py
new file mode 100644
index 0000000..88ed44a
--- /dev/null
+++ b/arm/cli/graphing/connStats.py
@@ -0,0 +1,60 @@
+"""
+Tracks stats concerning tor's current connections.
+"""
+
+from cli.graphing import graphPanel
+from util import connections, torTools
+
+from stem.control import State
+
+class ConnStats(graphPanel.GraphStats):
+  """
+  Tracks number of connections, counting client and directory connections as 
+  outbound. Control connections are excluded from counts.
+  """
+  
+  def __init__(self):
+    graphPanel.GraphStats.__init__(self)
+    
+    # listens for tor reload (sighup) events which can reset the ports tor uses
+    conn = torTools.getConn()
+    self.orPort, self.dirPort, self.controlPort = "0", "0", "0"
+    self.resetListener(conn.getController(), State.INIT, None) # initialize port values
+    conn.addStatusListener(self.resetListener)
+  
+  def clone(self, newCopy=None):
+    if not newCopy: newCopy = ConnStats()
+    return graphPanel.GraphStats.clone(self, newCopy)
+  
+  def resetListener(self, controller, eventType, _):
+    if eventType in (State.INIT, State.RESET):
+      self.orPort = controller.get_conf("ORPort", "0")
+      self.dirPort = controller.get_conf("DirPort", "0")
+      self.controlPort = controller.get_conf("ControlPort", "0")
+  
+  def eventTick(self):
+    """
+    Fetches connection stats from cached information.
+    """
+    
+    inboundCount, outboundCount = 0, 0
+    
+    for entry in connections.getResolver("tor").getConnections():
+      localPort = entry[1]
+      if localPort in (self.orPort, self.dirPort): inboundCount += 1
+      elif localPort == self.controlPort: pass # control connection
+      else: outboundCount += 1
+    
+    self._processEvent(inboundCount, outboundCount)
+  
+  def getTitle(self, width):
+    return "Connection Count:"
+  
+  def getHeaderLabel(self, width, isPrimary):
+    avg = (self.primaryTotal if isPrimary else self.secondaryTotal) / max(1, self.tick)
+    if isPrimary: return "Inbound (%s, avg: %s):" % (self.lastPrimary, avg)
+    else: return "Outbound (%s, avg: %s):" % (self.lastSecondary, avg)
+  
+  def getRefreshRate(self):
+    return 5
+
diff --git a/arm/cli/graphing/graphPanel.py b/arm/cli/graphing/graphPanel.py
new file mode 100644
index 0000000..a0a348a
--- /dev/null
+++ b/arm/cli/graphing/graphPanel.py
@@ -0,0 +1,518 @@
+"""
+Flexible panel for presenting bar graphs for a variety of stats. This panel is
+just concerned with the rendering of information, which is actually collected
+and stored by implementations of the GraphStats interface. Panels are made up
+of a title, followed by headers and graphs for two sets of stats. For
+instance...
+
+Bandwidth (cap: 5 MB, burst: 10 MB):
+Downloaded (0.0 B/sec):           Uploaded (0.0 B/sec):
+  34                                30
+                            *                                 *
+                    **  *   *                          *      **
+      *   *  *      ** **   **          ***  **       ** **   **
+     *********      ******  ******     *********      ******  ******
+   0 ************ ****************   0 ************ ****************
+         25s  50   1m   1.6  2.0           25s  50   1m   1.6  2.0
+"""
+
+import copy
+import curses
+
+import cli.popups
+import cli.controller
+
+import stem.control
+
+from util import panel, torTools, uiTools
+
+from stem.util import conf, enum, str_tools
+
+# time intervals at which graphs can be updated
+UPDATE_INTERVALS = [("each second", 1), ("5 seconds", 5),   ("30 seconds", 30),
+                    ("minutely", 60),   ("15 minute", 900), ("30 minute", 1800),
+                    ("hourly", 3600),   ("daily", 86400)]
+
+DEFAULT_CONTENT_HEIGHT = 4 # space needed for labeling above and below the graph
+DEFAULT_COLOR_PRIMARY, DEFAULT_COLOR_SECONDARY = "green", "cyan"
+MIN_GRAPH_HEIGHT = 1
+
+# enums for graph bounds:
+#   Bounds.GLOBAL_MAX - global maximum (highest value ever seen)
+#   Bounds.LOCAL_MAX - local maximum (highest value currently on the graph)
+#   Bounds.TIGHT - local maximum and minimum
+Bounds = enum.Enum("GLOBAL_MAX", "LOCAL_MAX", "TIGHT")
+
+WIDE_LABELING_GRAPH_COL = 50  # minimum graph columns to use wide spacing for x-axis labels
+
+def conf_handler(key, value):
+  if key == "features.graph.height":
+    return max(MIN_GRAPH_HEIGHT, value)
+  elif key == "features.graph.maxWidth":
+    return max(1, value)
+  elif key == "features.graph.interval":
+    return max(0, min(len(UPDATE_INTERVALS) - 1, value))
+  elif key == "features.graph.bound":
+    return max(0, min(2, value))
+
+# used for setting defaults when initializing GraphStats and GraphPanel instances
+CONFIG = conf.config_dict("arm", {
+  "features.graph.height": 7,
+  "features.graph.interval": 0,
+  "features.graph.bound": 1,
+  "features.graph.maxWidth": 150,
+  "features.graph.showIntermediateBounds": True,
+}, conf_handler)
+
+class GraphStats:
+  """
+  Module that's expected to update dynamically and provide attributes to be
+  graphed. Up to two graphs (a 'primary' and 'secondary') can be displayed at a
+  time and timescale parameters use the labels defined in UPDATE_INTERVALS.
+  """
+  
+  def __init__(self):
+    """
+    Initializes parameters needed to present a graph.
+    """
+    
+    # panel to be redrawn when updated (set when added to GraphPanel)
+    self._graphPanel = None
+    self.isSelected = False
+    self.isPauseBuffer = False
+    
+    # tracked stats
+    self.tick = 0                                 # number of processed events
+    self.lastPrimary, self.lastSecondary = 0, 0   # most recent registered stats
+    self.primaryTotal, self.secondaryTotal = 0, 0 # sum of all stats seen
+    
+    # timescale dependent stats
+    self.maxCol = CONFIG["features.graph.maxWidth"]
+    self.maxPrimary, self.maxSecondary = {}, {}
+    self.primaryCounts, self.secondaryCounts = {}, {}
+    
+    for i in range(len(UPDATE_INTERVALS)):
+      # recent rates for graph
+      self.maxPrimary[i] = 0
+      self.maxSecondary[i] = 0
+      
+      # historic stats for graph, first is accumulator
+      # iterative insert needed to avoid making shallow copies (nasty, nasty gotcha)
+      self.primaryCounts[i] = (self.maxCol + 1) * [0]
+      self.secondaryCounts[i] = (self.maxCol + 1) * [0]
+    
+    # tracks BW events
+    torTools.getConn().addEventListener(self.bandwidth_event, stem.control.EventType.BW)
+  
+  def clone(self, newCopy=None):
+    """
+    Provides a deep copy of this instance.
+    
+    Arguments:
+      newCopy - base instance to build copy off of
+    """
+    
+    if not newCopy: newCopy = GraphStats()
+    newCopy.tick = self.tick
+    newCopy.lastPrimary = self.lastPrimary
+    newCopy.lastSecondary = self.lastSecondary
+    newCopy.primaryTotal = self.primaryTotal
+    newCopy.secondaryTotal = self.secondaryTotal
+    newCopy.maxPrimary = dict(self.maxPrimary)
+    newCopy.maxSecondary = dict(self.maxSecondary)
+    newCopy.primaryCounts = copy.deepcopy(self.primaryCounts)
+    newCopy.secondaryCounts = copy.deepcopy(self.secondaryCounts)
+    newCopy.isPauseBuffer = True
+    return newCopy
+  
+  def eventTick(self):
+    """
+    Called when it's time to process another event. All graphs use tor BW
+    events to keep in sync with each other (this happens once a second).
+    """
+    
+    pass
+  
+  def isNextTickRedraw(self):
+    """
+    Provides true if the following tick (call to _processEvent) will result in
+    being redrawn.
+    """
+    
+    if self._graphPanel and self.isSelected and not self._graphPanel.isPaused():
+      # use the minimum of the current refresh rate and the panel's
+      updateRate = UPDATE_INTERVALS[self._graphPanel.updateInterval][1]
+      return (self.tick + 1) % min(updateRate, self.getRefreshRate()) == 0
+    else: return False
+  
+  def getTitle(self, width):
+    """
+    Provides top label.
+    """
+    
+    return ""
+  
+  def getHeaderLabel(self, width, isPrimary):
+    """
+    Provides labeling presented at the top of the graph.
+    """
+    
+    return ""
+  
+  def getColor(self, isPrimary):
+    """
+    Provides the color to be used for the graph and stats.
+    """
+    
+    return DEFAULT_COLOR_PRIMARY if isPrimary else DEFAULT_COLOR_SECONDARY
+  
+  def getContentHeight(self):
+    """
+    Provides the height content should take up (not including the graph).
+    """
+    
+    return DEFAULT_CONTENT_HEIGHT
+  
+  def getRefreshRate(self):
+    """
+    Provides the number of ticks between when the stats have new values to be
+    redrawn.
+    """
+    
+    return 1
+  
+  def isVisible(self):
+    """
+    True if the stat has content to present, false if it should be hidden.
+    """
+    
+    return True
+  
+  def draw(self, panel, width, height):
+    """
+    Allows for any custom drawing monitor wishes to append.
+    """
+    
+    pass
+  
+  def bandwidth_event(self, event):
+    if not self.isPauseBuffer: self.eventTick()
+  
+  def _processEvent(self, primary, secondary):
+    """
+    Includes new stats in graphs and notifies associated GraphPanel of changes.
+    """
+    
+    isRedraw = self.isNextTickRedraw()
+    
+    self.lastPrimary, self.lastSecondary = primary, secondary
+    self.primaryTotal += primary
+    self.secondaryTotal += secondary
+    
+    # updates for all time intervals
+    self.tick += 1
+    for i in range(len(UPDATE_INTERVALS)):
+      lable, timescale = UPDATE_INTERVALS[i]
+      
+      self.primaryCounts[i][0] += primary
+      self.secondaryCounts[i][0] += secondary
+      
+      if self.tick % timescale == 0:
+        self.maxPrimary[i] = max(self.maxPrimary[i], self.primaryCounts[i][0] / timescale)
+        self.primaryCounts[i][0] /= timescale
+        self.primaryCounts[i].insert(0, 0)
+        del self.primaryCounts[i][self.maxCol + 1:]
+        
+        self.maxSecondary[i] = max(self.maxSecondary[i], self.secondaryCounts[i][0] / timescale)
+        self.secondaryCounts[i][0] /= timescale
+        self.secondaryCounts[i].insert(0, 0)
+        del self.secondaryCounts[i][self.maxCol + 1:]
+    
+    if isRedraw and self._graphPanel: self._graphPanel.redraw(True)
+
+class GraphPanel(panel.Panel):
+  """
+  Panel displaying a graph, drawing statistics from custom GraphStats
+  implementations.
+  """
+  
+  def __init__(self, stdscr):
+    panel.Panel.__init__(self, stdscr, "graph", 0)
+    self.updateInterval = CONFIG["features.graph.interval"]
+    self.bounds = list(Bounds)[CONFIG["features.graph.bound"]]
+    self.graphHeight = CONFIG["features.graph.height"]
+    self.currentDisplay = None    # label of the stats currently being displayed
+    self.stats = {}               # available stats (mappings of label -> instance)
+    self.setPauseAttr("stats")
+  
+  def getUpdateInterval(self):
+    """
+    Provides the rate that we update the graph at.
+    """
+    
+    return self.updateInterval
+  
+  def setUpdateInterval(self, updateInterval):
+    """
+    Sets the rate that we update the graph at.
+    
+    Arguments:
+      updateInterval - update time enum
+    """
+    
+    self.updateInterval = updateInterval
+  
+  def getBoundsType(self):
+    """
+    Provides the type of graph bounds used.
+    """
+    
+    return self.bounds
+  
+  def setBoundsType(self, boundsType):
+    """
+    Sets the type of graph boundaries we use.
+    
+    Arguments:
+      boundsType - graph bounds enum
+    """
+    
+    self.bounds = boundsType
+  
+  def getHeight(self):
+    """
+    Provides the height requested by the currently displayed GraphStats (zero
+    if hidden).
+    """
+    
+    if self.currentDisplay and self.stats[self.currentDisplay].isVisible():
+      return self.stats[self.currentDisplay].getContentHeight() + self.graphHeight
+    else: return 0
+  
+  def setGraphHeight(self, newGraphHeight):
+    """
+    Sets the preferred height used for the graph (restricted to the
+    MIN_GRAPH_HEIGHT minimum).
+    
+    Arguments:
+      newGraphHeight - new height for the graph
+    """
+    
+    self.graphHeight = max(MIN_GRAPH_HEIGHT, newGraphHeight)
+  
+  def resizeGraph(self):
+    """
+    Prompts for user input to resize the graph panel. Options include...
+      down arrow - grow graph
+      up arrow - shrink graph
+      enter / space - set size
+    """
+    
+    control = cli.controller.getController()
+    
+    panel.CURSES_LOCK.acquire()
+    try:
+      while True:
+        msg = "press the down/up to resize the graph, and enter when done"
+        control.setMsg(msg, curses.A_BOLD, True)
+        curses.cbreak()
+        key = control.getScreen().getch()
+        
+        if key == curses.KEY_DOWN:
+          # don't grow the graph if it's already consuming the whole display
+          # (plus an extra line for the graph/log gap)
+          maxHeight = self.parent.getmaxyx()[0] - self.top
+          currentHeight = self.getHeight()
+          
+          if currentHeight < maxHeight + 1:
+            self.setGraphHeight(self.graphHeight + 1)
+        elif key == curses.KEY_UP:
+          self.setGraphHeight(self.graphHeight - 1)
+        elif uiTools.isSelectionKey(key): break
+        
+        control.redraw()
+    finally:
+      control.setMsg()
+      panel.CURSES_LOCK.release()
+  
+  def handleKey(self, key):
+    isKeystrokeConsumed = True
+    if key == ord('r') or key == ord('R'):
+      self.resizeGraph()
+    elif key == ord('b') or key == ord('B'):
+      # uses the next boundary type
+      self.bounds = Bounds.next(self.bounds)
+      self.redraw(True)
+    elif key == ord('s') or key == ord('S'):
+      # provides a menu to pick the graphed stats
+      availableStats = self.stats.keys()
+      availableStats.sort()
+      
+      # uses sorted, camel cased labels for the options
+      options = ["None"]
+      for label in availableStats:
+        words = label.split()
+        options.append(" ".join(word[0].upper() + word[1:] for word in words))
+      
+      if self.currentDisplay:
+        initialSelection = availableStats.index(self.currentDisplay) + 1
+      else: initialSelection = 0
+      
+      selection = cli.popups.showMenu("Graphed Stats:", options, initialSelection)
+      
+      # applies new setting
+      if selection == 0: self.setStats(None)
+      elif selection != -1: self.setStats(availableStats[selection - 1])
+    elif key == ord('i') or key == ord('I'):
+      # provides menu to pick graph panel update interval
+      options = [label for (label, _) in UPDATE_INTERVALS]
+      selection = cli.popups.showMenu("Update Interval:", options, self.updateInterval)
+      if selection != -1: self.updateInterval = selection
+    else: isKeystrokeConsumed = False
+    
+    return isKeystrokeConsumed
+  
+  def getHelp(self):
+    if self.currentDisplay: graphedStats = self.currentDisplay
+    else: graphedStats = "none"
+    
+    options = []
+    options.append(("r", "resize graph", None))
+    options.append(("s", "graphed stats", graphedStats))
+    options.append(("b", "graph bounds", self.bounds.lower()))
+    options.append(("i", "graph update interval", UPDATE_INTERVALS[self.updateInterval][0]))
+    return options
+  
+  def draw(self, width, height):
+    """ Redraws graph panel """
+    
+    if self.currentDisplay:
+      param = self.getAttr("stats")[self.currentDisplay]
+      graphCol = min((width - 10) / 2, param.maxCol)
+      
+      primaryColor = uiTools.getColor(param.getColor(True))
+      secondaryColor = uiTools.getColor(param.getColor(False))
+      
+      if self.isTitleVisible(): self.addstr(0, 0, param.getTitle(width), curses.A_STANDOUT)
+      
+      # top labels
+      left, right = param.getHeaderLabel(width / 2, True), param.getHeaderLabel(width / 2, False)
+      if left: self.addstr(1, 0, left, curses.A_BOLD | primaryColor)
+      if right: self.addstr(1, graphCol + 5, right, curses.A_BOLD | secondaryColor)
+      
+      # determines max/min value on the graph
+      if self.bounds == Bounds.GLOBAL_MAX:
+        primaryMaxBound = int(param.maxPrimary[self.updateInterval])
+        secondaryMaxBound = int(param.maxSecondary[self.updateInterval])
+      else:
+        # both Bounds.LOCAL_MAX and Bounds.TIGHT use local maxima
+        if graphCol < 2:
+          # nothing being displayed
+          primaryMaxBound, secondaryMaxBound = 0, 0
+        else:
+          primaryMaxBound = int(max(param.primaryCounts[self.updateInterval][1:graphCol + 1]))
+          secondaryMaxBound = int(max(param.secondaryCounts[self.updateInterval][1:graphCol + 1]))
+      
+      primaryMinBound = secondaryMinBound = 0
+      if self.bounds == Bounds.TIGHT:
+        primaryMinBound = int(min(param.primaryCounts[self.updateInterval][1:graphCol + 1]))
+        secondaryMinBound = int(min(param.secondaryCounts[self.updateInterval][1:graphCol + 1]))
+        
+        # if the max = min (ie, all values are the same) then use zero lower
+        # bound so a graph is still displayed
+        if primaryMinBound == primaryMaxBound: primaryMinBound = 0
+        if secondaryMinBound == secondaryMaxBound: secondaryMinBound = 0
+      
+      # displays upper and lower bounds
+      self.addstr(2, 0, "%4i" % primaryMaxBound, primaryColor)
+      self.addstr(self.graphHeight + 1, 0, "%4i" % primaryMinBound, primaryColor)
+      
+      self.addstr(2, graphCol + 5, "%4i" % secondaryMaxBound, secondaryColor)
+      self.addstr(self.graphHeight + 1, graphCol + 5, "%4i" % secondaryMinBound, secondaryColor)
+      
+      # displays intermediate bounds on every other row
+      if CONFIG["features.graph.showIntermediateBounds"]:
+        ticks = (self.graphHeight - 3) / 2
+        for i in range(ticks):
+          row = self.graphHeight - (2 * i) - 3
+          if self.graphHeight % 2 == 0 and i >= (ticks / 2): row -= 1
+          
+          if primaryMinBound != primaryMaxBound:
+            primaryVal = (primaryMaxBound - primaryMinBound) * (self.graphHeight - row - 1) / (self.graphHeight - 1)
+            if not primaryVal in (primaryMinBound, primaryMaxBound): self.addstr(row + 2, 0, "%4i" % primaryVal, primaryColor)
+          
+          if secondaryMinBound != secondaryMaxBound:
+            secondaryVal = (secondaryMaxBound - secondaryMinBound) * (self.graphHeight - row - 1) / (self.graphHeight - 1)
+            if not secondaryVal in (secondaryMinBound, secondaryMaxBound): self.addstr(row + 2, graphCol + 5, "%4i" % secondaryVal, secondaryColor)
+      
+      # creates bar graph (both primary and secondary)
+      for col in range(graphCol):
+        colCount = int(param.primaryCounts[self.updateInterval][col + 1]) - primaryMinBound
+        colHeight = min(self.graphHeight, self.graphHeight * colCount / (max(1, primaryMaxBound) - primaryMinBound))
+        for row in range(colHeight): self.addstr(self.graphHeight + 1 - row, col + 5, " ", curses.A_STANDOUT | primaryColor)
+        
+        colCount = int(param.secondaryCounts[self.updateInterval][col + 1]) - secondaryMinBound
+        colHeight = min(self.graphHeight, self.graphHeight * colCount / (max(1, secondaryMaxBound) - secondaryMinBound))
+        for row in range(colHeight): self.addstr(self.graphHeight + 1 - row, col + graphCol + 10, " ", curses.A_STANDOUT | secondaryColor)
+      
+      # bottom labeling of x-axis
+      intervalSec = 1 # seconds per labeling
+      for i in range(len(UPDATE_INTERVALS)):
+        if i == self.updateInterval: intervalSec = UPDATE_INTERVALS[i][1]
+      
+      intervalSpacing = 10 if graphCol >= WIDE_LABELING_GRAPH_COL else 5
+      unitsLabel, decimalPrecision = None, 0
+      for i in range((graphCol - 4) / intervalSpacing):
+        loc = (i + 1) * intervalSpacing
+        timeLabel = str_tools.get_time_label(loc * intervalSec, decimalPrecision)
+        
+        if not unitsLabel: unitsLabel = timeLabel[-1]
+        elif unitsLabel != timeLabel[-1]:
+          # upped scale so also up precision of future measurements
+          unitsLabel = timeLabel[-1]
+          decimalPrecision += 1
+        else:
+          # if constrained on space then strips labeling since already provided
+          timeLabel = timeLabel[:-1]
+        
+        self.addstr(self.graphHeight + 2, 4 + loc, timeLabel, primaryColor)
+        self.addstr(self.graphHeight + 2, graphCol + 10 + loc, timeLabel, secondaryColor)
+        
+      param.draw(self, width, height) # allows current stats to modify the display
+  
+  def addStats(self, label, stats):
+    """
+    Makes GraphStats instance available in the panel.
+    """
+    
+    stats._graphPanel = self
+    self.stats[label] = stats
+  
+  def getStats(self):
+    """
+    Provides the currently selected stats label.
+    """
+    
+    return self.currentDisplay
+  
+  def setStats(self, label):
+    """
+    Sets the currently displayed stats instance, hiding panel if None.
+    """
+    
+    if label != self.currentDisplay:
+      if self.currentDisplay: self.stats[self.currentDisplay].isSelected = False
+      
+      if not label:
+        self.currentDisplay = None
+      elif label in self.stats.keys():
+        self.currentDisplay = label
+        self.stats[self.currentDisplay].isSelected = True
+      else: raise ValueError("Unrecognized stats label: %s" % label)
+  
+  def copyAttr(self, attr):
+    if attr == "stats":
+      # uses custom clone method to copy GraphStats instances
+      return dict([(key, self.stats[key].clone()) for key in self.stats])
+    else: return panel.Panel.copyAttr(self, attr)
+
diff --git a/arm/cli/graphing/resourceStats.py b/arm/cli/graphing/resourceStats.py
new file mode 100644
index 0000000..c0f18c9
--- /dev/null
+++ b/arm/cli/graphing/resourceStats.py
@@ -0,0 +1,53 @@
+"""
+Tracks the system resource usage (cpu and memory) of the tor process.
+"""
+
+from cli.graphing import graphPanel
+from util import sysTools, torTools
+
+from stem.util import str_tools
+
+class ResourceStats(graphPanel.GraphStats):
+  """
+  System resource usage tracker.
+  """
+  
+  def __init__(self):
+    graphPanel.GraphStats.__init__(self)
+    self.queryPid = torTools.getConn().controller.get_pid(None)
+  
+  def clone(self, newCopy=None):
+    if not newCopy: newCopy = ResourceStats()
+    return graphPanel.GraphStats.clone(self, newCopy)
+  
+  def getTitle(self, width):
+    return "System Resources:"
+  
+  def getHeaderLabel(self, width, isPrimary):
+    avg = (self.primaryTotal if isPrimary else self.secondaryTotal) / max(1, self.tick)
+    lastAmount = self.lastPrimary if isPrimary else self.lastSecondary
+    
+    if isPrimary:
+      return "CPU (%0.1f%%, avg: %0.1f%%):" % (lastAmount, avg)
+    else:
+      # memory sizes are converted from MB to B before generating labels
+      usageLabel = str_tools.get_size_label(lastAmount * 1048576, 1)
+      avgLabel = str_tools.get_size_label(avg * 1048576, 1)
+      return "Memory (%s, avg: %s):" % (usageLabel, avgLabel)
+  
+  def eventTick(self):
+    """
+    Fetch the cached measurement of resource usage from the ResourceTracker.
+    """
+    
+    primary, secondary = 0, 0
+    if self.queryPid:
+      resourceTracker = sysTools.getResourceTracker(self.queryPid, True)
+      
+      if resourceTracker and not resourceTracker.lastQueryFailed():
+        primary, _, secondary, _ = resourceTracker.getResourceUsage()
+        primary *= 100        # decimal percentage to whole numbers
+        secondary /= 1048576  # translate size to MB so axis labels are short
+    
+    self._processEvent(primary, secondary)
+
diff --git a/arm/cli/headerPanel.py b/arm/cli/headerPanel.py
new file mode 100644
index 0000000..f1704dc
--- /dev/null
+++ b/arm/cli/headerPanel.py
@@ -0,0 +1,590 @@
+"""
+Top panel for every page, containing basic system and tor related information.
+If there's room available then this expands to present its information in two
+columns, otherwise it's laid out as follows:
+  arm - <hostname> (<os> <sys/version>)         Tor <tor/version> (<new, old, recommended, etc>)
+  <nickname> - <address>:<orPort>, [Dir Port: <dirPort>, ]Control Port (<open, password, cookie>): <controlPort>
+  cpu: <cpu%> mem: <mem> (<mem%>) uid: <uid> uptime: <upmin>:<upsec>
+  fingerprint: <fingerprint>
+
+Example:
+  arm - odin (Linux 2.6.24-24-generic)         Tor 0.2.1.19 (recommended)
+  odin - 76.104.132.98:9001, Dir Port: 9030, Control Port (cookie): 9051
+  cpu: 14.6%    mem: 42 MB (4.2%)    pid: 20060   uptime: 48:27
+  fingerprint: BDAD31F6F318E0413833E8EBDA956F76E4D66788
+"""
+
+import os
+import time
+import curses
+import threading
+
+import stem
+import stem.connection
+
+from stem.control import State, Controller
+from stem.util import conf, str_tools
+
+import starter
+import cli.popups
+import cli.controller
+
+from util import panel, sysTools, torTools, uiTools
+
+from stem.util import log, str_tools
+
+# minimum width for which panel attempts to double up contents (two columns to
+# better use screen real estate)
+MIN_DUAL_COL_WIDTH = 141
+
+FLAG_COLORS = {"Authority": "white",  "BadExit": "red",     "BadDirectory": "red",    "Exit": "cyan",
+               "Fast": "yellow",      "Guard": "green",     "HSDir": "magenta",       "Named": "blue",
+               "Stable": "blue",      "Running": "yellow",  "Unnamed": "magenta",     "Valid": "green",
+               "V2Dir": "cyan",       "V3Dir": "white"}
+
+VERSION_STATUS_COLORS = {"new": "blue", "new in series": "blue", "obsolete": "red", "recommended": "green",  
+                         "old": "red",  "unrecommended": "red",  "unknown": "cyan"}
+
+CONFIG = conf.config_dict("arm", {
+  "startup.interface.ipAddress": "127.0.0.1",
+  "startup.interface.port": 9051,
+  "startup.interface.socket": "/var/run/tor/control",
+  "features.showFdUsage": False,
+})
+
+class HeaderPanel(panel.Panel, threading.Thread):
+  """
+  Top area contenting tor settings and system information. Stats are stored in
+  the vals mapping, keys including:
+    tor/  version, versionStatus, nickname, orPort, dirPort, controlPort,
+          socketPath, exitPolicy, isAuthPassword (bool), isAuthCookie (bool),
+          orListenAddr, *address, *fingerprint, *flags, pid, startTime,
+          *fdUsed, fdLimit, isFdLimitEstimate
+    sys/  hostname, os, version
+    stat/ *%torCpu, *%armCpu, *rss, *%mem
+  
+  * volatile parameter that'll be reset on each update
+  """
+  
+  def __init__(self, stdscr, startTime):
+    panel.Panel.__init__(self, stdscr, "header", 0)
+    threading.Thread.__init__(self)
+    self.setDaemon(True)
+    
+    self._isTorConnected = torTools.getConn().isAlive()
+    self._lastUpdate = -1       # time the content was last revised
+    self._halt = False          # terminates thread if true
+    self._cond = threading.Condition()  # used for pausing the thread
+    
+    # Time when the panel was paused or tor was stopped. This is used to
+    # freeze the uptime statistic (uptime increments normally when None).
+    self._haltTime = None
+    
+    # The last arm cpu usage sampling taken. This is a tuple of the form:
+    # (total arm cpu time, sampling timestamp)
+    # 
+    # The initial cpu total should be zero. However, at startup the cpu time
+    # in practice is often greater than the real time causing the initially
+    # reported cpu usage to be over 100% (which shouldn't be possible on
+    # single core systems).
+    # 
+    # Setting the initial cpu total to the value at this panel's init tends to
+    # give smoother results (staying in the same ballpark as the second
+    # sampling) so fudging the numbers this way for now.
+    
+    self._armCpuSampling = (sum(os.times()[:3]), startTime)
+    
+    # Last sampling received from the ResourceTracker, used to detect when it
+    # changes.
+    self._lastResourceFetch = -1
+    
+    # flag to indicate if we've already given file descriptor warnings
+    self._isFdSixtyPercentWarned = False
+    self._isFdNinetyPercentWarned = False
+    
+    self.vals = {}
+    self.valsLock = threading.RLock()
+    self._update(True)
+    
+    # listens for tor reload (sighup) events
+    torTools.getConn().addStatusListener(self.resetListener)
+  
+  def getHeight(self):
+    """
+    Provides the height of the content, which is dynamically determined by the
+    panel's maximum width.
+    """
+    
+    isWide = self.getParent().getmaxyx()[1] >= MIN_DUAL_COL_WIDTH
+    if self.vals["tor/orPort"]: return 4 if isWide else 6
+    else: return 3 if isWide else 4
+  
+  def sendNewnym(self):
+    """
+    Requests a new identity and provides a visual queue.
+    """
+    
+    torTools.getConn().sendNewnym()
+    
+    # If we're wide then the newnym label in this panel will give an
+    # indication that the signal was sent. Otherwise use a msg.
+    isWide = self.getParent().getmaxyx()[1] >= MIN_DUAL_COL_WIDTH
+    if not isWide: cli.popups.showMsg("Requesting a new identity", 1)
+  
+  def handleKey(self, key):
+    isKeystrokeConsumed = True
+    
+    if key in (ord('n'), ord('N')) and torTools.getConn().isNewnymAvailable():
+      self.sendNewnym()
+    elif key in (ord('r'), ord('R')) and not self._isTorConnected:
+      controller = None
+      allowPortConnection, allowSocketConnection, _ = starter.allowConnectionTypes()
+      
+      if os.path.exists(CONFIG["startup.interface.socket"]) and allowSocketConnection:
+        try:
+          # TODO: um... what about passwords?
+          controller = Controller.from_socket_file(CONFIG["startup.interface.socket"])
+          controller.authenticate()
+        except (IOError, stem.SocketError), exc:
+          controller = None
+          
+          if not allowPortConnection:
+            cli.popups.showMsg("Unable to reconnect (%s)" % exc, 3)
+      elif not allowPortConnection:
+        cli.popups.showMsg("Unable to reconnect (socket '%s' doesn't exist)" % CONFIG["startup.interface.socket"], 3)
+      
+      if not controller and allowPortConnection:
+        # TODO: This has diverged from starter.py's connection, for instance it
+        # doesn't account for relative cookie paths or multiple authentication
+        # methods. We can't use the starter.py's connection function directly
+        # due to password prompts, but we could certainly make this mess more
+        # manageable.
+        
+        try:
+          ctlAddr, ctlPort = CONFIG["startup.interface.ipAddress"], CONFIG["startup.interface.port"]
+          controller = Controller.from_port(ctlAddr, ctlPort)
+          
+          try:
+            controller.authenticate()
+          except stem.connection.MissingPassword:
+            controller.authenticate(authValue) # already got the password above
+        except Exception, exc:
+          controller = None
+      
+      if controller:
+        torTools.getConn().init(controller)
+        log.notice("Reconnected to Tor's control port")
+        cli.popups.showMsg("Tor reconnected", 1)
+    else: isKeystrokeConsumed = False
+    
+    return isKeystrokeConsumed
+  
+  def draw(self, width, height):
+    self.valsLock.acquire()
+    isWide = width + 1 >= MIN_DUAL_COL_WIDTH
+    
+    # space available for content
+    if isWide:
+      leftWidth = max(width / 2, 77)
+      rightWidth = width - leftWidth
+    else: leftWidth = rightWidth = width
+    
+    # Line 1 / Line 1 Left (system and tor version information)
+    sysNameLabel = "arm - %s" % self.vals["sys/hostname"]
+    contentSpace = min(leftWidth, 40)
+    
+    if len(sysNameLabel) + 10 <= contentSpace:
+      sysTypeLabel = "%s %s" % (self.vals["sys/os"], self.vals["sys/version"])
+      sysTypeLabel = uiTools.cropStr(sysTypeLabel, contentSpace - len(sysNameLabel) - 3, 4)
+      self.addstr(0, 0, "%s (%s)" % (sysNameLabel, sysTypeLabel))
+    else:
+      self.addstr(0, 0, uiTools.cropStr(sysNameLabel, contentSpace))
+    
+    contentSpace = leftWidth - 43
+    if 7 + len(self.vals["tor/version"]) + len(self.vals["tor/versionStatus"]) <= contentSpace:
+      if self.vals["tor/version"] != "Unknown":
+        versionColor = VERSION_STATUS_COLORS[self.vals["tor/versionStatus"]] if \
+            self.vals["tor/versionStatus"] in VERSION_STATUS_COLORS else "white"
+        labelPrefix = "Tor %s (" % self.vals["tor/version"]
+        self.addstr(0, 43, labelPrefix)
+        self.addstr(0, 43 + len(labelPrefix), self.vals["tor/versionStatus"], uiTools.getColor(versionColor))
+        self.addstr(0, 43 + len(labelPrefix) + len(self.vals["tor/versionStatus"]), ")")
+    elif 11 <= contentSpace:
+      self.addstr(0, 43, uiTools.cropStr("Tor %s" % self.vals["tor/version"], contentSpace, 4))
+    
+    # Line 2 / Line 2 Left (tor ip/port information)
+    x, includeControlPort = 0, True
+    if self.vals["tor/orPort"]:
+      myAddress = "Unknown"
+      if self.vals["tor/orListenAddr"]: myAddress = self.vals["tor/orListenAddr"]
+      elif self.vals["tor/address"]: myAddress = self.vals["tor/address"]
+      
+      # acting as a relay (we can assume certain parameters are set
+      dirPortLabel = ", Dir Port: %s" % self.vals["tor/dirPort"] if self.vals["tor/dirPort"] != "0" else ""
+      for label in (self.vals["tor/nickname"], " - " + myAddress, ":" + self.vals["tor/orPort"], dirPortLabel):
+        if x + len(label) <= leftWidth:
+          self.addstr(1, x, label)
+          x += len(label)
+        else: break
+    else:
+      # non-relay (client only)
+      if self._isTorConnected:
+        self.addstr(1, x, "Relaying Disabled", uiTools.getColor("cyan"))
+        x += 17
+      else:
+        statusTime = torTools.getConn().controller.get_latest_heartbeat()
+        
+        if statusTime:
+          statusTimeLabel = time.strftime("%H:%M %m/%d/%Y, ", time.localtime(statusTime))
+        else: statusTimeLabel = "" # never connected to tor
+        
+        self.addstr(1, x, "Tor Disconnected", curses.A_BOLD | uiTools.getColor("red"))
+        self.addstr(1, x + 16, " (%spress r to reconnect)" % statusTimeLabel)
+        x += 39 + len(statusTimeLabel)
+        includeControlPort = False
+    
+    if includeControlPort:
+      if self.vals["tor/controlPort"] == "0":
+        # connected via a control socket
+        self.addstr(1, x, ", Control Socket: %s" % self.vals["tor/socketPath"])
+      else:
+        if self.vals["tor/isAuthPassword"]: authType = "password"
+        elif self.vals["tor/isAuthCookie"]: authType = "cookie"
+        else: authType = "open"
+        
+        if x + 19 + len(self.vals["tor/controlPort"]) + len(authType) <= leftWidth:
+          authColor = "red" if authType == "open" else "green"
+          self.addstr(1, x, ", Control Port (")
+          self.addstr(1, x + 16, authType, uiTools.getColor(authColor))
+          self.addstr(1, x + 16 + len(authType), "): %s" % self.vals["tor/controlPort"])
+        elif x + 16 + len(self.vals["tor/controlPort"]) <= leftWidth:
+          self.addstr(1, 0, ", Control Port: %s" % self.vals["tor/controlPort"])
+    
+    # Line 3 / Line 1 Right (system usage info)
+    y, x = (0, leftWidth) if isWide else (2, 0)
+    if self.vals["stat/rss"] != "0": memoryLabel = str_tools.get_size_label(int(self.vals["stat/rss"]))
+    else: memoryLabel = "0"
+    
+    uptimeLabel = ""
+    if self.vals["tor/startTime"]:
+      if self.isPaused() or not self._isTorConnected:
+        # freeze the uptime when paused or the tor process is stopped
+        uptimeLabel = str_tools.get_short_time_label(self.getPauseTime() - self.vals["tor/startTime"])
+      else:
+        uptimeLabel = str_tools.get_short_time_label(time.time() - self.vals["tor/startTime"])
+    
+    sysFields = ((0, "cpu: %s%% tor, %s%% arm" % (self.vals["stat/%torCpu"], self.vals["stat/%armCpu"])),
+                 (27, "mem: %s (%s%%)" % (memoryLabel, self.vals["stat/%mem"])),
+                 (47, "pid: %s" % (self.vals["tor/pid"] if self._isTorConnected else "")),
+                 (59, "uptime: %s" % uptimeLabel))
+    
+    for (start, label) in sysFields:
+      if start + len(label) <= rightWidth: self.addstr(y, x + start, label)
+      else: break
+    
+    if self.vals["tor/orPort"]:
+      # Line 4 / Line 2 Right (fingerprint, and possibly file descriptor usage)
+      y, x = (1, leftWidth) if isWide else (3, 0)
+      
+      fingerprintLabel = uiTools.cropStr("fingerprint: %s" % self.vals["tor/fingerprint"], width)
+      self.addstr(y, x, fingerprintLabel)
+      
+      # if there's room and we're able to retrieve both the file descriptor
+      # usage and limit then it might be presented
+      if width - x - 59 >= 20 and self.vals["tor/fdUsed"] and self.vals["tor/fdLimit"]:
+        # display file descriptor usage if we're either configured to do so or
+        # running out
+        
+        fdPercent = 100 * self.vals["tor/fdUsed"] / self.vals["tor/fdLimit"]
+        
+        if fdPercent >= 60 or CONFIG["features.showFdUsage"]:
+          fdPercentLabel, fdPercentFormat = "%i%%" % fdPercent, curses.A_NORMAL
+          if fdPercent >= 95:
+            fdPercentFormat = curses.A_BOLD | uiTools.getColor("red")
+          elif fdPercent >= 90:
+            fdPercentFormat = uiTools.getColor("red")
+          elif fdPercent >= 60:
+            fdPercentFormat = uiTools.getColor("yellow")
+          
+          estimateChar = "?" if self.vals["tor/isFdLimitEstimate"] else ""
+          baseLabel = "file desc: %i / %i%s (" % (self.vals["tor/fdUsed"], self.vals["tor/fdLimit"], estimateChar)
+          
+          self.addstr(y, x + 59, baseLabel)
+          self.addstr(y, x + 59 + len(baseLabel), fdPercentLabel, fdPercentFormat)
+          self.addstr(y, x + 59 + len(baseLabel) + len(fdPercentLabel), ")")
+      
+      # Line 5 / Line 3 Left (flags)
+      if self._isTorConnected:
+        y, x = (2 if isWide else 4, 0)
+        self.addstr(y, x, "flags: ")
+        x += 7
+        
+        if len(self.vals["tor/flags"]) > 0:
+          for i in range(len(self.vals["tor/flags"])):
+            flag = self.vals["tor/flags"][i]
+            flagColor = FLAG_COLORS[flag] if flag in FLAG_COLORS.keys() else "white"
+            
+            self.addstr(y, x, flag, curses.A_BOLD | uiTools.getColor(flagColor))
+            x += len(flag)
+            
+            if i < len(self.vals["tor/flags"]) - 1:
+              self.addstr(y, x, ", ")
+              x += 2
+        else:
+          self.addstr(y, x, "none", curses.A_BOLD | uiTools.getColor("cyan"))
+      else:
+        y = 2 if isWide else 4
+        statusTime = torTools.getConn().controller.get_latest_heartbeat()
+        statusTimeLabel = time.strftime("%H:%M %m/%d/%Y", time.localtime(statusTime))
+        self.addstr(y, 0, "Tor Disconnected", curses.A_BOLD | uiTools.getColor("red"))
+        self.addstr(y, 16, " (%s) - press r to reconnect" % statusTimeLabel)
+      
+      # Undisplayed / Line 3 Right (exit policy)
+      if isWide:
+        exitPolicy = self.vals["tor/exitPolicy"]
+        
+        # adds note when default exit policy is appended
+        if exitPolicy == "": exitPolicy = "<default>"
+        elif not exitPolicy.endswith((" *:*", " *")): exitPolicy += ", <default>"
+        
+        self.addstr(2, leftWidth, "exit policy: ")
+        x = leftWidth + 13
+        
+        # color codes accepts to be green, rejects to be red, and default marker to be cyan
+        isSimple = len(exitPolicy) > rightWidth - 13
+        policies = exitPolicy.split(", ")
+        for i in range(len(policies)):
+          policy = policies[i].strip()
+          policyLabel = policy.replace("accept", "").replace("reject", "").strip() if isSimple else policy
+          
+          policyColor = "white"
+          if policy.startswith("accept"): policyColor = "green"
+          elif policy.startswith("reject"): policyColor = "red"
+          elif policy.startswith("<default>"): policyColor = "cyan"
+          
+          self.addstr(2, x, policyLabel, curses.A_BOLD | uiTools.getColor(policyColor))
+          x += len(policyLabel)
+          
+          if i < len(policies) - 1:
+            self.addstr(2, x, ", ")
+            x += 2
+    else:
+      # (Client only) Undisplayed / Line 2 Right (new identity option)
+      if isWide:
+        conn = torTools.getConn()
+        newnymWait = conn.getNewnymWait()
+        
+        msg = "press 'n' for a new identity"
+        if newnymWait > 0:
+          pluralLabel = "s" if newnymWait > 1 else ""
+          msg = "building circuits, available again in %i second%s" % (newnymWait, pluralLabel)
+        
+        self.addstr(1, leftWidth, msg)
+    
+    self.valsLock.release()
+  
+  def getPauseTime(self):
+    """
+    Provides the time Tor stopped if it isn't running. Otherwise this is the
+    time we were last paused.
+    """
+    
+    if self._haltTime: return self._haltTime
+    else: return panel.Panel.getPauseTime(self)
+  
+  def run(self):
+    """
+    Keeps stats updated, checking for new information at a set rate.
+    """
+    
+    lastDraw = time.time() - 1
+    while not self._halt:
+      currentTime = time.time()
+      
+      if self.isPaused() or currentTime - lastDraw < 1 or not self._isTorConnected:
+        self._cond.acquire()
+        if not self._halt: self._cond.wait(0.2)
+        self._cond.release()
+      else:
+        # Update the volatile attributes (cpu, memory, flags, etc) if we have
+        # a new resource usage sampling (the most dynamic stat) or its been
+        # twenty seconds since last fetched (so we still refresh occasionally
+        # when resource fetches fail).
+        # 
+        # Otherwise, just redraw the panel to change the uptime field.
+        
+        isChanged = False
+        if self.vals["tor/pid"]:
+          resourceTracker = sysTools.getResourceTracker(self.vals["tor/pid"])
+          isChanged = self._lastResourceFetch != resourceTracker.getRunCount()
+        
+        if isChanged or currentTime - self._lastUpdate >= 20:
+          self._update()
+        
+        self.redraw(True)
+        lastDraw += 1
+  
+  def stop(self):
+    """
+    Halts further resolutions and terminates the thread.
+    """
+    
+    self._cond.acquire()
+    self._halt = True
+    self._cond.notifyAll()
+    self._cond.release()
+  
+  def resetListener(self, controller, eventType, _):
+    """
+    Updates static parameters on tor reload (sighup) events.
+    """
+    
+    if eventType in (State.INIT, State.RESET):
+      initialHeight = self.getHeight()
+      self._isTorConnected = True
+      self._haltTime = None
+      self._update(True)
+      
+      if self.getHeight() != initialHeight:
+        # We're toggling between being a relay and client, causing the height
+        # of this panel to change. Redraw all content so we don't get
+        # overlapping content.
+        cli.controller.getController().redraw()
+      else:
+        # just need to redraw ourselves
+        self.redraw(True)
+    elif eventType == State.CLOSED:
+      self._isTorConnected = False
+      self._haltTime = time.time()
+      self._update()
+      self.redraw(True)
+  
+  def _update(self, setStatic=False):
+    """
+    Updates stats in the vals mapping. By default this just revises volatile
+    attributes.
+    
+    Arguments:
+      setStatic - resets all parameters, including relatively static values
+    """
+    
+    self.valsLock.acquire()
+    conn = torTools.getConn()
+    
+    if setStatic:
+      # version is truncated to first part, for instance:
+      # 0.2.2.13-alpha (git-feb8c1b5f67f2c6f) -> 0.2.2.13-alpha
+      self.vals["tor/version"] = conn.getInfo("version", "Unknown").split()[0]
+      self.vals["tor/versionStatus"] = conn.getInfo("status/version/current", "Unknown")
+      self.vals["tor/nickname"] = conn.getOption("Nickname", "")
+      self.vals["tor/orPort"] = conn.getOption("ORPort", "0")
+      self.vals["tor/dirPort"] = conn.getOption("DirPort", "0")
+      self.vals["tor/controlPort"] = conn.getOption("ControlPort", "0")
+      self.vals["tor/socketPath"] = conn.getOption("ControlSocket", "")
+      self.vals["tor/isAuthPassword"] = conn.getOption("HashedControlPassword", None) != None
+      self.vals["tor/isAuthCookie"] = conn.getOption("CookieAuthentication", None) == "1"
+      
+      # orport is reported as zero if unset
+      if self.vals["tor/orPort"] == "0": self.vals["tor/orPort"] = ""
+      
+      # overwrite address if ORListenAddress is set (and possibly orPort too)
+      self.vals["tor/orListenAddr"] = ""
+      listenAddr = conn.getOption("ORListenAddress", None)
+      if listenAddr:
+        if ":" in listenAddr:
+          # both ip and port overwritten
+          self.vals["tor/orListenAddr"] = listenAddr[:listenAddr.find(":")]
+          self.vals["tor/orPort"] = listenAddr[listenAddr.find(":") + 1:]
+        else:
+          self.vals["tor/orListenAddr"] = listenAddr
+      
+      # fetch exit policy (might span over multiple lines)
+      policyEntries = []
+      for exitPolicy in conn.getOption("ExitPolicy", [], True):
+        policyEntries += [policy.strip() for policy in exitPolicy.split(",")]
+      self.vals["tor/exitPolicy"] = ", ".join(policyEntries)
+      
+      # file descriptor limit for the process, if this can't be determined
+      # then the limit is None
+      fdLimit, fdIsEstimate = conn.getMyFileDescriptorLimit()
+      self.vals["tor/fdLimit"] = fdLimit
+      self.vals["tor/isFdLimitEstimate"] = fdIsEstimate
+      
+      # system information
+      unameVals = os.uname()
+      self.vals["sys/hostname"] = unameVals[1]
+      self.vals["sys/os"] = unameVals[0]
+      self.vals["sys/version"] = unameVals[2]
+      
+      self.vals["tor/pid"] = conn.controller.get_pid("")
+      
+      startTime = conn.getStartTime()
+      self.vals["tor/startTime"] = startTime if startTime else ""
+      
+      # reverts volatile parameters to defaults
+      self.vals["tor/fingerprint"] = "Unknown"
+      self.vals["tor/flags"] = []
+      self.vals["tor/fdUsed"] = 0
+      self.vals["stat/%torCpu"] = "0"
+      self.vals["stat/%armCpu"] = "0"
+      self.vals["stat/rss"] = "0"
+      self.vals["stat/%mem"] = "0"
+    
+    # sets volatile parameters
+    # TODO: This can change, being reported by STATUS_SERVER -> EXTERNAL_ADDRESS
+    # events. Introduce caching via torTools?
+    self.vals["tor/address"] = conn.getInfo("address", "")
+    
+    self.vals["tor/fingerprint"] = conn.getInfo("fingerprint", self.vals["tor/fingerprint"])
+    self.vals["tor/flags"] = conn.getMyFlags(self.vals["tor/flags"])
+    
+    # Updates file descriptor usage and logs if the usage is high. If we don't
+    # have a known limit or it's obviously faulty (being lower than our
+    # current usage) then omit file descriptor functionality.
+    if self.vals["tor/fdLimit"]:
+      fdUsed = conn.getMyFileDescriptorUsage()
+      if fdUsed and fdUsed <= self.vals["tor/fdLimit"]: self.vals["tor/fdUsed"] = fdUsed
+      else: self.vals["tor/fdUsed"] = 0
+    
+    if self.vals["tor/fdUsed"] and self.vals["tor/fdLimit"]:
+      fdPercent = 100 * self.vals["tor/fdUsed"] / self.vals["tor/fdLimit"]
+      estimatedLabel = " estimated" if self.vals["tor/isFdLimitEstimate"] else ""
+      msg = "Tor's%s file descriptor usage is at %i%%." % (estimatedLabel, fdPercent)
+      
+      if fdPercent >= 90 and not self._isFdNinetyPercentWarned:
+        self._isFdSixtyPercentWarned, self._isFdNinetyPercentWarned = True, True
+        msg += " If you run out Tor will be unable to continue functioning."
+        log.warn(msg)
+      elif fdPercent >= 60 and not self._isFdSixtyPercentWarned:
+        self._isFdSixtyPercentWarned = True
+        log.notice(msg)
+    
+    # ps or proc derived resource usage stats
+    if self.vals["tor/pid"]:
+      resourceTracker = sysTools.getResourceTracker(self.vals["tor/pid"])
+      
+      if resourceTracker.lastQueryFailed():
+        self.vals["stat/%torCpu"] = "0"
+        self.vals["stat/rss"] = "0"
+        self.vals["stat/%mem"] = "0"
+      else:
+        cpuUsage, _, memUsage, memUsagePercent = resourceTracker.getResourceUsage()
+        self._lastResourceFetch = resourceTracker.getRunCount()
+        self.vals["stat/%torCpu"] = "%0.1f" % (100 * cpuUsage)
+        self.vals["stat/rss"] = str(memUsage)
+        self.vals["stat/%mem"] = "%0.1f" % (100 * memUsagePercent)
+    
+    # determines the cpu time for the arm process (including user and system
+    # time of both the primary and child processes)
+    
+    totalArmCpuTime, currentTime = sum(os.times()[:3]), time.time()
+    armCpuDelta = totalArmCpuTime - self._armCpuSampling[0]
+    armTimeDelta = currentTime - self._armCpuSampling[1]
+    pythonCpuTime = armCpuDelta / armTimeDelta
+    sysCallCpuTime = sysTools.getSysCpuUsage()
+    self.vals["stat/%armCpu"] = "%0.1f" % (100 * (pythonCpuTime + sysCallCpuTime))
+    self._armCpuSampling = (totalArmCpuTime, currentTime)
+    
+    self._lastUpdate = currentTime
+    self.valsLock.release()
+
diff --git a/arm/cli/logPanel.py b/arm/cli/logPanel.py
new file mode 100644
index 0000000..d85144c
--- /dev/null
+++ b/arm/cli/logPanel.py
@@ -0,0 +1,1270 @@
+"""
+Panel providing a chronological log of events its been configured to listen
+for. This provides prepopulation from the log file and supports filtering by
+regular expressions.
+"""
+
+import re
+import os
+import time
+import curses
+import logging
+import threading
+
+import stem
+from stem.control import State
+from stem.response import events
+from stem.util import conf, log, system
+
+import popups
+from version import VERSION
+from util import panel, sysTools, torTools, uiTools
+
+TOR_EVENT_TYPES = {
+  "d": "DEBUG",   "a": "ADDRMAP",          "k": "DESCCHANGED",  "s": "STREAM",
+  "i": "INFO",    "f": "AUTHDIR_NEWDESCS", "g": "GUARD",        "r": "STREAM_BW",
+  "n": "NOTICE",  "h": "BUILDTIMEOUT_SET", "l": "NEWCONSENSUS", "t": "STATUS_CLIENT",
+  "w": "WARN",    "b": "BW",               "m": "NEWDESC",      "u": "STATUS_GENERAL",
+  "e": "ERR",     "c": "CIRC",             "p": "NS",           "v": "STATUS_SERVER",
+                  "j": "CLIENTS_SEEN",     "q": "ORCONN"}
+
+EVENT_LISTING = """        d DEBUG      a ADDRMAP           k DESCCHANGED   s STREAM
+        i INFO       f AUTHDIR_NEWDESCS  g GUARD         r STREAM_BW
+        n NOTICE     h BUILDTIMEOUT_SET  l NEWCONSENSUS  t STATUS_CLIENT
+        w WARN       b BW                m NEWDESC       u STATUS_GENERAL
+        e ERR        c CIRC              p NS            v STATUS_SERVER
+                     j CLIENTS_SEEN      q ORCONN
+          DINWE tor runlevel+            A All Events
+          12345 arm runlevel+            X No Events
+                                         U Unknown Events"""
+
+RUNLEVEL_EVENT_COLOR = {log.DEBUG: "magenta", log.INFO: "blue", log.NOTICE: "green",
+                        log.WARN: "yellow", log.ERR: "red"}
+DAYBREAK_EVENT = "DAYBREAK" # special event for marking when the date changes
+TIMEZONE_OFFSET = time.altzone if time.localtime()[8] else time.timezone
+
+ENTRY_INDENT = 2 # spaces an entry's message is indented after the first line
+
+def conf_handler(key, value):
+  if key == "features.log.maxLinesPerEntry":
+    return max(1, value)
+  elif key == "features.log.prepopulateReadLimit":
+    return max(0, value)
+  elif key == "features.log.maxRefreshRate":
+    return max(10, value)
+  elif key == "cache.logPanel.size":
+    return max(1000, value)
+
+CONFIG = conf.config_dict("arm", {
+  "features.logFile": "",
+  "features.log.showDateDividers": True,
+  "features.log.showDuplicateEntries": False,
+  "features.log.entryDuration": 7,
+  "features.log.maxLinesPerEntry": 6,
+  "features.log.prepopulate": True,
+  "features.log.prepopulateReadLimit": 5000,
+  "features.log.maxRefreshRate": 300,
+  "features.log.regex": [],
+  "cache.logPanel.size": 1000,
+}, conf_handler)
+
+DUPLICATE_MSG = " [%i duplicate%s hidden]"
+
+# The height of the drawn content is estimated based on the last time we redrew
+# the panel. It's chiefly used for scrolling and the bar indicating its
+# position. Letting the estimate be too inaccurate results in a display bug, so
+# redraws the display if it's off by this threshold.
+CONTENT_HEIGHT_REDRAW_THRESHOLD = 3
+
+# static starting portion of common log entries, fetched from the config when
+# needed if None
+COMMON_LOG_MESSAGES = None
+
+# cached values and the arguments that generated it for the getDaybreaks and
+# getDuplicates functions
+CACHED_DAYBREAKS_ARGUMENTS = (None, None) # events, current day
+CACHED_DAYBREAKS_RESULT = None
+CACHED_DUPLICATES_ARGUMENTS = None # events
+CACHED_DUPLICATES_RESULT = None
+
+# duration we'll wait for the deduplication function before giving up (in ms)
+DEDUPLICATION_TIMEOUT = 100
+
+# maximum number of regex filters we'll remember
+MAX_REGEX_FILTERS = 5
+
+def daysSince(timestamp=None):
+  """
+  Provides the number of days since the epoch converted to local time (rounded
+  down).
+  
+  Arguments:
+    timestamp - unix timestamp to convert, current time if undefined
+  """
+  
+  if timestamp == None: timestamp = time.time()
+  return int((timestamp - TIMEZONE_OFFSET) / 86400)
+
+def expandEvents(eventAbbr):
+  """
+  Expands event abbreviations to their full names. Beside mappings provided in
+  TOR_EVENT_TYPES this recognizes the following special events and aliases:
+  U - UKNOWN events
+  A - all events
+  X - no events
+  DINWE - runlevel and higher
+  12345 - arm/stem runlevel and higher (ARM_DEBUG - ARM_ERR)
+  Raises ValueError with invalid input if any part isn't recognized.
+  
+  Examples:
+  "inUt" -> ["INFO", "NOTICE", "UNKNOWN", "STREAM_BW"]
+  "N4" -> ["NOTICE", "WARN", "ERR", "ARM_WARN", "ARM_ERR"]
+  "cfX" -> []
+  
+  Arguments:
+    eventAbbr - flags to be parsed to event types
+  """
+  
+  expandedEvents, invalidFlags = set(), ""
+  
+  for flag in eventAbbr:
+    if flag == "A":
+      armRunlevels = ["ARM_" + runlevel for runlevel in log.Runlevel]
+      expandedEvents = set(list(TOR_EVENT_TYPES) + armRunlevels + ["UNKNOWN"])
+      break
+    elif flag == "X":
+      expandedEvents = set()
+      break
+    elif flag in "DINWE12345":
+      # all events for a runlevel and higher
+      if flag in "D1": runlevelIndex = 1
+      elif flag in "I2": runlevelIndex = 2
+      elif flag in "N3": runlevelIndex = 3
+      elif flag in "W4": runlevelIndex = 4
+      elif flag in "E5": runlevelIndex = 5
+      
+      if flag in "DINWE":
+        runlevelSet = [runlevel for runlevel in list(log.Runlevel)[runlevelIndex:]]
+        expandedEvents = expandedEvents.union(set(runlevelSet))
+      elif flag in "12345":
+        runlevelSet = ["ARM_" + runlevel for runlevel in list(log.Runlevel)[runlevelIndex:]]
+        expandedEvents = expandedEvents.union(set(runlevelSet))
+    elif flag == "U":
+      expandedEvents.add("UNKNOWN")
+    elif flag in TOR_EVENT_TYPES:
+      expandedEvents.add(TOR_EVENT_TYPES[flag])
+    else:
+      invalidFlags += flag
+  
+  if invalidFlags: raise ValueError(invalidFlags)
+  else: return expandedEvents
+
+def getMissingEventTypes():
+  """
+  Provides the event types the current tor connection supports but arm
+  doesn't. This provides an empty list if no event types are missing, and None
+  if the GETINFO query fails.
+  """
+  
+  torEventTypes = torTools.getConn().getInfo("events/names", None)
+  
+  if torEventTypes:
+    torEventTypes = torEventTypes.split(" ")
+    armEventTypes = TOR_EVENT_TYPES.values()
+    return [event for event in torEventTypes if not event in armEventTypes]
+  else: return None # GETINFO call failed
+
+def loadLogMessages():
+  """
+  Fetches a mapping of common log messages to their runlevels from the config.
+  """
+  
+  global COMMON_LOG_MESSAGES
+  armConf = conf.get_config("arm")
+  
+  COMMON_LOG_MESSAGES = {}
+  for confKey in armConf.keys():
+    if confKey.startswith("msg."):
+      eventType = confKey[4:].upper()
+      messages = armConf.get(confKey, [])
+      COMMON_LOG_MESSAGES[eventType] = messages
+
+def getLogFileEntries(runlevels, readLimit = None, addLimit = None):
+  """
+  Parses tor's log file for past events matching the given runlevels, providing
+  a list of log entries (ordered newest to oldest). Limiting the number of read
+  entries is suggested to avoid parsing everything from logs in the GB and TB
+  range.
+  
+  Arguments:
+    runlevels - event types (DEBUG - ERR) to be returned
+    readLimit - max lines of the log file that'll be read (unlimited if None)
+    addLimit  - maximum entries to provide back (unlimited if None)
+  """
+  
+  startTime = time.time()
+  if not runlevels: return []
+  
+  # checks tor's configuration for the log file's location (if any exists)
+  loggingTypes, loggingLocation = None, None
+  for loggingEntry in torTools.getConn().getOption("Log", [], True):
+    # looks for an entry like: notice file /var/log/tor/notices.log
+    entryComp = loggingEntry.split()
+    
+    if entryComp[1] == "file":
+      loggingTypes, loggingLocation = entryComp[0], entryComp[2]
+      break
+  
+  if not loggingLocation: return []
+  
+  # includes the prefix for tor paths
+  loggingLocation = torTools.getConn().getPathPrefix() + loggingLocation
+  
+  # if the runlevels argument is a superset of the log file then we can
+  # limit the read contents to the addLimit
+  runlevels = list(log.Runlevel)
+  loggingTypes = loggingTypes.upper()
+  if addLimit and (not readLimit or readLimit > addLimit):
+    if "-" in loggingTypes:
+      divIndex = loggingTypes.find("-")
+      sIndex = runlevels.index(loggingTypes[:divIndex])
+      eIndex = runlevels.index(loggingTypes[divIndex+1:])
+      logFileRunlevels = runlevels[sIndex:eIndex+1]
+    else:
+      sIndex = runlevels.index(loggingTypes)
+      logFileRunlevels = runlevels[sIndex:]
+    
+    # checks if runlevels we're reporting are a superset of the file's contents
+    isFileSubset = True
+    for runlevelType in logFileRunlevels:
+      if runlevelType not in runlevels:
+        isFileSubset = False
+        break
+    
+    if isFileSubset: readLimit = addLimit
+  
+  # tries opening the log file, cropping results to avoid choking on huge logs
+  lines = []
+  try:
+    if readLimit:
+      lines = system.call("tail -n %i %s" % (readLimit, loggingLocation))
+      if not lines: raise IOError()
+    else:
+      logFile = open(loggingLocation, "r")
+      lines = logFile.readlines()
+      logFile.close()
+  except IOError:
+    log.warn("Unable to read tor's log file: %s" % loggingLocation)
+  
+  if not lines: return []
+  
+  loggedEvents = []
+  currentUnixTime, currentLocalTime = time.time(), time.localtime()
+  for i in range(len(lines) - 1, -1, -1):
+    line = lines[i]
+    
+    # entries look like:
+    # Jul 15 18:29:48.806 [notice] Parsing GEOIP file.
+    lineComp = line.split()
+    
+    # Checks that we have all the components we expect. This could happen if
+    # we're either not parsing a tor log or in weird edge cases (like being
+    # out of disk space)
+    
+    if len(lineComp) < 4: continue
+    
+    eventType = lineComp[3][1:-1].upper()
+    
+    if eventType in runlevels:
+      # converts timestamp to unix time
+      timestamp = " ".join(lineComp[:3])
+      
+      # strips the decimal seconds
+      if "." in timestamp: timestamp = timestamp[:timestamp.find(".")]
+      
+      # Ignoring wday and yday since they aren't used.
+      #
+      # Pretend the year is 2012, because 2012 is a leap year, and parsing a
+      # date with strptime fails if Feb 29th is passed without a year that's
+      # actually a leap year. We can't just use the current year, because we
+      # might be parsing old logs which didn't get rotated.
+      #
+      # https://trac.torproject.org/projects/tor/ticket/5265
+      
+      timestamp = "2012 " + timestamp
+      eventTimeComp = list(time.strptime(timestamp, "%Y %b %d %H:%M:%S"))
+      eventTimeComp[8] = currentLocalTime.tm_isdst
+      eventTime = time.mktime(eventTimeComp) # converts local to unix time
+      
+      # The above is gonna be wrong if the logs are for the previous year. If
+      # the event's in the future then correct for this.
+      if eventTime > currentUnixTime + 60:
+        eventTimeComp[0] -= 1
+        eventTime = time.mktime(eventTimeComp)
+      
+      eventMsg = " ".join(lineComp[4:])
+      loggedEvents.append(LogEntry(eventTime, eventType, eventMsg, RUNLEVEL_EVENT_COLOR[eventType]))
+    
+    if "opening log file" in line:
+      break # this entry marks the start of this tor instance
+  
+  if addLimit: loggedEvents = loggedEvents[:addLimit]
+  log.info("Read %i entries from tor's log file: %s (read limit: %i, runtime: %0.3f)" % (len(loggedEvents), loggingLocation, readLimit, time.time() - startTime))
+  return loggedEvents
+
+def getDaybreaks(events, ignoreTimeForCache = False):
+  """
+  Provides the input events back with special 'DAYBREAK_EVENT' markers inserted
+  whenever the date changed between log entries (or since the most recent
+  event). The timestamp matches the beginning of the day for the following
+  entry.
+  
+  Arguments:
+    events             - chronologically ordered listing of events
+    ignoreTimeForCache - skips taking the day into consideration for providing
+                         cached results if true
+  """
+  
+  global CACHED_DAYBREAKS_ARGUMENTS, CACHED_DAYBREAKS_RESULT
+  if not events: return []
+  
+  newListing = []
+  currentDay = daysSince()
+  lastDay = currentDay
+  
+  if CACHED_DAYBREAKS_ARGUMENTS[0] == events and \
+    (ignoreTimeForCache or CACHED_DAYBREAKS_ARGUMENTS[1] == currentDay):
+    return list(CACHED_DAYBREAKS_RESULT)
+  
+  for entry in events:
+    eventDay = daysSince(entry.timestamp)
+    if eventDay != lastDay:
+      markerTimestamp = (eventDay * 86400) + TIMEZONE_OFFSET
+      newListing.append(LogEntry(markerTimestamp, DAYBREAK_EVENT, "", "white"))
+    
+    newListing.append(entry)
+    lastDay = eventDay
+  
+  CACHED_DAYBREAKS_ARGUMENTS = (list(events), currentDay)
+  CACHED_DAYBREAKS_RESULT = list(newListing)
+  
+  return newListing
+
+def getDuplicates(events):
+  """
+  Deduplicates a list of log entries, providing back a tuple listing with the
+  log entry and count of duplicates following it. Entries in different days are
+  not considered to be duplicates. This times out, returning None if it takes
+  longer than DEDUPLICATION_TIMEOUT.
+  
+  Arguments:
+    events - chronologically ordered listing of events
+  """
+  
+  global CACHED_DUPLICATES_ARGUMENTS, CACHED_DUPLICATES_RESULT
+  if CACHED_DUPLICATES_ARGUMENTS == events:
+    return list(CACHED_DUPLICATES_RESULT)
+  
+  # loads common log entries from the config if they haven't been
+  if COMMON_LOG_MESSAGES == None: loadLogMessages()
+  
+  startTime = time.time()
+  eventsRemaining = list(events)
+  returnEvents = []
+  
+  while eventsRemaining:
+    entry = eventsRemaining.pop(0)
+    duplicateIndices = isDuplicate(entry, eventsRemaining, True)
+    
+    # checks if the call timeout has been reached
+    if (time.time() - startTime) > DEDUPLICATION_TIMEOUT / 1000.0:
+      return None
+    
+    # drops duplicate entries
+    duplicateIndices.reverse()
+    for i in duplicateIndices: del eventsRemaining[i]
+    
+    returnEvents.append((entry, len(duplicateIndices)))
+  
+  CACHED_DUPLICATES_ARGUMENTS = list(events)
+  CACHED_DUPLICATES_RESULT = list(returnEvents)
+  
+  return returnEvents
+
+def isDuplicate(event, eventSet, getDuplicates = False):
+  """
+  True if the event is a duplicate for something in the eventSet, false
+  otherwise. If the getDuplicates flag is set this provides the indices of
+  the duplicates instead.
+  
+  Arguments:
+    event         - event to search for duplicates of
+    eventSet      - set to look for the event in
+    getDuplicates - instead of providing back a boolean this gives a list of
+                    the duplicate indices in the eventSet
+  """
+  
+  duplicateIndices = []
+  for i in range(len(eventSet)):
+    forwardEntry = eventSet[i]
+    
+    # if showing dates then do duplicate detection for each day, rather
+    # than globally
+    if forwardEntry.type == DAYBREAK_EVENT: break
+    
+    if event.type == forwardEntry.type:
+      isDuplicate = False
+      if event.msg == forwardEntry.msg: isDuplicate = True
+      elif event.type in COMMON_LOG_MESSAGES:
+        for commonMsg in COMMON_LOG_MESSAGES[event.type]:
+          # if it starts with an asterisk then check the whole message rather
+          # than just the start
+          if commonMsg[0] == "*":
+            isDuplicate = commonMsg[1:] in event.msg and commonMsg[1:] in forwardEntry.msg
+          else:
+            isDuplicate = event.msg.startswith(commonMsg) and forwardEntry.msg.startswith(commonMsg)
+          
+          if isDuplicate: break
+      
+      if isDuplicate:
+        if getDuplicates: duplicateIndices.append(i)
+        else: return True
+  
+  if getDuplicates: return duplicateIndices
+  else: return False
+
+class LogEntry():
+  """
+  Individual log file entry, having the following attributes:
+    timestamp - unix timestamp for when the event occurred
+    eventType - event type that occurred ("INFO", "BW", "ARM_WARN", etc)
+    msg       - message that was logged
+    color     - color of the log entry
+  """
+  
+  def __init__(self, timestamp, eventType, msg, color):
+    self.timestamp = timestamp
+    self.type = eventType
+    self.msg = msg
+    self.color = color
+    self._displayMessage = None
+  
+  def getDisplayMessage(self, includeDate = False):
+    """
+    Provides the entry's message for the log.
+    
+    Arguments:
+      includeDate - appends the event's date to the start of the message
+    """
+    
+    if includeDate:
+      # not the common case so skip caching
+      entryTime = time.localtime(self.timestamp)
+      timeLabel =  "%i/%i/%i %02i:%02i:%02i" % (entryTime[1], entryTime[2], entryTime[0], entryTime[3], entryTime[4], entryTime[5])
+      return "%s [%s] %s" % (timeLabel, self.type, self.msg)
+    
+    if not self._displayMessage:
+      entryTime = time.localtime(self.timestamp)
+      self._displayMessage = "%02i:%02i:%02i [%s] %s" % (entryTime[3], entryTime[4], entryTime[5], self.type, self.msg)
+    
+    return self._displayMessage
+
+class LogPanel(panel.Panel, threading.Thread, logging.Handler):
+  """
+  Listens for and displays tor, arm, and stem events. This can prepopulate
+  from tor's log file if it exists.
+  """
+  
+  def __init__(self, stdscr, loggedEvents):
+    panel.Panel.__init__(self, stdscr, "log", 0)
+    logging.Handler.__init__(self, level = log.logging_level(log.DEBUG))
+    
+    self.setFormatter(logging.Formatter(
+      fmt = '%(asctime)s [%(levelname)s] %(message)s',
+      datefmt = '%m/%d/%Y %H:%M:%S'),
+    )
+    
+    threading.Thread.__init__(self)
+    self.setDaemon(True)
+    
+    # Make sure that the msg.* messages are loaded. Lazy loading it later is
+    # fine, but this way we're sure it happens before warning about unused
+    # config options.
+    loadLogMessages()
+    
+    # regex filters the user has defined
+    self.filterOptions = []
+    
+    for filter in CONFIG["features.log.regex"]:
+      # checks if we can't have more filters
+      if len(self.filterOptions) >= MAX_REGEX_FILTERS: break
+      
+      try:
+        re.compile(filter)
+        self.filterOptions.append(filter)
+      except re.error, exc:
+        log.notice("Invalid regular expression pattern (%s): %s" % (exc, filter))
+    
+    self.loggedEvents = [] # needs to be set before we receive any events
+    
+    # restricts the input to the set of events we can listen to, and
+    # configures the controller to liten to them
+    self.loggedEvents = self.setEventListening(loggedEvents)
+    
+    self.setPauseAttr("msgLog")         # tracks the message log when we're paused
+    self.msgLog = []                    # log entries, sorted by the timestamp
+    self.regexFilter = None             # filter for presented log events (no filtering if None)
+    self.lastContentHeight = 0          # height of the rendered content when last drawn
+    self.logFile = None                 # file log messages are saved to (skipped if None)
+    self.scroll = 0
+    
+    self._lastUpdate = -1               # time the content was last revised
+    self._halt = False                  # terminates thread if true
+    self._cond = threading.Condition()  # used for pausing/resuming the thread
+    
+    # restricts concurrent write access to attributes used to draw the display
+    # and pausing:
+    # msgLog, loggedEvents, regexFilter, scroll
+    self.valsLock = threading.RLock()
+    
+    # cached parameters (invalidated if arguments for them change)
+    # last set of events we've drawn with
+    self._lastLoggedEvents = []
+    
+    # _getTitle (args: loggedEvents, regexFilter pattern, width)
+    self._titleCache = None
+    self._titleArgs = (None, None, None)
+    
+    self.reprepopulateEvents()
+    
+    # leaving lastContentHeight as being too low causes initialization problems
+    self.lastContentHeight = len(self.msgLog)
+    
+    # adds listeners for tor and stem events
+    conn = torTools.getConn()
+    conn.addStatusListener(self._resetListener)
+    
+    # opens log file if we'll be saving entries
+    if CONFIG["features.logFile"]:
+      logPath = CONFIG["features.logFile"]
+      
+      try:
+        # make dir if the path doesn't already exist
+        baseDir = os.path.dirname(logPath)
+        if not os.path.exists(baseDir): os.makedirs(baseDir)
+        
+        self.logFile = open(logPath, "a")
+        log.notice("arm %s opening log file (%s)" % (VERSION, logPath))
+      except (IOError, OSError), exc:
+        log.error("Unable to write to log file: %s" % sysTools.getFileErrorMsg(exc))
+        self.logFile = None
+    
+    stem_logger = log.get_logger()
+    stem_logger.addHandler(self)
+  
+  def emit(self, record):
+    if record.levelname == "ERROR":
+      record.levelname = "ERR"
+    elif record.levelname == "WARNING":
+      record.levelname = "WARN"
+    
+    eventColor = RUNLEVEL_EVENT_COLOR[record.levelname]
+    self.registerEvent(LogEntry(int(record.created), "ARM_%s" % record.levelname, record.msg, eventColor))
+  
+  def reprepopulateEvents(self):
+    """
+    Clears the event log and repopulates it from the arm and tor backlogs.
+    """
+    
+    self.valsLock.acquire()
+    
+    # clears the event log
+    self.msgLog = []
+    
+    # fetches past tor events from log file, if available
+    if CONFIG["features.log.prepopulate"]:
+      setRunlevels = list(set.intersection(set(self.loggedEvents), set(list(log.Runlevel))))
+      readLimit = CONFIG["features.log.prepopulateReadLimit"]
+      addLimit = CONFIG["cache.logPanel.size"]
+      for entry in getLogFileEntries(setRunlevels, readLimit, addLimit):
+        self.msgLog.append(entry)
+    
+    # crops events that are either too old, or more numerous than the caching size
+    self._trimEvents(self.msgLog)
+    
+    self.valsLock.release()
+  
+  def setDuplicateVisability(self, isVisible):
+    """
+    Sets if duplicate log entries are collaped or expanded.
+    
+    Arguments:
+      isVisible - if true all log entries are shown, otherwise they're
+                  deduplicated
+    """
+    
+    armConf = conf.get_config("arm")
+    armConf.set("features.log.showDuplicateEntries", str(isVisible))
+  
+  def registerTorEvent(self, event):
+    """
+    Translates a stem.response.event.Event instance into a LogEvent, and calls
+    registerEvent().
+    """
+    
+    msg, color = ' '.join(str(event).split(' ')[1:]), "white"
+    
+    if isinstance(event, events.CircuitEvent):
+      color = "yellow"
+    elif isinstance(event, events.BandwidthEvent):
+      color = "cyan"
+      msg = "READ: %i, WRITTEN: %i" % (event.read, event.written)
+    elif isinstance(event, events.LogEvent):
+      color = RUNLEVEL_EVENT_COLOR[event.runlevel]
+      msg = event.message
+    elif isinstance(event, events.NetworkStatusEvent):
+      color = "blue"
+    elif isinstance(event, events.NewConsensusEvent):
+      color = "magenta"
+    elif isinstance(event, events.GuardEvent):
+      color = "yellow"
+    elif not event.type in TOR_EVENT_TYPES.values():
+      color = "red" # unknown event type
+    
+    self.registerEvent(LogEntry(event.arrived_at, event.type, msg, color))
+  
+  def registerEvent(self, event):
+    """
+    Notes event and redraws log. If paused it's held in a temporary buffer.
+    
+    Arguments:
+      event - LogEntry for the event that occurred
+    """
+    
+    if not event.type in self.loggedEvents: return
+    
+    # strips control characters to avoid screwing up the terminal
+    event.msg = uiTools.getPrintable(event.msg)
+    
+    # note event in the log file if we're saving them
+    if self.logFile:
+      try:
+        self.logFile.write(event.getDisplayMessage(True) + "\n")
+        self.logFile.flush()
+      except IOError, exc:
+        log.error("Unable to write to log file: %s" % sysTools.getFileErrorMsg(exc))
+        self.logFile = None
+    
+    self.valsLock.acquire()
+    self.msgLog.insert(0, event)
+    self._trimEvents(self.msgLog)
+    
+    # notifies the display that it has new content
+    if not self.regexFilter or self.regexFilter.search(event.getDisplayMessage()):
+      self._cond.acquire()
+      self._cond.notifyAll()
+      self._cond.release()
+    
+    self.valsLock.release()
+  
+  def setLoggedEvents(self, eventTypes):
+    """
+    Sets the event types recognized by the panel.
+    
+    Arguments:
+      eventTypes - event types to be logged
+    """
+    
+    if eventTypes == self.loggedEvents: return
+    self.valsLock.acquire()
+    
+    # configures the controller to listen for these tor events, and provides
+    # back a subset without anything we're failing to listen to
+    setTypes = self.setEventListening(eventTypes)
+    self.loggedEvents = setTypes
+    self.redraw(True)
+    self.valsLock.release()
+  
+  def getFilter(self):
+    """
+    Provides our currently selected regex filter.
+    """
+    
+    return self.filterOptions[0] if self.regexFilter else None
+  
+  def setFilter(self, logFilter):
+    """
+    Filters log entries according to the given regular expression.
+    
+    Arguments:
+      logFilter - regular expression used to determine which messages are
+                  shown, None if no filter should be applied
+    """
+    
+    if logFilter == self.regexFilter: return
+    
+    self.valsLock.acquire()
+    self.regexFilter = logFilter
+    self.redraw(True)
+    self.valsLock.release()
+  
+  def makeFilterSelection(self, selectedOption):
+    """
+    Makes the given filter selection, applying it to the log and reorganizing
+    our filter selection.
+    
+    Arguments:
+      selectedOption - regex filter we've already added, None if no filter
+                       should be applied
+    """
+    
+    if selectedOption:
+      try:
+        self.setFilter(re.compile(selectedOption))
+        
+        # move selection to top
+        self.filterOptions.remove(selectedOption)
+        self.filterOptions.insert(0, selectedOption)
+      except re.error, exc:
+        # shouldn't happen since we've already checked validity
+        log.warn("Invalid regular expression ('%s': %s) - removing from listing" % (selectedOption, exc))
+        self.filterOptions.remove(selectedOption)
+    else: self.setFilter(None)
+  
+  def showFilterPrompt(self):
+    """
+    Prompts the user to add a new regex filter.
+    """
+    
+    regexInput = popups.inputPrompt("Regular expression: ")
+    
+    if regexInput:
+      try:
+        self.setFilter(re.compile(regexInput))
+        if regexInput in self.filterOptions: self.filterOptions.remove(regexInput)
+        self.filterOptions.insert(0, regexInput)
+      except re.error, exc:
+        popups.showMsg("Unable to compile expression: %s" % exc, 2)
+  
+  def showEventSelectionPrompt(self):
+    """
+    Prompts the user to select the events being listened for.
+    """
+    
+    # allow user to enter new types of events to log - unchanged if left blank
+    popup, width, height = popups.init(11, 80)
+    
+    if popup:
+      try:
+        # displays the available flags
+        popup.win.box()
+        popup.addstr(0, 0, "Event Types:", curses.A_STANDOUT)
+        eventLines = EVENT_LISTING.split("\n")
+        
+        for i in range(len(eventLines)):
+          popup.addstr(i + 1, 1, eventLines[i][6:])
+        
+        popup.win.refresh()
+        
+        userInput = popups.inputPrompt("Events to log: ")
+        if userInput:
+          userInput = userInput.replace(' ', '') # strips spaces
+          try: self.setLoggedEvents(expandEvents(userInput))
+          except ValueError, exc:
+            popups.showMsg("Invalid flags: %s" % str(exc), 2)
+      finally: popups.finalize()
+  
+  def showSnapshotPrompt(self):
+    """
+    Lets user enter a path to take a snapshot, canceling if left blank.
+    """
+    
+    pathInput = popups.inputPrompt("Path to save log snapshot: ")
+    
+    if pathInput:
+      try:
+        self.saveSnapshot(pathInput)
+        popups.showMsg("Saved: %s" % pathInput, 2)
+      except IOError, exc:
+        popups.showMsg("Unable to save snapshot: %s" % sysTools.getFileErrorMsg(exc), 2)
+  
+  def clear(self):
+    """
+    Clears the contents of the event log.
+    """
+    
+    self.valsLock.acquire()
+    self.msgLog = []
+    self.redraw(True)
+    self.valsLock.release()
+  
+  def saveSnapshot(self, path):
+    """
+    Saves the log events currently being displayed to the given path. This
+    takes filers into account. This overwrites the file if it already exists,
+    and raises an IOError if there's a problem.
+    
+    Arguments:
+      path - path where to save the log snapshot
+    """
+    
+    path = os.path.abspath(os.path.expanduser(path))
+    
+    # make dir if the path doesn't already exist
+    baseDir = os.path.dirname(path)
+    
+    try:
+      if not os.path.exists(baseDir): os.makedirs(baseDir)
+    except OSError, exc:
+      raise IOError("unable to make directory '%s'" % baseDir)
+    
+    snapshotFile = open(path, "w")
+    self.valsLock.acquire()
+    try:
+      for entry in self.msgLog:
+        isVisible = not self.regexFilter or self.regexFilter.search(entry.getDisplayMessage())
+        if isVisible: snapshotFile.write(entry.getDisplayMessage(True) + "\n")
+      
+      self.valsLock.release()
+    except Exception, exc:
+      self.valsLock.release()
+      raise exc
+  
+  def handleKey(self, key):
+    isKeystrokeConsumed = True
+    if uiTools.isScrollKey(key):
+      pageHeight = self.getPreferredSize()[0] - 1
+      newScroll = uiTools.getScrollPosition(key, self.scroll, pageHeight, self.lastContentHeight)
+      
+      if self.scroll != newScroll:
+        self.valsLock.acquire()
+        self.scroll = newScroll
+        self.redraw(True)
+        self.valsLock.release()
+    elif key in (ord('u'), ord('U')):
+      self.valsLock.acquire()
+      self.setDuplicateVisability(not CONFIG["features.log.showDuplicateEntries"])
+      self.redraw(True)
+      self.valsLock.release()
+    elif key == ord('c') or key == ord('C'):
+      msg = "This will clear the log. Are you sure (c again to confirm)?"
+      keyPress = popups.showMsg(msg, attr = curses.A_BOLD)
+      if keyPress in (ord('c'), ord('C')): self.clear()
+    elif key == ord('f') or key == ord('F'):
+      # Provides menu to pick regular expression filters or adding new ones:
+      # for syntax see: http://docs.python.org/library/re.html#regular-expression-syntax
+      options = ["None"] + self.filterOptions + ["New..."]
+      oldSelection = 0 if not self.regexFilter else 1
+      
+      # does all activity under a curses lock to prevent redraws when adding
+      # new filters
+      panel.CURSES_LOCK.acquire()
+      try:
+        selection = popups.showMenu("Log Filter:", options, oldSelection)
+        
+        # applies new setting
+        if selection == 0:
+          self.setFilter(None)
+        elif selection == len(options) - 1:
+          # selected 'New...' option - prompt user to input regular expression
+          self.showFilterPrompt()
+        elif selection != -1:
+          self.makeFilterSelection(self.filterOptions[selection - 1])
+      finally:
+        panel.CURSES_LOCK.release()
+      
+      if len(self.filterOptions) > MAX_REGEX_FILTERS: del self.filterOptions[MAX_REGEX_FILTERS:]
+    elif key == ord('e') or key == ord('E'):
+      self.showEventSelectionPrompt()
+    elif key == ord('a') or key == ord('A'):
+      self.showSnapshotPrompt()
+    else: isKeystrokeConsumed = False
+    
+    return isKeystrokeConsumed
+  
+  def getHelp(self):
+    options = []
+    options.append(("up arrow", "scroll log up a line", None))
+    options.append(("down arrow", "scroll log down a line", None))
+    options.append(("a", "save snapshot of the log", None))
+    options.append(("e", "change logged events", None))
+    options.append(("f", "log regex filter", "enabled" if self.regexFilter else "disabled"))
+    options.append(("u", "duplicate log entries", "visible" if CONFIG["features.log.showDuplicateEntries"] else "hidden"))
+    options.append(("c", "clear event log", None))
+    return options
+  
+  def draw(self, width, height):
+    """
+    Redraws message log. Entries stretch to use available space and may
+    contain up to two lines. Starts with newest entries.
+    """
+    
+    currentLog = self.getAttr("msgLog")
+    
+    self.valsLock.acquire()
+    self._lastLoggedEvents, self._lastUpdate = list(currentLog), time.time()
+    
+    # draws the top label
+    if self.isTitleVisible():
+      self.addstr(0, 0, self._getTitle(width), curses.A_STANDOUT)
+    
+    # restricts scroll location to valid bounds
+    self.scroll = max(0, min(self.scroll, self.lastContentHeight - height + 1))
+    
+    # draws left-hand scroll bar if content's longer than the height
+    msgIndent, dividerIndent = 1, 0 # offsets for scroll bar
+    isScrollBarVisible = self.lastContentHeight > height - 1
+    if isScrollBarVisible:
+      msgIndent, dividerIndent = 3, 2
+      self.addScrollBar(self.scroll, self.scroll + height - 1, self.lastContentHeight, 1)
+    
+    # draws log entries
+    lineCount = 1 - self.scroll
+    seenFirstDateDivider = False
+    dividerAttr, duplicateAttr = curses.A_BOLD | uiTools.getColor("yellow"), curses.A_BOLD | uiTools.getColor("green")
+    
+    isDatesShown = self.regexFilter == None and CONFIG["features.log.showDateDividers"]
+    eventLog = getDaybreaks(currentLog, self.isPaused()) if isDatesShown else list(currentLog)
+    if not CONFIG["features.log.showDuplicateEntries"]:
+      deduplicatedLog = getDuplicates(eventLog)
+      
+      if deduplicatedLog == None:
+        log.warn("Deduplication took too long. Its current implementation has difficulty handling large logs so disabling it to keep the interface responsive.")
+        self.setDuplicateVisability(True)
+        deduplicatedLog = [(entry, 0) for entry in eventLog]
+    else: deduplicatedLog = [(entry, 0) for entry in eventLog]
+    
+    # determines if we have the minimum width to show date dividers
+    showDaybreaks = width - dividerIndent >= 3
+    
+    while deduplicatedLog:
+      entry, duplicateCount = deduplicatedLog.pop(0)
+      
+      if self.regexFilter and not self.regexFilter.search(entry.getDisplayMessage()):
+        continue  # filter doesn't match log message - skip
+      
+      # checks if we should be showing a divider with the date
+      if entry.type == DAYBREAK_EVENT:
+        # bottom of the divider
+        if seenFirstDateDivider:
+          if lineCount >= 1 and lineCount < height and showDaybreaks:
+            self.addch(lineCount, dividerIndent, curses.ACS_LLCORNER,  dividerAttr)
+            self.hline(lineCount, dividerIndent + 1, width - dividerIndent - 2, dividerAttr)
+            self.addch(lineCount, width - 1, curses.ACS_LRCORNER, dividerAttr)
+          
+          lineCount += 1
+        
+        # top of the divider
+        if lineCount >= 1 and lineCount < height and showDaybreaks:
+          timeLabel = time.strftime(" %B %d, %Y ", time.localtime(entry.timestamp))
+          self.addch(lineCount, dividerIndent, curses.ACS_ULCORNER, dividerAttr)
+          self.addch(lineCount, dividerIndent + 1, curses.ACS_HLINE, dividerAttr)
+          self.addstr(lineCount, dividerIndent + 2, timeLabel, curses.A_BOLD | dividerAttr)
+          
+          lineLength = width - dividerIndent - len(timeLabel) - 3
+          self.hline(lineCount, dividerIndent + len(timeLabel) + 2, lineLength, dividerAttr)
+          self.addch(lineCount, dividerIndent + len(timeLabel) + 2 + lineLength, curses.ACS_URCORNER, dividerAttr)
+        
+        seenFirstDateDivider = True
+        lineCount += 1
+      else:
+        # entry contents to be displayed, tuples of the form:
+        # (msg, formatting, includeLinebreak)
+        displayQueue = []
+        
+        msgComp = entry.getDisplayMessage().split("\n")
+        for i in range(len(msgComp)):
+          font = curses.A_BOLD if "ERR" in entry.type else curses.A_NORMAL # emphasizes ERR messages
+          displayQueue.append((msgComp[i].strip(), font | uiTools.getColor(entry.color), i != len(msgComp) - 1))
+        
+        if duplicateCount:
+          pluralLabel = "s" if duplicateCount > 1 else ""
+          duplicateMsg = DUPLICATE_MSG % (duplicateCount, pluralLabel)
+          displayQueue.append((duplicateMsg, duplicateAttr, False))
+        
+        cursorLoc, lineOffset = msgIndent, 0
+        maxEntriesPerLine = CONFIG["features.log.maxLinesPerEntry"]
+        while displayQueue:
+          msg, format, includeBreak = displayQueue.pop(0)
+          drawLine = lineCount + lineOffset
+          if lineOffset == maxEntriesPerLine: break
+          
+          maxMsgSize = width - cursorLoc - 1
+          if len(msg) > maxMsgSize:
+            # message is too long - break it up
+            if lineOffset == maxEntriesPerLine - 1:
+              msg = uiTools.cropStr(msg, maxMsgSize)
+            else:
+              msg, remainder = uiTools.cropStr(msg, maxMsgSize, 4, 4, uiTools.Ending.HYPHEN, True)
+              displayQueue.insert(0, (remainder.strip(), format, includeBreak))
+            
+            includeBreak = True
+          
+          if drawLine < height and drawLine >= 1:
+            if seenFirstDateDivider and width - dividerIndent >= 3 and showDaybreaks:
+              self.addch(drawLine, dividerIndent, curses.ACS_VLINE, dividerAttr)
+              self.addch(drawLine, width - 1, curses.ACS_VLINE, dividerAttr)
+            
+            self.addstr(drawLine, cursorLoc, msg, format)
+          
+          cursorLoc += len(msg)
+          
+          if includeBreak or not displayQueue:
+            lineOffset += 1
+            cursorLoc = msgIndent + ENTRY_INDENT
+        
+        lineCount += lineOffset
+      
+      # if this is the last line and there's room, then draw the bottom of the divider
+      if not deduplicatedLog and seenFirstDateDivider:
+        if lineCount < height and showDaybreaks:
+          self.addch(lineCount, dividerIndent, curses.ACS_LLCORNER, dividerAttr)
+          self.hline(lineCount, dividerIndent + 1, width - dividerIndent - 2, dividerAttr)
+          self.addch(lineCount, width - 1, curses.ACS_LRCORNER, dividerAttr)
+        
+        lineCount += 1
+    
+    # redraw the display if...
+    # - lastContentHeight was off by too much
+    # - we're off the bottom of the page
+    newContentHeight = lineCount + self.scroll - 1
+    contentHeightDelta = abs(self.lastContentHeight - newContentHeight)
+    forceRedraw, forceRedrawReason = True, ""
+    
+    if contentHeightDelta >= CONTENT_HEIGHT_REDRAW_THRESHOLD:
+      forceRedrawReason = "estimate was off by %i" % contentHeightDelta
+    elif newContentHeight > height and self.scroll + height - 1 > newContentHeight:
+      forceRedrawReason = "scrolled off the bottom of the page"
+    elif not isScrollBarVisible and newContentHeight > height - 1:
+      forceRedrawReason = "scroll bar wasn't previously visible"
+    elif isScrollBarVisible and newContentHeight <= height - 1:
+      forceRedrawReason = "scroll bar shouldn't be visible"
+    else: forceRedraw = False
+    
+    self.lastContentHeight = newContentHeight
+    if forceRedraw:
+      log.debug("redrawing the log panel with the corrected content height (%s)" % forceRedrawReason)
+      self.redraw(True)
+    
+    self.valsLock.release()
+  
+  def redraw(self, forceRedraw=False, block=False):
+    # determines if the content needs to be redrawn or not
+    panel.Panel.redraw(self, forceRedraw, block)
+  
+  def run(self):
+    """
+    Redraws the display, coalescing updates if events are rapidly logged (for
+    instance running at the DEBUG runlevel) while also being immediately
+    responsive if additions are less frequent.
+    """
+    
+    lastDay = daysSince() # used to determine if the date has changed
+    while not self._halt:
+      currentDay = daysSince()
+      timeSinceReset = time.time() - self._lastUpdate
+      maxLogUpdateRate = CONFIG["features.log.maxRefreshRate"] / 1000.0
+      
+      sleepTime = 0
+      if (self.msgLog == self._lastLoggedEvents and lastDay == currentDay) or self.isPaused():
+        sleepTime = 5
+      elif timeSinceReset < maxLogUpdateRate:
+        sleepTime = max(0.05, maxLogUpdateRate - timeSinceReset)
+      
+      if sleepTime:
+        self._cond.acquire()
+        if not self._halt: self._cond.wait(sleepTime)
+        self._cond.release()
+      else:
+        lastDay = currentDay
+        self.redraw(True)
+        
+        # makes sure that we register this as an update, otherwise lacking the
+        # curses lock can cause a busy wait here
+        self._lastUpdate = time.time()
+  
+  def stop(self):
+    """
+    Halts further resolutions and terminates the thread.
+    """
+    
+    self._cond.acquire()
+    self._halt = True
+    self._cond.notifyAll()
+    self._cond.release()
+  
+  def setEventListening(self, events):
+    """
+    Configures the events Tor listens for, filtering non-tor events from what we
+    request from the controller. This returns a sorted list of the events we
+    successfully set.
+    
+    Arguments:
+      events - event types to attempt to set
+    """
+    
+    events = set(events) # drops duplicates
+    
+    # accounts for runlevel naming difference
+    if "ERROR" in events:
+      events.add("ERR")
+      events.remove("ERROR")
+    
+    if "WARNING" in events:
+      events.add("WARN")
+      events.remove("WARNING")
+    
+    torEvents = events.intersection(set(TOR_EVENT_TYPES.values()))
+    armEvents = events.intersection(set(["ARM_%s" % runlevel for runlevel in log.Runlevel.keys()]))
+    
+    # adds events unrecognized by arm if we're listening to the 'UNKNOWN' type
+    if "UNKNOWN" in events:
+      torEvents.update(set(getMissingEventTypes()))
+    
+    torConn = torTools.getConn()
+    torConn.removeEventListener(self.registerTorEvent)
+    
+    for eventType in list(torEvents):
+      try:
+        torConn.addEventListener(self.registerTorEvent, eventType)
+      except stem.ProtocolError:
+        torEvents.remove(eventType)
+    
+    # provides back the input set minus events we failed to set
+    return sorted(torEvents.union(armEvents))
+  
+  def _resetListener(self, controller, eventType, _):
+    # if we're attaching to a new tor instance then clears the log and
+    # prepopulates it with the content belonging to this instance
+    
+    if eventType == State.INIT:
+      self.reprepopulateEvents()
+      self.redraw(True)
+    elif eventType == State.CLOSED:
+      log.notice("Tor control port closed")
+  
+  def _getTitle(self, width):
+    """
+    Provides the label used for the panel, looking like:
+      Events (ARM NOTICE - ERR, BW - filter: prepopulate):
+    
+    This truncates the attributes (with an ellipse) if too long, and condenses
+    runlevel ranges if there's three or more in a row (for instance ARM_INFO,
+    ARM_NOTICE, and ARM_WARN becomes "ARM_INFO - WARN").
+    
+    Arguments:
+      width - width constraint the label needs to fix in
+    """
+    
+    # usually the attributes used to make the label are decently static, so
+    # provide cached results if they're unchanged
+    self.valsLock.acquire()
+    currentPattern = self.regexFilter.pattern if self.regexFilter else None
+    isUnchanged = self._titleArgs[0] == self.loggedEvents
+    isUnchanged &= self._titleArgs[1] == currentPattern
+    isUnchanged &= self._titleArgs[2] == width
+    if isUnchanged:
+      self.valsLock.release()
+      return self._titleCache
+    
+    eventsList = list(self.loggedEvents)
+    if not eventsList:
+      if not currentPattern:
+        panelLabel = "Events:"
+      else:
+        labelPattern = uiTools.cropStr(currentPattern, width - 18)
+        panelLabel = "Events (filter: %s):" % labelPattern
+    else:
+      # does the following with all runlevel types (tor, arm, and stem):
+      # - pulls to the start of the list
+      # - condenses range if there's three or more in a row (ex. "ARM_INFO - WARN")
+      # - condense further if there's identical runlevel ranges for multiple
+      #   types (ex. "NOTICE - ERR, ARM_NOTICE - ERR" becomes "TOR/ARM NOTICE - ERR")
+      tmpRunlevels = [] # runlevels pulled from the list (just the runlevel part)
+      runlevelRanges = [] # tuple of type, startLevel, endLevel for ranges to be consensed
+      
+      # reverses runlevels and types so they're appended in the right order
+      reversedRunlevels = list(log.Runlevel)
+      reversedRunlevels.reverse()
+      for prefix in ("ARM_", ""):
+        # blank ending runlevel forces the break condition to be reached at the end
+        for runlevel in reversedRunlevels + [""]:
+          eventType = prefix + runlevel
+          if runlevel and eventType in eventsList:
+            # runlevel event found, move to the tmp list
+            eventsList.remove(eventType)
+            tmpRunlevels.append(runlevel)
+          elif tmpRunlevels:
+            # adds all tmp list entries to the start of eventsList
+            if len(tmpRunlevels) >= 3:
+              # save condense sequential runlevels to be added later
+              runlevelRanges.append((prefix, tmpRunlevels[-1], tmpRunlevels[0]))
+            else:
+              # adds runlevels individaully
+              for tmpRunlevel in tmpRunlevels:
+                eventsList.insert(0, prefix + tmpRunlevel)
+            
+            tmpRunlevels = []
+      
+      # adds runlevel ranges, condensing if there's identical ranges
+      for i in range(len(runlevelRanges)):
+        if runlevelRanges[i]:
+          prefix, startLevel, endLevel = runlevelRanges[i]
+          
+          # check for matching ranges
+          matches = []
+          for j in range(i + 1, len(runlevelRanges)):
+            if runlevelRanges[j] and runlevelRanges[j][1] == startLevel and runlevelRanges[j][2] == endLevel:
+              matches.append(runlevelRanges[j])
+              runlevelRanges[j] = None
+          
+          if matches:
+            # strips underscores and replaces empty entries with "TOR"
+            prefixes = [entry[0] for entry in matches] + [prefix]
+            for k in range(len(prefixes)):
+              if prefixes[k] == "": prefixes[k] = "TOR"
+              else: prefixes[k] = prefixes[k].replace("_", "")
+            
+            eventsList.insert(0, "%s %s - %s" % ("/".join(prefixes), startLevel, endLevel))
+          else:
+            eventsList.insert(0, "%s%s - %s" % (prefix, startLevel, endLevel))
+      
+      # truncates to use an ellipsis if too long, for instance:
+      attrLabel = ", ".join(eventsList)
+      if currentPattern: attrLabel += " - filter: %s" % currentPattern
+      attrLabel = uiTools.cropStr(attrLabel, width - 10, 1)
+      if attrLabel: attrLabel = " (%s)" % attrLabel
+      panelLabel = "Events%s:" % attrLabel
+    
+    # cache results and return
+    self._titleCache = panelLabel
+    self._titleArgs = (list(self.loggedEvents), currentPattern, width)
+    self.valsLock.release()
+    return panelLabel
+  
+  def _trimEvents(self, eventListing):
+    """
+    Crops events that have either:
+    - grown beyond the cache limit
+    - outlived the configured log duration
+    
+    Argument:
+      eventListing - listing of log entries
+    """
+    
+    cacheSize = CONFIG["cache.logPanel.size"]
+    if len(eventListing) > cacheSize: del eventListing[cacheSize:]
+    
+    logTTL = CONFIG["features.log.entryDuration"]
+    if logTTL > 0:
+      currentDay = daysSince()
+      
+      breakpoint = None # index at which to crop from
+      for i in range(len(eventListing) - 1, -1, -1):
+        daysSinceEvent = currentDay - daysSince(eventListing[i].timestamp)
+        if daysSinceEvent > logTTL: breakpoint = i # older than the ttl
+        else: break
+      
+      # removes entries older than the ttl
+      if breakpoint != None: del eventListing[breakpoint:]
+
diff --git a/arm/cli/menu/__init__.py b/arm/cli/menu/__init__.py
new file mode 100644
index 0000000..f6d43ec
--- /dev/null
+++ b/arm/cli/menu/__init__.py
@@ -0,0 +1,6 @@
+"""
+Resources for displaying the menu.
+"""
+
+__all__ = ["actions", "item", "menu"]
+
diff --git a/arm/cli/menu/actions.py b/arm/cli/menu/actions.py
new file mode 100644
index 0000000..8cc265b
--- /dev/null
+++ b/arm/cli/menu/actions.py
@@ -0,0 +1,296 @@
+"""
+Generates the menu for arm, binding options with their related actions.
+"""
+
+import functools
+
+import cli.popups
+import cli.controller
+import cli.menu.item
+import cli.graphing.graphPanel
+
+from util import connections, torTools, uiTools
+
+from stem.util import conf, str_tools
+
+CONFIG = conf.config_dict("arm", {
+  "features.log.showDuplicateEntries": False,
+})
+
+def makeMenu():
+  """
+  Constructs the base menu and all of its contents.
+  """
+  
+  baseMenu = cli.menu.item.Submenu("")
+  baseMenu.add(makeActionsMenu())
+  baseMenu.add(makeViewMenu())
+  
+  control = cli.controller.getController()
+  
+  for pagePanel in control.getDisplayPanels(includeSticky = False):
+    if pagePanel.getName() == "graph":
+      baseMenu.add(makeGraphMenu(pagePanel))
+    elif pagePanel.getName() == "log":
+      baseMenu.add(makeLogMenu(pagePanel))
+    elif pagePanel.getName() == "connections":
+      baseMenu.add(makeConnectionsMenu(pagePanel))
+    elif pagePanel.getName() == "configuration":
+      baseMenu.add(makeConfigurationMenu(pagePanel))
+    elif pagePanel.getName() == "torrc":
+      baseMenu.add(makeTorrcMenu(pagePanel))
+  
+  baseMenu.add(makeHelpMenu())
+  
+  return baseMenu
+
+def makeActionsMenu():
+  """
+  Submenu consisting of...
+    Close Menu
+    New Identity
+    Pause / Unpause
+    Reset Tor
+    Exit
+  """
+  
+  control = cli.controller.getController()
+  conn = torTools.getConn()
+  headerPanel = control.getPanel("header")
+  actionsMenu = cli.menu.item.Submenu("Actions")
+  actionsMenu.add(cli.menu.item.MenuItem("Close Menu", None))
+  actionsMenu.add(cli.menu.item.MenuItem("New Identity", headerPanel.sendNewnym))
+  
+  if conn.isAlive():
+    actionsMenu.add(cli.menu.item.MenuItem("Stop Tor", conn.shutdown))
+  
+  actionsMenu.add(cli.menu.item.MenuItem("Reset Tor", conn.reload))
+  
+  if control.isPaused(): label, arg = "Unpause", False
+  else: label, arg = "Pause", True
+  actionsMenu.add(cli.menu.item.MenuItem(label, functools.partial(control.setPaused, arg)))
+  
+  actionsMenu.add(cli.menu.item.MenuItem("Exit", control.quit))
+  return actionsMenu
+
+def makeViewMenu():
+  """
+  Submenu consisting of...
+    [X] <Page 1>
+    [ ] <Page 2>
+    [ ] etc...
+        Color (Submenu)
+  """
+  
+  viewMenu = cli.menu.item.Submenu("View")
+  control = cli.controller.getController()
+  
+  if control.getPageCount() > 0:
+    pageGroup = cli.menu.item.SelectionGroup(control.setPage, control.getPage())
+    
+    for i in range(control.getPageCount()):
+      pagePanels = control.getDisplayPanels(pageNumber = i, includeSticky = False)
+      label = " / ".join([str_tools._to_camel_case(panel.getName()) for panel in pagePanels])
+      
+      viewMenu.add(cli.menu.item.SelectionMenuItem(label, pageGroup, i))
+  
+  if uiTools.isColorSupported():
+    colorMenu = cli.menu.item.Submenu("Color")
+    colorGroup = cli.menu.item.SelectionGroup(uiTools.setColorOverride, uiTools.getColorOverride())
+    
+    colorMenu.add(cli.menu.item.SelectionMenuItem("All", colorGroup, None))
+    
+    for color in uiTools.COLOR_LIST:
+      colorMenu.add(cli.menu.item.SelectionMenuItem(str_tools._to_camel_case(color), colorGroup, color))
+    
+    viewMenu.add(colorMenu)
+  
+  return viewMenu
+
+def makeHelpMenu():
+  """
+  Submenu consisting of...
+    Hotkeys
+    About
+  """
+  
+  helpMenu = cli.menu.item.Submenu("Help")
+  helpMenu.add(cli.menu.item.MenuItem("Hotkeys", cli.popups.showHelpPopup))
+  helpMenu.add(cli.menu.item.MenuItem("About", cli.popups.showAboutPopup))
+  return helpMenu
+
+def makeGraphMenu(graphPanel):
+  """
+  Submenu for the graph panel, consisting of...
+    [X] <Stat 1>
+    [ ] <Stat 2>
+    [ ] <Stat 2>
+        Resize...
+        Interval (Submenu)
+        Bounds (Submenu)
+  
+  Arguments:
+    graphPanel - instance of the graph panel
+  """
+  
+  graphMenu = cli.menu.item.Submenu("Graph")
+  
+  # stats options
+  statGroup = cli.menu.item.SelectionGroup(graphPanel.setStats, graphPanel.getStats())
+  availableStats = graphPanel.stats.keys()
+  availableStats.sort()
+  
+  for statKey in ["None"] + availableStats:
+    label = str_tools._to_camel_case(statKey, divider = " ")
+    statKey = None if statKey == "None" else statKey
+    graphMenu.add(cli.menu.item.SelectionMenuItem(label, statGroup, statKey))
+  
+  # resizing option
+  graphMenu.add(cli.menu.item.MenuItem("Resize...", graphPanel.resizeGraph))
+  
+  # interval submenu
+  intervalMenu = cli.menu.item.Submenu("Interval")
+  intervalGroup = cli.menu.item.SelectionGroup(graphPanel.setUpdateInterval, graphPanel.getUpdateInterval())
+  
+  for i in range(len(cli.graphing.graphPanel.UPDATE_INTERVALS)):
+    label = cli.graphing.graphPanel.UPDATE_INTERVALS[i][0]
+    label = str_tools._to_camel_case(label, divider = " ")
+    intervalMenu.add(cli.menu.item.SelectionMenuItem(label, intervalGroup, i))
+  
+  graphMenu.add(intervalMenu)
+  
+  # bounds submenu
+  boundsMenu = cli.menu.item.Submenu("Bounds")
+  boundsGroup = cli.menu.item.SelectionGroup(graphPanel.setBoundsType, graphPanel.getBoundsType())
+  
+  for boundsType in cli.graphing.graphPanel.Bounds:
+    boundsMenu.add(cli.menu.item.SelectionMenuItem(boundsType, boundsGroup, boundsType))
+  
+  graphMenu.add(boundsMenu)
+  
+  return graphMenu
+
+def makeLogMenu(logPanel):
+  """
+  Submenu for the log panel, consisting of...
+    Events...
+    Snapshot...
+    Clear
+    Show / Hide Duplicates
+    Filter (Submenu)
+  
+  Arguments:
+    logPanel - instance of the log panel
+  """
+  
+  logMenu = cli.menu.item.Submenu("Log")
+  
+  logMenu.add(cli.menu.item.MenuItem("Events...", logPanel.showEventSelectionPrompt))
+  logMenu.add(cli.menu.item.MenuItem("Snapshot...", logPanel.showSnapshotPrompt))
+  logMenu.add(cli.menu.item.MenuItem("Clear", logPanel.clear))
+  
+  if CONFIG["features.log.showDuplicateEntries"]:
+    label, arg = "Hide", False
+  else: label, arg = "Show", True
+  logMenu.add(cli.menu.item.MenuItem("%s Duplicates" % label, functools.partial(logPanel.setDuplicateVisability, arg)))
+  
+  # filter submenu
+  filterMenu = cli.menu.item.Submenu("Filter")
+  filterGroup = cli.menu.item.SelectionGroup(logPanel.makeFilterSelection, logPanel.getFilter())
+  
+  filterMenu.add(cli.menu.item.SelectionMenuItem("None", filterGroup, None))
+  
+  for option in logPanel.filterOptions:
+    filterMenu.add(cli.menu.item.SelectionMenuItem(option, filterGroup, option))
+  
+  filterMenu.add(cli.menu.item.MenuItem("New...", logPanel.showFilterPrompt))
+  logMenu.add(filterMenu)
+  
+  return logMenu
+
+def makeConnectionsMenu(connPanel):
+  """
+  Submenu for the connections panel, consisting of...
+    [X] IP Address
+    [ ] Fingerprint
+    [ ] Nickname
+        Sorting...
+        Resolver (Submenu)
+  
+  Arguments:
+    connPanel - instance of the connections panel
+  """
+  
+  connectionsMenu = cli.menu.item.Submenu("Connections")
+  
+  # listing options
+  listingGroup = cli.menu.item.SelectionGroup(connPanel.setListingType, connPanel.getListingType())
+  
+  listingOptions = list(cli.connections.entries.ListingType)
+  listingOptions.remove(cli.connections.entries.ListingType.HOSTNAME)
+  
+  for option in listingOptions:
+    connectionsMenu.add(cli.menu.item.SelectionMenuItem(option, listingGroup, option))
+  
+  # sorting option
+  connectionsMenu.add(cli.menu.item.MenuItem("Sorting...", connPanel.showSortDialog))
+  
+  # resolver submenu
+  connResolver = connections.getResolver("tor")
+  resolverMenu = cli.menu.item.Submenu("Resolver")
+  resolverGroup = cli.menu.item.SelectionGroup(connResolver.setOverwriteResolver, connResolver.getOverwriteResolver())
+  
+  resolverMenu.add(cli.menu.item.SelectionMenuItem("auto", resolverGroup, None))
+  
+  for option in connections.Resolver:
+    resolverMenu.add(cli.menu.item.SelectionMenuItem(option, resolverGroup, option))
+  
+  connectionsMenu.add(resolverMenu)
+  
+  return connectionsMenu
+
+def makeConfigurationMenu(configPanel):
+  """
+  Submenu for the configuration panel, consisting of...
+    Save Config...
+    Sorting...
+    Filter / Unfilter Options
+  
+  Arguments:
+    configPanel - instance of the configuration panel
+  """
+  
+  configMenu = cli.menu.item.Submenu("Configuration")
+  configMenu.add(cli.menu.item.MenuItem("Save Config...", configPanel.showWriteDialog))
+  configMenu.add(cli.menu.item.MenuItem("Sorting...", configPanel.showSortDialog))
+  
+  if configPanel.showAll: label, arg = "Filter", True
+  else: label, arg = "Unfilter", False
+  configMenu.add(cli.menu.item.MenuItem("%s Options" % label, functools.partial(configPanel.setFiltering, arg)))
+  
+  return configMenu
+
+def makeTorrcMenu(torrcPanel):
+  """
+  Submenu for the torrc panel, consisting of...
+    Reload
+    Show / Hide Comments
+    Show / Hide Line Numbers
+  
+  Arguments:
+    torrcPanel - instance of the torrc panel
+  """
+  
+  torrcMenu = cli.menu.item.Submenu("Torrc")
+  torrcMenu.add(cli.menu.item.MenuItem("Reload", torrcPanel.reloadTorrc))
+  
+  if torrcPanel.stripComments: label, arg = "Show", True
+  else: label, arg = "Hide", False
+  torrcMenu.add(cli.menu.item.MenuItem("%s Comments" % label, functools.partial(torrcPanel.setCommentsVisible, arg)))
+  
+  if torrcPanel.showLineNum: label, arg = "Hide", False
+  else: label, arg = "Show", True
+  torrcMenu.add(cli.menu.item.MenuItem("%s Line Numbers" % label, functools.partial(torrcPanel.setLineNumberVisible, arg)))
+  
+  return torrcMenu
+
diff --git a/arm/cli/menu/item.py b/arm/cli/menu/item.py
new file mode 100644
index 0000000..1ed3f1f
--- /dev/null
+++ b/arm/cli/menu/item.py
@@ -0,0 +1,201 @@
+"""
+Menu item, representing an option in the drop-down menu.
+"""
+
+import cli.controller
+
+class MenuItem():
+  """
+  Option in a drop-down menu.
+  """
+  
+  def __init__(self, label, callback):
+    self._label = label
+    self._callback = callback
+    self._parent = None
+  
+  def getLabel(self):
+    """
+    Provides a tuple of three strings representing the prefix, label, and
+    suffix for this item.
+    """
+    
+    return ("", self._label, "")
+  
+  def getParent(self):
+    """
+    Provides the Submenu we're contained within.
+    """
+    
+    return self._parent
+  
+  def getHierarchy(self):
+    """
+    Provides a list with all of our parents, up to the root.
+    """
+    
+    myHierarchy = [self]
+    while myHierarchy[-1].getParent():
+      myHierarchy.append(myHierarchy[-1].getParent())
+    
+    myHierarchy.reverse()
+    return myHierarchy
+  
+  def getRoot(self):
+    """
+    Provides the base submenu we belong to.
+    """
+    
+    if self._parent: return self._parent.getRoot()
+    else: return self
+  
+  def select(self):
+    """
+    Performs the callback for the menu item, returning true if we should close
+    the menu and false otherwise.
+    """
+    
+    if self._callback:
+      control = cli.controller.getController()
+      control.setMsg()
+      control.redraw()
+      self._callback()
+    return True
+  
+  def next(self):
+    """
+    Provides the next option for the submenu we're in, raising a ValueError
+    if we don't have a parent.
+    """
+    
+    return self._getSibling(1)
+  
+  def prev(self):
+    """
+    Provides the previous option for the submenu we're in, raising a ValueError
+    if we don't have a parent.
+    """
+    
+    return self._getSibling(-1)
+  
+  def _getSibling(self, offset):
+    """
+    Provides our sibling with a given index offset from us, raising a
+    ValueError if we don't have a parent.
+    
+    Arguments:
+      offset - index offset for the sibling to be returned
+    """
+    
+    if self._parent:
+      mySiblings = self._parent.getChildren()
+      
+      try:
+        myIndex = mySiblings.index(self)
+        return mySiblings[(myIndex + offset) % len(mySiblings)]
+      except ValueError:
+        # We expect a bidirectional references between submenus and their
+        # children. If we don't have this then our menu's screwed up.
+        
+        msg = "The '%s' submenu doesn't contain '%s' (children: '%s')" % (self, self._parent, "', '".join(mySiblings))
+        raise ValueError(msg)
+    else: raise ValueError("Menu option '%s' doesn't have a parent" % self)
+  
+  def __str__(self):
+    return self._label
+
+class Submenu(MenuItem):
+  """
+  Menu item that lists other menu options.
+  """
+  
+  def __init__(self, label):
+    MenuItem.__init__(self, label, None)
+    self._children = []
+  
+  def getLabel(self):
+    """
+    Provides our label with a ">" suffix to indicate that we have suboptions.
+    """
+    
+    myLabel = MenuItem.getLabel(self)[1]
+    return ("", myLabel, " >")
+  
+  def add(self, menuItem):
+    """
+    Adds the given menu item to our listing. This raises a ValueError if the
+    item already has a parent.
+    
+    Arguments:
+      menuItem - menu option to be added
+    """
+    
+    if menuItem.getParent():
+      raise ValueError("Menu option '%s' already has a parent" % menuItem)
+    else:
+      menuItem._parent = self
+      self._children.append(menuItem)
+  
+  def getChildren(self):
+    """
+    Provides the menu and submenus we contain.
+    """
+    
+    return list(self._children)
+  
+  def isEmpty(self):
+    """
+    True if we have no children, false otherwise.
+    """
+    
+    return not bool(self._children)
+  
+  def select(self):
+    return False
+
+class SelectionGroup():
+  """
+  Radio button groups that SelectionMenuItems can belong to.
+  """
+  
+  def __init__(self, action, selectedArg):
+    self.action = action
+    self.selectedArg = selectedArg
+
+class SelectionMenuItem(MenuItem):
+  """
+  Menu item with an associated group which determines the selection. This is
+  for the common single argument getter/setter pattern.
+  """
+  
+  def __init__(self, label, group, arg):
+    MenuItem.__init__(self, label, None)
+    self._group = group
+    self._arg = arg
+  
+  def isSelected(self):
+    """
+    True if we're the selected item, false otherwise.
+    """
+    
+    return self._arg == self._group.selectedArg
+  
+  def getLabel(self):
+    """
+    Provides our label with a "[X]" prefix if selected and "[ ]" if not.
+    """
+    
+    myLabel = MenuItem.getLabel(self)[1]
+    myPrefix = "[X] " if self.isSelected() else "[ ] "
+    return (myPrefix, myLabel, "")
+  
+  def select(self):
+    """
+    Performs the group's setter action with our argument.
+    """
+    
+    if not self.isSelected():
+      self._group.action(self._arg)
+    
+    return True
+
diff --git a/arm/cli/menu/menu.py b/arm/cli/menu/menu.py
new file mode 100644
index 0000000..a93a1e0
--- /dev/null
+++ b/arm/cli/menu/menu.py
@@ -0,0 +1,164 @@
+"""
+Display logic for presenting the menu.
+"""
+
+import curses
+
+import cli.popups
+import cli.controller
+import cli.menu.item
+import cli.menu.actions
+
+from util import uiTools
+
+class MenuCursor:
+  """
+  Tracks selection and key handling in the menu.
+  """
+  
+  def __init__(self, initialSelection):
+    self._selection = initialSelection
+    self._isDone = False
+  
+  def isDone(self):
+    """
+    Provides true if a selection has indicated that we should close the menu.
+    False otherwise.
+    """
+    
+    return self._isDone
+  
+  def getSelection(self):
+    """
+    Provides the currently selected menu item.
+    """
+    
+    return self._selection
+  
+  def handleKey(self, key):
+    isSelectionSubmenu = isinstance(self._selection, cli.menu.item.Submenu)
+    selectionHierarchy = self._selection.getHierarchy()
+    
+    if uiTools.isSelectionKey(key):
+      if isSelectionSubmenu:
+        if not self._selection.isEmpty():
+          self._selection = self._selection.getChildren()[0]
+      else: self._isDone = self._selection.select()
+    elif key == curses.KEY_UP:
+      self._selection = self._selection.prev()
+    elif key == curses.KEY_DOWN:
+      self._selection = self._selection.next()
+    elif key == curses.KEY_LEFT:
+      if len(selectionHierarchy) <= 3:
+        # shift to the previous main submenu
+        prevSubmenu = selectionHierarchy[1].prev()
+        self._selection = prevSubmenu.getChildren()[0]
+      else:
+        # go up a submenu level
+        self._selection = self._selection.getParent()
+    elif key == curses.KEY_RIGHT:
+      if isSelectionSubmenu:
+        # open submenu (same as making a selection)
+        if not self._selection.isEmpty():
+          self._selection = self._selection.getChildren()[0]
+      else:
+        # shift to the next main submenu
+        nextSubmenu = selectionHierarchy[1].next()
+        self._selection = nextSubmenu.getChildren()[0]
+    elif key in (27, ord('m'), ord('M')):
+      # close menu
+      self._isDone = True
+
+def showMenu():
+  popup, _, _ = cli.popups.init(1, belowStatic = False)
+  if not popup: return
+  control = cli.controller.getController()
+  
+  try:
+    # generates the menu and uses the initial selection of the first item in
+    # the file menu
+    menu = cli.menu.actions.makeMenu()
+    cursor = MenuCursor(menu.getChildren()[0].getChildren()[0])
+    
+    while not cursor.isDone():
+      # sets the background color
+      popup.win.clear()
+      popup.win.bkgd(' ', curses.A_STANDOUT | uiTools.getColor("red"))
+      selectionHierarchy = cursor.getSelection().getHierarchy()
+      
+      # provide a message saying how to close the menu
+      control.setMsg("Press m or esc to close the menu.", curses.A_BOLD, True)
+      
+      # renders the menu bar, noting where the open submenu is positioned
+      drawLeft, selectionLeft = 0, 0
+      
+      for topLevelItem in menu.getChildren():
+        drawFormat = curses.A_BOLD
+        if topLevelItem == selectionHierarchy[1]:
+          drawFormat |= curses.A_UNDERLINE
+          selectionLeft = drawLeft
+        
+        drawLabel = " %s " % topLevelItem.getLabel()[1]
+        popup.addstr(0, drawLeft, drawLabel, drawFormat)
+        popup.addch(0, drawLeft + len(drawLabel), curses.ACS_VLINE)
+        
+        drawLeft += len(drawLabel) + 1
+      
+      # recursively shows opened submenus
+      _drawSubmenu(cursor, 1, 1, selectionLeft)
+      
+      popup.win.refresh()
+      
+      curses.cbreak()
+      key = control.getScreen().getch()
+      cursor.handleKey(key)
+      
+      # redraws the rest of the interface if we're rendering on it again
+      if not cursor.isDone(): control.redraw()
+  finally:
+    control.setMsg()
+    cli.popups.finalize()
+
+def _drawSubmenu(cursor, level, top, left):
+  selectionHierarchy = cursor.getSelection().getHierarchy()
+  
+  # checks if there's nothing to display
+  if len(selectionHierarchy) < level + 2: return
+  
+  # fetches the submenu and selection we're displaying
+  submenu = selectionHierarchy[level]
+  selection = selectionHierarchy[level + 1]
+  
+  # gets the size of the prefix, middle, and suffix columns
+  allLabelSets = [entry.getLabel() for entry in submenu.getChildren()]
+  prefixColSize = max([len(entry[0]) for entry in allLabelSets])
+  middleColSize = max([len(entry[1]) for entry in allLabelSets])
+  suffixColSize = max([len(entry[2]) for entry in allLabelSets])
+  
+  # formatted string so we can display aligned menu entries
+  labelFormat = " %%-%is%%-%is%%-%is " % (prefixColSize, middleColSize, suffixColSize)
+  menuWidth = len(labelFormat % ("", "", ""))
+  
+  popup, _, _ = cli.popups.init(len(submenu.getChildren()), menuWidth, top, left, belowStatic = False)
+  if not popup: return
+  
+  try:
+    # sets the background color
+    popup.win.bkgd(' ', curses.A_STANDOUT | uiTools.getColor("red"))
+    
+    drawTop, selectionTop = 0, 0
+    for menuItem in submenu.getChildren():
+      if menuItem == selection:
+        drawFormat = curses.A_BOLD | uiTools.getColor("white")
+        selectionTop = drawTop
+      else: drawFormat = curses.A_NORMAL
+      
+      popup.addstr(drawTop, 0, labelFormat % menuItem.getLabel(), drawFormat)
+      drawTop += 1
+    
+    popup.win.refresh()
+    
+    # shows the next submenu
+    _drawSubmenu(cursor, level + 1, top + selectionTop, left + menuWidth)
+  finally: cli.popups.finalize()
+  
diff --git a/arm/cli/popups.py b/arm/cli/popups.py
new file mode 100644
index 0000000..8a41f73
--- /dev/null
+++ b/arm/cli/popups.py
@@ -0,0 +1,337 @@
+"""
+Functions for displaying popups in the interface.
+"""
+
+import curses
+
+import version
+import cli.controller
+
+from util import panel, uiTools
+
+def init(height = -1, width = -1, top = 0, left = 0, belowStatic = True):
+  """
+  Preparation for displaying a popup. This creates a popup with a valid
+  subwindow instance. If that's successful then the curses lock is acquired
+  and this returns a tuple of the...
+  (popup, draw width, draw height)
+  Otherwise this leaves curses unlocked and returns None.
+  
+  Arguments:
+    height      - maximum height of the popup
+    width       - maximum width of the popup
+    top         - top position, relative to the sticky content
+    left        - left position from the screen
+    belowStatic - positions popup below static content if true
+  """
+  
+  control = cli.controller.getController()
+  if belowStatic:
+    stickyHeight = sum([stickyPanel.getHeight() for stickyPanel in control.getStickyPanels()])
+  else: stickyHeight = 0
+  
+  popup = panel.Panel(control.getScreen(), "popup", top + stickyHeight, left, height, width)
+  popup.setVisible(True)
+  
+  # Redraws the popup to prepare a subwindow instance. If none is spawned then
+  # the panel can't be drawn (for instance, due to not being visible).
+  popup.redraw(True)
+  if popup.win != None:
+    panel.CURSES_LOCK.acquire()
+    return (popup, popup.maxX - 1, popup.maxY)
+  else: return (None, 0, 0)
+
+def finalize():
+  """
+  Cleans up after displaying a popup, releasing the cureses lock and redrawing
+  the rest of the display.
+  """
+  
+  cli.controller.getController().requestRedraw()
+  panel.CURSES_LOCK.release()
+
+def inputPrompt(msg, initialValue = ""):
+  """
+  Prompts the user to enter a string on the control line (which usually
+  displays the page number and basic controls).
+  
+  Arguments:
+    msg          - message to prompt the user for input with
+    initialValue - initial value of the field
+  """
+  
+  panel.CURSES_LOCK.acquire()
+  control = cli.controller.getController()
+  msgPanel = control.getPanel("msg")
+  msgPanel.setMessage(msg)
+  msgPanel.redraw(True)
+  userInput = msgPanel.getstr(0, len(msg), initialValue)
+  control.setMsg()
+  panel.CURSES_LOCK.release()
+  return userInput
+
+def showMsg(msg, maxWait = -1, attr = curses.A_STANDOUT):
+  """
+  Displays a single line message on the control line for a set time. Pressing
+  any key will end the message. This returns the key pressed.
+  
+  Arguments:
+    msg     - message to be displayed to the user
+    maxWait - time to show the message, indefinite if -1
+    attr    - attributes with which to draw the message
+  """
+  
+  panel.CURSES_LOCK.acquire()
+  control = cli.controller.getController()
+  control.setMsg(msg, attr, True)
+  
+  if maxWait == -1: curses.cbreak()
+  else: curses.halfdelay(maxWait * 10)
+  keyPress = control.getScreen().getch()
+  control.setMsg()
+  panel.CURSES_LOCK.release()
+  
+  return keyPress
+
+def showHelpPopup():
+  """
+  Presents a popup with instructions for the current page's hotkeys. This
+  returns the user input used to close the popup. If the popup didn't close
+  properly, this is an arrow, enter, or scroll key then this returns None.
+  """
+  
+  popup, _, height = init(9, 80)
+  if not popup: return
+  
+  exitKey = None
+  try:
+    control = cli.controller.getController()
+    pagePanels = control.getDisplayPanels()
+    
+    # the first page is the only one with multiple panels, and it looks better
+    # with the log entries first, so reversing the order
+    pagePanels.reverse()
+    
+    helpOptions = []
+    for entry in pagePanels:
+      helpOptions += entry.getHelp()
+    
+    # test doing afterward in case of overwriting
+    popup.win.box()
+    popup.addstr(0, 0, "Page %i Commands:" % (control.getPage() + 1), curses.A_STANDOUT)
+    
+    for i in range(len(helpOptions)):
+      if i / 2 >= height - 2: break
+      
+      # draws entries in the form '<key>: <description>[ (<selection>)]', for
+      # instance...
+      # u: duplicate log entries (hidden)
+      key, description, selection = helpOptions[i]
+      if key: description = ": " + description
+      row = (i / 2) + 1
+      col = 2 if i % 2 == 0 else 41
+      
+      popup.addstr(row, col, key, curses.A_BOLD)
+      col += len(key)
+      popup.addstr(row, col, description)
+      col += len(description)
+      
+      if selection:
+        popup.addstr(row, col, " (")
+        popup.addstr(row, col + 2, selection, curses.A_BOLD)
+        popup.addstr(row, col + 2 + len(selection), ")")
+    
+    # tells user to press a key if the lower left is unoccupied
+    if len(helpOptions) < 13 and height == 9:
+      popup.addstr(7, 2, "Press any key...")
+    
+    popup.win.refresh()
+    curses.cbreak()
+    exitKey = control.getScreen().getch()
+  finally: finalize()
+  
+  if not uiTools.isSelectionKey(exitKey) and \
+    not uiTools.isScrollKey(exitKey) and \
+    not exitKey in (curses.KEY_LEFT, curses.KEY_RIGHT):
+    return exitKey
+  else: return None
+
+def showAboutPopup():
+  """
+  Presents a popup with author and version information.
+  """
+  
+  popup, _, height = init(9, 80)
+  if not popup: return
+  
+  try:
+    control = cli.controller.getController()
+    
+    popup.win.box()
+    popup.addstr(0, 0, "About:", curses.A_STANDOUT)
+    popup.addstr(1, 2, "arm, version %s (released %s)" % (version.VERSION, version.LAST_MODIFIED), curses.A_BOLD)
+    popup.addstr(2, 4, "Written by Damian Johnson (atagar at torproject.org)")
+    popup.addstr(3, 4, "Project page: www.atagar.com/arm")
+    popup.addstr(5, 2, "Released under the GPL v3 (http://www.gnu.org/licenses/gpl.html)")
+    popup.addstr(7, 2, "Press any key...")
+    popup.win.refresh()
+    
+    curses.cbreak()
+    control.getScreen().getch()
+  finally: finalize()
+
+def showSortDialog(title, options, oldSelection, optionColors):
+  """
+  Displays a sorting dialog of the form:
+  
+    Current Order: <previous selection>
+    New Order: <selections made>
+    
+    <option 1>    <option 2>    <option 3>   Cancel
+  
+  Options are colored when among the "Current Order" or "New Order", but not
+  when an option below them. If cancel is selected or the user presses escape
+  then this returns None. Otherwise, the new ordering is provided.
+  
+  Arguments:
+    title   - title displayed for the popup window
+    options      - ordered listing of option labels
+    oldSelection - current ordering
+    optionColors - mappings of options to their color
+  """
+  
+  popup, _, _ = init(9, 80)
+  if not popup: return
+  newSelections = []  # new ordering
+  
+  try:
+    cursorLoc = 0     # index of highlighted option
+    curses.cbreak()   # wait indefinitely for key presses (no timeout)
+    
+    selectionOptions = list(options)
+    selectionOptions.append("Cancel")
+    
+    while len(newSelections) < len(oldSelection):
+      popup.win.erase()
+      popup.win.box()
+      popup.addstr(0, 0, title, curses.A_STANDOUT)
+      
+      _drawSortSelection(popup, 1, 2, "Current Order: ", oldSelection, optionColors)
+      _drawSortSelection(popup, 2, 2, "New Order: ", newSelections, optionColors)
+      
+      # presents remaining options, each row having up to four options with
+      # spacing of nineteen cells
+      row, col = 4, 0
+      for i in range(len(selectionOptions)):
+        optionFormat = curses.A_STANDOUT if cursorLoc == i else curses.A_NORMAL
+        popup.addstr(row, col * 19 + 2, selectionOptions[i], optionFormat)
+        col += 1
+        if col == 4: row, col = row + 1, 0
+      
+      popup.win.refresh()
+      
+      key = cli.controller.getController().getScreen().getch()
+      if key == curses.KEY_LEFT:
+        cursorLoc = max(0, cursorLoc - 1)
+      elif key == curses.KEY_RIGHT:
+        cursorLoc = min(len(selectionOptions) - 1, cursorLoc + 1)
+      elif key == curses.KEY_UP:
+        cursorLoc = max(0, cursorLoc - 4)
+      elif key == curses.KEY_DOWN:
+        cursorLoc = min(len(selectionOptions) - 1, cursorLoc + 4)
+      elif uiTools.isSelectionKey(key):
+        selection = selectionOptions[cursorLoc]
+        
+        if selection == "Cancel": break
+        else:
+          newSelections.append(selection)
+          selectionOptions.remove(selection)
+          cursorLoc = min(cursorLoc, len(selectionOptions) - 1)
+      elif key == 27: break # esc - cancel
+  finally: finalize()
+  
+  if len(newSelections) == len(oldSelection):
+    return newSelections
+  else: return None
+
+def _drawSortSelection(popup, y, x, prefix, options, optionColors):
+  """
+  Draws a series of comma separated sort selections. The whole line is bold
+  and sort options also have their specified color. Example:
+  
+    Current Order: Man Page Entry, Option Name, Is Default
+  
+  Arguments:
+    popup        - panel in which to draw sort selection
+    y            - vertical location
+    x            - horizontal location
+    prefix       - initial string description
+    options      - sort options to be shown
+    optionColors - mappings of options to their color
+  """
+  
+  popup.addstr(y, x, prefix, curses.A_BOLD)
+  x += len(prefix)
+  
+  for i in range(len(options)):
+    sortType = options[i]
+    sortColor = uiTools.getColor(optionColors.get(sortType, "white"))
+    popup.addstr(y, x, sortType, sortColor | curses.A_BOLD)
+    x += len(sortType)
+    
+    # comma divider between options, if this isn't the last
+    if i < len(options) - 1:
+      popup.addstr(y, x, ", ", curses.A_BOLD)
+      x += 2
+
+def showMenu(title, options, oldSelection):
+  """
+  Provides menu with options laid out in a single column. User can cancel
+  selection with the escape key, in which case this proives -1. Otherwise this
+  returns the index of the selection.
+  
+  Arguments:
+    title        - title displayed for the popup window
+    options      - ordered listing of options to display
+    oldSelection - index of the initially selected option (uses the first
+                   selection without a carrot if -1)
+  """
+  
+  maxWidth = max(map(len, options)) + 9
+  popup, _, _ = init(len(options) + 2, maxWidth)
+  if not popup: return
+  key, selection = 0, oldSelection if oldSelection != -1 else 0
+  
+  try:
+    # hides the title of the first panel on the page
+    control = cli.controller.getController()
+    topPanel = control.getDisplayPanels(includeSticky = False)[0]
+    topPanel.setTitleVisible(False)
+    topPanel.redraw(True)
+    
+    curses.cbreak()   # wait indefinitely for key presses (no timeout)
+    
+    while not uiTools.isSelectionKey(key):
+      popup.win.erase()
+      popup.win.box()
+      popup.addstr(0, 0, title, curses.A_STANDOUT)
+      
+      for i in range(len(options)):
+        label = options[i]
+        format = curses.A_STANDOUT if i == selection else curses.A_NORMAL
+        tab = "> " if i == oldSelection else "  "
+        popup.addstr(i + 1, 2, tab)
+        popup.addstr(i + 1, 4, " %s " % label, format)
+      
+      popup.win.refresh()
+      
+      key = control.getScreen().getch()
+      if key == curses.KEY_UP: selection = max(0, selection - 1)
+      elif key == curses.KEY_DOWN: selection = min(len(options) - 1, selection + 1)
+      elif key == 27: selection, key = -1, curses.KEY_ENTER # esc - cancel
+  finally:
+    topPanel.setTitleVisible(True)
+    finalize()
+  
+  return selection
+
diff --git a/arm/cli/torrcPanel.py b/arm/cli/torrcPanel.py
new file mode 100644
index 0000000..c9d83e6
--- /dev/null
+++ b/arm/cli/torrcPanel.py
@@ -0,0 +1,311 @@
+"""
+Panel displaying the torrc or armrc with the validation done against it.
+"""
+
+import math
+import curses
+import threading
+
+import popups
+
+from util import panel, torConfig, torTools, uiTools
+
+from stem.control import State
+from stem.util import conf, enum
+
+def conf_handler(key, value):
+  if key == "features.config.file.maxLinesPerEntry":
+    return max(1, value)
+
+CONFIG = conf.config_dict("arm", {
+  "features.config.file.showScrollbars": True,
+  "features.config.file.maxLinesPerEntry": 8,
+}, conf_handler)
+
+# TODO: The armrc use case is incomplete. There should be equivilant reloading
+# and validation capabilities to the torrc.
+Config = enum.Enum("TORRC", "ARMRC") # configuration file types that can be displayed
+
+class TorrcPanel(panel.Panel):
+  """
+  Renders the current torrc or armrc with syntax highlighting in a scrollable
+  area.
+  """
+  
+  def __init__(self, stdscr, configType):
+    panel.Panel.__init__(self, stdscr, "torrc", 0)
+    
+    self.valsLock = threading.RLock()
+    self.configType = configType
+    self.scroll = 0
+    self.showLineNum = True     # shows left aligned line numbers
+    self.stripComments = False  # drops comments and extra whitespace
+    
+    # height of the content when last rendered (the cached value is invalid if
+    # _lastContentHeightArgs is None or differs from the current dimensions)
+    self._lastContentHeight = 1
+    self._lastContentHeightArgs = None
+    
+    # listens for tor reload (sighup) events
+    conn = torTools.getConn()
+    conn.addStatusListener(self.resetListener)
+    if conn.isAlive(): self.resetListener(None, State.INIT, None)
+  
+  def resetListener(self, controller, eventType, _):
+    """
+    Reloads and displays the torrc on tor reload (sighup) events.
+    """
+    
+    if eventType == State.INIT:
+      # loads the torrc and provides warnings in case of validation errors
+      try:
+        loadedTorrc = torConfig.getTorrc()
+        loadedTorrc.load(True)
+        loadedTorrc.logValidationIssues()
+        self.redraw(True)
+      except: pass
+    elif eventType == State.RESET:
+      try:
+        torConfig.getTorrc().load(True)
+        self.redraw(True)
+      except: pass
+  
+  def setCommentsVisible(self, isVisible):
+    """
+    Sets if comments and blank lines are shown or stripped.
+    
+    Arguments:
+      isVisible - displayed comments and blank lines if true, strips otherwise
+    """
+    
+    self.stripComments = not isVisible
+    self._lastContentHeightArgs = None
+    self.redraw(True)
+  
+  def setLineNumberVisible(self, isVisible):
+    """
+    Sets if line numbers are shown or hidden.
+    
+    Arguments:
+      isVisible - displays line numbers if true, hides otherwise
+    """
+    
+    self.showLineNum = isVisible
+    self._lastContentHeightArgs = None
+    self.redraw(True)
+  
+  def reloadTorrc(self):
+    """
+    Reloads the torrc, displaying an indicator of success or failure.
+    """
+    
+    try:
+      torConfig.getTorrc().load()
+      self._lastContentHeightArgs = None
+      self.redraw(True)
+      resultMsg = "torrc reloaded"
+    except IOError:
+      resultMsg = "failed to reload torrc"
+    
+    self._lastContentHeightArgs = None
+    self.redraw(True)
+    popups.showMsg(resultMsg, 1)
+  
+  def handleKey(self, key):
+    self.valsLock.acquire()
+    isKeystrokeConsumed = True
+    if uiTools.isScrollKey(key):
+      pageHeight = self.getPreferredSize()[0] - 1
+      newScroll = uiTools.getScrollPosition(key, self.scroll, pageHeight, self._lastContentHeight)
+      
+      if self.scroll != newScroll:
+        self.scroll = newScroll
+        self.redraw(True)
+    elif key == ord('n') or key == ord('N'):
+      self.setLineNumberVisible(not self.showLineNum)
+    elif key == ord('s') or key == ord('S'):
+      self.setCommentsVisible(self.stripComments)
+    elif key == ord('r') or key == ord('R'):
+      self.reloadTorrc()
+    else: isKeystrokeConsumed = False
+    
+    self.valsLock.release()
+    return isKeystrokeConsumed
+  
+  def setVisible(self, isVisible):
+    if not isVisible:
+      self._lastContentHeightArgs = None # redraws when next displayed
+    
+    panel.Panel.setVisible(self, isVisible)
+  
+  def getHelp(self):
+    options = []
+    options.append(("up arrow", "scroll up a line", None))
+    options.append(("down arrow", "scroll down a line", None))
+    options.append(("page up", "scroll up a page", None))
+    options.append(("page down", "scroll down a page", None))
+    options.append(("s", "comment stripping", "on" if self.stripComments else "off"))
+    options.append(("n", "line numbering", "on" if self.showLineNum else "off"))
+    options.append(("r", "reload torrc", None))
+    options.append(("x", "reset tor (issue sighup)", None))
+    return options
+  
+  def draw(self, width, height):
+    self.valsLock.acquire()
+    
+    # If true, we assume that the cached value in self._lastContentHeight is
+    # still accurate, and stop drawing when there's nothing more to display.
+    # Otherwise the self._lastContentHeight is suspect, and we'll process all
+    # the content to check if it's right (and redraw again with the corrected
+    # height if not).
+    trustLastContentHeight = self._lastContentHeightArgs == (width, height)
+    
+    # restricts scroll location to valid bounds
+    self.scroll = max(0, min(self.scroll, self._lastContentHeight - height + 1))
+    
+    renderedContents, corrections, confLocation = None, {}, None
+    if self.configType == Config.TORRC:
+      loadedTorrc = torConfig.getTorrc()
+      loadedTorrc.getLock().acquire()
+      confLocation = loadedTorrc.getConfigLocation()
+      
+      if not loadedTorrc.isLoaded():
+        renderedContents = ["### Unable to load the torrc ###"]
+      else:
+        renderedContents = loadedTorrc.getDisplayContents(self.stripComments)
+        
+        # constructs a mapping of line numbers to the issue on it
+        corrections = dict((lineNum, (issue, msg)) for lineNum, issue, msg in loadedTorrc.getCorrections())
+      
+      loadedTorrc.getLock().release()
+    else:
+      loadedArmrc = conf.get_config("arm")
+      confLocation = loadedArmrc._path
+      renderedContents = list(loadedArmrc._raw_contents)
+    
+    # offset to make room for the line numbers
+    lineNumOffset = 0
+    if self.showLineNum:
+      if len(renderedContents) == 0: lineNumOffset = 2
+      else: lineNumOffset = int(math.log10(len(renderedContents))) + 2
+    
+    # draws left-hand scroll bar if content's longer than the height
+    scrollOffset = 0
+    if CONFIG["features.config.file.showScrollbars"] and self._lastContentHeight > height - 1:
+      scrollOffset = 3
+      self.addScrollBar(self.scroll, self.scroll + height - 1, self._lastContentHeight, 1)
+    
+    displayLine = -self.scroll + 1 # line we're drawing on
+    
+    # draws the top label
+    if self.isTitleVisible():
+      sourceLabel = "Tor" if self.configType == Config.TORRC else "Arm"
+      locationLabel = " (%s)" % confLocation if confLocation else ""
+      self.addstr(0, 0, "%s Configuration File%s:" % (sourceLabel, locationLabel), curses.A_STANDOUT)
+    
+    isMultiline = False # true if we're in the middle of a multiline torrc entry
+    for lineNumber in range(0, len(renderedContents)):
+      lineText = renderedContents[lineNumber]
+      lineText = lineText.rstrip() # remove ending whitespace
+      
+      # blank lines are hidden when stripping comments
+      if self.stripComments and not lineText: continue
+      
+      # splits the line into its component (msg, format) tuples
+      lineComp = {"option": ["", curses.A_BOLD | uiTools.getColor("green")],
+                  "argument": ["", curses.A_BOLD | uiTools.getColor("cyan")],
+                  "correction": ["", curses.A_BOLD | uiTools.getColor("cyan")],
+                  "comment": ["", uiTools.getColor("white")]}
+      
+      # parses the comment
+      commentIndex = lineText.find("#")
+      if commentIndex != -1:
+        lineComp["comment"][0] = lineText[commentIndex:]
+        lineText = lineText[:commentIndex]
+      
+      # splits the option and argument, preserving any whitespace around them
+      strippedLine = lineText.strip()
+      optionIndex = strippedLine.find(" ")
+      if isMultiline:
+        # part of a multiline entry started on a previous line so everything
+        # is part of the argument
+        lineComp["argument"][0] = lineText
+      elif optionIndex == -1:
+        # no argument provided
+        lineComp["option"][0] = lineText
+      else:
+        optionText = strippedLine[:optionIndex]
+        optionEnd = lineText.find(optionText) + len(optionText)
+        lineComp["option"][0] = lineText[:optionEnd]
+        lineComp["argument"][0] = lineText[optionEnd:]
+      
+      # flags following lines as belonging to this multiline entry if it ends
+      # with a slash
+      if strippedLine: isMultiline = strippedLine.endswith("\\")
+      
+      # gets the correction
+      if lineNumber in corrections:
+        lineIssue, lineIssueMsg = corrections[lineNumber]
+        
+        if lineIssue in (torConfig.ValidationError.DUPLICATE, torConfig.ValidationError.IS_DEFAULT):
+          lineComp["option"][1] = curses.A_BOLD | uiTools.getColor("blue")
+          lineComp["argument"][1] = curses.A_BOLD | uiTools.getColor("blue")
+        elif lineIssue == torConfig.ValidationError.MISMATCH:
+          lineComp["argument"][1] = curses.A_BOLD | uiTools.getColor("red")
+          lineComp["correction"][0] = " (%s)" % lineIssueMsg
+        else:
+          # For some types of configs the correction field is simply used to
+          # provide extra data (for instance, the type for tor state fields).
+          lineComp["correction"][0] = " (%s)" % lineIssueMsg
+          lineComp["correction"][1] = curses.A_BOLD | uiTools.getColor("magenta")
+      
+      # draws the line number
+      if self.showLineNum and displayLine < height and displayLine >= 1:
+        lineNumStr = ("%%%ii" % (lineNumOffset - 1)) % (lineNumber + 1)
+        self.addstr(displayLine, scrollOffset, lineNumStr, curses.A_BOLD | uiTools.getColor("yellow"))
+      
+      # draws the rest of the components with line wrap
+      cursorLoc, lineOffset = lineNumOffset + scrollOffset, 0
+      maxLinesPerEntry = CONFIG["features.config.file.maxLinesPerEntry"]
+      displayQueue = [lineComp[entry] for entry in ("option", "argument", "correction", "comment")]
+      
+      while displayQueue:
+        msg, format = displayQueue.pop(0)
+        
+        maxMsgSize, includeBreak = width - cursorLoc, False
+        if len(msg) >= maxMsgSize:
+          # message is too long - break it up
+          if lineOffset == maxLinesPerEntry - 1:
+            msg = uiTools.cropStr(msg, maxMsgSize)
+          else:
+            includeBreak = True
+            msg, remainder = uiTools.cropStr(msg, maxMsgSize, 4, 4, uiTools.Ending.HYPHEN, True)
+            displayQueue.insert(0, (remainder.strip(), format))
+        
+        drawLine = displayLine + lineOffset
+        if msg and drawLine < height and drawLine >= 1:
+          self.addstr(drawLine, cursorLoc, msg, format)
+        
+        # If we're done, and have added content to this line, then start
+        # further content on the next line.
+        cursorLoc += len(msg)
+        includeBreak |= not displayQueue and cursorLoc != lineNumOffset + scrollOffset
+        
+        if includeBreak:
+          lineOffset += 1
+          cursorLoc = lineNumOffset + scrollOffset
+      
+      displayLine += max(lineOffset, 1)
+      
+      if trustLastContentHeight and displayLine >= height: break
+    
+    if not trustLastContentHeight:
+      self._lastContentHeightArgs = (width, height)
+      newContentHeight = displayLine + self.scroll - 1
+      
+      if self._lastContentHeight != newContentHeight:
+        self._lastContentHeight = newContentHeight
+        self.redraw(True)
+    
+    self.valsLock.release()
+
diff --git a/arm/prereq.py b/arm/prereq.py
new file mode 100644
index 0000000..c01dfbd
--- /dev/null
+++ b/arm/prereq.py
@@ -0,0 +1,141 @@
+"""
+Provides a warning and error code if python version isn't compatible.
+"""
+
+import os
+import sys
+import shutil
+import urllib
+import hashlib
+import tarfile
+import tempfile
+
+# Library dependencies can be fetched on request. By default this is via
+# the following mirrors with their sha256 signatures checked.
+#STEM_ARCHIVE = "http://www.atagar.com/arm/resources/deps/11-06-16/torctl.tar.gz"
+#STEM_SIG = "5460adb1394c368ba492cc33d6681618b3d3062b3f5f70b2a87520fc291701c3"
+
+# optionally we can do an unverified fetch from the library's sources
+STEM_REPO = "git://git.torproject.org/stem.git"
+
+def isStemAvailable():
+  """
+  True if stem is already available on the platform, false otherwise.
+  """
+  
+  try:
+    import stem
+    return True
+  except ImportError:
+    return False
+
+def promptStemInstall():
+  """
+  Asks the user to install stem. This returns True if it was installed and
+  False otherwise (if it was either declined or failed to be fetched).
+  """
+  
+  userInput = raw_input("Arm requires stem to run, but it's unavailable. Would you like to install it? (y/n): ")
+  
+  # if user says no then terminate
+  if not userInput.lower() in ("y", "yes"): return False
+  
+  # attempt to install stem, printing the issue if unsuccessful
+  try:
+    #fetchLibrary(STEM_ARCHIVE, STEM_SIG)
+    installStem()
+    
+    if not isStemAvailable():
+      raise IOError("Unable to install stem, sorry")
+    
+    print "Stem successfully installed"
+    return True
+  except IOError, exc:
+    print exc
+    return False
+
+def fetchLibrary(url, sig):
+  """
+  Downloads the given archive, verifies its signature, then installs the
+  library. This raises an IOError if any of these steps fail.
+  
+  Arguments:
+    url - url from which to fetch the gzipped tarball
+    sig - sha256 signature for the archive
+  """
+  
+  tmpDir = tempfile.mkdtemp()
+  destination = tmpDir + "/" + url.split("/")[-1]
+  urllib.urlretrieve(url, destination)
+  
+  # checks the signature, reading the archive in 256-byte chunks
+  m = hashlib.sha256()
+  fd = open(destination, "rb")
+  
+  while True:
+    data = fd.read(256)
+    if not data: break
+    m.update(data)
+  
+  fd.close()
+  actualSig = m.hexdigest()
+  
+  if sig != actualSig:
+    raise IOError("Signature of the library is incorrect (got '%s' rather than '%s')" % (actualSig, sig))
+  
+  # extracts the tarball
+  tarFd = tarfile.open(destination, 'r:gz')
+  tarFd.extractall("src/")
+  tarFd.close()
+  
+  # clean up the temporary contents (fails quietly if unsuccessful)
+  shutil.rmtree(destination, ignore_errors=True)
+
+def installStem():
+  """
+  Checks out the current git head release for stem and bundles it with arm.
+  This raises an IOError if unsuccessful.
+  """
+  
+  if isStemAvailable(): return
+  
+  # temporary destination for stem's git clone, guarenteed to be unoccupied
+  # (to avoid conflicting with files that are already there)
+  tmpFilename = tempfile.mktemp("/stem")
+  
+  # fetches stem
+  exitStatus = os.system("git clone --quiet %s %s > /dev/null" % (STEM_REPO, tmpFilename))
+  if exitStatus: raise IOError("Unable to get stem from %s. Is git installed?" % STEM_REPO)
+  
+  # the destination for stem will be our directory
+  ourDir = os.path.dirname(os.path.realpath(__file__))
+  
+  # exports stem to our location
+  exitStatus = os.system("(cd %s && git archive --format=tar master stem) | (cd %s && tar xf - 2> /dev/null)" % (tmpFilename, ourDir))
+  if exitStatus: raise IOError("Unable to install stem to %s" % ourDir)
+  
+  # Clean up the temporary contents. This isn't vital so quietly fails in case
+  # of errors.
+  shutil.rmtree(tmpFilename, ignore_errors=True)
+
+if __name__ == '__main__':
+  majorVersion = sys.version_info[0]
+  minorVersion = sys.version_info[1]
+  
+  if majorVersion > 2:
+    print("arm isn't compatible beyond the python 2.x series\n")
+    sys.exit(1)
+  elif majorVersion < 2 or minorVersion < 5:
+    print("arm requires python version 2.5 or greater\n")
+    sys.exit(1)
+  
+  if not isStemAvailable():
+    isInstalled = promptStemInstall()
+    if not isInstalled: sys.exit(1)
+  
+  try:
+    import curses
+  except ImportError:
+    print("arm requires curses - try installing the python-curses package\n")
+    sys.exit(1)
+
diff --git a/arm/resources/arm.1 b/arm/resources/arm.1
new file mode 100644
index 0000000..3b8cfd4
--- /dev/null
+++ b/arm/resources/arm.1
@@ -0,0 +1,74 @@
+.TH arm 1 "27 August 2010"
+.SH NAME
+arm - Terminal Tor status monitor
+
+.SH SYNOPSIS
+arm [\fIOPTION\fR]
+
+.SH DESCRIPTION
+The anonymizing relay monitor (arm) is a terminal status monitor for Tor
+relays, intended for command-line aficionados, ssh connections, and anyone
+stuck with a tty terminal. This works much like top does for system usage,
+providing real time statistics for:
+  * bandwidth, cpu, and memory usage
+  * relay's current configuration
+  * logged events
+  * connection details (ip, hostname, fingerprint, and consensus data)
+  * etc
+
+Defaults and interface properties are configurable via a user provided
+configuration file (for an example see the provided \fBarmrc.sample\fR).
+Releases and information are available at \fIhttp://www.atagar.com/arm\fR.
+
+.SH OPTIONS
+.TP
+\fB\-i\fR, \fB\-\-interface [ADDRESS:]PORT\fR
+tor control port arm should attach to (default is \fB127.0.0.1:9051\fR)
+
+.TP
+\fB\-c\fR, \fB\-\-config CONFIG_PATH\fR
+user provided configuration file (default is \fB~/.arm/armrc\fR)
+
+.TP
+\fB\-d\fR, \fB\-\-debug\fR
+writes all arm logs to ~/.arm/log
+
+.TP
+\fB\-b\fR, \fB\-\-blind\fR
+disable connection lookups (netstat, lsof, and ss), dropping the parts of the
+interface that rely on this information
+
+.TP
+\fB\-e\fR, \fB\-\-event EVENT_FLAGS\fR
+flags for tor, arm, and torctl events to be logged (default is \fBN3\fR)
+
+  d DEBUG      a ADDRMAP           k DESCCHANGED   s STREAM
+  i INFO       f AUTHDIR_NEWDESCS  g GUARD         r STREAM_BW
+  n NOTICE     h BUILDTIMEOUT_SET  l NEWCONSENSUS  t STATUS_CLIENT
+  w WARN       b BW                m NEWDESC       u STATUS_GENERAL
+  e ERR        c CIRC              p NS            v STATUS_SERVER
+               j CLIENTS_SEEN      q ORCONN
+    DINWE tor runlevel+            A All Events
+    12345 arm runlevel+            X No Events
+    67890 torctl runlevel+         U Unknown Events
+
+.TP
+\fB\-v\fR, \fB\-\-version\fR
+provides version information
+
+.TP
+\fB\-h\fR, \fB\-\-help\fR
+provides usage information
+
+.SH FILES
+.TP
+\fB~/.arm/armrc\fR
+Your personal arm configuration file
+
+.TP
+\fB/usr/share/doc/arm/armrc.sample\fR
+Sample armrc configuration file that documents all options
+
+.SH AUTHOR
+Written by Damian Johnson (atagar at torproject.org)
+
diff --git a/arm/resources/tor-arm.desktop b/arm/resources/tor-arm.desktop
new file mode 100644
index 0000000..da94017
--- /dev/null
+++ b/arm/resources/tor-arm.desktop
@@ -0,0 +1,12 @@
+[Desktop Entry]
+Name=Tor monitor
+Name[es]=Monitor de Tor
+Comment=Status monitor for Tor routers
+Comment[es]=Monitor de estado para routers Tor
+GenericName=Monitor
+GenericName[es]=Monitor
+Exec=arm -g
+Icon=tor-arm
+Terminal=false
+Type=Application
+Categories=System;Monitor;GTK;
diff --git a/arm/resources/tor-arm.svg b/arm/resources/tor-arm.svg
new file mode 100644
index 0000000..8e710ab
--- /dev/null
+++ b/arm/resources/tor-arm.svg
@@ -0,0 +1,1074 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   version="1.0"
+   width="128"
+   height="128"
+   id="svg2"
+   inkscape:version="0.48.1 r9760"
+   sodipodi:docname="utilities-system-monitor.svg">
+  <metadata
+     id="metadata261">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1024"
+     inkscape:window-height="550"
+     id="namedview259"
+     showgrid="false"
+     inkscape:zoom="2.3828125"
+     inkscape:cx="64"
+     inkscape:cy="63.692344"
+     inkscape:window-x="0"
+     inkscape:window-y="25"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="layer1" />
+  <defs
+     id="defs4">
+    <linearGradient
+       id="linearGradient4199">
+      <stop
+         style="stop-color:white;stop-opacity:1"
+         offset="0"
+         id="stop4201" />
+      <stop
+         style="stop-color:white;stop-opacity:0"
+         offset="1"
+         id="stop4203" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient4167">
+      <stop
+         style="stop-color:#171717;stop-opacity:1"
+         offset="0"
+         id="stop4169" />
+      <stop
+         style="stop-color:#777;stop-opacity:1"
+         offset="1"
+         id="stop4171" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient4159">
+      <stop
+         style="stop-color:white;stop-opacity:1"
+         offset="0"
+         id="stop4161" />
+      <stop
+         style="stop-color:white;stop-opacity:0"
+         offset="1"
+         id="stop4163" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient4142">
+      <stop
+         style="stop-color:#e5ff00;stop-opacity:1"
+         offset="0"
+         id="stop4144" />
+      <stop
+         style="stop-color:#e5ff00;stop-opacity:0"
+         offset="1"
+         id="stop4146" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3399">
+      <stop
+         style="stop-color:yellow;stop-opacity:1"
+         offset="0"
+         id="stop3401" />
+      <stop
+         style="stop-color:yellow;stop-opacity:0"
+         offset="1"
+         id="stop3403" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3391">
+      <stop
+         style="stop-color:#ffff1d;stop-opacity:1"
+         offset="0"
+         id="stop3393" />
+      <stop
+         style="stop-color:#ffff6f;stop-opacity:0"
+         offset="1"
+         id="stop3395" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3383">
+      <stop
+         style="stop-color:yellow;stop-opacity:1"
+         offset="0"
+         id="stop3385" />
+      <stop
+         style="stop-color:yellow;stop-opacity:0"
+         offset="1"
+         id="stop3387" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient4111">
+      <stop
+         style="stop-color:black;stop-opacity:1"
+         offset="0"
+         id="stop4113" />
+      <stop
+         style="stop-color:black;stop-opacity:0"
+         offset="1"
+         id="stop4115" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient4031">
+      <stop
+         style="stop-color:#292929;stop-opacity:1"
+         offset="0"
+         id="stop4033" />
+      <stop
+         style="stop-color:#e9e9e9;stop-opacity:1"
+         offset="1"
+         id="stop4035" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient4002">
+      <stop
+         style="stop-color:lime;stop-opacity:1"
+         offset="0"
+         id="stop4004" />
+      <stop
+         style="stop-color:#f0ff80;stop-opacity:0"
+         offset="1"
+         id="stop4006" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3785">
+      <stop
+         style="stop-color:black;stop-opacity:1"
+         offset="0"
+         id="stop3787" />
+      <stop
+         style="stop-color:black;stop-opacity:0"
+         offset="1"
+         id="stop3789" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3761">
+      <stop
+         style="stop-color:#f6f6f6;stop-opacity:1"
+         offset="0"
+         id="stop3763" />
+      <stop
+         style="stop-color:#5a5a5a;stop-opacity:1"
+         offset="1"
+         id="stop3765" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3749">
+      <stop
+         style="stop-color:#181818;stop-opacity:1"
+         offset="0"
+         id="stop3751" />
+      <stop
+         style="stop-color:#ababab;stop-opacity:1"
+         offset="1"
+         id="stop3753" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3737">
+      <stop
+         style="stop-color:gray;stop-opacity:1"
+         offset="0"
+         id="stop3739" />
+      <stop
+         style="stop-color:#232323;stop-opacity:1"
+         offset="1"
+         id="stop3741" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3729">
+      <stop
+         style="stop-color:#ededed;stop-opacity:1"
+         offset="0"
+         id="stop3731" />
+      <stop
+         style="stop-color:#bcbcbc;stop-opacity:1"
+         offset="1"
+         id="stop3733" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3570">
+      <stop
+         style="stop-color:black;stop-opacity:1"
+         offset="0"
+         id="stop3572" />
+      <stop
+         style="stop-color:black;stop-opacity:0"
+         offset="1"
+         id="stop3574" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3470">
+      <stop
+         style="stop-color:#ddd;stop-opacity:1"
+         offset="0"
+         id="stop3472" />
+      <stop
+         style="stop-color:#fbfbfb;stop-opacity:1"
+         offset="1"
+         id="stop3474" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3452">
+      <stop
+         style="stop-color:#979797;stop-opacity:1"
+         offset="0"
+         id="stop3454" />
+      <stop
+         style="stop-color:#454545;stop-opacity:1"
+         offset="1"
+         id="stop3456" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3440">
+      <stop
+         style="stop-color:black;stop-opacity:1"
+         offset="0"
+         id="stop3442" />
+      <stop
+         style="stop-color:black;stop-opacity:0"
+         offset="1"
+         id="stop3444" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3384">
+      <stop
+         style="stop-color:black;stop-opacity:1"
+         offset="0"
+         id="stop3386" />
+      <stop
+         style="stop-color:black;stop-opacity:0"
+         offset="1"
+         id="stop3388" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3292">
+      <stop
+         style="stop-color:#5e5e5e;stop-opacity:1"
+         offset="0"
+         id="stop3294" />
+      <stop
+         style="stop-color:#292929;stop-opacity:1"
+         offset="1"
+         id="stop3296" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3275">
+      <stop
+         style="stop-color:#323232;stop-opacity:1"
+         offset="0"
+         id="stop3277" />
+      <stop
+         style="stop-color:#1a1a1a;stop-opacity:1"
+         offset="1"
+         id="stop3279" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3265">
+      <stop
+         style="stop-color:white;stop-opacity:1"
+         offset="0"
+         id="stop3267" />
+      <stop
+         style="stop-color:white;stop-opacity:0"
+         offset="1"
+         id="stop3269" />
+    </linearGradient>
+    <filter
+       id="filter3162">
+      <feGaussianBlur
+         id="feGaussianBlur3164"
+         stdDeviation="0.14753906"
+         inkscape:collect="always" />
+    </filter>
+    <filter
+       id="filter3193">
+      <feGaussianBlur
+         id="feGaussianBlur3195"
+         stdDeviation="0.12753906"
+         inkscape:collect="always" />
+    </filter>
+    <filter
+       id="filter3247"
+       height="1.60944"
+       y="-0.30472"
+       width="1.03826"
+       x="-0.019130022">
+      <feGaussianBlur
+         id="feGaussianBlur3249"
+         stdDeviation="0.89273437"
+         inkscape:collect="always" />
+    </filter>
+    <radialGradient
+       cx="64"
+       cy="7.1979251"
+       r="56"
+       fx="64"
+       fy="7.1979251"
+       id="radialGradient3271"
+       xlink:href="#linearGradient3265"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.236503,0,0,0.798045,-15.13621,10.25573)" />
+    <radialGradient
+       cx="56"
+       cy="65.961678"
+       r="44"
+       fx="56"
+       fy="64.752823"
+       id="radialGradient3281"
+       xlink:href="#linearGradient3292"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(2.206761,0,0,2.057714,-67.57862,-106.9325)" />
+    <radialGradient
+       cx="56"
+       cy="60"
+       r="44"
+       fx="56"
+       fy="99.821198"
+       id="radialGradient3287"
+       xlink:href="#linearGradient3275"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.497439,3.473066e-8,-3.238492e-8,1.3963,-27.85656,-45.05228)" />
+    <radialGradient
+       cx="56"
+       cy="60"
+       r="44"
+       fx="56"
+       fy="99.821198"
+       id="radialGradient3289"
+       xlink:href="#linearGradient3275"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.497439,3.473066e-8,-3.238492e-8,1.3963,-27.85656,-44.05228)" />
+    <clipPath
+       id="clipPath3361">
+      <rect
+         width="88"
+         height="72"
+         rx="5.0167508"
+         ry="5.0167508"
+         x="12"
+         y="24"
+         style="opacity:1;fill:url(#radialGradient3365);fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         id="rect3363" />
+    </clipPath>
+    <radialGradient
+       cx="56"
+       cy="65.961678"
+       r="44"
+       fx="56"
+       fy="64.752823"
+       id="radialGradient3365"
+       xlink:href="#linearGradient3292"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(2.206761,0,0,2.057714,-67.57862,-106.9325)" />
+    <linearGradient
+       x1="52.513512"
+       y1="97"
+       x2="52.513512"
+       y2="74.244766"
+       id="linearGradient3390"
+       xlink:href="#linearGradient3384"
+       gradientUnits="userSpaceOnUse" />
+    <clipPath
+       id="clipPath3402">
+      <rect
+         width="88"
+         height="72"
+         rx="5.0167508"
+         ry="5.0167508"
+         x="12"
+         y="24"
+         style="opacity:1;fill:url(#radialGradient3406);fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         id="rect3404" />
+    </clipPath>
+    <radialGradient
+       cx="56"
+       cy="65.961678"
+       r="44"
+       fx="56"
+       fy="64.752823"
+       id="radialGradient3406"
+       xlink:href="#linearGradient3292"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(2.206761,0,0,2.057714,-67.57862,-106.9325)" />
+    <filter
+       id="filter3424">
+      <feGaussianBlur
+         id="feGaussianBlur3426"
+         stdDeviation="0.23507812"
+         inkscape:collect="always" />
+    </filter>
+    <filter
+       id="filter3430">
+      <feGaussianBlur
+         id="feGaussianBlur3432"
+         stdDeviation="0.23507812"
+         inkscape:collect="always" />
+    </filter>
+    <linearGradient
+       x1="100"
+       y1="92.763115"
+       x2="100"
+       y2="60"
+       id="linearGradient3446"
+       xlink:href="#linearGradient3440"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       x1="100"
+       y1="92.763115"
+       x2="100"
+       y2="60"
+       id="linearGradient3450"
+       xlink:href="#linearGradient3440"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="translate(0,-120)" />
+    <radialGradient
+       cx="108.33566"
+       cy="25.487402"
+       r="4.171701"
+       fx="108.33566"
+       fy="25.487402"
+       id="radialGradient3458"
+       xlink:href="#linearGradient3452"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.432375,0,0,1.432375,-46.84166,-11.02012)" />
+    <linearGradient
+       x1="110.75722"
+       y1="32.559616"
+       x2="106.72433"
+       y2="24.216215"
+       id="linearGradient3476"
+       xlink:href="#linearGradient3470"
+       gradientUnits="userSpaceOnUse" />
+    <filter
+       id="filter3549"
+       height="1.348368"
+       y="-0.17418399"
+       width="1.1806649"
+       x="-0.090332433">
+      <feGaussianBlur
+         id="feGaussianBlur3551"
+         stdDeviation="0.099971814"
+         inkscape:collect="always" />
+    </filter>
+    <filter
+       id="filter3553"
+       height="1.2047423"
+       y="-0.10237114"
+       width="1.2103517"
+       x="-0.10517583">
+      <feGaussianBlur
+         id="feGaussianBlur3555"
+         stdDeviation="0.099971814"
+         inkscape:collect="always" />
+    </filter>
+    <filter
+       id="filter3557"
+       height="1.348368"
+       y="-0.17418399"
+       width="1.1806649"
+       x="-0.090332433">
+      <feGaussianBlur
+         id="feGaussianBlur3559"
+         stdDeviation="0.099971814"
+         inkscape:collect="always" />
+    </filter>
+    <filter
+       id="filter3561"
+       height="1.2047423"
+       y="-0.10237114"
+       width="1.2103517"
+       x="-0.10517583">
+      <feGaussianBlur
+         id="feGaussianBlur3563"
+         stdDeviation="0.099971814"
+         inkscape:collect="always" />
+    </filter>
+    <linearGradient
+       x1="111.58585"
+       y1="31.213261"
+       x2="116.79939"
+       y2="35.079716"
+       id="linearGradient3576"
+       xlink:href="#linearGradient3570"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="translate(-0.559618,-0.203498)" />
+    <filter
+       id="filter3590">
+      <feGaussianBlur
+         id="feGaussianBlur3592"
+         stdDeviation="0.29695312"
+         inkscape:collect="always" />
+    </filter>
+    <linearGradient
+       x1="111.58585"
+       y1="31.213261"
+       x2="116.79939"
+       y2="35.079716"
+       id="linearGradient3671"
+       xlink:href="#linearGradient3570"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="translate(-0.559618,-0.203498)" />
+    <radialGradient
+       cx="108.33566"
+       cy="25.487402"
+       r="4.171701"
+       fx="108.33566"
+       fy="25.487402"
+       id="radialGradient3673"
+       xlink:href="#linearGradient3452"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.432375,0,0,1.432375,-46.84166,-11.02012)" />
+    <linearGradient
+       x1="110.75722"
+       y1="32.559616"
+       x2="106.72433"
+       y2="24.216215"
+       id="linearGradient3675"
+       xlink:href="#linearGradient3470"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       x1="111.58585"
+       y1="31.213261"
+       x2="116.79939"
+       y2="35.079716"
+       id="linearGradient3711"
+       xlink:href="#linearGradient3570"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="translate(-0.559618,-0.203498)" />
+    <radialGradient
+       cx="108.33566"
+       cy="25.487402"
+       r="4.171701"
+       fx="108.33566"
+       fy="25.487402"
+       id="radialGradient3713"
+       xlink:href="#linearGradient3452"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.432375,0,0,1.432375,-46.84166,-11.02012)" />
+    <linearGradient
+       x1="110.75722"
+       y1="32.559616"
+       x2="106.72433"
+       y2="24.216215"
+       id="linearGradient3715"
+       xlink:href="#linearGradient3470"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       x1="110"
+       y1="84"
+       x2="110"
+       y2="72.081078"
+       id="linearGradient3735"
+       xlink:href="#linearGradient3729"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       x1="110"
+       y1="84"
+       x2="110"
+       y2="88"
+       id="linearGradient3743"
+       xlink:href="#linearGradient3737"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       x1="110"
+       y1="84"
+       x2="110"
+       y2="72.081078"
+       id="linearGradient3747"
+       xlink:href="#linearGradient3729"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1,0,0,0.2,0,-90.8)" />
+    <radialGradient
+       cx="110"
+       cy="87.735802"
+       r="4"
+       fx="110"
+       fy="87.735802"
+       id="radialGradient3755"
+       xlink:href="#linearGradient3749"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(5.343975,0,0,6.161922,-477.8373,-454.2492)" />
+    <linearGradient
+       x1="113.34818"
+       y1="79.669319"
+       x2="118.02862"
+       y2="79.669319"
+       id="linearGradient3791"
+       xlink:href="#linearGradient3785"
+       gradientUnits="userSpaceOnUse" />
+    <filter
+       id="filter3853"
+       height="1.1794737"
+       y="-0.089736843"
+       width="1.6153383"
+       x="-0.30766916">
+      <feGaussianBlur
+         id="feGaussianBlur3855"
+         stdDeviation="0.54783699"
+         inkscape:collect="always" />
+    </filter>
+    <linearGradient
+       x1="98.899841"
+       y1="40.170177"
+       x2="98.899841"
+       y2="104.503"
+       id="linearGradient4008"
+       xlink:href="#linearGradient4002"
+       gradientUnits="userSpaceOnUse" />
+    <clipPath
+       id="clipPath4019">
+      <rect
+         width="88"
+         height="72"
+         rx="5.0167508"
+         ry="5.0167508"
+         x="12"
+         y="24"
+         style="opacity:0.65263157;fill:url(#linearGradient4023);fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         id="rect4021" />
+    </clipPath>
+    <linearGradient
+       x1="100"
+       y1="92.763115"
+       x2="100"
+       y2="60"
+       id="linearGradient4023"
+       xlink:href="#linearGradient3440"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       x1="100"
+       y1="92.763115"
+       x2="100"
+       y2="72.820351"
+       id="linearGradient4027"
+       xlink:href="#linearGradient3440"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       x1="100"
+       y1="65.697929"
+       x2="95.716316"
+       y2="65.697929"
+       id="linearGradient4099"
+       xlink:href="#linearGradient3440"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       x1="100"
+       y1="65.697929"
+       x2="95.909744"
+       y2="65.697929"
+       id="linearGradient4103"
+       xlink:href="#linearGradient3440"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="translate(-112,0)" />
+    <linearGradient
+       x1="48.9221"
+       y1="24"
+       x2="48.9221"
+       y2="30.250481"
+       id="linearGradient4107"
+       xlink:href="#linearGradient3440"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="translate(-112,0)" />
+    <radialGradient
+       cx="64"
+       cy="73.977821"
+       r="52"
+       fx="64"
+       fy="73.977821"
+       id="radialGradient4119"
+       xlink:href="#linearGradient4111"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1,0,0,0.285229,0,74.89936)"
+       spreadMethod="reflect" />
+    <filter
+       id="filter4137"
+       height="1.5494737"
+       y="-0.27473684"
+       width="1.0634008"
+       x="-0.031700405">
+      <feGaussianBlur
+         id="feGaussianBlur4139"
+         stdDeviation="1.3736842"
+         inkscape:collect="always" />
+    </filter>
+    <clipPath
+       id="clipPath3379">
+      <rect
+         width="88"
+         height="72"
+         rx="5.0167508"
+         ry="5.0167508"
+         x="-100"
+         y="23"
+         transform="scale(-1,1)"
+         style="opacity:0.32105264;fill:black;fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         id="rect3381" />
+    </clipPath>
+    <linearGradient
+       x1="100.11033"
+       y1="69.474098"
+       x2="-17.198158"
+       y2="69.474098"
+       id="linearGradient3389"
+       xlink:href="#linearGradient3383"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       x1="101.41602"
+       y1="64.334373"
+       x2="-35.975773"
+       y2="64.334373"
+       id="linearGradient3397"
+       xlink:href="#linearGradient3391"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       x1="99.727539"
+       y1="63.027271"
+       x2="-3.3565123"
+       y2="63.027271"
+       id="linearGradient3405"
+       xlink:href="#linearGradient3399"
+       gradientUnits="userSpaceOnUse" />
+    <filter
+       id="filter3411"
+       height="1.3350769"
+       y="-0.16753846"
+       width="1.0821887"
+       x="-0.04109434">
+      <feGaussianBlur
+         id="feGaussianBlur3413"
+         stdDeviation="1.815"
+         inkscape:collect="always" />
+    </filter>
+    <filter
+       id="filter4138"
+       height="1.252"
+       y="-0.126"
+       width="1.252"
+       x="-0.126">
+      <feGaussianBlur
+         id="feGaussianBlur4140"
+         stdDeviation="0.21"
+         inkscape:collect="always" />
+    </filter>
+    <radialGradient
+       cx="18"
+       cy="102"
+       r="2"
+       fx="18"
+       fy="102"
+       id="radialGradient4148"
+       xlink:href="#linearGradient4142"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(3.25543,0,0,3.25543,-40.59774,-230.0538)" />
+    <linearGradient
+       x1="20.930662"
+       y1="96.872108"
+       x2="23.156008"
+       y2="105.17721"
+       id="linearGradient4165"
+       xlink:href="#linearGradient4159"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       x1="34.736519"
+       y1="106.93066"
+       x2="21.263483"
+       y2="100"
+       id="linearGradient4173"
+       xlink:href="#linearGradient4167"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(0.666667,0,0,1,5.333334,0)" />
+    <filter
+       id="filter4190">
+      <feGaussianBlur
+         id="feGaussianBlur4192"
+         stdDeviation="2.6020349"
+         inkscape:collect="always" />
+    </filter>
+    <linearGradient
+       x1="29.355932"
+       y1="27.119223"
+       x2="35.527592"
+       y2="50.152176"
+       id="linearGradient4205"
+       xlink:href="#linearGradient4199"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3440"
+       id="linearGradient3238"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.4101341,0,0,1.4101341,-142.94128,-20.830999)"
+       x1="100"
+       y1="65.697929"
+       x2="95.909744"
+       y2="65.697929" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3440"
+       id="linearGradient3240"
+       gradientUnits="userSpaceOnUse"
+       x1="100"
+       y1="65.697929"
+       x2="95.716316"
+       y2="65.697929"
+       gradientTransform="matrix(1.4101341,0,0,1.4101341,-14.993741,-20.830999)" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3440"
+       id="linearGradient3242"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.4101341,0,0,1.4101341,-142.94128,-20.830999)"
+       x1="48.9221"
+       y1="24"
+       x2="48.9221"
+       y2="30.250481" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient4199"
+       id="linearGradient3244"
+       gradientUnits="userSpaceOnUse"
+       x1="29.355932"
+       y1="27.119223"
+       x2="35.527592"
+       y2="50.152176"
+       gradientTransform="matrix(1.4101341,0,0,1.4101341,-14.993741,-20.830999)" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3440"
+       id="linearGradient3246"
+       gradientUnits="userSpaceOnUse"
+       x1="100"
+       y1="92.763115"
+       x2="100"
+       y2="72.820351"
+       gradientTransform="matrix(1.4101341,0,0,1.4101341,-14.993741,-20.830999)" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3383"
+       id="linearGradient3248"
+       gradientUnits="userSpaceOnUse"
+       x1="100.11033"
+       y1="69.474098"
+       x2="-17.198158"
+       y2="69.474098" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3391"
+       id="linearGradient3250"
+       gradientUnits="userSpaceOnUse"
+       x1="101.41602"
+       y1="64.334373"
+       x2="-35.975773"
+       y2="64.334373" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3399"
+       id="linearGradient3252"
+       gradientUnits="userSpaceOnUse"
+       x1="99.727539"
+       y1="63.027271"
+       x2="-3.3565123"
+       y2="63.027271" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3440"
+       id="linearGradient3254"
+       gradientUnits="userSpaceOnUse"
+       x1="100"
+       y1="92.763115"
+       x2="100"
+       y2="60"
+       gradientTransform="matrix(1.4101341,0,0,1.4101341,-14.993741,-20.830999)" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3440"
+       id="linearGradient3256"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.4101341,0,0,1.4101341,-14.993741,-148.3851)"
+       x1="100"
+       y1="92.763115"
+       x2="100"
+       y2="60" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3292"
+       id="radialGradient3258"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(3.111829,0,0,2.9016527,-110.28866,-171.62017)"
+       cx="56"
+       cy="65.961678"
+       fx="56"
+       fy="64.752823"
+       r="44" />
+  </defs>
+  <g
+     id="layer1">
+    <rect
+       width="124.0918"
+       height="101.52966"
+       rx="7.0742917"
+       ry="7.0742917"
+       x="1.9278687"
+       y="13.01222"
+       style="fill:url(#radialGradient3258);fill-opacity:1;stroke:none"
+       id="rect3273" />
+    <g
+       style="opacity:0.25789478;fill:#ff7e00;stroke:#d3d7cf"
+       clip-path="url(#clipPath3361)"
+       id="g3349"
+       transform="matrix(1.4101341,0,0,1.4101341,-14.993741,-20.830999)">
+      <path
+         d="m 24.5,19.5 0,80"
+         style="fill:#ff7e00;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+         id="path3300"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 40.5,19.5 0,80"
+         style="fill:#ff7e00;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+         id="path3307"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 56.5,19.5 0,80"
+         style="fill:#ff7e00;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+         id="path3309"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 72.5,19.5 0,80"
+         style="fill:#ff7e00;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+         id="path3311"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 88.5,19.5 0,80"
+         style="fill:#ff7e00;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+         id="path3317"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 0.5,60.5 110.61729,0"
+         style="fill:#ff7e00;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+         id="path3325"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 0.5,79.5 110.61729,0"
+         style="fill:#ff7e00;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+         id="path3327"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 0.5,40.5 110.61729,0"
+         style="fill:#ff7e00;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+         id="path3329"
+         inkscape:connector-curvature="0" />
+    </g>
+    <rect
+       width="124.0918"
+       height="101.52966"
+       rx="7.0742917"
+       ry="7.0742917"
+       x="1.9278687"
+       y="-114.54188"
+       transform="scale(1,-1)"
+       style="opacity:0.32105264;fill:url(#linearGradient3256);fill-opacity:1;stroke:none"
+       id="rect3448" />
+    <rect
+       width="124.0918"
+       height="101.52966"
+       rx="7.0742917"
+       ry="7.0742917"
+       x="1.9278687"
+       y="13.01222"
+       style="opacity:0.43684214;fill:url(#linearGradient3254);fill-opacity:1;stroke:none"
+       id="rect4025" />
+    <g
+       transform="matrix(1.4101341,0,0,1.4101341,-14.993741,-19.420865)"
+       clip-path="url(#clipPath3379)"
+       id="g4010">
+      <path
+         d="M 16.246914,126.84803 -2.6446783,98.771282 12,79.49 l 12,0 12,-24 16,0 12,16 12,0 8,-12 15.306836,0 5.779584,0 -0.0494,65.38272"
+         style="opacity:0.28494622;fill:url(#linearGradient3248);fill-opacity:1;fill-rule:evenodd;stroke:none"
+         id="path3431"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 4,59.49 8,20 12,0 12,-24 16,0 12,16 12,0 8,-12 15.306836,0 8.693164,0"
+         style="fill:none;stroke:url(#linearGradient3250);stroke-width:2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+         id="path3413"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 4,59.49 8,20 12,0 12,-24 16,0 12,16 12,0 8,-12 15.306836,0 8.693164,0"
+         style="fill:none;stroke:url(#linearGradient3252);stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+         id="path3857"
+         inkscape:connector-curvature="0" />
+    </g>
+    <rect
+       width="124.0918"
+       height="101.52966"
+       rx="7.0742917"
+       ry="7.0742917"
+       x="1.9278687"
+       y="13.01222"
+       style="opacity:0.32105264;fill:url(#linearGradient3246);fill-opacity:1;stroke:none"
+       id="rect3438" />
+    <path
+       d="m 9.0226062,13.012221 c -3.9191562,0 -7.0947373,3.175579 -7.0947373,7.094737 l 0,30.62635 C 25.678508,39.547637 58.966862,32.577831 95.833988,32.577831 c 10.395432,0 20.489952,0.541015 30.185682,1.586401 l 0,-14.057274 c 0,-3.919156 -3.17558,-7.094737 -7.09474,-7.094737 l -109.9023238,0 z"
+       style="opacity:0.225;fill:url(#linearGradient3244);fill-opacity:1;stroke:none"
+       id="rect4194"
+       inkscape:connector-curvature="0" />
+    <rect
+       width="124.0918"
+       height="101.52966"
+       rx="7.0742917"
+       ry="7.0742917"
+       x="-126.01967"
+       y="13.01222"
+       transform="scale(-1,1)"
+       style="opacity:0.32105264;fill:url(#linearGradient3242);fill-opacity:1;stroke:none"
+       id="rect4105" />
+    <rect
+       width="124.0918"
+       height="101.52966"
+       rx="7.0742917"
+       ry="7.0742917"
+       x="1.9278687"
+       y="13.01222"
+       style="opacity:0.32105264;fill:url(#linearGradient3240);fill-opacity:1;stroke:none"
+       id="rect4097" />
+    <rect
+       width="124.0918"
+       height="101.52966"
+       rx="7.0742917"
+       ry="7.0742917"
+       x="-126.01967"
+       y="13.01222"
+       transform="scale(-1,1)"
+       style="opacity:0.32105264;fill:url(#linearGradient3238);fill-opacity:1;stroke:none"
+       id="rect4101" />
+  </g>
+</svg>
diff --git a/arm/resources/torConfigDesc.txt b/arm/resources/torConfigDesc.txt
new file mode 100644
index 0000000..9fa83e0
--- /dev/null
+++ b/arm/resources/torConfigDesc.txt
@@ -0,0 +1,1123 @@
+Tor Version 0.2.2.13-alpha
+General
+index: 46
+acceldir
+DIR
+Specify this option if using dynamic hardware acceleration and the engine implementation library resides somewhere other than the OpenSSL default.
+--------------------------------------------------------------------------------
+General
+index: 45
+accelname
+NAME
+When using OpenSSL hardware crypto acceleration attempt to load the dynamic engine of this name. This must be used for any dynamic hardware engine. Names can be verified with the openssl engine command.
+--------------------------------------------------------------------------------
+Relay
+index: 119
+accountingmax
+N bytes|KB|MB|GB|TB
+Never send more than the specified number of bytes in a given accounting period, or receive more than that number in the period. For example, with AccountingMax set to 1 GB, a server could send 900 MB and receive 800 MB and continue running. It will only hibernate once one of the two reaches 1 GB. When the number of bytes is exhausted, Tor will hibernate until some time in the next accounting period. To prevent all servers from waking at the same time, Tor will also wait until a random point in each period before waking up. If you have bandwidth cost issues, enabling hibernation is preferable to setting a low bandwidth, since it provides users with a collection of fast servers that are up some of the time, which is more useful than a set of slow servers that are always "available".
+--------------------------------------------------------------------------------
+Relay
+index: 120
+accountingstart
+day|week|month [day] HH:MM
+Specify how long accounting periods last. If month is given, each accounting period runs from the time HH:MM on the dayth day of one month to the same day and time of the next. (The day must be between 1 and 28.) If week is given, each accounting period runs from the time HH:MM of the dayth day of one week to the same day and time of the next week, with Monday as day 1 and Sunday as day 7. If day is given, each accounting period runs from the time HH:MM each day to the same time on the next day. All times are local, and given in 24-hour time. (Defaults to "month 1 0:00".)
+--------------------------------------------------------------------------------
+Relay
+index: 104
+address
+address
+The IP address or fully qualified domain name of this server (e.g. moria.mit.edu). You can leave this unset, and Tor will guess your IP address.
+--------------------------------------------------------------------------------
+Client
+index: 89
+allowdotexit
+0|1
+If enabled, we convert "www.google.com.foo.exit" addresses on the SocksPort/TransPort/NatdPort into "www.google.com" addresses that exit from the node "foo". Disabled by default since attacking websites and exit relays can use it to manipulate your path selection. (Default: 0)
+--------------------------------------------------------------------------------
+Client
+index: 51
+allowinvalidnodes
+entry|exit|middle|introduction|rendezvous|...
+If some Tor servers are obviously not working right, the directory authorities can manually mark them as invalid, meaning that it's not recommended you use them for entry or exit positions in your circuits. You can opt to use them in some circuit positions, though. The default is "middle,rendezvous", and other choices are not advised.
+--------------------------------------------------------------------------------
+Client
+index: 88
+allownonrfc953hostnames
+0|1
+When this option is disabled, Tor blocks hostnames containing illegal characters (like @ and :) rather than sending them to an exit node to be resolved. This helps trap accidental attempts to resolve URLs and so on. (Default: 0)
+--------------------------------------------------------------------------------
+Relay
+index: 105
+allowsinglehopexits
+0|1
+This option controls whether clients can use this server as a single hop proxy. If set to 1, clients can use this server as an exit even if it is the only hop in the circuit. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 20
+alternatebridgeauthority
+[nickname] [flags] address:port  fingerprint
+As DirServer, but replaces less of the default directory authorities. Using AlternateDirAuthority replaces the default Tor directory authorities, but leaves the hidden service authorities and bridge authorities in place. Similarly, Using AlternateHSAuthority replaces the default hidden service authorities, but not the directory or bridge authorities.
+--------------------------------------------------------------------------------
+General
+index: 18
+alternatedirauthority
+[nickname] [flags] address:port fingerprint
+
+--------------------------------------------------------------------------------
+General
+index: 19
+alternatehsauthority
+[nickname] [flags] address:port fingerprint
+
+--------------------------------------------------------------------------------
+Relay
+index: 106
+assumereachable
+0|1
+This option is used when bootstrapping a new Tor network. If set to 1, don't do self-reachability testing; just upload your server descriptor immediately. If AuthoritativeDirectory is also set, this option instructs the dirserver to bypass remote reachability testing too and list all connected servers as running.
+--------------------------------------------------------------------------------
+Authority
+index: 154
+authdirbaddir
+AddressPattern...
+Authoritative directories only. A set of address patterns for servers that will be listed as bad directories in any network status document this authority publishes, if AuthDirListBadDirs is set.
+--------------------------------------------------------------------------------
+Authority
+index: 155
+authdirbadexit
+AddressPattern...
+Authoritative directories only. A set of address patterns for servers that will be listed as bad exits in any network status document this authority publishes, if AuthDirListBadExits is set.
+--------------------------------------------------------------------------------
+Authority
+index: 156
+authdirinvalid
+AddressPattern...
+Authoritative directories only. A set of address patterns for servers that will never be listed as "valid" in any network status document that this authority publishes.
+--------------------------------------------------------------------------------
+Authority
+index: 158
+authdirlistbaddirs
+0|1
+Authoritative directories only. If set to 1, this directory has some opinion about which nodes are unsuitable as directory caches. (Do not set this to 1 unless you plan to list non-functioning directories as bad; otherwise, you are effectively voting in favor of every declared directory.)
+--------------------------------------------------------------------------------
+Authority
+index: 159
+authdirlistbadexits
+0|1
+Authoritative directories only. If set to 1, this directory has some opinion about which nodes are unsuitable as exit nodes. (Do not set this to 1 unless you plan to list non-functioning exits as bad; otherwise, you are effectively voting in favor of every declared exit as an exit.)
+--------------------------------------------------------------------------------
+Authority
+index: 161
+authdirmaxserversperaddr
+NUM
+Authoritative directories only. The maximum number of servers that we will list as acceptable on a single IP address. Set this to "0" for "no limit". (Default: 2)
+--------------------------------------------------------------------------------
+Authority
+index: 162
+authdirmaxserversperauthaddr
+NUM
+Authoritative directories only. Like AuthDirMaxServersPerAddr, but applies to addresses shared with directory authorities. (Default: 5)
+--------------------------------------------------------------------------------
+Authority
+index: 157
+authdirreject
+AddressPattern...
+Authoritative directories only. A set of address patterns for servers that will never be listed at all in any network status document that this authority publishes, or accepted as an OR address in any descriptor submitted for publication by this authority.
+--------------------------------------------------------------------------------
+Authority
+index: 160
+authdirrejectunlisted
+0|1
+Authoritative directories only. If set to 1, the directory server rejects all uploaded server descriptors that aren't explicitly listed in the fingerprints file. This acts as a "panic button" if we get hit with a Sybil attack. (Default: 0)
+--------------------------------------------------------------------------------
+Directory
+index: 135
+authoritativedirectory
+0|1
+When this option is set to 1, Tor operates as an authoritative directory server. Instead of caching the directory, it generates its own list of good servers, signs it, and sends that to the clients. Unless the clients already have you listed as a trusted directory, you probably do not want to set this option. Please coordinate with the other admins at tor-ops at torproject.org if you think you should be a directory.
+--------------------------------------------------------------------------------
+Client
+index: 95
+automaphostsonresolve
+0|1
+When this option is enabled, and we get a request to resolve an address that ends with one of the suffixes in AutomapHostsSuffixes, we map an unused virtual address to that address, and return the new virtual address. This is handy for making ".onion" addresses work with applications that resolve an address and then connect to it. (Default: 0).
+--------------------------------------------------------------------------------
+Client
+index: 96
+automaphostssuffixes
+SUFFIX,SUFFIX,...
+A comma-separated list of suffixes to use with AutomapHostsOnResolve. The "." suffix is equivalent to "all addresses." (Default: .exit,.onion).
+--------------------------------------------------------------------------------
+General
+index: 47
+avoiddiskwrites
+0|1
+If non-zero, try to write to disk less frequently than we would otherwise. This is useful when running on flash memory or other media that support only a limited number of writes. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 1
+bandwidthburst
+N bytes|KB|MB|GB
+Limit the maximum token bucket size (also known as the burst) to the given number of bytes in each direction. (Default: 10 MB)
+--------------------------------------------------------------------------------
+General
+index: 0
+bandwidthrate
+N bytes|KB|MB|GB
+A token bucket limits the average incoming bandwidth usage on this node to the specified number of bytes per second, and the average outgoing bandwidth usage to that same value. (Default: 5 MB)
+--------------------------------------------------------------------------------
+Client
+index: 53
+bridge
+IP:ORPort [fingerprint]
+When set along with UseBridges, instructs Tor to use the relay at "IP:ORPort" as a "bridge" relaying into the Tor network. If "fingerprint" is provided (using the same format as for DirServer), we will verify that the relay running at that location has the right fingerprint. We also use fingerprint to look up the bridge descriptor at the bridge authority, if it's provided and if UpdateBridgesFromAuthority is set too.
+--------------------------------------------------------------------------------
+Directory
+index: 144
+bridgeauthoritativedir
+0|1
+When this option is set in addition to AuthoritativeDirectory, Tor accepts and serves router descriptors, but it caches and serves the main networkstatus documents rather than generating its own. (Default: 0)
+--------------------------------------------------------------------------------
+Relay
+index: 127
+bridgerecordusagebycountry
+0|1
+When this option is enabled and BridgeRelay is also enabled, and we have GeoIP data, Tor keeps a keep a per-country count of how many client addresses have contacted it so that it can help the bridge authority guess which countries have blocked access to it. (Default: 1)
+--------------------------------------------------------------------------------
+Relay
+index: 107
+bridgerelay
+0|1
+Sets the relay to act as a "bridge" with respect to relaying connections from bridge users to the Tor network. Mainly it influences how the relay will cache and serve directory information. Usually used in combination with PublishServerDescriptor.
+--------------------------------------------------------------------------------
+Relay
+index: 130
+cellstatistics
+0|1
+When this option is enabled, Tor writes statistics on the mean time that cells spend in circuit queues to disk every 24 hours. Cannot be changed while Tor is running. (Default: 0)
+--------------------------------------------------------------------------------
+Client
+index: 54
+circuitbuildtimeout
+NUM
+Try for at most NUM seconds when building circuits. If the circuit isn't open in that time, give up on it. (Default: 1 minute.)
+--------------------------------------------------------------------------------
+Client
+index: 55
+circuitidletimeout
+NUM
+If we have kept a clean (never used) circuit around for NUM seconds, then close it. This way when the Tor client is entirely idle, it can expire all of its circuits, and then expire its TLS connections. Also, if we end up making a circuit that is not useful for exiting any of the requests we're receiving, it won't forever take up a slot in the circuit list. (Default: 1 hour.)
+--------------------------------------------------------------------------------
+General
+index: 50
+circuitpriorityhalflife
+NUM1
+If this value is set, we override the default algorithm for choosing which circuit's cell to deliver or relay next. When the value is 0, we round-robin between the active circuits on a connection, delivering one cell from each in turn. When the value is positive, we prefer delivering cells from whichever connection has the lowest weighted cell count, where cells are weighted exponentially according to the supplied CircuitPriorityHalflife value (in seconds). If this option is not set at all, we use the behavior recommended in the current consensus networkstatus. This is an advanced option; you generally shouldn't have to mess with it. (Default: not set.)
+--------------------------------------------------------------------------------
+Client
+index: 56
+circuitstreamtimeout
+NUM
+If non-zero, this option overrides our internal timeout schedule for how many seconds until we detach a stream from a circuit and try a new circuit. If your network is particularly slow, you might want to set this to a number like 60. (Default: 0)
+--------------------------------------------------------------------------------
+Client
+index: 99
+clientdnsrejectinternaladdresses
+0|1
+If true, Tor does not believe any anonymously retrieved DNS answer that tells it that an address resolves to an internal address (like 127.0.0.1 or 192.168.0.1). This option prevents certain browser-based attacks; don't turn it off unless you know what you're doing. (Default: 1).
+--------------------------------------------------------------------------------
+Client
+index: 57
+clientonly
+0|1
+If set to 1, Tor will under no circumstances run as a server or serve directory requests. The default is to run as a client unless ORPort is configured. (Usually, you don't need to set this; Tor is pretty smart at figuring out whether you are reliable and high-bandwidth enough to be a useful server.) (Default: 0)
+--------------------------------------------------------------------------------
+Authority
+index: 152
+consensusparams
+STRING
+STRING is a space-separated list of key=value pairs that Tor will include in the "params" line of its networkstatus vote.
+--------------------------------------------------------------------------------
+General
+index: 7
+constrainedsockets
+0|1
+If set, Tor will tell the kernel to attempt to shrink the buffers for all sockets to the size specified in ConstrainedSockSize. This is useful for virtual servers and other environments where system level TCP buffers may be limited. If you're on a virtual server, and you encounter the "Error creating network socket: No buffer space available" message, you are likely experiencing this problem. 
+
+The preferred solution is to have the admin increase the buffer pool for the host itself via /proc/sys/net/ipv4/tcp_mem or equivalent facility; this configuration option is a second-resort. 
+
+The DirPort option should also not be used if TCP buffers are scarce. The cached directory requests consume additional sockets which exacerbates the problem. 
+
+You should not enable this feature unless you encounter the "no buffer space available" issue. Reducing the TCP buffers affects window size for the TCP stream and will reduce throughput in proportion to round trip time on long paths. (Default: 0.)
+--------------------------------------------------------------------------------
+General
+index: 8
+constrainedsocksize
+N bytes|KB
+When ConstrainedSockets is enabled the receive and transmit buffers for all sockets will be set to this limit. Must be a value between 2048 and 262144, in 1024 byte increments. Default of 8192 is recommended.
+--------------------------------------------------------------------------------
+Relay
+index: 108
+contactinfo
+email_address
+Administrative contact information for server. This line might get picked up by spam harvesters, so you may want to obscure the fact that it's an email address.
+--------------------------------------------------------------------------------
+General
+index: 10
+controllistenaddress
+IP[:PORT]
+Bind the controller listener to this address. If you specify a port, bind to this port rather than the one specified in ControlPort. We strongly recommend that you leave this alone unless you know what you're doing, since giving attackers access to your control listener is really dangerous. (Default: 127.0.0.1) This directive can be specified multiple times to bind to multiple addresses/ports.
+--------------------------------------------------------------------------------
+General
+index: 9
+controlport
+Port
+If set, Tor will accept connections on this port and allow those connections to control the Tor process using the Tor Control Protocol (described in control-spec.txt). Note: unless you also specify one of HashedControlPassword or CookieAuthentication, setting this option will cause Tor to allow any process on the local host to control it. This option is required for many Tor controllers; most use the value of 9051.
+--------------------------------------------------------------------------------
+General
+index: 11
+controlsocket
+Path
+Like ControlPort, but listens on a Unix domain socket, rather than a TCP socket. (Unix and Unix-like systems only.)
+--------------------------------------------------------------------------------
+General
+index: 13
+cookieauthentication
+0|1
+If this option is set to 1, don't allow any connections on the control port except when the connecting process knows the contents of a file named "control_auth_cookie", which Tor will create in its data directory. This authentication method should only be used on systems with good filesystem security. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 14
+cookieauthfile
+Path
+If set, this option overrides the default location and file name for Tor's cookie file. (See CookieAuthentication above.)
+--------------------------------------------------------------------------------
+General
+index: 15
+cookieauthfilegroupreadable
+0|1|Groupname
+If this option is set to 0, don't allow the filesystem group to read the cookie file. If the option is set to 1, make the cookie file readable by the default GID. [Making the file readable by other groups is not yet implemented; let us know if you need this for some reason.] (Default: 0).
+--------------------------------------------------------------------------------
+General
+index: 16
+datadirectory
+DIR
+Store working data in DIR (Default: /usr/local/var/lib/tor)
+--------------------------------------------------------------------------------
+Authority
+index: 153
+dirallowprivateaddresses
+0|1
+If set to 1, Tor will accept router descriptors with arbitrary "Address" elements. Otherwise, if the address is not an IP address or is a private IP address, it will reject the router descriptor. Defaults to 0.
+--------------------------------------------------------------------------------
+Directory
+index: 147
+dirlistenaddress
+IP[:PORT]
+Bind the directory service to this address. If you specify a port, bind to this port rather than the one specified in DirPort. (Default: 0.0.0.0) This directive can be specified multiple times to bind to multiple addresses/ports.
+--------------------------------------------------------------------------------
+Directory
+index: 148
+dirpolicy
+policy,policy,...
+Set an entrance policy for this server, to limit who can connect to the directory ports. The policies have the same form as exit policies above.
+--------------------------------------------------------------------------------
+Directory
+index: 146
+dirport
+PORT
+Advertise the directory service on this port.
+--------------------------------------------------------------------------------
+Directory
+index: 136
+dirportfrontpage
+FILENAME
+When this option is set, it takes an HTML file and publishes it as "/" on the DirPort. Now relay operators can provide a disclaimer without needing to set up a separate webserver. There's a sample disclaimer in contrib/tor-exit-notice.html.
+--------------------------------------------------------------------------------
+Relay
+index: 131
+dirreqstatistics
+0|1
+When this option is enabled, Tor writes statistics on the number and response time of network status requests to disk every 24 hours. Cannot be changed while Tor is running. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 17
+dirserver
+[nickname] [flags] address:port fingerprint
+Use a nonstandard authoritative directory server at the provided address and port, with the specified key fingerprint. This option can be repeated many times, for multiple authoritative directory servers. Flags are separated by spaces, and determine what kind of an authority this directory is. By default, every authority is authoritative for current ("v2")-style directories, unless the "no-v2" flag is given. If the "v1" flags is provided, Tor will use this server as an authority for old-style (v1) directories as well. (Only directory mirrors care about this.) Tor will use this server as an authority for hidden service information if the "hs" flag is set, or if the "v1" flag is set and the "no-hs" flag is not set. Tor will use this authority as a bridge authoritative directory if the "bridge" flag is set. If a flag "orport=port" is given, Tor will use the given port when opening encrypted tunnels to the dirserver. Lastly, if a flag "v3ident=fp" is given, the dirserver is a v3 directo
 ry authority whose v3 long-term signing key has the fingerprint fp. 
+
+If no dirserver line is given, Tor will use the default directory servers. NOTE: this option is intended for setting up a private Tor network with its own directory authorities. If you use it, you will be distinguishable from other users, because you won't believe the same authorities they do.
+--------------------------------------------------------------------------------
+General
+index: 21
+disableallswap
+0|1
+If set to 1, Tor will attempt to lock all current and future memory pages, so that memory cannot be paged out. Windows, OS X and Solaris are currently not supported. We believe that this feature works on modern Gnu/Linux distributions, and that it should work on *BSD systems (untested). This option requires that you start your Tor as root, and you should use the User option to properly reduce Tor's privileges. (Default: 0)
+--------------------------------------------------------------------------------
+Client
+index: 98
+dnslistenaddress
+IP[:PORT]
+Bind to this address to listen for DNS connections. (Default: 127.0.0.1).
+--------------------------------------------------------------------------------
+Client
+index: 97
+dnsport
+PORT
+If non-zero, Tor listens for UDP DNS requests on this port and resolves them anonymously. (Default: 0).
+--------------------------------------------------------------------------------
+Client
+index: 100
+downloadextrainfo
+0|1
+If true, Tor downloads and caches "extra-info" documents. These documents contain information about servers other than the information in their regular router descriptors. Tor does not use this information for anything itself; to save bandwidth, leave this option turned off. (Default: 0).
+--------------------------------------------------------------------------------
+Client
+index: 74
+enforcedistinctsubnets
+0|1
+If 1, Tor will not put two servers whose IP addresses are "too close" on the same circuit. Currently, two addresses are "too close" if they lie in the same /16 range. (Default: 1)
+--------------------------------------------------------------------------------
+Client
+index: 60
+entrynodes
+node,node,...
+A list of identity fingerprints, nicknames, country codes and address patterns of nodes to use for the first hop in normal circuits. These are treated only as preferences unless StrictNodes (see below) is also set.
+--------------------------------------------------------------------------------
+Relay
+index: 132
+entrystatistics
+0|1
+When this option is enabled, Tor writes statistics on the number of directly connecting clients to disk every 24 hours. Cannot be changed while Tor is running. (Default: 0)
+--------------------------------------------------------------------------------
+Client
+index: 59
+excludeexitnodes
+node,node,...
+A list of identity fingerprints, nicknames, country codes and address patterns of nodes to never use when picking an exit node. Note that any node listed in ExcludeNodes is automatically considered to be part of this list.
+--------------------------------------------------------------------------------
+Client
+index: 58
+excludenodes
+node,node,...
+A list of identity fingerprints, nicknames, country codes and address patterns of nodes to never use when building a circuit. (Example: ExcludeNodes SlowServer, $ EFFFFFFFFFFFFFFF, {cc}, 255.254.0.0/8)
+--------------------------------------------------------------------------------
+Client
+index: 52
+excludesinglehoprelays
+0|1
+This option controls whether circuits built by Tor will include relays with the AllowSingleHopExits flag set to true. If ExcludeSingleHopRelays is set to 0, these relays will be included. Note that these relays might be at higher risk of being seized or observed, so they are not normally included. (Default: 1)
+--------------------------------------------------------------------------------
+Client
+index: 61
+exitnodes
+node,node,...
+A list of identity fingerprints, nicknames, country codes and address patterns of nodes to use for the last hop in normal exit circuits. These are treated only as preferences unless StrictNodes (see below) is also set.
+--------------------------------------------------------------------------------
+Relay
+index: 109
+exitpolicy
+policy,policy,...
+Set an exit policy for this server. Each policy is of the form "accept|reject ADDR[/MASK][:PORT]". If /MASK is omitted then this policy just applies to the host given. Instead of giving a host or network you can also use "*" to denote the universe (0.0.0.0/0).  PORT can be a single port number, an interval of ports "FROM_PORT-TO_PORT", or "*". If PORT is omitted, that means "*". 
+
+For example, "accept 18.7.22.69:*,reject 18.0.0.0/8:*,accept *:*" would reject any traffic destined for MIT except for web.mit.edu, and accept anything else. 
+
+To specify all internal and link-local networks (including 0.0.0.0/8, 169.254.0.0/16, 127.0.0.0/8, 192.168.0.0/16, 10.0.0.0/8, and 172.16.0.0/12), you can use the "private" alias instead of an address. These addresses are rejected by default (at the beginning of your exit policy), along with your public IP address, unless you set the ExitPolicyRejectPrivate config option to 0. For example, once you've done that, you could allow HTTP to 127.0.0.1 and block all other connections to internal networks with "accept 127.0.0.1:80,reject private:*", though that may also allow connections to your own computer that are addressed to its public (external) IP address. See RFC 1918 and RFC 3330 for more details about internal and reserved IP address space. 
+
+This directive can be specified multiple times so you don't have to put it all on one line. 
+
+Policies are considered first to last, and the first match wins. If you want to _replace_ the default exit policy, end your exit policy with either a reject *:* or an accept *:*. Otherwise, you're _augmenting_ (prepending to) the default exit policy. The default exit policy is: 
+
+    reject *:25
+    reject *:119
+    reject *:135-139
+    reject *:445
+    reject *:563
+    reject *:1214
+    reject *:4661-4666
+    reject *:6346-6429
+    reject *:6699
+    reject *:6881-6999
+    accept *:*
+--------------------------------------------------------------------------------
+Relay
+index: 110
+exitpolicyrejectprivate
+0|1
+Reject all private (local) networks, along with your own public IP address, at the beginning of your exit policy. See above entry on ExitPolicy. (Default: 1)
+--------------------------------------------------------------------------------
+Relay
+index: 133
+exitportstatistics
+0|1
+When this option is enabled, Tor writes statistics on the number of relayed bytes and opened stream per exit port to disk every 24 hours. Cannot be changed while Tor is running. (Default: 0)
+--------------------------------------------------------------------------------
+Relay
+index: 134
+extrainfostatistics
+0|1
+When this option is enabled, Tor includes previously gathered statistics in its extra-info documents that it uploads to the directory authorities. (Default: 0)
+--------------------------------------------------------------------------------
+Client
+index: 101
+fallbacknetworkstatusfile
+FILENAME
+If Tor doesn't have a cached networkstatus file, it starts out using this one instead. Even if this file is out of date, Tor can still use it to learn about directory mirrors, so it doesn't need to put load on the authorities. (Default: None).
+--------------------------------------------------------------------------------
+Client
+index: 63
+fascistfirewall
+0|1
+If 1, Tor will only create outgoing connections to ORs running on ports that your firewall allows (defaults to 80 and 443; see FirewallPorts). This will allow you to run Tor as a client behind a firewall with restrictive policies, but will not allow you to run as a server behind such a firewall. If you prefer more fine-grained control, use ReachableAddresses instead.
+--------------------------------------------------------------------------------
+Client
+index: 90
+fastfirsthoppk
+0|1
+When this option is disabled, Tor uses the public key step for the first hop of creating circuits. Skipping it is generally safe since we have already used TLS to authenticate the relay and to establish forward-secure keys. Turning this option off makes circuit building slower. 
+
+Note that Tor will always use the public key step for the first hop if it's operating as a relay, and it will never use the public key step if it doesn't yet know the onion key of the first hop. (Default: 1)
+--------------------------------------------------------------------------------
+General
+index: 22
+fetchdirinfoearly
+0|1
+If set to 1, Tor will always fetch directory information like other directory caches, even if you don't meet the normal criteria for fetching early. Normal users should leave it off. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 23
+fetchdirinfoextraearly
+0|1
+If set to 1, Tor will fetch directory information before other directory caches. It will attempt to download directory information closer to the start of the consensus period. Normal users should leave it off. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 24
+fetchhidservdescriptors
+0|1
+If set to 0, Tor will never fetch any hidden service descriptors from the rendezvous directories. This option is only useful if you're using a Tor controller that handles hidden service fetches for you. (Default: 1)
+--------------------------------------------------------------------------------
+General
+index: 25
+fetchserverdescriptors
+0|1
+If set to 0, Tor will never fetch any network status summaries or server descriptors from the directory servers. This option is only useful if you're using a Tor controller that handles directory fetches for you. (Default: 1)
+--------------------------------------------------------------------------------
+General
+index: 26
+fetchuselessdescriptors
+0|1
+If set to 1, Tor will fetch every non-obsolete descriptor from the authorities that it hears about. Otherwise, it will avoid fetching useless descriptors, for example for routers that are not running. This option is useful if you're using the contributed "exitlist" script to enumerate Tor nodes that exit to certain addresses. (Default: 0)
+--------------------------------------------------------------------------------
+Client
+index: 64
+firewallports
+PORTS
+A list of ports that your firewall allows you to connect to. Only used when FascistFirewall is set. This option is deprecated; use ReachableAddresses instead. (Default: 80, 443)
+--------------------------------------------------------------------------------
+Relay
+index: 129
+geoipfile
+filename
+A filename containing GeoIP data, for use with BridgeRecordUsageByCountry.
+--------------------------------------------------------------------------------
+General
+index: 44
+hardwareaccel
+0|1
+If non-zero, try to use built-in (static) crypto hardware acceleration when available. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 12
+hashedcontrolpassword
+hashed_password
+Don't allow any connections on the control port except when the other process knows the password whose one-way hash is hashed_password. You can compute the hash of a password by running "tor --hash-password password". You can provide several acceptable passwords by using more than one HashedControlPassword line.
+--------------------------------------------------------------------------------
+Hidden Service
+index: 171
+hiddenserviceauthorizeclient
+auth-type client-name,client-name,...
+If configured, the hidden service is accessible for authorized clients only. The auth-type can either be 'basic' for a general-purpose authorization protocol or 'stealth' for a less scalable protocol that also hides service activity from unauthorized clients. Only clients that are listed here are authorized to access the hidden service. Valid client names are 1 to 19 characters long and only use characters in A-Za-z0-9+-_ (no spaces). If this option is set, the hidden service is not accessible for clients without authorization any more. Generated authorization data can be found in the hostname file. Clients need to put this authorization data in their configuration file using HidServAuth.
+--------------------------------------------------------------------------------
+Hidden Service
+index: 167
+hiddenservicedir
+DIRECTORY
+Store data files for a hidden service in DIRECTORY. Every hidden service must have a separate directory. You may use this option multiple times to specify multiple services.
+--------------------------------------------------------------------------------
+Hidden Service
+index: 168
+hiddenserviceport
+VIRTPORT [TARGET]
+Configure a virtual port VIRTPORT for a hidden service. You may use this option multiple times; each time applies to the service using the most recent hiddenservicedir. By default, this option maps the virtual port to the same port on 127.0.0.1. You may override the target port, address, or both by specifying a target of addr, port, or addr:port. You may also have multiple lines with the same VIRTPORT: when a user connects to that VIRTPORT, one of the TARGETs from those lines will be chosen at random.
+--------------------------------------------------------------------------------
+Hidden Service
+index: 170
+hiddenserviceversion
+version,version,...
+A list of rendezvous service descriptor versions to publish for the hidden service. Currently, only version 2 is supported. (Default: 2)
+--------------------------------------------------------------------------------
+Client
+index: 65
+hidservauth
+onion-address auth-cookie [service-name]
+Client authorization for a hidden service. Valid onion addresses contain 16 characters in a-z2-7 plus ".onion", and valid auth cookies contain 22 characters in A-Za-z0-9+/. The service name is only used for internal purposes, e.g., for Tor controllers. This option may be used multiple times for different hidden services. If a hidden service uses authorization and this option is not set, the hidden service is not accessible. Hidden services can be configured to require authorization using the HiddenServiceAuthorizeClient option.
+--------------------------------------------------------------------------------
+Directory
+index: 143
+hidservdirectoryv2
+0|1
+When this option is set, Tor accepts and serves v2 hidden service descriptors. Setting DirPort is not required for this, because clients connect via the ORPort by default. (Default: 1)
+--------------------------------------------------------------------------------
+Directory
+index: 142
+hsauthoritativedir
+0|1
+When this option is set in addition to AuthoritativeDirectory, Tor also accepts and serves hidden service descriptors. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 27
+httpproxy
+host[:port]
+Tor will make all its directory requests through this host:port (or host:80 if port is not specified), rather than connecting directly to any directory servers.
+--------------------------------------------------------------------------------
+General
+index: 28
+httpproxyauthenticator
+username:password
+If defined, Tor will use this username:password for Basic HTTP proxy authentication, as in RFC 2617. This is currently the only form of HTTP proxy authentication that Tor supports; feel free to submit a patch if you want it to support others.
+--------------------------------------------------------------------------------
+General
+index: 29
+httpsproxy
+host[:port]
+Tor will make all its OR (SSL) connections through this host:port (or host:443 if port is not specified), via HTTP CONNECT rather than connecting directly to servers. You may want to set FascistFirewall to restrict the set of ports you might try to connect to, if your HTTPS proxy only allows connecting to certain ports.
+--------------------------------------------------------------------------------
+General
+index: 30
+httpsproxyauthenticator
+username:password
+If defined, Tor will use this username:password for Basic HTTPS proxy authentication, as in RFC 2617. This is currently the only form of HTTPS proxy authentication that Tor supports; feel free to submit a patch if you want it to support others.
+--------------------------------------------------------------------------------
+General
+index: 35
+keepaliveperiod
+NUM
+To keep firewalls from expiring connections, send a padding keepalive cell every NUM seconds on open connections that are in use. If the connection has no open circuits, it will instead be closed after NUM seconds of idleness. (Default: 5 minutes)
+--------------------------------------------------------------------------------
+General
+index: 37
+log
+minSeverity[-maxSeverity] file FILENAME
+As above, but send log messages to the listed filename. The "Log" option may appear more than once in a configuration file. Messages are sent to all the logs that match their severity level.
+--------------------------------------------------------------------------------
+Client
+index: 69
+longlivedports
+PORTS
+A list of ports for services that tend to have long-running connections (e.g. chat and interactive shells). Circuits for streams that use these ports will contain only high-uptime nodes, to reduce the chance that a node will go down before the stream is finished. (Default: 21, 22, 706, 1863, 5050, 5190, 5222, 5223, 6667, 6697, 8300)
+--------------------------------------------------------------------------------
+Client
+index: 70
+mapaddress
+address newaddress
+When a request for address arrives to Tor, it will rewrite it to newaddress before processing it. For example, if you always want connections to www.indymedia.org to exit via torserver (where torserver is the nickname of the server), use "MapAddress www.indymedia.org www.indymedia.org.torserver.exit".
+--------------------------------------------------------------------------------
+General
+index: 2
+maxadvertisedbandwidth
+N bytes|KB|MB|GB
+If set, we will not advertise more than this amount of bandwidth for our BandwidthRate. Server operators who want to reduce the number of clients who ask to build circuits through them (since this is proportional to advertised bandwidth rate) can thus reduce the CPU demands on their server without impacting network performance.
+--------------------------------------------------------------------------------
+Client
+index: 72
+maxcircuitdirtiness
+NUM
+Feel free to reuse a circuit that was first used at most NUM seconds ago, but never attach a new stream to a circuit that is too old. (Default: 10 minutes)
+--------------------------------------------------------------------------------
+Relay
+index: 111
+maxonionspending
+NUM
+If you have more than this number of onionskins queued for decrypt, reject new ones. (Default: 100)
+--------------------------------------------------------------------------------
+Directory
+index: 145
+minuptimehidservdirectoryv2
+N seconds|minutes|hours|days|weeks
+Minimum uptime of a v2 hidden service directory to be accepted as such by authoritative directories. (Default: 24 hours)
+--------------------------------------------------------------------------------
+Relay
+index: 112
+myfamily
+node,node,...
+Declare that this Tor server is controlled or administered by a group or organization identical or similar to that of the other servers, defined by their identity fingerprints or nicknames. When two servers both declare that they are in the same 'family', Tor clients will not use them in the same circuit. (Each server only needs to list the other servers in its family; it doesn't need to list itself, but it won't hurt.)
+--------------------------------------------------------------------------------
+Directory
+index: 141
+namingauthoritativedirectory
+0|1
+When this option is set to 1, then the server advertises that it has opinions about nickname-to-fingerprint bindings. It will include these opinions in its published network-status pages, by listing servers with the flag "Named" if a correct binding between that nickname and fingerprint has been registered with the dirserver. Naming dirservers will refuse to accept or publish descriptors that contradict a registered binding. See approved-routers in the FILES section below.
+--------------------------------------------------------------------------------
+Client
+index: 94
+natdlistenaddress
+IP[:PORT]
+Bind to this address to listen for NATD connections. (Default: 127.0.0.1).
+--------------------------------------------------------------------------------
+Client
+index: 93
+natdport
+PORT
+Allow old versions of ipfw (as included in old versions of FreeBSD, etc.) to send connections through Tor using the NATD protocol. This option is only for people who cannot use TransPort.
+--------------------------------------------------------------------------------
+Client
+index: 71
+newcircuitperiod
+NUM
+Every NUM seconds consider whether to build a new circuit. (Default: 30 seconds)
+--------------------------------------------------------------------------------
+Relay
+index: 113
+nickname
+name
+Set the server's nickname to 'name'. Nicknames must be between 1 and 19 characters inclusive, and must contain only the characters [a-zA-Z0-9].
+--------------------------------------------------------------------------------
+Client
+index: 73
+nodefamily
+node,node,...
+The Tor servers, defined by their identity fingerprints or nicknames, constitute a "family" of similar or co-administered servers, so never use any two of them in the same circuit. Defining a NodeFamily is only needed when a server doesn't list the family itself (with MyFamily). This option can be used multiple times.
+--------------------------------------------------------------------------------
+Relay
+index: 114
+numcpus
+num
+How many processes to use at once for decrypting onionskins. (Default: 1)
+--------------------------------------------------------------------------------
+Client
+index: 84
+numentryguards
+NUM
+If UseEntryGuards is set to 1, we will try to pick a total of NUM routers as long-term entries for our circuits. (Defaults to 3.)
+--------------------------------------------------------------------------------
+Relay
+index: 116
+orlistenaddress
+IP[:PORT]
+Bind to this IP address to listen for connections from Tor clients and servers. If you specify a port, bind to this port rather than the one specified in ORPort. (Default: 0.0.0.0) This directive can be specified multiple times to bind to multiple addresses/ports.
+--------------------------------------------------------------------------------
+Relay
+index: 115
+orport
+PORT
+Advertise this port to listen for connections from Tor clients and servers.
+--------------------------------------------------------------------------------
+General
+index: 38
+outboundbindaddress
+IP
+Make all outbound connections originate from the IP address specified. This is only useful when you have multiple network interfaces, and you want all of Tor's outgoing connections to use a single one. This setting will be ignored for connections to the loopback addresses (127.0.0.0/8 and ::1).
+--------------------------------------------------------------------------------
+General
+index: 6
+perconnbwburst
+N bytes|KB|MB|GB
+If set, do separate rate limiting for each connection from a non-relay. You should never need to change this value, since a network-wide value is published in the consensus and your relay will use that value. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 5
+perconnbwrate
+N bytes|KB|MB|GB
+If set, do separate rate limiting for each connection from a non-relay. You should never need to change this value, since a network-wide value is published in the consensus and your relay will use that value. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 39
+pidfile
+FILE
+On startup, write our PID to FILE. On clean shutdown, remove FILE.
+--------------------------------------------------------------------------------
+General
+index: 49
+prefertunneleddirconns
+0|1
+If non-zero, we will avoid directory servers that don't support tunneled directory connections, when possible. (Default: 1)
+--------------------------------------------------------------------------------
+General
+index: 40
+protocolwarnings
+0|1
+If 1, Tor will log with severity 'warn' various cases of other parties not following the Tor specification. Otherwise, they are logged with severity 'info'. (Default: 0)
+--------------------------------------------------------------------------------
+Hidden Service
+index: 169
+publishhidservdescriptors
+0|1
+If set to 0, Tor will run any hidden services you configure, but it won't advertise them to the rendezvous directory. This option is only useful if you're using a Tor controller that handles hidserv publishing for you. (Default: 1)
+--------------------------------------------------------------------------------
+Relay
+index: 117
+publishserverdescriptor
+0|1|v1|v2|v3|bridge|hidserv,...
+This option is only considered if you have an ORPort defined. You can choose multiple arguments, separated by commas. If set to 0, Tor will act as a server but it will not publish its descriptor to the directory authorities. (This is useful if you're testing out your server, or if you're using a Tor controller that handles directory publishing for you.) Otherwise, Tor will publish its descriptor to all directory authorities of the type(s) specified. The value "1" is the default, which means "publish to the appropriate authorities".
+--------------------------------------------------------------------------------
+Client
+index: 66
+reachableaddresses
+ADDR[/MASK][:PORT]...
+A comma-separated list of IP addresses and ports that your firewall allows you to connect to. The format is as for the addresses in ExitPolicy, except that "accept" is understood unless "reject" is explicitly provided. For example, 'ReachableAddresses 99.0.0.0/8, reject 18.0.0.0/8:80, accept *:80' means that your firewall allows connections to everything inside net 99, rejects port 80 connections to net 18, and accepts connections to port 80 otherwise. (Default: 'accept *:*'.)
+--------------------------------------------------------------------------------
+Client
+index: 67
+reachablediraddresses
+ADDR[/MASK][:PORT]...
+Like ReachableAddresses, a list of addresses and ports. Tor will obey these restrictions when fetching directory information, using standard HTTP GET requests. If not set explicitly then the value of ReachableAddresses is used. If HTTPProxy is set then these connections will go through that proxy.
+--------------------------------------------------------------------------------
+Client
+index: 68
+reachableoraddresses
+ADDR[/MASK][:PORT]...
+Like ReachableAddresses, a list of addresses and ports. Tor will obey these restrictions when connecting to Onion Routers, using TLS/SSL. If not set explicitly then the value of ReachableAddresses is used. If HTTPSProxy is set then these connections will go through that proxy. 
+
+The separation between ReachableORAddresses and ReachableDirAddresses is only interesting when you are connecting through proxies (see HTTPProxy and HTTPSProxy). Most proxies limit TLS connections (which Tor uses to connect to Onion Routers) to port 443, and some limit HTTP GET requests (which Tor uses for fetching directory information) to port 80.
+--------------------------------------------------------------------------------
+Authority
+index: 150
+recommendedclientversions
+STRING
+STRING is a comma-separated list of Tor versions currently believed to be safe for clients to use. This information is included in version 2 directories. If this is not set then the value of RecommendedVersions is used. When this is set then VersioningAuthoritativeDirectory should be set too.
+--------------------------------------------------------------------------------
+Authority
+index: 151
+recommendedserverversions
+STRING
+STRING is a comma-separated list of Tor versions currently believed to be safe for servers to use. This information is included in version 2 directories. If this is not set then the value of RecommendedVersions is used. When this is set then VersioningAuthoritativeDirectory should be set too.
+--------------------------------------------------------------------------------
+Authority
+index: 149
+recommendedversions
+STRING
+STRING is a comma-separated list of Tor versions currently believed to be safe. The list is included in each directory, and nodes which pull down the directory learn whether they need to upgrade. This option can appear multiple times: the values from multiple lines are spliced together. When this is set then VersioningAuthoritativeDirectory should be set too.
+--------------------------------------------------------------------------------
+Client
+index: 103
+rejectplaintextports
+port,port,...
+Like WarnPlaintextPorts, but instead of warning about risky port uses, Tor will instead refuse to make the connection. (Default: None).
+--------------------------------------------------------------------------------
+General
+index: 4
+relaybandwidthburst
+N bytes|KB|MB|GB
+Limit the maximum token bucket size (also known as the burst) for _relayed traffic_ to the given number of bytes in each direction. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 3
+relaybandwidthrate
+N bytes|KB|MB|GB
+If defined, a separate token bucket limits the average incoming bandwidth usage for _relayed traffic_ on this node to the specified number of bytes per second, and the average outgoing bandwidth usage to that same value. Relayed traffic currently is calculated to include answers to directory requests, but that may change in future versions. (Default: 0)
+--------------------------------------------------------------------------------
+Hidden Service
+index: 172
+rendpostperiod
+N seconds|minutes|hours|days|weeks
+Every time the specified period elapses, Tor uploads any rendezvous service descriptors to the directory servers. This information is also uploaded whenever it changes. (Default: 20 minutes)
+--------------------------------------------------------------------------------
+General
+index: 41
+runasdaemon
+0|1
+If 1, Tor forks and daemonizes to the background. This option has no effect on Windows; instead you should use the --service command-line option. (Default: 0)
+--------------------------------------------------------------------------------
+General
+index: 42
+safelogging
+0|1|relay
+Tor can scrub potentially sensitive strings from log messages (e.g. addresses) by replacing them with the string [scrubbed]. This way logs can still be useful, but they don't leave behind personally identifying information about what sites a user might have visited. 
+
+If this option is set to 0, Tor will not perform any scrubbing, if it is set to 1, all potentially sensitive strings are replaced. If it is set to relay, all log messages generated when acting as a relay are sanitized, but all messages generated when acting as a client are not. (Default: 1)
+--------------------------------------------------------------------------------
+Client
+index: 85
+safesocks
+0|1
+When this option is enabled, Tor will reject application connections that use unsafe variants of the socks protocol  ones that only provide an IP address, meaning the application is doing a DNS resolve first. Specifically, these are socks4 and socks5 when not doing remote DNS. (Defaults to 0.)
+--------------------------------------------------------------------------------
+Relay
+index: 122
+serverdnsallowbrokenconfig
+0|1
+If this option is false, Tor exits immediately if there are problems parsing the system DNS configuration or connecting to nameservers. Otherwise, Tor continues to periodically retry the system nameservers until it eventually succeeds. (Defaults to "1".)
+--------------------------------------------------------------------------------
+Relay
+index: 126
+serverdnsallownonrfc953hostnames
+0|1
+When this option is disabled, Tor does not try to resolve hostnames containing illegal characters (like @ and :) rather than sending them to an exit node to be resolved. This helps trap accidental attempts to resolve URLs and so on. This option only affects name lookups that your server does on behalf of clients. (Default: 0)
+--------------------------------------------------------------------------------
+Relay
+index: 124
+serverdnsdetecthijacking
+0|1
+When this option is set to 1, we will test periodically to determine whether our local nameservers have been configured to hijack failing DNS requests (usually to an advertising site). If they are, we will attempt to correct this. This option only affects name lookups that your server does on behalf of clients. (Defaults to "1".)
+--------------------------------------------------------------------------------
+Relay
+index: 128
+serverdnsrandomizecase
+0|1
+When this option is set, Tor sets the case of each character randomly in outgoing DNS requests, and makes sure that the case matches in DNS replies. This so-called "0x20 hack" helps resist some types of DNS poisoning attack. For more information, see "Increased DNS Forgery Resistance through 0x20-Bit Encoding". This option only affects name lookups that your server does on behalf of clients. (Default: 1)
+--------------------------------------------------------------------------------
+Relay
+index: 121
+serverdnsresolvconffile
+filename
+Overrides the default DNS configuration with the configuration in filename. The file format is the same as the standard Unix "resolv.conf" file (7). This option, like all other ServerDNS options, only affects name lookups that your server does on behalf of clients. (Defaults to use the system DNS configuration.)
+--------------------------------------------------------------------------------
+Relay
+index: 123
+serverdnssearchdomains
+0|1
+If set to 1, then we will search for addresses in the local search domain. For example, if this system is configured to believe it is in "example.com", and a client tries to connect to "www", the client will be connected to "www.example.com". This option only affects name lookups that your server does on behalf of clients. (Defaults to "0".)
+--------------------------------------------------------------------------------
+Relay
+index: 125
+serverdnstestaddresses
+address,address,...
+When we're detecting DNS hijacking, make sure that these valid addresses aren't getting redirected. If they are, then our DNS is completely useless, and we'll reset our exit policy to "reject :". This option only affects name lookups that your server does on behalf of clients. (Defaults to "www.google.com, www.mit.edu, www.yahoo.com, www.slashdot.org".)
+--------------------------------------------------------------------------------
+Relay
+index: 118
+shutdownwaitlength
+NUM
+When we get a SIGINT and we're a server, we begin shutting down: we close listeners and start refusing new circuits. After NUM seconds, we exit. If we get a second SIGINT, we exit immedi- ately. (Default: 30 seconds)
+--------------------------------------------------------------------------------
+General
+index: 31
+socks4proxy
+host[:port]
+Tor will make all OR connections through the SOCKS 4 proxy at host:port (or host:1080 if port is not specified).
+--------------------------------------------------------------------------------
+General
+index: 32
+socks5proxy
+host[:port]
+Tor will make all OR connections through the SOCKS 5 proxy at host:port (or host:1080 if port is not specified).
+--------------------------------------------------------------------------------
+General
+index: 34
+socks5proxypassword
+password
+If defined, authenticate to the SOCKS 5 server using username and password in accordance to RFC 1929. Both username and password must be between 1 and 255 characters.
+--------------------------------------------------------------------------------
+General
+index: 33
+socks5proxyusername
+username
+
+--------------------------------------------------------------------------------
+Client
+index: 76
+sockslistenaddress
+IP[:PORT]
+Bind to this address to listen for connections from Socks-speaking applications. (Default: 127.0.0.1) You can also specify a port (e.g. 192.168.0.1:9100). This directive can be specified multiple times to bind to multiple addresses/ports.
+--------------------------------------------------------------------------------
+Client
+index: 77
+sockspolicy
+policy,policy,...
+Set an entrance policy for this server, to limit who can connect to the SocksPort and DNSPort ports. The policies have the same form as exit policies below.
+--------------------------------------------------------------------------------
+Client
+index: 75
+socksport
+PORT
+Advertise this port to listen for connections from Socks-speaking applications. Set this to 0 if you don't want to allow application connections. (Default: 9050)
+--------------------------------------------------------------------------------
+Client
+index: 78
+sockstimeout
+NUM
+Let a socks connection wait NUM seconds handshaking, and NUM seconds unattached waiting for an appropriate circuit, before we fail it. (Default: 2 minutes.)
+--------------------------------------------------------------------------------
+Client
+index: 62
+strictnodes
+0|1
+If 1 and EntryNodes config option is set, Tor will never use any nodes besides those listed in EntryNodes for the first hop of a normal circuit. If 1 and ExitNodes config option is set, Tor will never use any nodes besides those listed in ExitNodes for the last hop of a normal exit circuit. Note that Tor might still use these nodes for non-exit circuits such as one-hop directory fetches or hidden service support circuits.
+--------------------------------------------------------------------------------
+Testing
+index: 177
+testingauthdirtimetolearnreachability
+N minutes|hours
+After starting as an authority, do not make claims about whether routers are Running until this much time has passed. Changing this requires that TestingTorNetwork is set. (Default: 30 minutes)
+--------------------------------------------------------------------------------
+Testing
+index: 178
+testingestimateddescriptorpropagationtime
+N minutes|hours
+Clients try downloading router descriptors from directory caches after this time. Changing this requires that TestingTorNetwork is set. (Default: 10 minutes) 
+
+SIGNALS
+--------------------------------------------------------------------------------
+Testing
+index: 173
+testingtornetwork
+0|1
+If set to 1, Tor adjusts default values of the configuration options below, so that it is easier to set up a testing Tor network. May only be set if non-default set of DirServers is set. Cannot be unset while Tor is running. (Default: 0) 
+
+    ServerDNSAllowBrokenConfig 1
+    DirAllowPrivateAddresses 1
+    EnforceDistinctSubnets 0
+    AssumeReachable 1
+    AuthDirMaxServersPerAddr 0
+    AuthDirMaxServersPerAuthAddr 0
+    ClientDNSRejectInternalAddresses 0
+    ExitPolicyRejectPrivate 0
+    V3AuthVotingInterval 5 minutes
+    V3AuthVoteDelay 20 seconds
+    V3AuthDistDelay 20 seconds
+    TestingV3AuthInitialVotingInterval 5 minutes
+    TestingV3AuthInitialVoteDelay 20 seconds
+    TestingV3AuthInitialDistDelay 20 seconds
+    TestingAuthDirTimeToLearnReachability 0 minutes
+    TestingEstimatedDescriptorPropagationTime 0 minutes
+--------------------------------------------------------------------------------
+Testing
+index: 176
+testingv3authinitialdistdelay
+N minutes|hours
+Like TestingV3AuthInitialDistDelay, but for initial voting interval before the first consensus has been created. Changing this requires that TestingTorNetwork is set. (Default: 5 minutes)
+--------------------------------------------------------------------------------
+Testing
+index: 175
+testingv3authinitialvotedelay
+N minutes|hours
+Like TestingV3AuthInitialVoteDelay, but for initial voting interval before the first consensus has been created. Changing this requires that TestingTorNetwork is set. (Default: 5 minutes)
+--------------------------------------------------------------------------------
+Testing
+index: 174
+testingv3authinitialvotinginterval
+N minutes|hours
+Like V3AuthVotingInterval, but for initial voting interval before the first consensus has been created. Changing this requires that TestingTorNetwork is set. (Default: 30 minutes)
+--------------------------------------------------------------------------------
+Client
+index: 86
+testsocks
+0|1
+When this option is enabled, Tor will make a notice-level log entry for each connection to the Socks port indicating whether the request used a safe socks protocol or an unsafe one (see above entry on SafeSocks). This helps to determine whether an application using Tor is possibly leaking DNS requests. (Default: 0)
+--------------------------------------------------------------------------------
+Client
+index: 79
+trackhostexits
+host,.domain,...
+For each value in the comma separated list, Tor will track recent connections to hosts that match this value and attempt to reuse the same exit node for each. If the value is prepended with a '.', it is treated as matching an entire domain. If one of the values is just a '.', it means match everything. This option is useful if you frequently connect to sites that will expire all your authentication cookies (i.e. log you out) if your IP address changes. Note that this option does have the disadvantage of making it more clear that a given history is associated with a single user. However, most people who would wish to observe this will observe it through cookies or other protocol-specific means anyhow.
+--------------------------------------------------------------------------------
+Client
+index: 80
+trackhostexitsexpire
+NUM
+Since exit servers go up and down, it is desirable to expire the association between host and exit server after NUM seconds. The default is 1800 seconds (30 minutes).
+--------------------------------------------------------------------------------
+Client
+index: 92
+translistenaddress
+IP[:PORT]
+Bind to this address to listen for transparent proxy connections. (Default: 127.0.0.1). This is useful for exporting a transparent proxy server to an entire network.
+--------------------------------------------------------------------------------
+Client
+index: 91
+transport
+PORT
+If non-zero, enables transparent proxy support on PORT (by convention, 9040). Requires OS support for transparent proxies, such as BSDs' pf or Linux's IPTables. If you're planning to use Tor as a transparent proxy for a network, you'll want to examine and change VirtualAddrNetwork from the default setting. You'll also want to set the TransListenAddress option for the network you'd like to proxy. (Default: 0).
+--------------------------------------------------------------------------------
+General
+index: 48
+tunneldirconns
+0|1
+If non-zero, when a directory server we contact supports it, we will build a one-hop circuit and make an encrypted connection via its ORPort. (Default: 1)
+--------------------------------------------------------------------------------
+Client
+index: 81
+updatebridgesfromauthority
+0|1
+When set (along with UseBridges), Tor will try to fetch bridge descriptors from the configured bridge authorities when feasible. It will fall back to a direct request if the authority responds with a 404. (Default: 0)
+--------------------------------------------------------------------------------
+Client
+index: 82
+usebridges
+0|1
+When set, Tor will fetch descriptors for each bridge listed in the "Bridge" config lines, and use these relays as both entry guards and directory guards. (Default: 0)
+--------------------------------------------------------------------------------
+Client
+index: 83
+useentryguards
+0|1
+If this option is set to 1, we pick a few long-term entry servers, and try to stick with them. This is desirable because constantly changing servers increases the odds that an adversary who owns some servers will observe a fraction of your paths. (Defaults to 1.)
+--------------------------------------------------------------------------------
+General
+index: 43
+user
+UID
+On startup, setuid to this user and setgid to their primary group.
+--------------------------------------------------------------------------------
+Directory
+index: 137
+v1authoritativedirectory
+0|1
+When this option is set in addition to AuthoritativeDirectory, Tor generates version 1 directory and running-routers documents (for legacy Tor clients up to 0.1.0.x).
+--------------------------------------------------------------------------------
+Directory
+index: 138
+v2authoritativedirectory
+0|1
+When this option is set in addition to AuthoritativeDirectory, Tor generates version 2 network statuses and serves descriptors, etc as described in doc/spec/dir-spec-v2.txt (for Tor clients and servers running 0.1.1.x and 0.1.2.x).
+--------------------------------------------------------------------------------
+Authority
+index: 165
+v3authdistdelay
+N minutes|hours
+V3 authoritative directories only. Configures the server's preferred delay between publishing its consensus and signature and assuming it has all the signatures from all the other authorities. Note that the actual time used is not the server's preferred time, but the consensus of all preferences. (Default: 5 minutes.)
+--------------------------------------------------------------------------------
+Authority
+index: 166
+v3authnintervalsvalid
+NUM
+V3 authoritative directories only. Configures the number of VotingIntervals for which each consensus should be valid for. Choosing high numbers increases network partitioning risks; choosing low numbers increases directory traffic. Note that the actual number of intervals used is not the server's preferred number, but the consensus of all preferences. Must be at least 2. (Default: 3.)
+--------------------------------------------------------------------------------
+Directory
+index: 139
+v3authoritativedirectory
+0|1
+When this option is set in addition to AuthoritativeDirectory, Tor generates version 3 network statuses and serves descriptors, etc as described in doc/spec/dir-spec.txt (for Tor clients and servers running at least 0.2.0.x).
+--------------------------------------------------------------------------------
+Authority
+index: 164
+v3authvotedelay
+N minutes|hours
+V3 authoritative directories only. Configures the server's preferred delay between publishing its vote and assuming it has all the votes from all the other authorities. Note that the actual time used is not the server's preferred time, but the consensus of all preferences. (Default: 5 minutes.)
+--------------------------------------------------------------------------------
+Authority
+index: 163
+v3authvotinginterval
+N minutes|hours
+V3 authoritative directories only. Configures the server's preferred voting interval. Note that voting will actually happen at an interval chosen by consensus from all the authorities' preferred intervals. This time SHOULD divide evenly into a day. (Default: 1 hour)
+--------------------------------------------------------------------------------
+Directory
+index: 140
+versioningauthoritativedirectory
+0|1
+When this option is set to 1, Tor adds information on which versions of Tor are still believed safe for use to the published directory. Each version 1 authority is automatically a versioning authority; version 2 authorities provide this service optionally. See RecommendedVersions, RecommendedClientVersions, and RecommendedServerVersions.
+--------------------------------------------------------------------------------
+Client
+index: 87
+virtualaddrnetwork
+Address/bits
+When a controller asks for a virtual (unused) address with the MAPADDRESS command, Tor picks an unassigned address from this range. (Default: 127.192.0.0/10) 
+
+When providing proxy server service to a network of computers using a tool like dns-proxy-tor, change this address to "10.192.0.0/10" or "172.16.0.0/12". The default VirtualAddrNetwork address range on a properly configured machine will route to the loopback interface. For local use, no change to the default VirtualAddrNetwork setting is needed.
+--------------------------------------------------------------------------------
+Client
+index: 102
+warnplaintextports
+port,port,...
+Tells Tor to issue a warnings whenever the user tries to make an anonymous connection to one of these ports. This option is designed to alert users to services that risk sending passwords in the clear. (Default: 23,109,110,143).
diff --git a/arm/settings.cfg b/arm/settings.cfg
new file mode 100644
index 0000000..8cd92c1
--- /dev/null
+++ b/arm/settings.cfg
@@ -0,0 +1,707 @@
+# Important tor configuration options (shown by default)
+config.important BandwidthRate
+config.important BandwidthBurst
+config.important RelayBandwidthRate
+config.important RelayBandwidthBurst
+config.important ControlPort
+config.important HashedControlPassword
+config.important CookieAuthentication
+config.important DataDirectory
+config.important Log
+config.important RunAsDaemon
+config.important User
+
+config.important Bridge
+config.important ExcludeNodes
+config.important MaxCircuitDirtiness
+config.important SocksPort
+config.important UseBridges
+
+config.important BridgeRelay
+config.important ContactInfo
+config.important ExitPolicy
+config.important MyFamily
+config.important Nickname
+config.important ORPort
+config.important PortForwarding
+config.important AccountingMax
+config.important AccountingStart
+
+config.important DirPortFrontPage
+config.important DirPort
+
+config.important HiddenServiceDir
+config.important HiddenServicePort
+
+# Summary descriptions for Tor configuration options
+# General Config Options
+config.summary.BandwidthRate Average bandwidth usage limit
+config.summary.BandwidthBurst Maximum bandwidth usage limit
+config.summary.MaxAdvertisedBandwidth Limit for the bandwidth we advertise as being available for relaying
+config.summary.RelayBandwidthRate Average bandwidth usage limit for relaying
+config.summary.RelayBandwidthBurst Maximum bandwidth usage limit for relaying
+config.summary.PerConnBWRate Average relayed bandwidth limit per connection
+config.summary.PerConnBWBurst Maximum relayed bandwidth limit per connection
+config.summary.ConnLimit Minimum number of file descriptors for Tor to start
+config.summary.ConstrainedSockets Shrinks sockets to ConstrainedSockSize
+config.summary.ConstrainedSockSize Limit for the received and transmit buffers of sockets
+config.summary.ControlPort Port providing access to tor controllers (arm, vidalia, etc)
+config.summary.ControlListenAddress Address providing controller access
+config.summary.ControlSocket Socket providing controller access
+config.summary.HashedControlPassword Hash of the password for authenticating to the control port
+config.summary.CookieAuthentication If set, authenticates controllers via a cookie
+config.summary.CookieAuthFile Location of the authentication cookie
+config.summary.CookieAuthFileGroupReadable Group read permissions for the authentication cookie
+config.summary.ControlPortWriteToFile Path for a file tor writes containing its control port
+config.summary.ControlPortFileGroupReadable Group read permissions for the control port file
+config.summary.DataDirectory Location for storing runtime data (state, keys, etc)
+config.summary.DirServer Alternative directory authorities
+config.summary.AlternateDirAuthority Alternative directory authorities (consensus only)
+config.summary.AlternateHSAuthority Alternative directory authorities (hidden services only)
+config.summary.AlternateBridgeAuthority Alternative directory authorities (bridges only)
+config.summary.DisableAllSwap Locks all allocated memory so they can't be paged out
+config.summary.FetchDirInfoEarly Keeps consensus information up to date, even if unnecessary
+config.summary.FetchDirInfoExtraEarly Updates consensus information when it's first available
+config.summary.FetchHidServDescriptors Toggles if hidden service descriptors are fetched automatically or not
+config.summary.FetchServerDescriptors Toggles if the consensus is fetched automatically or not
+config.summary.FetchUselessDescriptors Toggles if relay descriptors are fetched when they aren't strictly necessary
+config.summary.Group GID for the process when started
+config.summary.HttpProxy HTTP proxy for connecting to tor
+config.summary.HttpProxyAuthenticator Authentication credentials for HttpProxy
+config.summary.HttpsProxy SSL proxy for connecting to tor
+config.summary.HttpsProxyAuthenticator Authentication credentials for HttpsProxy
+config.summary.Socks4Proxy SOCKS 4 proxy for connecting to tor
+config.summary.Socks5Proxy SOCKS 5 for connecting to tor
+config.summary.Socks5ProxyUsername Username for connecting to the Socks5Proxy
+config.summary.Socks5ProxyPassword Password for connecting to the Socks5Proxy
+config.summary.KeepalivePeriod Rate at which to send keepalive packets
+config.summary.Log Runlevels and location for tor logging
+config.summary.LogMessageDomains Includes a domain when logging messages
+config.summary.OutboundBindAddress Sets the IP used for connecting to tor
+config.summary.PidFile Path for a file tor writes containing its process id
+config.summary.ProtocolWarnings Toggles if protocol errors give warnings or not
+config.summary.RunAsDaemon Toggles if tor runs as a daemon process
+config.summary.LogTimeGranularity limits granularity of log message timestamps
+config.summary.SafeLogging Toggles if logs are scrubbed of sensitive information
+config.summary.User UID for the process when started
+config.summary.HardwareAccel Toggles if tor attempts to use hardware acceleration
+config.summary.AccelName OpenSSL engine name for crypto acceleration
+config.summary.AccelDir Crypto acceleration library path
+config.summary.AvoidDiskWrites Toggles if tor avoids frequently writing to disk
+config.summary.TunnelDirConns Toggles if directory requests can be made over the ORPort
+config.summary.PreferTunneledDirConns Avoids directory requests that can't be made over the ORPort if set
+config.summary.CircuitPriorityHalflife Overwrite method for prioritizing traffic among relayed connections
+config.summary.DisableIOCP Disables use of the Windows IOCP networking API
+config.summary.CountPrivateBandwidth Applies rate limiting to private IP addresses
+
+# Client Config Options
+config.summary.AllowInvalidNodes Permits use of relays flagged as invalid by authorities
+config.summary.ExcludeSingleHopRelays Permits use of relays that allow single hop connections
+config.summary.Bridge Available bridges
+config.summary.LearnCircuitBuildTimeout Toggles adaptive timeouts for circuit creation
+config.summary.CircuitBuildTimeout Initial timeout for circuit creation
+config.summary.CircuitIdleTimeout Timeout for closing circuits that have never been used
+config.summary.CircuitStreamTimeout Timeout for shifting streams among circuits
+config.summary.ClientOnly Ensures that we aren't used as a relay or directory mirror
+config.summary.ExcludeNodes Relays or locales never to be used in circuits
+config.summary.ExcludeExitNodes Relays or locales never to be used for exits
+config.summary.ExitNodes Preferred final hop for circuits
+config.summary.EntryNodes Preferred first hops for circuits
+config.summary.StrictNodes Never uses notes outside of Entry/ExitNodes
+config.summary.FascistFirewall Only make outbound connections on FirewallPorts
+config.summary.FirewallPorts Ports used by FascistFirewall
+config.summary.HidServAuth Authentication credentials for connecting to a hidden service
+config.summary.ReachableAddresses Rules for bypassing the local firewall
+config.summary.ReachableDirAddresses Rules for bypassing the local firewall (directory fetches)
+config.summary.ReachableORAddresses Rules for bypassing the local firewall (OR connections)
+config.summary.LongLivedPorts Ports requiring highly reliable relays
+config.summary.MapAddress Alias mappings for address requests
+config.summary.NewCircuitPeriod Period for considering the creation of new circuits
+config.summary.MaxCircuitDirtiness Duration for reusing constructed circuits
+config.summary.NodeFamily Define relays as belonging to a family
+config.summary.EnforceDistinctSubnets Prevent use of multiple relays from the same subnet on a circuit
+config.summary.SocksPort Port for using tor as a Socks proxy
+config.summary.SocksListenAddress Address from which Socks connections can be made
+config.summary.SocksPolicy Access policy for the pocks port
+config.summary.SocksTimeout Time until idle or unestablished socks connections are closed
+config.summary.TrackHostExits Maintains use of the same exit whenever connecting to this destination
+config.summary.TrackHostExitsExpire Time until use of an exit for tracking expires
+config.summary.UpdateBridgesFromAuthority Toggles fetching bridge descriptors from the authorities
+config.summary.UseBridges Make use of configured bridges
+config.summary.UseEntryGuards Use guard relays for first hop
+config.summary.NumEntryGuards Pool size of guard relays we'll select from
+config.summary.SafeSocks Toggles rejecting unsafe variants of the socks protocol
+config.summary.TestSocks Provide notices for if socks connections are of the safe or unsafe variants
+config.summary.WarnUnsafeSocks Toggle warning of unsafe socks connection
+config.summary.VirtualAddrNetwork Address range used with MAPADDRESS
+config.summary.AllowNonRFC953Hostnames Toggles blocking invalid characters in hostname resolution
+config.summary.AllowDotExit Toggles allowing exit notation in addresses
+config.summary.FastFirstHopPK Toggle public key usage for the first hop
+config.summary.TransPort Port for transparent proxying if the OS supports it
+config.summary.TransListenAddress Address from which transparent proxy connections can be made
+config.summary.NATDPort Port for forwarding ipfw NATD connections
+config.summary.NATDListenAddress Address from which NATD forwarded connections can be made
+config.summary.AutomapHostsOnResolve Map addresses ending with special suffixes to virtual addresses
+config.summary.AutomapHostsSuffixes Address suffixes recognized by AutomapHostsOnResolve
+config.summary.DNSPort Port from which DNS responses are fetched instead of tor
+config.summary.DNSListenAddress Address for performing DNS resolution
+config.summary.ClientDNSRejectInternalAddresses Ignores DNS responses for internal addresses
+config.summary.ClientRejectInternalAddresses Disables use of Tor for internal connections
+config.summary.DownloadExtraInfo Toggles fetching of extra information about relays
+config.summary.FallbackNetworkstatusFile Path for a fallback cache of the consensus
+config.summary.WarnPlaintextPorts Toggles warnings for using risky ports
+config.summary.RejectPlaintextPorts Prevents connections on risky ports
+config.summary.AllowSingleHopCircuits Makes use of single hop exits if able
+
+# Server Config Options
+config.summary.Address Overwrites address others will use to reach this relay
+config.summary.AllowSingleHopExits Toggles permitting use of this relay as a single hop proxy
+config.summary.AssumeReachable Skips reachability test at startup
+config.summary.BridgeRelay Act as a bridge
+config.summary.ContactInfo Contact information for this relay
+config.summary.ExitPolicy Traffic destinations that can exit from this relay
+config.summary.ExitPolicyRejectPrivate Prevent exiting connection on the local network
+config.summary.MaxOnionsPending Decryption queue size
+config.summary.MyFamily Other relays this operator administers
+config.summary.Nickname Identifier for this relay
+config.summary.NumCPUs Number of processes spawned for decryption
+config.summary.ORPort Port used to accept relay traffic
+config.summary.ORListenAddress Address for relay connections
+config.summary.PortForwarding Use UPnP or NAT-PMP if needed to relay
+config.summary.PortForwardingHelper Executable for configuring port forwarding
+config.summary.PublishServerDescriptor Types of descriptors published
+config.summary.ShutdownWaitLength Delay before quitting after receiving a SIGINT signal
+config.summary.HeartbeatPeriod Rate at which an INFO level heartbeat message is sent
+config.summary.AccountingMax Amount of traffic before hibernating
+config.summary.AccountingStart Duration of an accounting period
+config.summary.RefuseUnknownExits Prevents relays not in the consensus from using us as an exit
+config.summary.ServerDNSResolvConfFile Overriding resolver config for DNS queries we provide
+config.summary.ServerDNSAllowBrokenConfig Toggles if we persist despite configuration parsing errors or not
+config.summary.ServerDNSSearchDomains Toggles if our DNS queries search for addresses in the local domain
+config.summary.ServerDNSDetectHijacking Toggles testing for DNS hijacking
+config.summary.ServerDNSTestAddresses Addresses to test to see if valid DNS queries are being hijacked
+config.summary.ServerDNSAllowNonRFC953Hostnames Toggles if we reject DNS queries with invalid characters
+config.summary.BridgeRecordUsageByCountry Tracks geoip information on bridge usage
+config.summary.ServerDNSRandomizeCase Toggles DNS query case randomization
+config.summary.GeoIPFile Path to file containing geoip information
+config.summary.CellStatistics Toggles storing circuit queue duration to disk
+config.summary.DirReqStatistics Toggles storing network status counts and performance to disk
+config.summary.EntryStatistics Toggles storing client connection counts to disk
+config.summary.ExitPortStatistics Toggles storing traffic and port usage data to disk
+config.summary.ConnDirectionStatistics Toggles storing connection use to disk
+config.summary.ExtraInfoStatistics Publishes statistic data in the extra-info documents
+
+# Directory Server Options
+config.summary.AuthoritativeDirectory Act as a directory authority
+config.summary.DirPortFrontPage Publish this html file on the DirPort
+config.summary.V1AuthoritativeDirectory Generates a version 1 consensus
+config.summary.V2AuthoritativeDirectory Generates a version 2 consensus
+config.summary.V3AuthoritativeDirectory Generates a version 3 consensus
+config.summary.VersioningAuthoritativeDirectory Provides opinions on recommended versions of tor
+config.summary.NamingAuthoritativeDirectory Provides opinions on fingerprint to nickname bindings
+config.summary.HSAuthoritativeDir Toggles accepting hidden service descriptors
+config.summary.HidServDirectoryV2 Toggles accepting version 2 hidden service descriptors
+config.summary.BridgeAuthoritativeDir Acts as a bridge authority
+config.summary.MinUptimeHidServDirectoryV2 Required uptime before accepting hidden service directory
+config.summary.DirPort Port for directory connections
+config.summary.DirListenAddress Address the directory service is bound to
+config.summary.DirPolicy Access policy for the DirPort
+config.summary.FetchV2Networkstatus Get the obsolete V2 consensus
+
+# Directory Authority Server Options
+config.summary.RecommendedVersions Tor versions believed to be safe
+config.summary.RecommendedClientVersions Tor versions believed to be safe for clients
+config.summary.RecommendedServerVersions Tor versions believed to be safe for relays
+config.summary.ConsensusParams Params entry of the networkstatus vote
+config.summary.DirAllowPrivateAddresses Toggles allowing arbitrary input or non-public IPs in descriptors
+config.summary.AuthDirBadDir Relays to be flagged as bad directory caches
+config.summary.AuthDirBadExit Relays to be flagged as bad exits
+config.summary.AuthDirInvalid Relays from which the valid flag is withheld
+config.summary.AuthDirReject Relays to be dropped from the consensus
+config.summary.AuthDirListBadDirs Toggles if we provide an opinion on bad directory caches
+config.summary.AuthDirListBadExits Toggles if we provide an opinion on bad exits
+config.summary.AuthDirRejectUnlisted Rejects further relay descriptors
+config.summary.AuthDirMaxServersPerAddr Limit on the number of relays accepted per ip
+config.summary.AuthDirMaxServersPerAuthAddr Limit on the number of relays accepted per an authority's ip
+config.summary.BridgePassword Password for requesting bridge information
+config.summary.V3AuthVotingInterval Consensus voting interval
+config.summary.V3AuthVoteDelay Wait time to collect votes of other authorities
+config.summary.V3AuthDistDelay Wait time to collect the signatures of other authorities
+config.summary.V3AuthNIntervalsValid Number of voting intervals a consensus is valid for
+config.summary.V3BandwidthsFile Path to a file containing measured relay bandwidths
+config.summary.V3AuthUseLegacyKey Signs consensus with both the current and legacy keys
+config.summary.RephistTrackTime Discards old, unchanged reliability informaition
+
+# Hidden Service Options
+config.summary.HiddenServiceDir Directory contents for the hidden service
+config.summary.HiddenServicePort Port the hidden service is provided on
+config.summary.PublishHidServDescriptors Toggles automated publishing of the hidden service to the rendezvous directory
+config.summary.HiddenServiceVersion Version for published hidden service descriptors
+config.summary.HiddenServiceAuthorizeClient Restricts access to the hidden service
+config.summary.RendPostPeriod Period at which the rendezvous service descriptors are refreshed
+
+# Testing Network Options
+config.summary.TestingTorNetwork Overrides other options to be a testing network
+config.summary.TestingV3AuthInitialVotingInterval Overrides V3AuthVotingInterval for the first consensus
+config.summary.TestingV3AuthInitialVoteDelay Overrides TestingV3AuthInitialVoteDelay for the first consensus
+config.summary.TestingV3AuthInitialDistDelay Overrides TestingV3AuthInitialDistDelay for the first consensus
+config.summary.TestingAuthDirTimeToLearnReachability Delay until opinions are given about which relays are running or not
+config.summary.TestingEstimatedDescriptorPropagationTime Delay before clients attempt to fetch descriptors from directory caches
+
+# Snippets from common log messages
+# These are static bits of log messages, used to determine when entries with
+# dynamic content (hostnames, numbers, etc) are the same. If this matches the
+# start of both messages then the entries are flagged as duplicates. If the
+# entry begins with an asterisk (*) then it checks if the substrings exist
+# anywhere in the messages.
+# 
+# Examples for the complete messages:
+# [BW] READ: 0, WRITTEN: 0
+# [DEBUG] connection_handle_write(): After TLS write of 512: 0 read, 586 written
+# [DEBUG] flush_chunk_tls(): flushed 512 bytes, 0 ready to flush, 0 remain.
+# [DEBUG] conn_read_callback(): socket 7 wants to read.
+# [DEBUG] conn_write_callback(): socket 51 wants to write.
+# [DEBUG] connection_remove(): removing socket -1 (type OR), n_conns now 50
+# [DEBUG] connection_or_process_cells_from_inbuf(): 7: starting, inbuf_datalen
+#         0 (0 pending in tls object).
+# [DEBUG] connection_read_to_buf(): 38: starting, inbuf_datalen 0 (0 pending in
+#         tls object). at_most 12800.
+# [DEBUG] connection_read_to_buf(): TLS connection closed on read. Closing.
+#         (Nickname moria1, address 128.31.0.34)
+# [INFO] run_connection_housekeeping(): Expiring non-open OR connection to fd
+#        16 (79.193.61.171:443).
+# [INFO] rep_hist_downrate_old_runs(): Discounting all old stability info by a
+#        factor of 0.950000
+# [NOTICE] Circuit build timeout of 96803ms is beyond the maximum build time we 
+#          have ever observed. Capping it to 96107ms.
+#   The above NOTICE changes to an INFO message in maint-0.2.2
+# [NOTICE] Based on 1000 circuit times, it looks like we don't need to wait so 
+#          long for circuits to finish. We will now assume a circuit is too slow
+#          to use after waiting 65 seconds.
+# [NOTICE] We stalled too much while trying to write 150 bytes to address
+#          [scrubbed].  If this happens a lot, either something is wrong with
+#          your network connection, or something is wrong with theirs. (fd 238,
+#          type Directory, state 1, marked at main.c:702).
+# [NOTICE] I learned some more directory information, but not enough to build a
+#          circuit: We have only 469/2027 usable descriptors.
+# [NOTICE] Attempt by %s to open a stream from unknown relay. Closing.
+# [NOTICE] Bootstrapped 72%: Loading relay descriptors.
+# [WARN] You specified a server "Amunet8" by name, but this name is not
+#        registered
+# [WARN] I have no descriptor for the router named "Amunet8" in my declared
+#        family; I'll use the nickname as is, but this   may confuse clients.
+# [WARN] Controller gave us config lines that didn't validate: Value
+#        'BandwidthRate  ' is malformed or out of bounds.
+# [WARN] Problem bootstrapping. Stuck at 80%: Connecting to the Tor network.
+#        (Network is unreachable; NOROUTE; count 47;    recommendation warn)
+# [WARN] 4 unknown, 1 missing key, 3 good, 0 bad, 1 no signature, 4 required
+# [ARM_DEBUG] refresh rate: 0.001 seconds
+# [ARM_DEBUG] proc call (process connections): /proc/net/[tcp|udp] (runtime: 0.0018)
+# [ARM_DEBUG] system call: ps -p 2354 -o %cpu,rss,%mem,etime (runtime: 0.02)
+# [ARM_DEBUG] system call: netstat -npt | grep 2354/tor (runtime: 0.02)
+# [ARM_DEBUG] recreating panel 'graph' with the dimensions of 14/124
+# [ARM_DEBUG] redrawing the log panel with the corrected content height (estimat was off by 4)
+# [ARM_DEBUG] GETINFO accounting/bytes-left (runtime: 0.0006)
+# [ARM_DEBUG] GETINFO traffic/read (runtime: 0.0004)
+# [ARM_DEBUG] GETINFO traffic/written (runtime: 0.0002)
+# [ARM_DEBUG] GETCONF MyFamily (runtime: 0.0007)
+# [ARM_DEBUG] Unable to query process resource usage from ps, waiting 6.25 seconds (unrecognized output from ps: ...)
+
+msg.BW READ:
+msg.DEBUG connection_handle_write(): After TLS write of
+msg.DEBUG flush_chunk_tls(): flushed
+msg.DEBUG conn_read_callback(): socket
+msg.DEBUG conn_write_callback(): socket
+msg.DEBUG connection_remove(): removing socket
+msg.DEBUG connection_or_process_cells_from_inbuf():
+msg.DEBUG *pending in tls object). at_most
+msg.DEBUG connection_read_to_buf(): TLS connection closed on read. Closing.
+msg.INFO run_connection_housekeeping(): Expiring
+msg.INFO rep_hist_downrate_old_runs(): Discounting all old stability info by a factor of
+msg.INFO *build time we have ever observed. Capping it to
+msg.NOTICE *build time we have ever observed. Capping it to
+msg.NOTICE *We will now assume a circuit is too slow to use after waiting
+msg.NOTICE We stalled too much while trying to write
+msg.NOTICE I learned some more directory information, but not enough to build a circuit
+msg.NOTICE Attempt by
+msg.NOTICE *Loading relay descriptors.
+msg.WARN You specified a server
+msg.WARN I have no descriptor for the router named
+msg.WARN Controller gave us config lines that didn't validate
+msg.WARN Problem bootstrapping. Stuck at
+msg.WARN *missing key,
+msg.ARM_DEBUG refresh rate:
+msg.ARM_DEBUG proc call (cwd):
+msg.ARM_DEBUG proc call (memory usage):
+msg.ARM_DEBUG proc call (process command
+msg.ARM_DEBUG proc call (process utime
+msg.ARM_DEBUG proc call (process stime
+msg.ARM_DEBUG proc call (process start time
+msg.ARM_DEBUG proc call (process connections):
+msg.ARM_DEBUG system call: ps
+msg.ARM_DEBUG system call: netstat
+msg.ARM_DEBUG recreating panel '
+msg.ARM_DEBUG redrawing the log panel with the corrected content height (
+msg.ARM_DEBUG GETINFO accounting/bytes
+msg.ARM_DEBUG GETINFO accounting/bytes-left
+msg.ARM_DEBUG GETINFO accounting/interval-end
+msg.ARM_DEBUG GETINFO accounting/hibernating
+msg.ARM_DEBUG GETINFO traffic/read
+msg.ARM_DEBUG GETINFO traffic/written
+msg.ARM_DEBUG GETCONF
+msg.ARM_DEBUG Unable to query process resource usage from ps
+
+# valid torrc aliases from the _option_abbrevs struct of src/or/config.c
+# These couldn't be requested via GETCONF (in 0.2.1.19), but I think this has
+# been fixed. Discussion is in:
+# https://trac.torproject.org/projects/tor/ticket/1802
+# 
+# TODO: This workaround should be dropped after a few releases.
+torrc.alias l => Log
+torrc.alias AllowUnverifiedNodes => AllowInvalidNodes
+torrc.alias AutomapHostSuffixes => AutomapHostsSuffixes
+torrc.alias AutomapHostOnResolve => AutomapHostsOnResolve
+torrc.alias BandwidthRateBytes => BandwidthRate
+torrc.alias BandwidthBurstBytes => BandwidthBurst
+torrc.alias DirFetchPostPeriod => StatusFetchPeriod
+torrc.alias MaxConn => ConnLimit
+torrc.alias ORBindAddress => ORListenAddress
+torrc.alias DirBindAddress => DirListenAddress
+torrc.alias SocksBindAddress => SocksListenAddress
+torrc.alias UseHelperNodes => UseEntryGuards
+torrc.alias NumHelperNodes => NumEntryGuards
+torrc.alias UseEntryNodes => UseEntryGuards
+torrc.alias NumEntryNodes => NumEntryGuards
+torrc.alias ResolvConf => ServerDNSResolvConfFile
+torrc.alias SearchDomains => ServerDNSSearchDomains
+torrc.alias ServerDNSAllowBrokenResolvConf => ServerDNSAllowBrokenConfig
+torrc.alias PreferTunnelledDirConns => PreferTunneledDirConns
+torrc.alias BridgeAuthoritativeDirectory => BridgeAuthoritativeDir
+torrc.alias StrictEntryNodes => StrictNodes
+torrc.alias StrictExitNodes => StrictNodes
+
+# using the following entry is problematic, despite being among the
+# __option_abbrevs mappings
+#torrc.alias HashedControlPassword => __HashedControlSessionPassword
+
+# size and time modifiers allowed by config.c
+torrc.label.size.b b, byte, bytes
+torrc.label.size.kb kb, kbyte, kbytes, kilobyte, kilobytes
+torrc.label.size.mb m, mb, mbyte, mbytes, megabyte, megabytes
+torrc.label.size.gb gb, gbyte, gbytes, gigabyte, gigabytes
+torrc.label.size.tb tb, terabyte, terabytes
+torrc.label.time.sec second, seconds
+torrc.label.time.min minute, minutes
+torrc.label.time.hour hour, hours
+torrc.label.time.day day, days
+torrc.label.time.week week, weeks
+
+# Common usages for ports based on:
+# https://secure.wikimedia.org/wikipedia/en/wiki/List_of_TCP_and_UDP_port_numbers
+# http://isc.sans.edu/services.html
+# 
+# Including all the official low ports (< 1024), and higher ones I recognize.
+
+port.label.1 TCPMUX
+port.label.2 CompressNET
+port.label.3 CompressNET
+port.label.5 RJE
+port.label.7 Echo
+port.label.9 Discard
+port.label.11 SYSTAT
+port.label.13 Daytime
+port.label.15 netstat
+port.label.17 QOTD
+port.label.18 MSP
+port.label.19 CHARGEN
+port.label.20 FTP
+port.label.21 FTP
+port.label.22 SSH
+port.label.23 Telnet
+port.label.24 Priv-mail
+port.label.25 SMTP
+port.label.34 RF
+port.label.35 Printer
+port.label.37 TIME
+port.label.39 RLP
+port.label.41 Graphics
+port.label.42 WINS
+port.label.43 WHOIS
+port.label.47 NI FTP
+port.label.49 TACACS
+port.label.50 Remote Mail
+port.label.51 IMP
+port.label.52 XNS
+port.label.53 DNS
+port.label.54 XNS
+port.label.55 ISI-GL
+port.label.56 RAP
+port.label.57 MTP
+port.label.58 XNS
+port.label.67 BOOTP
+port.label.68 BOOTP
+port.label.69 TFTP
+port.label.70 Gopher
+port.label.79 Finger
+port.label.80 HTTP
+port.label.81 HTTP Alternate
+port.label.82 Torpark
+port.label.83 MIT ML
+port.label.88 Kerberos
+port.label.90 dnsix
+port.label.99 WIP
+port.label.101 NIC
+port.label.102 ISO-TSAP
+port.label.104 ACR/NEMA
+port.label.105 CCSO
+port.label.107 Telnet
+port.label.108 SNA
+port.label.109 POP2
+port.label.110 POP3
+port.label.111 ONC RPC
+port.label.113 ident
+port.label.115 SFTP
+port.label.117 UUCP
+port.label.118 SQL
+port.label.119 NNTP
+port.label.123 NTP
+port.label.135 DCE
+port.label.137 NetBIOS
+port.label.138 NetBIOS
+port.label.139 NetBIOS
+port.label.143 IMAP
+port.label.152 BFTP
+port.label.153 SGMP
+port.label.156 SQL
+port.label.158 DMSP
+port.label.161 SNMP
+port.label.162 SNMPTRAP
+port.label.170 Print-srv
+port.label.177 XDMCP
+port.label.179 BGP
+port.label.194 IRC
+port.label.199 SMUX
+port.label.201 AppleTalk
+port.label.209 QMTP
+port.label.210 ANSI
+port.label.213 IPX
+port.label.218 MPP
+port.label.220 IMAP
+port.label.256 2DEV
+port.label.259 ESRO
+port.label.264 BGMP
+port.label.308 Novastor
+port.label.311 OSX Admin
+port.label.318 PKIX TSP
+port.label.319 PTP
+port.label.320 PTP
+port.label.323 IMMP
+port.label.350 MATIP
+port.label.351 MATIP
+port.label.366 ODMR
+port.label.369 Rpc2portmap
+port.label.370 codaauth2
+port.label.371 ClearCase
+port.label.383 HP Alarm Mgr
+port.label.384 ARNS
+port.label.387 AURP
+port.label.389 LDAP
+port.label.401 UPS
+port.label.402 Altiris
+port.label.427 SLP
+port.label.443 HTTPS
+port.label.444 SNPP
+port.label.445 SMB
+port.label.464 Kerberos (kpasswd)
+port.label.465 SMTP
+port.label.475 tcpnethaspsrv
+port.label.497 Retrospect
+port.label.500 ISAKMP
+port.label.501 STMF
+port.label.502 Modbus
+port.label.504 Citadel
+port.label.510 FirstClass
+port.label.512 Rexec
+port.label.513 rlogin
+port.label.514 rsh
+port.label.515 LPD
+port.label.517 Talk
+port.label.518 NTalk
+port.label.520 efs
+port.label.524 NCP
+port.label.530 RPC
+port.label.531 AIM/IRC
+port.label.532 netnews
+port.label.533 netwall
+port.label.540 UUCP
+port.label.542 commerce
+port.label.543 Kerberos (klogin)
+port.label.544 Kerberos (kshell)
+port.label.545 OSISoft PI
+port.label.546 DHCPv6
+port.label.547 DHCPv6
+port.label.548 AFP
+port.label.550 new-who
+port.label.554 RTSP
+port.label.556 RFS
+port.label.560 rmonitor
+port.label.561 monitor
+port.label.563 NNTPS
+port.label.587 SMTP
+port.label.591 FileMaker
+port.label.593 HTTP RPC
+port.label.604 TUNNEL
+port.label.623 ASF-RMCP
+port.label.631 CUPS
+port.label.635 RLZ DBase
+port.label.636 LDAPS
+port.label.639 MSDP
+port.label.641 SupportSoft
+port.label.646 LDP
+port.label.647 DHCP
+port.label.648 RRP
+port.label.651 IEEE-MMS
+port.label.652 DTCP
+port.label.653 SupportSoft
+port.label.654 MMS/MMP
+port.label.657 RMC
+port.label.660 OSX Admin
+port.label.665 sun-dr
+port.label.666 Doom
+port.label.674 ACAP
+port.label.691 MS Exchange
+port.label.692 Hyperwave-ISP
+port.label.694 Linux-HA
+port.label.695 IEEE-MMS-SSL
+port.label.698 OLSR
+port.label.699 Access Network
+port.label.700 EPP
+port.label.701 LMP
+port.label.702 IRIS
+port.label.706 SILC
+port.label.711 MPLS
+port.label.712 TBRPF
+port.label.720 SMQP
+port.label.749 Kerberos (admin)
+port.label.750 rfile
+port.label.751 pump
+port.label.752 qrh
+port.label.753 rrh
+port.label.754 tell send
+port.label.760 ns
+port.label.782 Conserver
+port.label.783 spamd
+port.label.829 CMP
+port.label.843 Flash
+port.label.847 DHCP
+port.label.860 iSCSI
+port.label.873 rsync
+port.label.888 CDDB
+port.label.901 SWAT
+port.label.902-904 VMware
+port.label.911 NCA
+port.label.953 DNS RNDC
+port.label.981 SofaWare Firewall
+port.label.989 FTPS
+port.label.990 FTPS
+port.label.991 NAS
+port.label.992 Telnet
+port.label.993 IMAPS
+port.label.994 IRC
+port.label.995 POP3S
+port.label.999 ScimoreDB
+port.label.1001 JtoMB
+port.label.1002 cogbot
+
+port.label.1080 SOCKS
+port.label.1085 WebObjects
+port.label.1109 KPOP
+port.label.1169 Tripwire
+port.label.1194 OpenVPN
+port.label.1214 Kazaa
+port.label.1220 QuickTime
+port.label.1234 VLC
+port.label.1241 Nessus
+port.label.1270 SCOM
+port.label.1293 IPSec
+port.label.1433 MSSQL
+port.label.1434 MSSQL
+port.label.1500 NetGuard
+port.label.1503 MSN
+port.label.1512 WINS
+port.label.1521 Oracle
+port.label.1526 Oracle
+port.label.1533 Sametime
+port.label.1666 Perforce
+port.label.1677 GroupWise
+port.label.1723 PPTP
+port.label.1725 Steam
+port.label.1863 MSNP
+port.label.2049 NFS
+port.label.2082 Infowave
+port.label.2083 radsec
+port.label.2086 GNUnet
+port.label.2087 ELI
+port.label.2095 NBX SER
+port.label.2096 NBX DIR
+port.label.2102-2104 Zephyr
+port.label.2401 CVS
+port.label.2525 SMTP
+port.label.2710 BitTorrent
+port.label.3074 XBox LIVE
+port.label.3101 BlackBerry
+port.label.3128 SQUID
+port.label.3306 MySQL
+port.label.3389 WBT
+port.label.3690 SVN
+port.label.3723 Battle.net
+port.label.3724 WoW
+port.label.4321 RWHOIS
+port.label.4643 Virtuozzo
+port.label.4662 eMule
+port.label.5003 FileMaker
+port.label.5050 Yahoo IM
+port.label.5060 SIP
+port.label.5061 SIP
+port.label.5190 AIM/ICQ
+port.label.5222 Jabber
+port.label.5223 Jabber
+port.label.5228 Android Market
+port.label.5269 Jabber
+port.label.5298 Jabber
+port.label.5432 PostgreSQL
+port.label.5500 VNC
+port.label.5556 Freeciv
+port.label.5666 NRPE
+port.label.5667 NSCA
+port.label.5800 VNC
+port.label.5900 VNC
+port.label.6346 gnutella
+port.label.6347 gnutella
+port.label.6660-6669 IRC
+port.label.6679 IRC
+port.label.6697 IRC
+port.label.6881-6999 BitTorrent
+port.label.8000 iRDMI
+port.label.8008 HTTP Alternate
+port.label.8010 XMPP
+port.label.8074 Gadu-Gadu
+port.label.8080 HTTP Proxy
+port.label.8087 SPP
+port.label.8088 Radan HTTP
+port.label.8118 Privoxy
+port.label.8123 Polipo
+port.label.8443 PCsync HTTPS
+port.label.8888 NewsEDGE
+port.label.9030 Tor
+port.label.9050 Tor
+port.label.9051 Tor
+port.label.9418 Git
+port.label.9999 distinct
+port.label.10000 Webmin
+port.label.19294 Google Voice
+port.label.19638 Ensim
+port.label.23399 Skype
+port.label.30301 BitTorrent
+port.label.33434 traceroute
+
diff --git a/arm/starter.py b/arm/starter.py
new file mode 100644
index 0000000..29e628f
--- /dev/null
+++ b/arm/starter.py
@@ -0,0 +1,465 @@
+#!/usr/bin/env python
+
+"""
+Command line application for monitoring Tor relays, providing real time status
+information. This is the starter for the application, handling and validating
+command line parameters.
+"""
+
+import os
+import sys
+import time
+import getopt
+import getpass
+import locale
+import logging
+import platform
+
+import version
+import cli.controller
+import cli.logPanel
+import util.connections
+import util.sysTools
+import util.torConfig
+import util.torTools
+import util.uiTools
+
+from stem.control import Controller
+
+import stem.connection
+import stem.util.conf
+import stem.util.log
+import stem.util.system
+
+LOG_DUMP_PATH = os.path.expanduser("~/.arm/log")
+DEFAULT_CONFIG = os.path.expanduser("~/.arm/armrc")
+
+CONFIG = stem.util.conf.config_dict("arm", {
+  "startup.controlPassword": None,
+  "startup.interface.ipAddress": "127.0.0.1",
+  "startup.interface.port": 9051,
+  "startup.interface.socket": "/var/run/tor/control",
+  "startup.blindModeEnabled": False,
+  "startup.events": "N3",
+  "startup.dataDirectory": "~/.arm",
+  "features.config.descriptions.enabled": True,
+  "features.config.descriptions.persist": True,
+})
+
+OPT = "gi:s:c:dbe:vh"
+OPT_EXPANDED = ["interface=", "socket=", "config=", "debug", "blind", "event=", "version", "help"]
+
+HELP_MSG = """Usage arm [OPTION]
+Terminal status monitor for Tor relays.
+
+  -p, --prompt                    only start the control interpretor
+  -i, --interface [ADDRESS:]PORT  change control interface from %s:%i
+  -s, --socket SOCKET_PATH        attach using unix domain socket if present,
+                                    SOCKET_PATH defaults to: %s
+  -c, --config CONFIG_PATH        loaded configuration options, CONFIG_PATH
+                                    defaults to: %s
+  -d, --debug                     writes all arm logs to %s
+  -b, --blind                     disable connection lookups
+  -e, --event EVENT_FLAGS         event types in message log  (default: %s)
+%s
+  -v, --version                   provides version information
+  -h, --help                      presents this help
+
+Example:
+arm -b -i 1643          hide connection data, attaching to control port 1643
+arm -e we -c /tmp/cfg   use this configuration file with 'WARN'/'ERR' events
+""" % (CONFIG["startup.interface.ipAddress"], CONFIG["startup.interface.port"], CONFIG["startup.interface.socket"], DEFAULT_CONFIG, LOG_DUMP_PATH, CONFIG["startup.events"], cli.logPanel.EVENT_LISTING)
+
+# filename used for cached tor config descriptions
+CONFIG_DESC_FILENAME = "torConfigDesc.txt"
+
+# messages related to loading the tor configuration descriptions
+DESC_LOAD_SUCCESS_MSG = "Loaded configuration descriptions from '%s' (runtime: %0.3f)"
+DESC_LOAD_FAILED_MSG = "Unable to load configuration descriptions (%s)"
+DESC_INTERNAL_LOAD_SUCCESS_MSG = "Falling back to descriptions for Tor %s"
+DESC_INTERNAL_LOAD_FAILED_MSG = "Unable to load fallback descriptions. Categories and help for Tor's configuration options won't be available. (%s)"
+DESC_READ_MAN_SUCCESS_MSG = "Read descriptions for tor's configuration options from its man page (runtime %0.3f)"
+DESC_READ_MAN_FAILED_MSG = "Unable to get the descriptions of Tor's configuration options from its man page (%s)"
+DESC_SAVE_SUCCESS_MSG = "Saved configuration descriptions to '%s' (runtime: %0.3f)"
+DESC_SAVE_FAILED_MSG = "Unable to save configuration descriptions (%s)"
+
+NO_INTERNAL_CFG_MSG = "Failed to load the parsing configuration. This will be problematic for a few things like torrc validation and log duplication detection (%s)"
+STANDARD_CFG_LOAD_FAILED_MSG = "Failed to load configuration (using defaults): \"%s\""
+STANDARD_CFG_NOT_FOUND_MSG = "No armrc loaded, using defaults. You can customize arm by placing a configuration file at '%s' (see the armrc.sample for its options)."
+
+# torrc entries that are scrubbed when dumping
+PRIVATE_TORRC_ENTRIES = ["HashedControlPassword", "Bridge", "HiddenServiceDir"]
+
+# notices given if the user is running arm or tor as root
+TOR_ROOT_NOTICE = "Tor is currently running with root permissions. This is not a good idea and shouldn't be necessary. See the 'User UID' option from Tor's man page for an easy method of reducing its permissions after startup."
+ARM_ROOT_NOTICE = "Arm is currently running with root permissions. This is not a good idea, and will still work perfectly well if it's run with the same user as Tor (ie, starting with \"sudo -u %s arm\")."
+
+# Makes subcommands provide us with English results (this is important so we
+# can properly parse it).
+
+os.putenv("LANG", "C")
+
+def allowConnectionTypes():
+  """
+  This provides a tuple with booleans indicating if we should or shouldn't
+  attempt to connect by various methods...
+  (allowPortConnection, allowSocketConnection)
+  """
+  
+  confKeys = stem.util.conf.get_config("arm").keys()
+  
+  isPortArgPresent = "startup.interface.ipAddress" in confKeys or "startup.interface.port" in confKeys
+  isSocketArgPresent = "startup.interface.socket" in confKeys
+  
+  skipPortConnection = isSocketArgPresent and not isPortArgPresent
+  skipSocketConnection = isPortArgPresent and not isSocketArgPresent
+  
+  return (not skipPortConnection, not skipSocketConnection)
+
+def _loadConfigurationDescriptions(pathPrefix):
+  """
+  Attempts to load descriptions for tor's configuration options, fetching them
+  from the man page and persisting them to a file to speed future startups.
+  """
+  
+  # It is important that this is loaded before entering the curses context,
+  # otherwise the man call pegs the cpu for around a minute (I'm not sure
+  # why... curses must mess the terminal in a way that's important to man).
+  
+  if CONFIG["features.config.descriptions.enabled"]:
+    isConfigDescriptionsLoaded = False
+    
+    # determines the path where cached descriptions should be persisted (left
+    # undefined if caching is disabled)
+    descriptorPath = None
+    if CONFIG["features.config.descriptions.persist"]:
+      dataDir = CONFIG["startup.dataDirectory"]
+      if not dataDir.endswith("/"): dataDir += "/"
+      
+      descriptorPath = os.path.expanduser(dataDir + "cache/") + CONFIG_DESC_FILENAME
+    
+    # attempts to load configuration descriptions cached in the data directory
+    if descriptorPath:
+      try:
+        loadStartTime = time.time()
+        util.torConfig.loadOptionDescriptions(descriptorPath)
+        isConfigDescriptionsLoaded = True
+        
+        stem.util.log.info(DESC_LOAD_SUCCESS_MSG % (descriptorPath, time.time() - loadStartTime))
+      except IOError, exc:
+        stem.util.log.info(DESC_LOAD_FAILED_MSG % util.sysTools.getFileErrorMsg(exc))
+    
+    # fetches configuration options from the man page
+    if not isConfigDescriptionsLoaded:
+      try:
+        loadStartTime = time.time()
+        util.torConfig.loadOptionDescriptions()
+        isConfigDescriptionsLoaded = True
+        
+        stem.util.log.info(DESC_READ_MAN_SUCCESS_MSG % (time.time() - loadStartTime))
+      except IOError, exc:
+        stem.util.log.notice(DESC_READ_MAN_FAILED_MSG % util.sysTools.getFileErrorMsg(exc))
+      
+      # persists configuration descriptions 
+      if isConfigDescriptionsLoaded and descriptorPath:
+        try:
+          loadStartTime = time.time()
+          util.torConfig.saveOptionDescriptions(descriptorPath)
+          stem.util.log.info(DESC_SAVE_SUCCESS_MSG % (descriptorPath, time.time() - loadStartTime))
+        except (IOError, OSError), exc:
+          stem.util.log.notice(DESC_SAVE_FAILED_MSG % util.sysTools.getFileErrorMsg(exc))
+    
+    # finally fall back to the cached descriptors provided with arm (this is
+    # often the case for tbb and manual builds)
+    if not isConfigDescriptionsLoaded:
+      try:
+        loadStartTime = time.time()
+        loadedVersion = util.torConfig.loadOptionDescriptions("%sresources/%s" % (pathPrefix, CONFIG_DESC_FILENAME), False)
+        isConfigDescriptionsLoaded = True
+        stem.util.log.notice(DESC_INTERNAL_LOAD_SUCCESS_MSG % loadedVersion)
+      except IOError, exc:
+        stem.util.log.error(DESC_INTERNAL_LOAD_FAILED_MSG % util.sysTools.getFileErrorMsg(exc))
+
+def _getController(controlAddr="127.0.0.1", controlPort=9051, passphrase=None, incorrectPasswordMsg=""):
+  """
+  Custom handler for establishing a stem connection (... needs an overhaul).
+  """
+  
+  controller = None
+  try:
+    chroot = util.torTools.getConn().getPathPrefix()
+    controller = Controller.from_port(controlAddr, controlPort)
+    
+    try:
+      controller.authenticate(password = passphrase, chroot_path = chroot)
+    except stem.connection.MissingPassword:
+      try:
+        passphrase = getpass.getpass("Controller password: ")
+        controller.authenticate(password = passphrase, chroot_path = chroot)
+      except:
+        return None
+    
+    return controller
+  except Exception, exc:
+    if controller: controller.close()
+    
+    if passphrase and str(exc) == "Unable to authenticate: password incorrect":
+      # provide a warning that the provided password didn't work, then try
+      # again prompting for the user to enter it
+      print incorrectPasswordMsg
+      return _getController(controlAddr, controlPort)
+    else:
+      print exc
+    
+    return None
+
+def _dumpConfig():
+  """
+  Dumps the current arm and tor configurations at the DEBUG runlevel. This
+  attempts to scrub private information, but naturally the user should double
+  check that I didn't miss anything.
+  """
+  
+  config = stem.util.conf.get_config("arm")
+  conn = util.torTools.getConn()
+  
+  # dumps arm's configuration
+  armConfigEntry = ""
+  armConfigKeys = list(config.keys())
+  armConfigKeys.sort()
+  
+  for configKey in armConfigKeys:
+    # Skips some config entries that are loaded by default. This fetches
+    # the config values directly to avoid misflagging them as being used by
+    # arm.
+    
+    if not configKey.startswith("config.summary.") and not configKey.startswith("torrc.") and not configKey.startswith("msg."):
+      armConfigEntry += "%s -> %s\n" % (configKey, config.get_value(configKey))
+  
+  if armConfigEntry: armConfigEntry = "Arm Configuration:\n%s" % armConfigEntry
+  else: armConfigEntry = "Arm Configuration: None"
+  
+  # dumps tor's version and configuration
+  torConfigEntry = "Tor (%s) Configuration:\n" % conn.getInfo("version", None)
+  
+  for line in conn.getInfo("config-text", "").split("\n"):
+    if not line: continue
+    elif " " in line: key, value = line.split(" ", 1)
+    else: key, value = line, ""
+    
+    if key in PRIVATE_TORRC_ENTRIES:
+      torConfigEntry += "%s <scrubbed>\n" % key
+    else:
+      torConfigEntry += "%s %s\n" % (key, value)
+  
+  stem.util.log.debug(armConfigEntry.strip())
+  stem.util.log.debug(torConfigEntry.strip())
+
+if __name__ == '__main__':
+  startTime = time.time()
+  param = dict([(key, None) for key in CONFIG.keys()])
+  isDebugMode = False
+  configPath = DEFAULT_CONFIG # path used for customized configuration
+  
+  # parses user input, noting any issues
+  try:
+    opts, args = getopt.getopt(sys.argv[1:], OPT, OPT_EXPANDED)
+  except getopt.GetoptError, exc:
+    print str(exc) + " (for usage provide --help)"
+    sys.exit()
+  
+  for opt, arg in opts:
+    if opt in ("-i", "--interface"):
+      # defines control interface address/port
+      controlAddr, controlPort = None, None
+      divIndex = arg.find(":")
+      
+      try:
+        if divIndex == -1:
+          controlPort = int(arg)
+        else:
+          controlAddr = arg[0:divIndex]
+          controlPort = int(arg[divIndex + 1:])
+      except ValueError:
+        print "'%s' isn't a valid port number" % arg
+        sys.exit()
+      
+      param["startup.interface.ipAddress"] = controlAddr
+      param["startup.interface.port"] = controlPort
+    elif opt in ("-s", "--socket"):
+      param["startup.interface.socket"] = arg
+    elif opt in ("-c", "--config"): configPath = arg  # sets path of user's config
+    elif opt in ("-d", "--debug"): isDebugMode = True # dumps all logs
+    elif opt in ("-b", "--blind"):
+      param["startup.blindModeEnabled"] = True        # prevents connection lookups
+    elif opt in ("-e", "--event"):
+      param["startup.events"] = arg                   # set event flags
+    elif opt in ("-v", "--version"):
+      print "arm version %s (released %s)\n" % (version.VERSION, version.LAST_MODIFIED)
+      sys.exit()
+    elif opt in ("-h", "--help"):
+      print HELP_MSG
+      sys.exit()
+  
+  if isDebugMode:
+    try:
+      stem_logger = stem.util.log.get_logger()
+      
+      debugHandler = logging.FileHandler(LOG_DUMP_PATH)
+      debugHandler.setLevel(stem.util.log.logging_level(stem.util.log.TRACE))
+      debugHandler.setFormatter(logging.Formatter(
+        fmt = '%(asctime)s [%(levelname)s] %(message)s',
+        datefmt = '%m/%d/%Y %H:%M:%S'
+      ))
+      
+      stem_logger.addHandler(debugHandler)
+      
+      currentTime = time.localtime()
+      timeLabel = time.strftime("%H:%M:%S %m/%d/%Y (%Z)", currentTime)
+      initMsg = "Arm %s Debug Dump, %s" % (version.VERSION, timeLabel)
+      pythonVersionLabel = "Python Version: %s" % (".".join([str(arg) for arg in sys.version_info[:3]]))
+      osLabel = "Platform: %s (%s)" % (platform.system(), " ".join(platform.dist()))
+      
+      stem.util.log.trace("%s\n%s\n%s\n%s\n" % (initMsg, pythonVersionLabel, osLabel, "-" * 80))
+    except (OSError, IOError), exc:
+      print "Unable to write to debug log file: %s" % util.sysTools.getFileErrorMsg(exc)
+  
+  config = stem.util.conf.get_config("arm")
+  
+  # attempts to fetch attributes for parsing tor's logs, configuration, etc
+  pathPrefix = os.path.dirname(sys.argv[0])
+  if pathPrefix and not pathPrefix.endswith("/"):
+    pathPrefix = pathPrefix + "/"
+  
+  try:
+    config.load("%ssettings.cfg" % pathPrefix)
+  except IOError, exc:
+    stem.util.log.warn(NO_INTERNAL_CFG_MSG % util.sysTools.getFileErrorMsg(exc))
+  
+  # loads user's personal armrc if available
+  if os.path.exists(configPath):
+    try:
+      config.load(configPath)
+    except IOError, exc:
+      stem.util.log.warn(STANDARD_CFG_LOAD_FAILED_MSG % util.sysTools.getFileErrorMsg(exc))
+  else:
+    # no armrc found, falling back to the defaults in the source
+    stem.util.log.notice(STANDARD_CFG_NOT_FOUND_MSG % configPath)
+  
+  # syncs config and parameters, saving changed config options and overwriting
+  # undefined parameters with defaults
+  for key in param.keys():
+    if param[key] == None: param[key] = CONFIG[key]
+    else: config.set(key, str(param[key]))
+  
+  # validates that input has a valid ip address and port
+  controlAddr = param["startup.interface.ipAddress"]
+  controlPort = param["startup.interface.port"]
+  
+  if not util.connections.isValidIpAddress(controlAddr):
+    print "'%s' isn't a valid IP address" % controlAddr
+    sys.exit()
+  elif controlPort < 0 or controlPort > 65535:
+    print "'%s' isn't a valid port number (ports range 0-65535)" % controlPort
+    sys.exit()
+  
+  # validates and expands log event flags
+  try:
+    cli.logPanel.expandEvents(param["startup.events"])
+  except ValueError, exc:
+    for flag in str(exc):
+      print "Unrecognized event flag: %s" % flag
+    sys.exit()
+  
+  # By default attempts to connect using the control socket if it exists. This
+  # skips attempting to connect by socket or port if the user has given
+  # arguments for connecting to the other.
+  
+  controller = None
+  allowPortConnection, allowSocketConnection = allowConnectionTypes()
+  
+  socketPath = param["startup.interface.socket"]
+  if os.path.exists(socketPath) and allowSocketConnection:
+    try:
+      # TODO: um... what about passwords?
+      # https://trac.torproject.org/6881
+      
+      controller = Controller.from_socket_file(socketPath)
+      controller.authenticate()
+    except IOError, exc:
+      if not allowPortConnection:
+        print "Unable to use socket '%s': %s" % (socketPath, exc)
+  elif not allowPortConnection:
+    print "Socket '%s' doesn't exist" % socketPath
+  
+  if not controller and allowPortConnection:
+    # sets up stem connection, prompting for the passphrase if necessary and
+    # sending problems to stdout if they arise
+    authPassword = config.get("startup.controlPassword", CONFIG["startup.controlPassword"])
+    incorrectPasswordMsg = "Password found in '%s' was incorrect" % configPath
+    controller = _getController(controlAddr, controlPort, authPassword, incorrectPasswordMsg)
+    
+    # removing references to the controller password so the memory can be freed
+    # (unfortunately python does allow for direct access to the memory so this
+    # is the best we can do)
+    del authPassword
+    if "startup.controlPassword" in config._contents:
+      del config._contents["startup.controlPassword"]
+      
+      pwLineNum = None
+      for i in range(len(config._raw_contents)):
+        if config._raw_contents[i].strip().startswith("startup.controlPassword"):
+          pwLineNum = i
+          break
+      
+      if pwLineNum != None:
+        del config._raw_contents[i]
+  
+  if controller is None: sys.exit(1)
+  
+  # initializing the connection may require user input (for the password)
+  # skewing the startup time results so this isn't counted
+  initTime = time.time() - startTime
+  controllerWrapper = util.torTools.getConn()
+  
+  torUser = None
+  if controller:
+    controllerWrapper.init(controller)
+    
+    # give a notice if tor is running with root
+    torUser = controllerWrapper.getMyUser()
+    if torUser == "root":
+      stem.util.log.notice(TOR_ROOT_NOTICE)
+  
+  # Give a notice if arm is running with root. Querying connections usually
+  # requires us to have the same permissions as tor so if tor is running as
+  # root then drop this notice (they're already then being warned about tor
+  # being root, anyway).
+  
+  if torUser != "root" and os.getuid() == 0:
+    torUserLabel = torUser if torUser else "<tor user>"
+    stem.util.log.notice(ARM_ROOT_NOTICE % torUserLabel)
+  
+  # fetches descriptions for tor's configuration options
+  _loadConfigurationDescriptions(pathPrefix)
+  
+  # dump tor and arm configuration when in debug mode
+  if isDebugMode:
+    stem.util.log.notice("Saving a debug log to '%s' (please check it for sensitive information before sharing)" % LOG_DUMP_PATH)
+    _dumpConfig()
+  
+  # Attempts to rename our process from "python setup.py <input args>" to
+  # "arm <input args>"
+  
+  try:
+    stem.util.system.set_process_name("arm\0%s" % "\0".join(sys.argv[1:]))
+  except: pass
+  
+  # If using our LANG variable for rendering multi-byte characters lets us
+  # get unicode support then then use it. This needs to be done before
+  # initializing curses.
+  if util.uiTools.isUnicodeAvailable():
+    locale.setlocale(locale.LC_ALL, "")
+  
+  cli.controller.startTorMonitor(time.time() - initTime)
+
diff --git a/arm/uninstall b/arm/uninstall
new file mode 100755
index 0000000..af68f3d
--- /dev/null
+++ b/arm/uninstall
@@ -0,0 +1,16 @@
+#!/bin/sh
+files="/usr/bin/arm /usr/share/man/man1/arm.1.gz /usr/share/arm"
+
+for i in $files
+do
+  if [ -f $i -o -d $i ]; then
+    rm -rf $i
+    
+    if [ $? = 0 ]; then
+      echo "removed $i"
+    else
+      exit 1
+    fi
+  fi
+done
+
diff --git a/arm/util/__init__.py b/arm/util/__init__.py
new file mode 100644
index 0000000..3e21520
--- /dev/null
+++ b/arm/util/__init__.py
@@ -0,0 +1,8 @@
+"""
+General purpose utilities for a variety of tasks including logging the 
+application's status, making cross platform system calls, parsing tor data, 
+and safely working with curses (hiding some of the gory details).
+"""
+
+__all__ = ["connections", "hostnames", "panel", "sysTools", "textInput", "torConfig", "torTools", "uiTools"]
+
diff --git a/arm/util/connections.py b/arm/util/connections.py
new file mode 100644
index 0000000..aa2aa2e
--- /dev/null
+++ b/arm/util/connections.py
@@ -0,0 +1,783 @@
+"""
+Fetches connection data (IP addresses and ports) associated with a given
+process. This sort of data can be retrieved via a variety of common *nix
+utilities:
+- netstat   netstat -np | grep "ESTABLISHED <pid>/<process>"
+- sockstat  sockstat | egrep "<process> *<pid>.*ESTABLISHED"
+- lsof      lsof -wnPi | egrep "^<process> *<pid>.*((UDP.*)|(\(ESTABLISHED\)))"
+- ss        ss -nptu | grep "ESTAB.*\"<process>\",<pid>"
+
+all queries dump its stderr (directing it to /dev/null). Results include UDP
+and established TCP connections.
+
+FreeBSD lacks support for the needed netstat flags and has a completely
+different program for 'ss'. However, lsof works and there's a couple other
+options that perform even better (thanks to Fabian Keil and Hans Schnehl):
+- sockstat    sockstat -4c | grep '<process> *<pid>'
+- procstat    procstat -f <pid> | grep TCP | grep -v 0.0.0.0:0
+"""
+
+import re
+import os
+import time
+import threading
+
+from stem.util import conf, enum, log, proc, system
+
+# enums for connection resolution utilities
+Resolver = enum.Enum(("PROC", "proc"),
+                     ("NETSTAT", "netstat"),
+                     ("SS", "ss"),
+                     ("LSOF", "lsof"),
+                     ("SOCKSTAT", "sockstat"),
+                     ("BSD_SOCKSTAT", "sockstat (bsd)"),
+                     ("BSD_PROCSTAT", "procstat (bsd)"))
+
+# If true this provides new instantiations for resolvers if the old one has
+# been stopped. This can make it difficult ensure all threads are terminated
+# when accessed concurrently.
+RECREATE_HALTED_RESOLVERS = False
+
+# formatted strings for the commands to be executed with the various resolvers
+# options are:
+# n = prevents dns lookups, p = include process
+# output:
+# tcp  0  0  127.0.0.1:9051  127.0.0.1:53308  ESTABLISHED 9912/tor
+# *note: bsd uses a different variant ('-t' => '-p tcp', but worse an
+#   equivilant -p doesn't exist so this can't function)
+RUN_NETSTAT = "netstat -np"
+
+# n = numeric ports, p = include process, t = tcp sockets, u = udp sockets
+# output:
+# ESTAB  0  0  127.0.0.1:9051  127.0.0.1:53308  users:(("tor",9912,20))
+# *note: under freebsd this command belongs to a spreadsheet program
+RUN_SS = "ss -nptu"
+
+# n = prevent dns lookups, P = show port numbers (not names), i = ip only,
+# -w = no warnings
+# output:
+# tor  3873  atagar  45u  IPv4  40994  0t0  TCP 10.243.55.20:45724->194.154.227.109:9001 (ESTABLISHED)
+# 
+# oddly, using the -p flag via:
+# lsof      lsof -nPi -p <pid> | grep "^<process>.*(ESTABLISHED)"
+# is much slower (11-28% in tests I ran)
+RUN_LSOF = "lsof -wnPi"
+
+# output:
+# atagar  tor  3475  tcp4  127.0.0.1:9051  127.0.0.1:38942  ESTABLISHED
+# *note: this isn't available by default under ubuntu
+RUN_SOCKSTAT = "sockstat"
+
+RUN_BSD_SOCKSTAT = "sockstat -4c"
+RUN_BSD_PROCSTAT = "procstat -f %s"
+
+RESOLVERS = []                      # connection resolvers available via the singleton constructor
+RESOLVER_FAILURE_TOLERANCE = 3      # number of subsequent failures before moving on to another resolver
+RESOLVER_SERIAL_FAILURE_MSG = "Unable to query connections with %s, trying %s"
+RESOLVER_FINAL_FAILURE_MSG = "All connection resolvers failed"
+
+def conf_handler(key, value):
+  if key.startswith("port.label."):
+    portEntry = key[11:]
+    
+    divIndex = portEntry.find("-")
+    if divIndex == -1:
+      # single port
+      if portEntry.isdigit():
+        PORT_USAGE[portEntry] = value
+      else:
+        msg = "Port value isn't numeric for entry: %s" % key
+        log.notice(msg)
+    else:
+      try:
+        # range of ports (inclusive)
+        minPort = int(portEntry[:divIndex])
+        maxPort = int(portEntry[divIndex + 1:])
+        if minPort > maxPort: raise ValueError()
+        
+        for port in range(minPort, maxPort + 1):
+          PORT_USAGE[str(port)] = value
+      except ValueError:
+        msg = "Unable to parse port range for entry: %s" % key
+        log.notice(msg)
+
+CONFIG = conf.config_dict("arm", {
+  "queries.connections.minRate": 5,
+}, conf_handler)
+
+PORT_USAGE = {}
+
+def isValidIpAddress(ipStr):
+  """
+  Returns true if input is a valid IPv4 address, false otherwise.
+  """
+  
+  # checks if theres four period separated values
+  if not ipStr.count(".") == 3: return False
+  
+  # checks that each value in the octet are decimal values between 0-255
+  for ipComp in ipStr.split("."):
+    if not ipComp.isdigit() or int(ipComp) < 0 or int(ipComp) > 255:
+      return False
+  
+  return True
+
+def isIpAddressPrivate(ipAddr):
+  """
+  Provides true if the IP address belongs on the local network or belongs to
+  loopback, false otherwise. These include:
+  Private ranges: 10.*, 172.16.* - 172.31.*, 192.168.*
+  Loopback: 127.*
+  
+  Arguments:
+    ipAddr - IP address to be checked
+  """
+  
+  # checks for any of the simple wildcard ranges
+  if ipAddr.startswith("10.") or ipAddr.startswith("192.168.") or ipAddr.startswith("127."):
+    return True
+  
+  # checks for the 172.16.* - 172.31.* range
+  if ipAddr.startswith("172.") and ipAddr.count(".") == 3:
+    secondOctet = ipAddr[4:ipAddr.find(".", 4)]
+    
+    if secondOctet.isdigit() and int(secondOctet) >= 16 and int(secondOctet) <= 31:
+      return True
+  
+  return False
+
+def ipToInt(ipAddr):
+  """
+  Provides an integer representation of the ip address, suitable for sorting.
+  
+  Arguments:
+    ipAddr - ip address to be converted
+  """
+  
+  total = 0
+  
+  for comp in ipAddr.split("."):
+    total *= 255
+    total += int(comp)
+  
+  return total
+
+def getPortUsage(port):
+  """
+  Provides the common use of a given port. If no useage is known then this
+  provides None.
+  
+  Arguments:
+    port - port number to look up
+  """
+  
+  return PORT_USAGE.get(port)
+
+def getResolverCommand(resolutionCmd, processName, processPid = ""):
+  """
+  Provides the command and line filter that would be processed for the given
+  resolver type. This raises a ValueError if either the resolutionCmd isn't
+  recognized or a pid was requited but not provided.
+  
+  Arguments:
+    resolutionCmd - command to use in resolving the address
+    processName   - name of the process for which connections are fetched
+    processPid    - process ID (this helps improve accuracy)
+  """
+  
+  if not processPid:
+    # the pid is required for procstat resolution
+    if resolutionCmd == Resolver.BSD_PROCSTAT:
+      raise ValueError("procstat resolution requires a pid")
+    
+    # if the pid was undefined then match any in that field
+    processPid = "[0-9]*"
+  
+  no_op_filter = lambda line: True
+  
+  if resolutionCmd == Resolver.PROC: return ("", no_op_filter)
+  elif resolutionCmd == Resolver.NETSTAT:
+    return (
+      RUN_NETSTAT,
+      lambda line: "ESTABLISHED %s/%s" % (processPid, processName) in line
+    )
+  elif resolutionCmd == Resolver.SS:
+    return (
+      RUN_SS,
+      lambda line: ("ESTAB" in line) and ("\"%s\",%s" % (processName, processPid) in line)
+    )
+  elif resolutionCmd == Resolver.LSOF:
+    return (
+      RUN_LSOF,
+      lambda line: re.match("^%s *%s.*((UDP.*)|(\(ESTABLISHED\)))" % (processName, processPid))
+    )
+  elif resolutionCmd == Resolver.SOCKSTAT:
+    return (
+      RUN_SOCKSTAT,
+      lambda line: re.match("%s *%s.*ESTABLISHED" % (processName, processPid))
+    )
+  elif resolutionCmd == Resolver.BSD_SOCKSTAT:
+    return (
+      RUN_BSD_SOCKSTAT,
+      lambda line: re.match("%s *%s" % (processName, processPid))
+    )
+  elif resolutionCmd == Resolver.BSD_PROCSTAT:
+    return (
+      RUN_BSD_PROCSTAT % processPid,
+      lambda line: "TCP" in line and "0.0.0.0:0" not in line
+    )
+  else: raise ValueError("Unrecognized resolution type: %s" % resolutionCmd)
+
+def getConnections(resolutionCmd, processName, processPid = ""):
+  """
+  Retrieves a list of the current connections for a given process, providing a
+  tuple list of the form:
+  [(local_ipAddr1, local_port1, foreign_ipAddr1, foreign_port1), ...]
+  this raises an IOError if no connections are available or resolution fails
+  (in most cases these appear identical). Common issues include:
+    - insufficient permissions
+    - resolution command is unavailable
+    - usage of the command is non-standard (particularly an issue for BSD)
+  
+  Arguments:
+    resolutionCmd - command to use in resolving the address
+    processName   - name of the process for which connections are fetched
+    processPid    - process ID (this helps improve accuracy)
+  """
+  
+  if resolutionCmd == Resolver.PROC:
+    # Attempts resolution via checking the proc contents.
+    if not processPid:
+      raise ValueError("proc resolution requires a pid")
+    
+    try:
+      return proc.get_connections(processPid)
+    except Exception, exc:
+      raise IOError(str(exc))
+  else:
+    # Queries a resolution utility (netstat, lsof, etc). This raises an
+    # IOError if the command fails or isn't available.
+    cmd, cmd_filter = getResolverCommand(resolutionCmd, processName, processPid)
+    results = system.call(cmd)
+    results = filter(cmd_filter, results)
+    
+    if not results: raise IOError("No results found using: %s" % cmd)
+    
+    # parses results for the resolution command
+    conn = []
+    for line in results:
+      if resolutionCmd == Resolver.LSOF:
+        # Different versions of lsof have different numbers of columns, so
+        # stripping off the optional 'established' entry so we can just use
+        # the last one.
+        comp = line.replace("(ESTABLISHED)", "").strip().split()
+      else: comp = line.split()
+      
+      if resolutionCmd == Resolver.NETSTAT:
+        localIp, localPort = comp[3].split(":")
+        foreignIp, foreignPort = comp[4].split(":")
+      elif resolutionCmd == Resolver.SS:
+        localIp, localPort = comp[4].split(":")
+        foreignIp, foreignPort = comp[5].split(":")
+      elif resolutionCmd == Resolver.LSOF:
+        local, foreign = comp[-1].split("->")
+        localIp, localPort = local.split(":")
+        foreignIp, foreignPort = foreign.split(":")
+      elif resolutionCmd == Resolver.SOCKSTAT:
+        localIp, localPort = comp[4].split(":")
+        foreignIp, foreignPort = comp[5].split(":")
+      elif resolutionCmd == Resolver.BSD_SOCKSTAT:
+        localIp, localPort = comp[5].split(":")
+        foreignIp, foreignPort = comp[6].split(":")
+      elif resolutionCmd == Resolver.BSD_PROCSTAT:
+        localIp, localPort = comp[9].split(":")
+        foreignIp, foreignPort = comp[10].split(":")
+      
+      conn.append((localIp, localPort, foreignIp, foreignPort))
+    
+    return conn
+
+def isResolverAlive(processName, processPid = ""):
+  """
+  This provides true if a singleton resolver instance exists for the given
+  process/pid combination, false otherwise.
+  
+  Arguments:
+    processName - name of the process being checked
+    processPid  - pid of the process being checked, if undefined this matches
+                  against any resolver with the process name
+  """
+  
+  for resolver in RESOLVERS:
+    if not resolver._halt and resolver.processName == processName and (not processPid or resolver.processPid == processPid):
+      return True
+  
+  return False
+
+def getResolver(processName, processPid = "", alias=None):
+  """
+  Singleton constructor for resolver instances. If a resolver already exists
+  for the process then it's returned. Otherwise one is created and started.
+  
+  Arguments:
+    processName - name of the process being resolved
+    processPid  - pid of the process being resolved, if undefined this matches
+                  against any resolver with the process name
+    alias       - alternative handle under which the resolver can be requested
+  """
+  
+  # check if one's already been created
+  requestHandle = alias if alias else processName
+  haltedIndex = -1 # old instance of this resolver with the _halt flag set
+  for i in range(len(RESOLVERS)):
+    resolver = RESOLVERS[i]
+    if resolver.handle == requestHandle and (not processPid or resolver.processPid == processPid):
+      if resolver._halt and RECREATE_HALTED_RESOLVERS: haltedIndex = i
+      else: return resolver
+  
+  # make a new resolver
+  r = ConnectionResolver(processName, processPid, handle = requestHandle)
+  r.start()
+  
+  # overwrites halted instance of this resolver if it exists, otherwise append
+  if haltedIndex == -1: RESOLVERS.append(r)
+  else: RESOLVERS[haltedIndex] = r
+  return r
+
+def getSystemResolvers(osType = None):
+  """
+  Provides the types of connection resolvers available on this operating
+  system.
+  
+  Arguments:
+    osType - operating system type, fetched from the os module if undefined
+  """
+  
+  if osType == None: osType = os.uname()[0]
+  
+  if osType == "FreeBSD":
+    resolvers = [Resolver.BSD_SOCKSTAT, Resolver.BSD_PROCSTAT, Resolver.LSOF]
+  elif osType in ("OpenBSD", "Darwin"):
+    resolvers = [Resolver.LSOF]
+  else:
+    resolvers = [Resolver.NETSTAT, Resolver.SOCKSTAT, Resolver.LSOF, Resolver.SS]
+  
+  # proc resolution, by far, outperforms the others so defaults to this is able
+  if proc.is_available():
+    resolvers = [Resolver.PROC] + resolvers
+  
+  return resolvers
+
+class ConnectionResolver(threading.Thread):
+  """
+  Service that periodically queries for a process' current connections. This
+  provides several benefits over on-demand queries:
+  - queries are non-blocking (providing cached results)
+  - falls back to use different resolution methods in case of repeated failures
+  - avoids overly frequent querying of connection data, which can be demanding
+    in terms of system resources
+  
+  Unless an overriding method of resolution is requested this defaults to
+  choosing a resolver the following way:
+  
+  - Checks the current PATH to determine which resolvers are available. This
+    uses the first of the following that's available:
+      netstat, ss, lsof (picks netstat if none are found)
+  
+  - Attempts to resolve using the selection. Single failures are logged at the
+    INFO level, and a series of failures at NOTICE. In the later case this
+    blacklists the resolver, moving on to the next. If all resolvers fail this
+    way then resolution's abandoned and logs a WARN message.
+  
+  The time between resolving connections, unless overwritten, is set to be
+  either five seconds or ten times the runtime of the resolver (whichever is
+  larger). This is to prevent systems either strapped for resources or with a
+  vast number of connections from being burdened too heavily by this daemon.
+  
+  Parameters:
+    processName       - name of the process being resolved
+    processPid        - pid of the process being resolved
+    resolveRate       - minimum time between resolving connections (in seconds,
+                        None if using the default)
+    * defaultRate     - default time between resolving connections
+    lastLookup        - time connections were last resolved (unix time, -1 if
+                        no resolutions have yet been successful)
+    overwriteResolver - method of resolution (uses default if None)
+    * defaultResolver - resolver used by default (None if all resolution
+                        methods have been exhausted)
+    resolverOptions   - resolvers to be cycled through (differ by os)
+    
+    * read-only
+  """
+  
+  def __init__(self, processName, processPid = "", resolveRate = None, handle = None):
+    """
+    Initializes a new resolver daemon. When no longer needed it's suggested
+    that this is stopped.
+    
+    Arguments:
+      processName - name of the process being resolved
+      processPid  - pid of the process being resolved
+      resolveRate - time between resolving connections (in seconds, None if
+                    chosen dynamically)
+      handle      - name used to query this resolver, this is the processName
+                    if undefined
+    """
+    
+    threading.Thread.__init__(self)
+    self.setDaemon(True)
+    
+    self.processName = processName
+    self.processPid = processPid
+    self.resolveRate = resolveRate
+    self.handle = handle if handle else processName
+    self.defaultRate = CONFIG["queries.connections.minRate"]
+    self.lastLookup = -1
+    self.overwriteResolver = None
+    self.defaultResolver = Resolver.PROC
+    
+    osType = os.uname()[0]
+    self.resolverOptions = getSystemResolvers(osType)
+    
+    log.info("Operating System: %s, Connection Resolvers: %s" % (osType, ", ".join(self.resolverOptions)))
+    
+    # sets the default resolver to be the first found in the system's PATH
+    # (left as netstat if none are found)
+    for resolver in self.resolverOptions:
+      # Resolver strings correspond to their command with the exception of bsd
+      # resolvers.
+      resolverCmd = resolver.replace(" (bsd)", "")
+      
+      if resolver == Resolver.PROC or system.is_available(resolverCmd):
+        self.defaultResolver = resolver
+        break
+    
+    self._connections = []        # connection cache (latest results)
+    self._resolutionCounter = 0   # number of successful connection resolutions
+    self._isPaused = False
+    self._halt = False            # terminates thread if true
+    self._cond = threading.Condition()  # used for pausing the thread
+    self._subsiquentFailures = 0  # number of failed resolutions with the default in a row
+    self._resolverBlacklist = []  # resolvers that have failed to resolve
+    
+    # Number of sequential times the threshold rate's been too low. This is to
+    # avoid having stray spikes up the rate.
+    self._rateThresholdBroken = 0
+  
+  def getOverwriteResolver(self):
+    """
+    Provides the resolver connection resolution is forced to use. This returns
+    None if it's dynamically determined.
+    """
+    
+    return self.overwriteResolver
+     
+  def setOverwriteResolver(self, overwriteResolver):
+    """
+    Sets the resolver used for connection resolution, if None then this is
+    automatically determined based on what is available.
+    
+    Arguments:
+      overwriteResolver - connection resolver to be used
+    """
+    
+    self.overwriteResolver = overwriteResolver
+  
+  def run(self):
+    while not self._halt:
+      minWait = self.resolveRate if self.resolveRate else self.defaultRate
+      timeSinceReset = time.time() - self.lastLookup
+      
+      if self._isPaused or timeSinceReset < minWait:
+        sleepTime = max(0.2, minWait - timeSinceReset)
+        
+        self._cond.acquire()
+        if not self._halt: self._cond.wait(sleepTime)
+        self._cond.release()
+        
+        continue # done waiting, try again
+      
+      isDefault = self.overwriteResolver == None
+      resolver = self.defaultResolver if isDefault else self.overwriteResolver
+      
+      # checks if there's nothing to resolve with
+      if not resolver:
+        self.lastLookup = time.time() # avoids a busy wait in this case
+        continue
+      
+      try:
+        resolveStart = time.time()
+        connResults = getConnections(resolver, self.processName, self.processPid)
+        lookupTime = time.time() - resolveStart
+        
+        self._connections = connResults
+        self._resolutionCounter += 1
+        
+        newMinDefaultRate = 100 * lookupTime
+        if self.defaultRate < newMinDefaultRate:
+          if self._rateThresholdBroken >= 3:
+            # adding extra to keep the rate from frequently changing
+            self.defaultRate = newMinDefaultRate + 0.5
+            
+            log.trace("connection lookup time increasing to %0.1f seconds per call" % self.defaultRate)
+          else: self._rateThresholdBroken += 1
+        else: self._rateThresholdBroken = 0
+        
+        if isDefault: self._subsiquentFailures = 0
+      except (ValueError, IOError), exc:
+        # this logs in a couple of cases:
+        # - special failures noted by getConnections (most cases are already
+        # logged via system)
+        # - note fail-overs for default resolution methods
+        if str(exc).startswith("No results found using:"):
+          log.info(exc)
+        
+        if isDefault:
+          self._subsiquentFailures += 1
+          
+          if self._subsiquentFailures >= RESOLVER_FAILURE_TOLERANCE:
+            # failed several times in a row - abandon resolver and move on to another
+            self._resolverBlacklist.append(resolver)
+            self._subsiquentFailures = 0
+            
+            # pick another (non-blacklisted) resolver
+            newResolver = None
+            for r in self.resolverOptions:
+              if not r in self._resolverBlacklist:
+                newResolver = r
+                break
+            
+            if newResolver:
+              # provide notice that failures have occurred and resolver is changing
+              log.notice(RESOLVER_SERIAL_FAILURE_MSG % (resolver, newResolver))
+            else:
+              # exhausted all resolvers, give warning
+              log.notice(RESOLVER_FINAL_FAILURE_MSG)
+            
+            self.defaultResolver = newResolver
+      finally:
+        self.lastLookup = time.time()
+  
+  def getConnections(self):
+    """
+    Provides the last queried connection results, an empty list if resolver
+    has been halted.
+    """
+    
+    if self._halt: return []
+    else: return list(self._connections)
+  
+  def getResolutionCount(self):
+    """
+    Provides the number of successful resolutions so far. This can be used to
+    determine if the connection results are new for the caller or not.
+    """
+    
+    return self._resolutionCounter
+  
+  def getPid(self):
+    """
+    Provides the pid used to narrow down connection resolution. This is an
+    empty string if undefined.
+    """
+    
+    return self.processPid
+  
+  def setPid(self, processPid):
+    """
+    Sets the pid used to narrow down connection resultions.
+    
+    Arguments:
+      processPid - pid for the process we're fetching connections for
+    """
+    
+    self.processPid = processPid
+  
+  def setPaused(self, isPause):
+    """
+    Allows or prevents further connection resolutions (this still makes use of
+    cached results).
+    
+    Arguments:
+      isPause - puts a freeze on further resolutions if true, allows them to
+                continue otherwise
+    """
+    
+    if isPause == self._isPaused: return
+    self._isPaused = isPause
+  
+  def stop(self):
+    """
+    Halts further resolutions and terminates the thread.
+    """
+    
+    self._cond.acquire()
+    self._halt = True
+    self._cond.notifyAll()
+    self._cond.release()
+
+class AppResolver:
+  """
+  Provides the names and pids of appliations attached to the given ports. This
+  stops attempting to query if it fails three times without successfully
+  getting lsof results.
+  """
+  
+  def __init__(self, scriptName = "python"):
+    """
+    Constructs a resolver instance.
+    
+    Arguments:
+      scriptName - name by which to all our own entries
+    """
+    
+    self.scriptName = scriptName
+    self.queryResults = {}
+    self.resultsLock = threading.RLock()
+    self._cond = threading.Condition()  # used for pausing when waiting for results
+    self.isResolving = False  # flag set if we're in the process of making a query
+    self.failureCount = 0     # -1 if we've made a successful query
+  
+  def getResults(self, maxWait=0):
+    """
+    Provides the last queried results. If we're in the process of making a
+    query then we can optionally block for a time to see if it finishes.
+    
+    Arguments:
+      maxWait - maximum second duration to block on getting results before
+                returning
+    """
+    
+    self._cond.acquire()
+    if self.isResolving and maxWait > 0:
+      self._cond.wait(maxWait)
+    self._cond.release()
+    
+    self.resultsLock.acquire()
+    results = dict(self.queryResults)
+    self.resultsLock.release()
+    
+    return results
+  
+  def resolve(self, ports):
+    """
+    Queues the given listing of ports to be resolved. This clears the last set
+    of results when completed.
+    
+    Arguments:
+      ports - list of ports to be resolved to applications
+    """
+    
+    if self.failureCount < 3:
+      self.isResolving = True
+      t = threading.Thread(target = self._queryApplications, kwargs = {"ports": ports})
+      t.setDaemon(True)
+      t.start()
+  
+  def _queryApplications(self, ports=[]):
+    """
+    Performs an lsof lookup on the given ports to get the command/pid tuples.
+    
+    Arguments:
+      ports - list of ports to be resolved to applications
+    """
+    
+    # atagar at fenrir:~/Desktop/arm$ lsof -i tcp:51849 -i tcp:37277
+    # COMMAND  PID   USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
+    # tor     2001 atagar   14u  IPv4  14048      0t0  TCP localhost:9051->localhost:37277 (ESTABLISHED)
+    # tor     2001 atagar   15u  IPv4  22024      0t0  TCP localhost:9051->localhost:51849 (ESTABLISHED)
+    # python  2462 atagar    3u  IPv4  14047      0t0  TCP localhost:37277->localhost:9051 (ESTABLISHED)
+    # python  3444 atagar    3u  IPv4  22023      0t0  TCP localhost:51849->localhost:9051 (ESTABLISHED)
+    
+    if not ports:
+      self.resultsLock.acquire()
+      self.queryResults = {}
+      self.isResolving = False
+      self.resultsLock.release()
+      
+      # wakes threads waiting on results
+      self._cond.acquire()
+      self._cond.notifyAll()
+      self._cond.release()
+      
+      return
+    
+    results = {}
+    lsofArgs = []
+    
+    # Uses results from the last query if we have any, otherwise appends the
+    # port to the lsof command. This has the potential for persisting dirty
+    # results but if we're querying by the dynamic port on the local tcp
+    # connections then this should be very rare (and definitely worth the
+    # chance of being able to skip an lsof query altogether).
+    for port in ports:
+      if port in self.queryResults:
+        results[port] = self.queryResults[port]
+      else: lsofArgs.append("-i tcp:%s" % port)
+    
+    if lsofArgs:
+      lsofResults = system.call("lsof -nP " + " ".join(lsofArgs))
+    else: lsofResults = None
+    
+    if not lsofResults and self.failureCount != -1:
+      # lsof query failed and we aren't yet sure if it's possible to
+      # successfully get results on this platform
+      self.failureCount += 1
+      self.isResolving = False
+      return
+    elif lsofResults:
+      # (iPort, oPort) tuple for our own process, if it was fetched
+      ourConnection = None
+      
+      for line in lsofResults:
+        lineComp = line.split()
+        
+        if len(lineComp) == 10 and lineComp[9] == "(ESTABLISHED)":
+          cmd, pid, _, _, _, _, _, _, portMap, _ = lineComp
+          
+          if "->" in portMap:
+            iPort, oPort = portMap.split("->")
+            iPort = iPort.split(":")[1]
+            oPort = oPort.split(":")[1]
+            
+            # entry belongs to our own process
+            if pid == str(os.getpid()):
+              cmd = self.scriptName
+              ourConnection = (iPort, oPort)
+            
+            if iPort.isdigit() and oPort.isdigit():
+              newEntry = (iPort, oPort, cmd, pid)
+              
+              # adds the entry under the key of whatever we queried it with
+              # (this might be both the inbound _and_ outbound ports)
+              for portMatch in (iPort, oPort):
+                if portMatch in ports:
+                  if portMatch in results:
+                    results[portMatch].append(newEntry)
+                  else: results[portMatch] = [newEntry]
+      
+      # making the lsof call generated an extraneous sh entry for our own connection
+      if ourConnection:
+        for ourPort in ourConnection:
+          if ourPort in results:
+            shIndex = None
+            
+            for i in range(len(results[ourPort])):
+              if results[ourPort][i][2] == "sh":
+                shIndex = i
+                break
+            
+            if shIndex != None:
+              del results[ourPort][shIndex]
+    
+    self.resultsLock.acquire()
+    self.failureCount = -1
+    self.queryResults = results
+    self.isResolving = False
+    self.resultsLock.release()
+    
+    # wakes threads waiting on results
+    self._cond.acquire()
+    self._cond.notifyAll()
+    self._cond.release()
+
diff --git a/arm/util/hostnames.py b/arm/util/hostnames.py
new file mode 100644
index 0000000..a58eb94
--- /dev/null
+++ b/arm/util/hostnames.py
@@ -0,0 +1,394 @@
+"""
+Service providing hostname resolution via reverse DNS lookups. This provides
+both resolution via a thread pool (looking up several addresses at a time) and
+caching of the results. If used, it's advisable that this service is stopped
+when it's no longer needed. All calls are both non-blocking and thread safe.
+
+Be aware that this relies on querying the system's DNS servers, possibly
+leaking the requested addresses to third parties.
+"""
+
+# The only points of concern in terms of concurrent calls are the RESOLVER and
+# RESOLVER.resolvedCache. This services provides (mostly) non-locking thread
+# safety via the following invariants:
+# - Resolver and cache instances are non-destructible
+#     Nothing can be removed or invalidated. Rather, halting resolvers and
+#     trimming the cache are done via reassignment (pointing the RESOLVER or
+#     RESOLVER.resolvedCache to another copy).
+# - Functions create and use local references to the resolver and its cache
+#     This is for consistency (ie, all operations are done on the same resolver
+#     or cache instance regardless of concurrent assignments). Usually it's
+#     assigned to a local variable called 'resolverRef' or 'cacheRef'.
+# - Locks aren't necessary, but used to help in the following cases:
+#     - When assigning to the RESOLVER (to avoid orphaned instances with
+#       running thread pools).
+#     - When adding/removing from the cache (prevents workers from updating
+#       an outdated cache reference).
+
+import time
+import socket
+import threading
+import itertools
+import Queue
+import distutils.sysconfig
+
+from stem.util import conf, log, system
+
+RESOLVER = None                       # hostname resolver (service is stopped if None)
+RESOLVER_LOCK = threading.RLock()     # regulates assignment to the RESOLVER
+RESOLVER_COUNTER = itertools.count()  # atomic counter, providing the age for new entries (for trimming)
+DNS_ERROR_CODES = ("1(FORMERR)", "2(SERVFAIL)", "3(NXDOMAIN)", "4(NOTIMP)", "5(REFUSED)", "6(YXDOMAIN)",
+                   "7(YXRRSET)", "8(NXRRSET)", "9(NOTAUTH)", "10(NOTZONE)", "16(BADVERS)")
+
+def conf_handler(key, value):
+  if key == "queries.hostnames.poolSize":
+    return max(1, value)
+  elif key == "cache.hostnames.size":
+    return max(100, value)
+  elif key == "cache.hostnames.trimSize":
+    return max(10, value)
+  elif key == "cache.hostnames.trimSize":
+    return min(value, CONFIG["cache.hostnames.size"] / 2)
+
+CONFIG = conf.config_dict("arm", {
+  "queries.hostnames.poolSize": 5,
+  "queries.hostnames.useSocketModule": False,
+  "cache.hostnames.size": 700000,
+  "cache.hostnames.trimSize": 200000,
+}, conf_handler)
+
+def start():
+  """
+  Primes the service to start resolving addresses. Calling this explicitly is
+  not necessary since resolving any address will start the service if it isn't
+  already running.
+  """
+  
+  global RESOLVER
+  RESOLVER_LOCK.acquire()
+  if not isRunning(): RESOLVER = _Resolver()
+  RESOLVER_LOCK.release()
+
+def stop():
+  """
+  Halts further resolutions and stops the service. This joins on the resolver's
+  thread pool and clears its lookup cache.
+  """
+  
+  global RESOLVER
+  RESOLVER_LOCK.acquire()
+  if isRunning():
+    # Releases resolver instance. This is done first so concurrent calls to the
+    # service won't try to use it. However, using a halted instance is fine and
+    # all calls currently in progress can still proceed on the RESOLVER's local
+    # references.
+    resolverRef, RESOLVER = RESOLVER, None
+    
+    # joins on its worker thread pool
+    resolverRef.stop()
+    for t in resolverRef.threadPool: t.join()
+  RESOLVER_LOCK.release()
+
+def setPaused(isPause):
+  """
+  Allows or prevents further hostname resolutions (resolutions still make use of
+  cached entries if available). This starts the service if it isn't already
+  running.
+  
+  Arguments:
+    isPause - puts a freeze on further resolutions if true, allows them to
+              continue otherwise
+  """
+  
+  # makes sure a running resolver is set with the pausing setting
+  RESOLVER_LOCK.acquire()
+  start()
+  RESOLVER.isPaused = isPause
+  RESOLVER_LOCK.release()
+
+def isRunning():
+  """
+  Returns True if the service is currently running, False otherwise.
+  """
+  
+  return bool(RESOLVER)
+
+def isPaused():
+  """
+  Returns True if the resolver is paused, False otherwise.
+  """
+  
+  resolverRef = RESOLVER
+  if resolverRef: return resolverRef.isPaused
+  else: return False
+
+def isResolving():
+  """
+  Returns True if addresses are currently waiting to be resolved, False
+  otherwise.
+  """
+  
+  resolverRef = RESOLVER
+  if resolverRef: return not resolverRef.unresolvedQueue.empty()
+  else: return False
+
+def resolve(ipAddr, timeout = 0, suppressIOExc = True):
+  """
+  Provides the hostname associated with a given IP address. By default this is
+  a non-blocking call, fetching cached results if available and queuing the
+  lookup if not. This provides None if the lookup fails (with a suppressed
+  exception) or timeout is reached without resolution. This starts the service
+  if it isn't already running.
+  
+  If paused this simply returns the cached reply (no request is queued and
+  returns immediately regardless of the timeout argument).
+  
+  Requests may raise the following exceptions:
+  - ValueError - address was unresolvable (includes the DNS error response)
+  - IOError - lookup failed due to os or network issues (suppressed by default)
+  
+  Arguments:
+    ipAddr        - ip address to be resolved
+    timeout       - maximum duration to wait for a resolution (blocks to
+                    completion if None)
+    suppressIOExc - suppresses lookup errors and re-runs failed calls if true,
+                    raises otherwise
+  """
+  
+  # starts the service if it isn't already running (making sure we have an
+  # instance in a thread safe fashion before continuing)
+  resolverRef = RESOLVER
+  if resolverRef == None:
+    RESOLVER_LOCK.acquire()
+    start()
+    resolverRef = RESOLVER
+    RESOLVER_LOCK.release()
+  
+  if resolverRef.isPaused:
+    # get cache entry, raising if an exception and returning if a hostname
+    cacheRef = resolverRef.resolvedCache
+    
+    if ipAddr in cacheRef:
+      entry = cacheRef[ipAddr][0]
+      if suppressIOExc and type(entry) == IOError: return None
+      elif isinstance(entry, Exception): raise entry
+      else: return entry
+    else: return None
+  elif suppressIOExc:
+    # if resolver has cached an IOError then flush the entry (this defaults to
+    # suppression since these error may be transient)
+    cacheRef = resolverRef.resolvedCache
+    flush = ipAddr in cacheRef and type(cacheRef[ipAddr]) == IOError
+    
+    try: return resolverRef.getHostname(ipAddr, timeout, flush)
+    except IOError: return None
+  else: return resolverRef.getHostname(ipAddr, timeout)
+
+def getPendingCount():
+  """
+  Provides an approximate count of the number of addresses still pending
+  resolution.
+  """
+  
+  resolverRef = RESOLVER
+  if resolverRef: return resolverRef.unresolvedQueue.qsize()
+  else: return 0
+
+def getRequestCount():
+  """
+  Provides the number of resolutions requested since starting the service.
+  """
+  
+  resolverRef = RESOLVER
+  if resolverRef: return resolverRef.totalResolves
+  else: return 0
+
+def _resolveViaSocket(ipAddr):
+  """
+  Performs hostname lookup via the socket module's gethostbyaddr function. This
+  raises an IOError if the lookup fails (network issue) and a ValueError in
+  case of DNS errors (address unresolvable).
+  
+  Arguments:
+    ipAddr - ip address to be resolved
+  """
+  
+  try:
+    # provides tuple like: ('localhost', [], ['127.0.0.1'])
+    return socket.gethostbyaddr(ipAddr)[0]
+  except socket.herror, exc:
+    if exc[0] == 2: raise IOError(exc[1]) # "Host name lookup failure"
+    else: raise ValueError(exc[1]) # usually "Unknown host"
+  except socket.error, exc: raise ValueError(exc[1])
+
+def _resolveViaHost(ipAddr):
+  """
+  Performs a host lookup for the given IP, returning the resolved hostname.
+  This raises an IOError if the lookup fails (os or network issue), and a
+  ValueError in the case of DNS errors (address is unresolvable).
+  
+  Arguments:
+    ipAddr - ip address to be resolved
+  """
+  
+  hostname = system.call("host %s" % ipAddr)[0].split()[-1:][0]
+  
+  if hostname == "reached":
+    # got message: ";; connection timed out; no servers could be reached"
+    raise IOError("lookup timed out")
+  elif hostname in DNS_ERROR_CODES:
+    # got error response (can't do resolution on address)
+    raise ValueError("address is unresolvable: %s" % hostname)
+  else:
+    # strips off ending period and returns hostname
+    return hostname[:-1]
+
+class _Resolver():
+  """
+  Performs reverse DNS resolutions. Lookups are a network bound operation so
+  this spawns a pool of worker threads to do several at a time in parallel.
+  """
+  
+  def __init__(self):
+    # IP Address => (hostname/error, age), resolution failures result in a
+    # ValueError with the lookup's status
+    self.resolvedCache = {}
+    
+    self.resolvedLock = threading.RLock() # governs concurrent access when modifying resolvedCache
+    self.unresolvedQueue = Queue.Queue()  # unprocessed lookup requests
+    self.recentQueries = []               # recent resolution requests to prevent duplicate requests
+    self.threadPool = []                  # worker threads that process requests
+    self.totalResolves = 0                # counter for the total number of addresses queried to be resolved
+    self.isPaused = False                 # prevents further resolutions if true
+    self.halt = False                     # if true, tells workers to stop
+    self.cond = threading.Condition()     # used for pausing threads
+    
+    # Determines if resolutions are made using os 'host' calls or python's
+    # 'socket.gethostbyaddr'. The following checks if the system has the
+    # gethostbyname_r function, which determines if python resolutions can be
+    # done in parallel or not. If so, this is preferable.
+    isSocketResolutionParallel = distutils.sysconfig.get_config_var("HAVE_GETHOSTBYNAME_R")
+    self.useSocketResolution = CONFIG["queries.hostnames.useSocketModule"] and isSocketResolutionParallel
+    
+    for _ in range(CONFIG["queries.hostnames.poolSize"]):
+      t = threading.Thread(target = self._workerLoop)
+      t.setDaemon(True)
+      t.start()
+      self.threadPool.append(t)
+  
+  def getHostname(self, ipAddr, timeout, flushCache = False):
+    """
+    Provides the hostname, queuing the request and returning None if the
+    timeout is reached before resolution. If a problem's encountered then this
+    either raises an IOError (for os and network issues) or ValueError (for DNS
+    resolution errors).
+    
+    Arguments:
+      ipAddr     - ip address to be resolved
+      timeout    - maximum duration to wait for a resolution (blocks to
+                   completion if None)
+      flushCache - if true the cache is skipped and address re-resolved
+    """
+    
+    # if outstanding requests are done then clear recentQueries to allow
+    # entries removed from the cache to be re-run
+    if self.unresolvedQueue.empty(): self.recentQueries = []
+    
+    # copies reference cache (this is important in case the cache is trimmed
+    # during this call)
+    cacheRef = self.resolvedCache
+    
+    if not flushCache and ipAddr in cacheRef:
+      # cached response is available - raise if an error, return if a hostname
+      response = cacheRef[ipAddr][0]
+      if isinstance(response, Exception): raise response
+      else: return response
+    elif flushCache or ipAddr not in self.recentQueries:
+      # new request - queue for resolution
+      self.totalResolves += 1
+      self.recentQueries.append(ipAddr)
+      self.unresolvedQueue.put(ipAddr)
+    
+    # periodically check cache if requester is willing to wait
+    if timeout == None or timeout > 0:
+      startTime = time.time()
+      
+      while timeout == None or time.time() - startTime < timeout:
+        if ipAddr in cacheRef:
+          # address was resolved - raise if an error, return if a hostname
+          response = cacheRef[ipAddr][0]
+          if isinstance(response, Exception): raise response
+          else: return response
+        else: time.sleep(0.1)
+    
+    return None # timeout reached without resolution
+  
+  def stop(self):
+    """
+    Halts further resolutions and terminates the thread.
+    """
+    
+    self.cond.acquire()
+    self.halt = True
+    self.cond.notifyAll()
+    self.cond.release()
+  
+  def _workerLoop(self):
+    """
+    Simple producer-consumer loop followed by worker threads. This takes
+    addresses from the unresolvedQueue, attempts to look up its hostname, and
+    adds its results or the error to the resolved cache. Resolver reference
+    provides shared resources used by the thread pool.
+    """
+    
+    while not self.halt:
+      # if resolver is paused then put a hold on further resolutions
+      if self.isPaused:
+        self.cond.acquire()
+        if not self.halt: self.cond.wait(1)
+        self.cond.release()
+        continue
+      
+      # snags next available ip, timeout is because queue can't be woken up
+      # when 'halt' is set
+      try: ipAddr = self.unresolvedQueue.get_nowait()
+      except Queue.Empty:
+        # no elements ready, wait a little while and try again
+        self.cond.acquire()
+        if not self.halt: self.cond.wait(1)
+        self.cond.release()
+        continue
+      if self.halt: break
+      
+      try:
+        if self.useSocketResolution: result = _resolveViaSocket(ipAddr)
+        else: result = _resolveViaHost(ipAddr)
+      except IOError, exc: result = exc # lookup failed
+      except ValueError, exc: result = exc # dns error
+      
+      self.resolvedLock.acquire()
+      self.resolvedCache[ipAddr] = (result, RESOLVER_COUNTER.next())
+      
+      # trim cache if excessively large (clearing out oldest entries)
+      if len(self.resolvedCache) > CONFIG["cache.hostnames.size"]:
+        # Providing for concurrent, non-blocking calls require that entries are
+        # never removed from the cache, so this creates a new, trimmed version
+        # instead.
+        
+        # determines minimum age of entries to be kept
+        currentCount = RESOLVER_COUNTER.next()
+        newCacheSize = CONFIG["cache.hostnames.size"] - CONFIG["cache.hostnames.trimSize"]
+        threshold = currentCount - newCacheSize
+        newCache = {}
+        
+        msg = "trimming hostname cache from %i entries to %i" % (len(self.resolvedCache), newCacheSize)
+        log.info(msg)
+        
+        # checks age of each entry, adding to toDelete if too old
+        for ipAddr, entry in self.resolvedCache.iteritems():
+          if entry[1] >= threshold: newCache[ipAddr] = entry
+        
+        self.resolvedCache = newCache
+      
+      self.resolvedLock.release()
+  
diff --git a/arm/util/panel.py b/arm/util/panel.py
new file mode 100644
index 0000000..f9b3933
--- /dev/null
+++ b/arm/util/panel.py
@@ -0,0 +1,729 @@
+"""
+Wrapper for safely working with curses subwindows.
+"""
+
+import copy
+import time
+import curses
+import curses.ascii
+import curses.textpad
+from threading import RLock
+
+from util import textInput, uiTools
+
+from stem.util import log
+
+# global ui lock governing all panel instances (curses isn't thread save and 
+# concurrency bugs produce especially sinister glitches)
+CURSES_LOCK = RLock()
+
+# tags used by addfstr - this maps to functor/argument combinations since the
+# actual values (in the case of color attributes) might not yet be initialized
+def _noOp(arg): return arg
+FORMAT_TAGS = {"<b>": (_noOp, curses.A_BOLD),
+               "<u>": (_noOp, curses.A_UNDERLINE),
+               "<h>": (_noOp, curses.A_STANDOUT)}
+for colorLabel in uiTools.COLOR_LIST: FORMAT_TAGS["<%s>" % colorLabel] = (uiTools.getColor, colorLabel)
+
+# prevents curses redraws if set
+HALT_ACTIVITY = False
+
+class Panel():
+  """
+  Wrapper for curses subwindows. This hides most of the ugliness in common
+  curses operations including:
+    - locking when concurrently drawing to multiple windows
+    - gracefully handle terminal resizing
+    - clip text that falls outside the panel
+    - convenience methods for word wrap, in-line formatting, etc
+  
+  This uses a design akin to Swing where panel instances provide their display
+  implementation by overwriting the draw() method, and are redrawn with
+  redraw().
+  """
+  
+  def __init__(self, parent, name, top, left=0, height=-1, width=-1):
+    """
+    Creates a durable wrapper for a curses subwindow in the given parent.
+    
+    Arguments:
+      parent - parent curses window
+      name   - identifier for the panel
+      top    - positioning of top within parent
+      left   - positioning of the left edge within the parent
+      height - maximum height of panel (uses all available space if -1)
+      width  - maximum width of panel (uses all available space if -1)
+    """
+    
+    # The not-so-pythonic getters for these parameters are because some
+    # implementations aren't entirely deterministic (for instance panels
+    # might chose their height based on its parent's current width).
+    
+    self.panelName = name
+    self.parent = parent
+    self.visible = False
+    self.titleVisible = True
+    
+    # Attributes for pausing. The pauseAttr contains variables our getAttr
+    # method is tracking, and the pause buffer has copies of the values from
+    # when we were last unpaused (unused unless we're paused).
+    
+    self.paused = False
+    self.pauseAttr = []
+    self.pauseBuffer = {}
+    self.pauseTime = -1
+    
+    self.top = top
+    self.left = left
+    self.height = height
+    self.width = width
+    
+    # The panel's subwindow instance. This is made available to implementors
+    # via their draw method and shouldn't be accessed directly.
+    # 
+    # This is None if either the subwindow failed to be created or needs to be
+    # remade before it's used. The later could be for a couple reasons:
+    # - The subwindow was never initialized.
+    # - Any of the parameters used for subwindow initialization have changed.
+    self.win = None
+    
+    self.maxY, self.maxX = -1, -1 # subwindow dimensions when last redrawn
+  
+  def getName(self):
+    """
+    Provides panel's identifier.
+    """
+    
+    return self.panelName
+  
+  def isTitleVisible(self):
+    """
+    True if the title is configured to be visible, False otherwise.
+    """
+    
+    return self.titleVisible
+  
+  def setTitleVisible(self, isVisible):
+    """
+    Configures the panel's title to be visible or not when it's next redrawn.
+    This is not guarenteed to be respected (not all panels have a title).
+    """
+    
+    self.titleVisible = isVisible
+  
+  def getParent(self):
+    """
+    Provides the parent used to create subwindows.
+    """
+    
+    return self.parent
+  
+  def setParent(self, parent):
+    """
+    Changes the parent used to create subwindows.
+    
+    Arguments:
+      parent - parent curses window
+    """
+    
+    if self.parent != parent:
+      self.parent = parent
+      self.win = None
+  
+  def isVisible(self):
+    """
+    Provides if the panel's configured to be visible or not.
+    """
+    
+    return self.visible
+  
+  def setVisible(self, isVisible):
+    """
+    Toggles if the panel is visible or not.
+    
+    Arguments:
+      isVisible - panel is redrawn when requested if true, skipped otherwise
+    """
+    
+    self.visible = isVisible
+  
+  def isPaused(self):
+    """
+    Provides if the panel's configured to be paused or not.
+    """
+    
+    return self.paused
+  
+  def setPauseAttr(self, attr):
+    """
+    Configures the panel to track the given attribute so that getAttr provides
+    the value when it was last unpaused (or its current value if we're
+    currently unpaused). For instance...
+    
+    > self.setPauseAttr("myVar")
+    > self.myVar = 5
+    > self.myVar = 6 # self.getAttr("myVar") -> 6
+    > self.setPaused(True)
+    > self.myVar = 7 # self.getAttr("myVar") -> 6
+    > self.setPaused(False)
+    > self.myVar = 7 # self.getAttr("myVar") -> 7
+    
+    Arguments:
+      attr - parameter to be tracked for getAttr
+    """
+    
+    self.pauseAttr.append(attr)
+    self.pauseBuffer[attr] = self.copyAttr(attr)
+  
+  def getAttr(self, attr):
+    """
+    Provides the value of the given attribute when we were last unpaused. If
+    we're currently unpaused then this is the current value. If untracked this
+    returns None.
+    
+    Arguments:
+      attr - local variable to be returned
+    """
+    
+    if not attr in self.pauseAttr: return None
+    elif self.paused: return self.pauseBuffer[attr]
+    else: return self.__dict__.get(attr)
+  
+  def copyAttr(self, attr):
+    """
+    Provides a duplicate of the given configuration value, suitable for the
+    pause buffer.
+    
+    Arguments:
+      attr - parameter to be provided back
+    """
+    
+    currentValue = self.__dict__.get(attr)
+    return copy.copy(currentValue)
+  
+  def setPaused(self, isPause, suppressRedraw = False):
+    """
+    Toggles if the panel is paused or not. This causes the panel to be redrawn
+    when toggling is pause state unless told to do otherwise. This is
+    important when pausing since otherwise the panel's display could change
+    when redrawn for other reasons.
+    
+    This returns True if the panel's pause state was changed, False otherwise.
+    
+    Arguments:
+      isPause        - freezes the state of the pause attributes if true, makes
+                       them editable otherwise
+      suppressRedraw - if true then this will never redraw the panel
+    """
+    
+    if isPause != self.paused:
+      if isPause: self.pauseTime = time.time()
+      self.paused = isPause
+      
+      if isPause:
+        # copies tracked attributes so we know what they were before pausing
+        for attr in self.pauseAttr:
+          self.pauseBuffer[attr] = self.copyAttr(attr)
+      
+      if not suppressRedraw: self.redraw(True)
+      return True
+    else: return False
+  
+  def getPauseTime(self):
+    """
+    Provides the time that we were last paused, returning -1 if we've never
+    been paused.
+    """
+    
+    return self.pauseTime
+  
+  def getTop(self):
+    """
+    Provides the position subwindows are placed at within its parent.
+    """
+    
+    return self.top
+  
+  def setTop(self, top):
+    """
+    Changes the position where subwindows are placed within its parent.
+    
+    Arguments:
+      top - positioning of top within parent
+    """
+    
+    if self.top != top:
+      self.top = top
+      self.win = None
+  
+  def getLeft(self):
+    """
+    Provides the left position where this subwindow is placed within its
+    parent.
+    """
+    
+    return self.left
+  
+  def setLeft(self, left):
+    """
+    Changes the left position where this subwindow is placed within its parent.
+    
+    Arguments:
+      left - positioning of top within parent
+    """
+    
+    if self.left != left:
+      self.left = left
+      self.win = None
+  
+  def getHeight(self):
+    """
+    Provides the height used for subwindows (-1 if it isn't limited).
+    """
+    
+    return self.height
+  
+  def setHeight(self, height):
+    """
+    Changes the height used for subwindows. This uses all available space if -1.
+    
+    Arguments:
+      height - maximum height of panel (uses all available space if -1)
+    """
+    
+    if self.height != height:
+      self.height = height
+      self.win = None
+  
+  def getWidth(self):
+    """
+    Provides the width used for subwindows (-1 if it isn't limited).
+    """
+    
+    return self.width
+  
+  def setWidth(self, width):
+    """
+    Changes the width used for subwindows. This uses all available space if -1.
+    
+    Arguments:
+      width - maximum width of panel (uses all available space if -1)
+    """
+    
+    if self.width != width:
+      self.width = width
+      self.win = None
+  
+  def getPreferredSize(self):
+    """
+    Provides the dimensions the subwindow would use when next redrawn, given
+    that none of the properties of the panel or parent change before then. This
+    returns a tuple of (height, width).
+    """
+    
+    newHeight, newWidth = self.parent.getmaxyx()
+    setHeight, setWidth = self.getHeight(), self.getWidth()
+    newHeight = max(0, newHeight - self.top)
+    newWidth = max(0, newWidth - self.left)
+    if setHeight != -1: newHeight = min(newHeight, setHeight)
+    if setWidth != -1: newWidth = min(newWidth, setWidth)
+    return (newHeight, newWidth)
+  
+  def handleKey(self, key):
+    """
+    Handler for user input. This returns true if the key press was consumed,
+    false otherwise.
+    
+    Arguments:
+      key - keycode for the key pressed
+    """
+    
+    return False
+  
+  def getHelp(self):
+    """
+    Provides help information for the controls this page provides. This is a
+    list of tuples of the form...
+    (control, description, status)
+    """
+    
+    return []
+  
+  def draw(self, width, height):
+    """
+    Draws display's content. This is meant to be overwritten by 
+    implementations and not called directly (use redraw() instead). The
+    dimensions provided are the drawable dimensions, which in terms of width is
+    a column less than the actual space.
+    
+    Arguments:
+      width  - horizontal space available for content
+      height - vertical space available for content
+    """
+    
+    pass
+  
+  def redraw(self, forceRedraw=False, block=False):
+    """
+    Clears display and redraws its content. This can skip redrawing content if
+    able (ie, the subwindow's unchanged), instead just refreshing the display.
+    
+    Arguments:
+      forceRedraw - forces the content to be cleared and redrawn if true
+      block       - if drawing concurrently with other panels this determines
+                    if the request is willing to wait its turn or should be
+                    abandoned
+    """
+    
+    # skipped if not currently visible or activity has been halted
+    if not self.isVisible() or HALT_ACTIVITY: return
+    
+    # if the panel's completely outside its parent then this is a no-op
+    newHeight, newWidth = self.getPreferredSize()
+    if newHeight == 0 or newWidth == 0:
+      self.win = None
+      return
+    
+    # recreates the subwindow if necessary
+    isNewWindow = self._resetSubwindow()
+    
+    # The reset argument is disregarded in a couple of situations:
+    # - The subwindow's been recreated (obviously it then doesn't have the old
+    #   content to refresh).
+    # - The subwindow's dimensions have changed since last drawn (this will
+    #   likely change the content's layout)
+    
+    subwinMaxY, subwinMaxX = self.win.getmaxyx()
+    if isNewWindow or subwinMaxY != self.maxY or subwinMaxX != self.maxX:
+      forceRedraw = True
+    
+    self.maxY, self.maxX = subwinMaxY, subwinMaxX
+    if not CURSES_LOCK.acquire(block): return
+    try:
+      if forceRedraw:
+        self.win.erase() # clears any old contents
+        self.draw(self.maxX, self.maxY)
+      self.win.refresh()
+    finally:
+      CURSES_LOCK.release()
+  
+  def hline(self, y, x, length, attr=curses.A_NORMAL):
+    """
+    Draws a horizontal line. This should only be called from the context of a
+    panel's draw method.
+    
+    Arguments:
+      y      - vertical location
+      x      - horizontal location
+      length - length the line spans
+      attr   - text attributes
+    """
+    
+    if self.win and self.maxX > x and self.maxY > y:
+      try:
+        drawLength = min(length, self.maxX - x)
+        self.win.hline(y, x, curses.ACS_HLINE | attr, drawLength)
+      except:
+        # in edge cases drawing could cause a _curses.error
+        pass
+  
+  def vline(self, y, x, length, attr=curses.A_NORMAL):
+    """
+    Draws a vertical line. This should only be called from the context of a
+    panel's draw method.
+    
+    Arguments:
+      y      - vertical location
+      x      - horizontal location
+      length - length the line spans
+      attr   - text attributes
+    """
+    
+    if self.win and self.maxX > x and self.maxY > y:
+      try:
+        drawLength = min(length, self.maxY - y)
+        self.win.vline(y, x, curses.ACS_VLINE | attr, drawLength)
+      except:
+        # in edge cases drawing could cause a _curses.error
+        pass
+  
+  def addch(self, y, x, char, attr=curses.A_NORMAL):
+    """
+    Draws a single character. This should only be called from the context of a
+    panel's draw method.
+    
+    Arguments:
+      y    - vertical location
+      x    - horizontal location
+      char - character to be drawn
+      attr - text attributes
+    """
+    
+    if self.win and self.maxX > x and self.maxY > y:
+      try:
+        self.win.addch(y, x, char, attr)
+      except:
+        # in edge cases drawing could cause a _curses.error
+        pass
+  
+  def addstr(self, y, x, msg, attr=curses.A_NORMAL):
+    """
+    Writes string to subwindow if able. This takes into account screen bounds
+    to avoid making curses upset. This should only be called from the context
+    of a panel's draw method.
+    
+    Arguments:
+      y    - vertical location
+      x    - horizontal location
+      msg  - text to be added
+      attr - text attributes
+    """
+    
+    # subwindows need a single character buffer (either in the x or y 
+    # direction) from actual content to prevent crash when shrank
+    if self.win and self.maxX > x and self.maxY > y:
+      try:
+        self.win.addstr(y, x, msg[:self.maxX - x], attr)
+      except:
+        # this might produce a _curses.error during edge cases, for instance
+        # when resizing with visible popups
+        pass
+  
+  def addfstr(self, y, x, msg):
+    """
+    Writes string to subwindow. The message can contain xhtml-style tags for
+    formatting, including:
+    <b>text</b>               bold
+    <u>text</u>               underline
+    <h>text</h>               highlight
+    <[color]>text</[color]>   use color (see uiTools.getColor() for constants)
+    
+    Tag nesting is supported and tag closing is strictly enforced (raising an
+    exception for invalid formatting). Unrecognized tags are treated as normal
+    text. This should only be called from the context of a panel's draw method.
+    
+    Text in multiple color tags (for instance "<blue><red>hello</red></blue>")
+    uses the bitwise OR of those flags (hint: that's probably not what you
+    want).
+    
+    Arguments:
+      y    - vertical location
+      x    - horizontal location
+      msg  - formatted text to be added
+    """
+    
+    if self.win and self.maxY > y:
+      formatting = [curses.A_NORMAL]
+      expectedCloseTags = []
+      unusedMsg = msg
+      
+      while self.maxX > x and len(unusedMsg) > 0:
+        # finds next consumeable tag (left as None if there aren't any left)
+        nextTag, tagStart, tagEnd = None, -1, -1
+        
+        tmpChecked = 0 # portion of the message cleared for having any valid tags
+        expectedTags = FORMAT_TAGS.keys() + expectedCloseTags
+        while nextTag == None:
+          tagStart = unusedMsg.find("<", tmpChecked)
+          tagEnd = unusedMsg.find(">", tagStart) + 1 if tagStart != -1 else -1
+          
+          if tagStart == -1 or tagEnd == -1: break # no more tags to consume
+          else:
+            # check if the tag we've found matches anything being expected
+            if unusedMsg[tagStart:tagEnd] in expectedTags:
+              nextTag = unusedMsg[tagStart:tagEnd]
+              break # found a tag to use
+            else:
+              # not a valid tag - narrow search to everything after it
+              tmpChecked = tagEnd
+        
+        # splits into text before and after tag
+        if nextTag:
+          msgSegment = unusedMsg[:tagStart]
+          unusedMsg = unusedMsg[tagEnd:]
+        else:
+          msgSegment = unusedMsg
+          unusedMsg = ""
+        
+        # adds text before tag with current formatting
+        attr = 0
+        for format in formatting: attr |= format
+        self.win.addstr(y, x, msgSegment[:self.maxX - x - 1], attr)
+        x += len(msgSegment)
+        
+        # applies tag attributes for future text
+        if nextTag:
+          formatTag = "<" + nextTag[2:] if nextTag.startswith("</") else nextTag
+          formatMatch = FORMAT_TAGS[formatTag][0](FORMAT_TAGS[formatTag][1])
+          
+          if not nextTag.startswith("</"):
+            # open tag - add formatting
+            expectedCloseTags.append("</" + nextTag[1:])
+            formatting.append(formatMatch)
+          else:
+            # close tag - remove formatting
+            expectedCloseTags.remove(nextTag)
+            formatting.remove(formatMatch)
+      
+      # only check for unclosed tags if we processed the whole message (if we
+      # stopped processing prematurely it might still be valid)
+      if expectedCloseTags and not unusedMsg:
+        # if we're done then raise an exception for any unclosed tags (tisk, tisk)
+        baseMsg = "Unclosed formatting tag%s:" % ("s" if len(expectedCloseTags) > 1 else "")
+        raise ValueError("%s: '%s'\n  \"%s\"" % (baseMsg, "', '".join(expectedCloseTags), msg))
+  
+  def getstr(self, y, x, initialText = "", format = None, maxWidth = None, validator = None):
+    """
+    Provides a text field where the user can input a string, blocking until
+    they've done so and returning the result. If the user presses escape then
+    this terminates and provides back None. This should only be called from
+    the context of a panel's draw method.
+    
+    This blanks any content within the space that the input field is rendered
+    (otherwise stray characters would be interpreted as part of the initial
+    input).
+    
+    Arguments:
+      y           - vertical location
+      x           - horizontal location
+      initialText - starting text in this field
+      format      - format used for the text
+      maxWidth    - maximum width for the text field
+      validator   - custom TextInputValidator for handling keybindings
+    """
+    
+    if not format: format = curses.A_NORMAL
+    
+    # makes cursor visible
+    try: previousCursorState = curses.curs_set(1)
+    except curses.error: previousCursorState = 0
+    
+    # temporary subwindow for user input
+    displayWidth = self.getPreferredSize()[1]
+    if maxWidth: displayWidth = min(displayWidth, maxWidth + x)
+    inputSubwindow = self.parent.subwin(1, displayWidth - x, self.top + y, self.left + x)
+    
+    # blanks the field's area, filling it with the font in case it's hilighting
+    inputSubwindow.clear()
+    inputSubwindow.bkgd(' ', format)
+    
+    # prepopulates the initial text
+    if initialText:
+      inputSubwindow.addstr(0, 0, initialText[:displayWidth - x - 1], format)
+    
+    # Displays the text field, blocking until the user's done. This closes the
+    # text panel and returns userInput to the initial text if the user presses
+    # escape.
+    
+    textbox = curses.textpad.Textbox(inputSubwindow)
+    
+    if not validator:
+      validator = textInput.BasicValidator()
+    
+    textbox.win.attron(format)
+    userInput = textbox.edit(lambda key: validator.validate(key, textbox)).strip()
+    textbox.win.attroff(format)
+    if textbox.lastcmd == curses.ascii.BEL: userInput = None
+    
+    # reverts visability settings
+    try: curses.curs_set(previousCursorState)
+    except curses.error: pass
+    
+    return userInput
+  
+  def addScrollBar(self, top, bottom, size, drawTop = 0, drawBottom = -1, drawLeft = 0):
+    """
+    Draws a left justified scroll bar reflecting position within a vertical
+    listing. This is shorted if necessary, and left undrawn if no space is
+    available. The bottom is squared off, having a layout like:
+     | 
+    *|
+    *|
+    *|
+     |
+    -+
+    
+    This should only be called from the context of a panel's draw method.
+    
+    Arguments:
+      top        - list index for the top-most visible element
+      bottom     - list index for the bottom-most visible element
+      size       - size of the list in which the listed elements are contained
+      drawTop    - starting row where the scroll bar should be drawn
+      drawBottom - ending row where the scroll bar should end, -1 if it should
+                   span to the bottom of the panel
+      drawLeft   - left offset at which to draw the scroll bar
+    """
+    
+    if (self.maxY - drawTop) < 2: return # not enough room
+    
+    # sets drawBottom to be the actual row on which the scrollbar should end
+    if drawBottom == -1: drawBottom = self.maxY - 1
+    else: drawBottom = min(drawBottom, self.maxY - 1)
+    
+    # determines scrollbar dimensions
+    scrollbarHeight = drawBottom - drawTop
+    sliderTop = scrollbarHeight * top / size
+    sliderSize = scrollbarHeight * (bottom - top) / size
+    
+    # ensures slider isn't at top or bottom unless really at those extreme bounds
+    if top > 0: sliderTop = max(sliderTop, 1)
+    if bottom != size: sliderTop = min(sliderTop, scrollbarHeight - sliderSize - 2)
+    
+    # avoids a rounding error that causes the scrollbar to be too low when at
+    # the bottom
+    if bottom == size: sliderTop = scrollbarHeight - sliderSize - 1
+    
+    # draws scrollbar slider
+    for i in range(scrollbarHeight):
+      if i >= sliderTop and i <= sliderTop + sliderSize:
+        self.addstr(i + drawTop, drawLeft, " ", curses.A_STANDOUT)
+      else:
+        self.addstr(i + drawTop, drawLeft, " ")
+    
+    # draws box around the scroll bar
+    self.vline(drawTop, drawLeft + 1, drawBottom - 1)
+    self.addch(drawBottom, drawLeft + 1, curses.ACS_LRCORNER)
+    self.addch(drawBottom, drawLeft, curses.ACS_HLINE)
+  
+  def _resetSubwindow(self):
+    """
+    Create a new subwindow instance for the panel if:
+    - Panel currently doesn't have a subwindow (was uninitialized or
+      invalidated).
+    - There's room for the panel to grow vertically (curses automatically
+      lets subwindows regrow horizontally, but not vertically).
+    - The subwindow has been displaced. This is a curses display bug that
+      manifests if the terminal's shrank then re-expanded. Displaced
+      subwindows are never restored to their proper position, resulting in
+      graphical glitches if we draw to them.
+    - The preferred size is smaller than the actual size (should shrink).
+    
+    This returns True if a new subwindow instance was created, False otherwise.
+    """
+    
+    newHeight, newWidth = self.getPreferredSize()
+    if newHeight == 0: return False # subwindow would be outside its parent
+    
+    # determines if a new subwindow should be recreated
+    recreate = self.win == None
+    if self.win:
+      subwinMaxY, subwinMaxX = self.win.getmaxyx()
+      recreate |= subwinMaxY < newHeight              # check for vertical growth
+      recreate |= self.top > self.win.getparyx()[0]   # check for displacement
+      recreate |= subwinMaxX > newWidth or subwinMaxY > newHeight # shrinking
+    
+    # I'm not sure if recreating subwindows is some sort of memory leak but the
+    # Python curses bindings seem to lack all of the following:
+    # - subwindow deletion (to tell curses to free the memory)
+    # - subwindow moving/resizing (to restore the displaced windows)
+    # so this is the only option (besides removing subwindows entirely which 
+    # would mean far more complicated code and no more selective refreshing)
+    
+    if recreate:
+      self.win = self.parent.subwin(newHeight, newWidth, self.top, self.left)
+      
+      # note: doing this log before setting win produces an infinite loop
+      log.debug("recreating panel '%s' with the dimensions of %i/%i" % (self.getName(), newHeight, newWidth))
+    return recreate
+
diff --git a/arm/util/sysTools.py b/arm/util/sysTools.py
new file mode 100644
index 0000000..af38ced
--- /dev/null
+++ b/arm/util/sysTools.py
@@ -0,0 +1,326 @@
+"""
+Helper functions for working with the underlying system.
+"""
+
+import os
+import time
+import threading
+
+from stem.util import conf, log, proc, str_tools, system
+
+PROCESS_NAME_CACHE = {} # mapping of pids to their process names
+RESOURCE_TRACKERS = {}  # mapping of pids to their resource tracker instances
+
+# Runtimes for system calls, used to estimate cpu usage. Entries are tuples of
+# the form:
+# (time called, runtime)
+RUNTIMES = []
+SAMPLING_PERIOD = 5 # time of the sampling period
+
+CONFIG = conf.config_dict("arm", {
+  "queries.resourceUsage.rate": 5,
+})
+
+# TODO: This was a bit of a hack, and one that won't work now that we lack our
+# call() method to populate RUNTIMES.
+
+def getSysCpuUsage():
+  """
+  Provides an estimate of the cpu usage for system calls made through this
+  module, based on a sampling period of five seconds. The os.times() function,
+  unfortunately, doesn't seem to take popen calls into account. This returns a
+  float representing the percentage used.
+  """
+  
+  currentTime = time.time()
+  
+  # removes any runtimes outside of our sampling period
+  while RUNTIMES and currentTime - RUNTIMES[0][0] > SAMPLING_PERIOD:
+    RUNTIMES.pop(0)
+  
+  runtimeSum = sum([entry[1] for entry in RUNTIMES])
+  return runtimeSum / SAMPLING_PERIOD
+
+def getFileErrorMsg(exc):
+  """
+  Strips off the error number prefix for file related IOError messages. For
+  instance, instead of saying:
+  [Errno 2] No such file or directory
+  
+  this would return:
+  no such file or directory
+  
+  Arguments:
+    exc - file related IOError exception
+  """
+  
+  excStr = str(exc)
+  if excStr.startswith("[Errno ") and "] " in excStr:
+    excStr = excStr[excStr.find("] ") + 2:].strip()
+    excStr = excStr[0].lower() + excStr[1:]
+  
+  return excStr
+
+def getProcessName(pid, default = None, cacheFailure = True):
+  """
+  Provides the name associated with the given process id. This isn't available
+  on all platforms.
+  
+  Arguments:
+    pid          - process id for the process being returned
+    default      - result if the process name can't be retrieved (raises an
+                   IOError on failure instead if undefined)
+    cacheFailure - if the lookup fails and there's a default then caches the
+                   default value to prevent further lookups
+  """
+  
+  if pid in PROCESS_NAME_CACHE:
+    return PROCESS_NAME_CACHE[pid]
+  
+  processName, raisedExc = "", None
+  
+  # fetch it from proc contents if available
+  if proc.is_available():
+    try:
+      processName = proc.get_stats(pid, proc.Stat.COMMAND)[0]
+    except IOError, exc:
+      raisedExc = exc
+  
+  # fall back to querying via ps
+  if not processName:
+    # the ps call formats results as:
+    # COMMAND
+    # tor
+    psCall = system.call("ps -p %s -o command" % pid)
+    
+    if psCall and len(psCall) >= 2 and not " " in psCall[1]:
+      processName, raisedExc = psCall[1].strip(), None
+    else:
+      raisedExc = ValueError("Unexpected output from ps: %s" % psCall)
+  
+  if raisedExc:
+    if default == None: raise raisedExc
+    else:
+      if cacheFailure:
+        PROCESS_NAME_CACHE[pid] = default
+      
+      return default
+  else:
+    processName = os.path.basename(processName)
+    PROCESS_NAME_CACHE[pid] = processName
+    return processName
+
+def getResourceTracker(pid, noSpawn = False):
+  """
+  Provides a running singleton ResourceTracker instance for the given pid.
+  
+  Arguments:
+    pid     - pid of the process being tracked
+    noSpawn - returns None rather than generating a singleton instance if True
+  """
+  
+  if pid in RESOURCE_TRACKERS:
+    tracker = RESOURCE_TRACKERS[pid]
+    if tracker.isAlive(): return tracker
+    else: del RESOURCE_TRACKERS[pid]
+  
+  if noSpawn: return None
+  tracker = ResourceTracker(pid, CONFIG["queries.resourceUsage.rate"])
+  RESOURCE_TRACKERS[pid] = tracker
+  tracker.start()
+  return tracker
+
+class ResourceTracker(threading.Thread):
+  """
+  Periodically fetches the resource usage (cpu and memory usage) for a given
+  process.
+  """
+  
+  def __init__(self, processPid, resolveRate):
+    """
+    Initializes a new resolver daemon. When no longer needed it's suggested
+    that this is stopped.
+    
+    Arguments:
+      processPid  - pid of the process being tracked
+      resolveRate - time between resolving resource usage, resolution is
+                    disabled if zero
+    """
+    
+    threading.Thread.__init__(self)
+    self.setDaemon(True)
+    
+    self.processPid = processPid
+    self.resolveRate = resolveRate
+    
+    self.cpuSampling = 0.0  # latest cpu usage sampling
+    self.cpuAvg = 0.0       # total average cpu usage
+    self.memUsage = 0       # last sampled memory usage in bytes
+    self.memUsagePercentage = 0.0 # percentage cpu usage
+    
+    # resolves usage via proc results if true, ps otherwise
+    self._useProc = proc.is_available()
+    
+    # used to get the deltas when querying cpu time
+    self._lastCpuTotal = 0
+    
+    self.lastLookup = -1
+    self._halt = False      # terminates thread if true
+    self._valLock = threading.RLock()
+    self._cond = threading.Condition()  # used for pausing the thread
+    
+    # number of successful calls we've made
+    self._runCount = 0
+    
+    # sequential times we've failed with this method of resolution
+    self._failureCount = 0
+  
+  def getResourceUsage(self):
+    """
+    Provides the last cached resource usage as a tuple of the form:
+    (cpuUsage_sampling, cpuUsage_avg, memUsage_bytes, memUsage_percent)
+    """
+    
+    self._valLock.acquire()
+    results = (self.cpuSampling, self.cpuAvg, self.memUsage, self.memUsagePercentage)
+    self._valLock.release()
+    
+    return results
+  
+  def getRunCount(self):
+    """
+    Provides the number of times we've successfully fetched the resource
+    usages.
+    """
+    
+    return self._runCount
+  
+  def lastQueryFailed(self):
+    """
+    Provides true if, since we fetched the currently cached results, we've
+    failed to get new results. False otherwise.
+    """
+    
+    return self._failureCount != 0
+  
+  def run(self):
+    while not self._halt:
+      timeSinceReset = time.time() - self.lastLookup
+      
+      if self.resolveRate == 0:
+        self._cond.acquire()
+        if not self._halt: self._cond.wait(0.2)
+        self._cond.release()
+        
+        continue
+      elif timeSinceReset < self.resolveRate:
+        sleepTime = max(0.2, self.resolveRate - timeSinceReset)
+        
+        self._cond.acquire()
+        if not self._halt: self._cond.wait(sleepTime)
+        self._cond.release()
+        
+        continue # done waiting, try again
+      
+      newValues = {}
+      try:
+        if self._useProc:
+          utime, stime, startTime = proc.get_stats(self.processPid, proc.Stat.CPU_UTIME, proc.Stat.CPU_STIME, proc.Stat.START_TIME)
+          totalCpuTime = float(utime) + float(stime)
+          cpuDelta = totalCpuTime - self._lastCpuTotal
+          newValues["cpuSampling"] = cpuDelta / timeSinceReset
+          newValues["cpuAvg"] = totalCpuTime / (time.time() - float(startTime))
+          newValues["_lastCpuTotal"] = totalCpuTime
+          
+          memUsage = int(proc.get_memory_usage(self.processPid)[0])
+          totalMemory = proc.get_physical_memory()
+          newValues["memUsage"] = memUsage
+          newValues["memUsagePercentage"] = float(memUsage) / totalMemory
+        else:
+          # the ps call formats results as:
+          # 
+          #     TIME     ELAPSED   RSS %MEM
+          # 3-08:06:32 21-00:00:12 121844 23.5
+          # 
+          # or if Tor has only recently been started:
+          # 
+          #     TIME      ELAPSED    RSS %MEM
+          #  0:04.40        37:57  18772  0.9
+          
+          psCall = system.call("ps -p %s -o cputime,etime,rss,%%mem" % self.processPid)
+          
+          isSuccessful = False
+          if psCall and len(psCall) >= 2:
+            stats = psCall[1].strip().split()
+            
+            if len(stats) == 4:
+              try:
+                totalCpuTime = str_tools.parse_short_time_label(stats[0])
+                uptime = str_tools.parse_short_time_label(stats[1])
+                cpuDelta = totalCpuTime - self._lastCpuTotal
+                newValues["cpuSampling"] = cpuDelta / timeSinceReset
+                newValues["cpuAvg"] = totalCpuTime / uptime
+                newValues["_lastCpuTotal"] = totalCpuTime
+                
+                newValues["memUsage"] = int(stats[2]) * 1024 # ps size is in kb
+                newValues["memUsagePercentage"] = float(stats[3]) / 100.0
+                isSuccessful = True
+              except ValueError, exc: pass
+          
+          if not isSuccessful:
+            raise IOError("unrecognized output from ps: %s" % psCall)
+      except IOError, exc:
+        newValues = {}
+        self._failureCount += 1
+        
+        if self._useProc:
+          if self._failureCount >= 3:
+            # We've failed three times resolving via proc. Warn, and fall back
+            # to ps resolutions.
+            log.info("Failed three attempts to get process resource usage from proc, falling back to ps (%s)" % exc)
+            
+            self._useProc = False
+            self._failureCount = 1 # prevents lastQueryFailed() from thinking that we succeeded
+          else:
+            # wait a bit and try again
+            log.debug("Unable to query process resource usage from proc (%s)" % exc)
+            self._cond.acquire()
+            if not self._halt: self._cond.wait(0.5)
+            self._cond.release()
+        else:
+          # exponential backoff on making failed ps calls
+          sleepTime = 0.01 * (2 ** self._failureCount) + self._failureCount
+          log.debug("Unable to query process resource usage from ps, waiting %0.2f seconds (%s)" % (sleepTime, exc))
+          self._cond.acquire()
+          if not self._halt: self._cond.wait(sleepTime)
+          self._cond.release()
+      
+      # sets the new values
+      if newValues:
+        # If this is the first run then the cpuSampling stat is meaningless
+        # (there isn't a previous tick to sample from so it's zero at this
+        # point). Setting it to the average, which is a fairer estimate.
+        if self.lastLookup == -1:
+          newValues["cpuSampling"] = newValues["cpuAvg"]
+        
+        self._valLock.acquire()
+        self.cpuSampling = newValues["cpuSampling"]
+        self.cpuAvg = newValues["cpuAvg"]
+        self.memUsage = newValues["memUsage"]
+        self.memUsagePercentage = newValues["memUsagePercentage"]
+        self._lastCpuTotal = newValues["_lastCpuTotal"]
+        self.lastLookup = time.time()
+        self._runCount += 1
+        self._failureCount = 0
+        self._valLock.release()
+  
+  def stop(self):
+    """
+    Halts further resolutions and terminates the thread.
+    """
+    
+    self._cond.acquire()
+    self._halt = True
+    self._cond.notifyAll()
+    self._cond.release()
+
diff --git a/arm/util/textInput.py b/arm/util/textInput.py
new file mode 100644
index 0000000..c0f01ce
--- /dev/null
+++ b/arm/util/textInput.py
@@ -0,0 +1,195 @@
+"""
+Provides input validators that provide text input with various capabilities.
+These can be chained together with the first matching validator taking
+precidence.
+"""
+
+import os
+import curses
+
+PASS = -1
+
+class TextInputValidator:
+  """
+  Basic interface for validators. Implementations should override the handleKey
+  method.
+  """
+  
+  def __init__(self, nextValidator = None):
+    self.nextValidator = nextValidator
+  
+  def validate(self, key, textbox):
+    """
+    Processes the given key input for the textbox. This may modify the
+    textbox's content, cursor position, etc depending on the functionality
+    of the validator. This returns the key that the textbox should interpret,
+    PASS if this validator doesn't want to take any action.
+    
+    Arguments:
+      key     - key code input from the user
+      textbox - curses Textbox instance the input came from
+    """
+    
+    result = self.handleKey(key, textbox)
+    
+    if result != PASS:
+      return result
+    elif self.nextValidator:
+      return self.nextValidator.validate(key, textbox)
+    else: return key
+  
+  def handleKey(self, key, textbox):
+    """
+    Process the given keycode with this validator, returning the keycode for
+    the textbox to process, and PASS if this doesn't want to modify it.
+    
+    Arguments:
+      key     - key code input from the user
+      textbox - curses Textbox instance the input came from
+    """
+    
+    return PASS
+
+class BasicValidator(TextInputValidator):
+  """
+  Interceptor for keystrokes given to a textbox, doing the following:
+  - quits by setting the input to curses.ascii.BEL when escape is pressed
+  - stops the cursor at the end of the box's content when pressing the right
+    arrow
+  - home and end keys move to the start/end of the line
+  """
+  
+  def handleKey(self, key, textbox):
+    y, x = textbox.win.getyx()
+    
+    if curses.ascii.isprint(key) and x < textbox.maxx:
+      # Shifts the existing text forward so input is an insert method rather
+      # than replacement. The curses.textpad accepts an insert mode flag but
+      # this has a couple issues...
+      # - The flag is only available for Python 2.6+, before that the
+      #   constructor only accepted a subwindow argument as per:
+      #   https://trac.torproject.org/projects/tor/ticket/2354
+      # - The textpad doesn't shift text that has text attributes. This is
+      #   because keycodes read by textbox.win.inch() includes formatting,
+      #   causing the curses.ascii.isprint() check it does to fail.
+      
+      currentInput = textbox.gather()
+      textbox.win.addstr(y, x + 1, currentInput[x:textbox.maxx - 1])
+      textbox.win.move(y, x) # reverts cursor movement during gather call
+    elif key == 27:
+      # curses.ascii.BEL is a character codes that causes textpad to terminate
+      return curses.ascii.BEL
+    elif key == curses.KEY_HOME:
+      textbox.win.move(y, 0)
+      return None
+    elif key in (curses.KEY_END, curses.KEY_RIGHT):
+      msgLen = len(textbox.gather())
+      textbox.win.move(y, x) # reverts cursor movement during gather call
+      
+      if key == curses.KEY_END and msgLen > 0 and x < msgLen - 1:
+        # if we're in the content then move to the end
+        textbox.win.move(y, msgLen - 1)
+        return None
+      elif key == curses.KEY_RIGHT and x >= msgLen - 1:
+        # don't move the cursor if there's no content after it
+        return None
+    elif key == 410:
+      # if we're resizing the display during text entry then cancel it
+      # (otherwise the input field is filled with nonprintable characters)
+      return curses.ascii.BEL
+    
+    return PASS
+
+class HistoryValidator(TextInputValidator):
+  """
+  This intercepts the up and down arrow keys to scroll through a backlog of
+  previous commands.
+  """
+  
+  def __init__(self, commandBacklog = [], nextValidator = None):
+    TextInputValidator.__init__(self, nextValidator)
+    
+    # contents that can be scrolled back through, newest to oldest
+    self.commandBacklog = commandBacklog
+    
+    # selected item from the backlog, -1 if we're not on a backlog item
+    self.selectionIndex = -1
+    
+    # the fields input prior to selecting a backlog item
+    self.customInput = ""
+  
+  def handleKey(self, key, textbox):
+    if key in (curses.KEY_UP, curses.KEY_DOWN):
+      offset = 1 if key == curses.KEY_UP else -1
+      newSelection = self.selectionIndex + offset
+      
+      # constrains the new selection to valid bounds
+      newSelection = max(-1, newSelection)
+      newSelection = min(len(self.commandBacklog) - 1, newSelection)
+      
+      # skips if this is a no-op
+      if self.selectionIndex == newSelection:
+        return None
+      
+      # saves the previous input if we weren't on the backlog
+      if self.selectionIndex == -1:
+        self.customInput = textbox.gather().strip()
+      
+      if newSelection == -1: newInput = self.customInput
+      else: newInput = self.commandBacklog[newSelection]
+      
+      y, _ = textbox.win.getyx()
+      _, maxX = textbox.win.getmaxyx()
+      textbox.win.clear()
+      textbox.win.addstr(y, 0, newInput[:maxX - 1])
+      textbox.win.move(y, min(len(newInput), maxX - 1))
+      
+      self.selectionIndex = newSelection
+      return None
+    
+    return PASS
+
+class TabCompleter(TextInputValidator):
+  """
+  Provides tab completion based on the current input, finishing if there's only
+  a single match. This expects a functor that accepts the current input and
+  provides matches.
+  """
+  
+  def __init__(self, completer, nextValidator = None):
+    TextInputValidator.__init__(self, nextValidator)
+    
+    # functor that accepts a string and gives a list of matches
+    self.completer = completer
+  
+  def handleKey(self, key, textbox):
+    # Matches against the tab key. The ord('\t') is nine, though strangely none
+    # of the curses.KEY_*TAB constants match this...
+    if key == 9:
+      currentContents = textbox.gather().strip()
+      matches = self.completer(currentContents)
+      newInput = None
+      
+      if len(matches) == 1:
+        # only a single match, fill it in
+        newInput = matches[0]
+      elif len(matches) > 1:
+        # looks for a common prefix we can complete
+        commonPrefix = os.path.commonprefix(matches) # weird that this comes from path...
+        
+        if commonPrefix != currentContents:
+          newInput = commonPrefix
+        
+        # TODO: somehow display matches... this is not gonna be fun
+      
+      if newInput:
+        y, _ = textbox.win.getyx()
+        _, maxX = textbox.win.getmaxyx()
+        textbox.win.clear()
+        textbox.win.addstr(y, 0, newInput[:maxX - 1])
+        textbox.win.move(y, min(len(newInput), maxX - 1))
+      
+      return None
+    
+    return PASS
+
diff --git a/arm/util/torConfig.py b/arm/util/torConfig.py
new file mode 100644
index 0000000..5f4e525
--- /dev/null
+++ b/arm/util/torConfig.py
@@ -0,0 +1,1041 @@
+"""
+Helper functions for working with tor's configuration file.
+"""
+
+import os
+import time
+import socket
+import threading
+
+import stem.version
+
+from util import sysTools, torTools, uiTools
+
+from stem.util import conf, enum, log, str_tools, system
+
+def conf_handler(key, value):
+  if key == "config.important":
+    # stores lowercase entries to drop case sensitivity
+    return [entry.lower() for entry in value]
+
+CONFIG = conf.config_dict("arm", {
+  "features.torrc.validate": True,
+  "config.important": [],
+  "torrc.alias": {},
+  "torrc.label.size.b": [],
+  "torrc.label.size.kb": [],
+  "torrc.label.size.mb": [],
+  "torrc.label.size.gb": [],
+  "torrc.label.size.tb": [],
+  "torrc.label.time.sec": [],
+  "torrc.label.time.min": [],
+  "torrc.label.time.hour": [],
+  "torrc.label.time.day": [],
+  "torrc.label.time.week": [],
+}, conf_handler)
+
+def general_conf_handler(config, key):
+  value = config.get(key)
+  
+  if key.startswith("config.summary."):
+    # we'll look for summary keys with a lowercase config name
+    CONFIG[key.lower()] = value
+  elif key.startswith("torrc.label.") and value:
+    # all the torrc.label.* values are comma separated lists
+    return [entry.strip() for entry in value[0].split(",")]
+
+conf.get_config("arm").add_listener(general_conf_handler, backfill = True)
+
+# enums and values for numeric torrc entries
+ValueType = enum.Enum("UNRECOGNIZED", "SIZE", "TIME")
+SIZE_MULT = {"b": 1, "kb": 1024, "mb": 1048576, "gb": 1073741824, "tb": 1099511627776}
+TIME_MULT = {"sec": 1, "min": 60, "hour": 3600, "day": 86400, "week": 604800}
+
+# enums for issues found during torrc validation:
+# DUPLICATE  - entry is ignored due to being a duplicate
+# MISMATCH   - the value doesn't match tor's current state
+# MISSING    - value differs from its default but is missing from the torrc
+# IS_DEFAULT - the configuration option matches tor's default
+ValidationError = enum.Enum("DUPLICATE", "MISMATCH", "MISSING", "IS_DEFAULT")
+
+# descriptions of tor's configuration options fetched from its man page
+CONFIG_DESCRIPTIONS_LOCK = threading.RLock()
+CONFIG_DESCRIPTIONS = {}
+
+# categories for tor configuration options
+Category = enum.Enum("GENERAL", "CLIENT", "RELAY", "DIRECTORY", "AUTHORITY", "HIDDEN_SERVICE", "TESTING", "UNKNOWN")
+
+TORRC = None # singleton torrc instance
+MAN_OPT_INDENT = 7 # indentation before options in the man page
+MAN_EX_INDENT = 15 # indentation used for man page examples
+PERSIST_ENTRY_DIVIDER = "-" * 80 + "\n" # splits config entries when saving to a file
+MULTILINE_PARAM = None # cached multiline parameters (lazily loaded)
+
+# torrc options that bind to ports
+PORT_OPT = ("SocksPort", "ORPort", "DirPort", "ControlPort", "TransPort")
+
+class ManPageEntry:
+  """
+  Information provided about a tor configuration option in its man page entry.
+  """
+  
+  def __init__(self, option, index, category, argUsage, description):
+    self.option = option
+    self.index = index
+    self.category = category
+    self.argUsage = argUsage
+    self.description = description
+
+def getTorrc():
+  """
+  Singleton constructor for a Controller. Be aware that this starts as being
+  unloaded, needing the torrc contents to be loaded before being functional.
+  """
+  
+  global TORRC
+  if TORRC == None: TORRC = Torrc()
+  return TORRC
+
+def loadOptionDescriptions(loadPath = None, checkVersion = True):
+  """
+  Fetches and parses descriptions for tor's configuration options from its man
+  page. This can be a somewhat lengthy call, and raises an IOError if issues
+  occure. When successful loading from a file this returns the version for the
+  contents loaded.
+  
+  If available, this can load the configuration descriptions from a file where
+  they were previously persisted to cut down on the load time (latency for this
+  is around 200ms).
+  
+  Arguments:
+    loadPath     - if set, this attempts to fetch the configuration
+                   descriptions from the given path instead of the man page
+    checkVersion - discards the results if true and tor's version doens't
+                   match the cached descriptors, otherwise accepts anyway
+  """
+  
+  CONFIG_DESCRIPTIONS_LOCK.acquire()
+  CONFIG_DESCRIPTIONS.clear()
+  
+  raisedExc = None
+  loadedVersion = ""
+  try:
+    if loadPath:
+      # Input file is expected to be of the form:
+      # <option>
+      # <arg description>
+      # <description, possibly multiple lines>
+      # <PERSIST_ENTRY_DIVIDER>
+      inputFile = open(loadPath, "r")
+      inputFileContents = inputFile.readlines()
+      inputFile.close()
+      
+      try:
+        versionLine = inputFileContents.pop(0).rstrip()
+        
+        if versionLine.startswith("Tor Version "):
+          fileVersion = versionLine[12:]
+          loadedVersion = fileVersion
+          torVersion = torTools.getConn().getInfo("version", "")
+          
+          if checkVersion and fileVersion != torVersion:
+            msg = "wrong version, tor is %s but the file's from %s" % (torVersion, fileVersion)
+            raise IOError(msg)
+        else:
+          raise IOError("unable to parse version")
+        
+        while inputFileContents:
+          # gets category enum, failing if it doesn't exist
+          category = inputFileContents.pop(0).rstrip()
+          if not category in Category:
+            baseMsg = "invalid category in input file: '%s'"
+            raise IOError(baseMsg % category)
+          
+          # gets the position in the man page
+          indexArg, indexStr = -1, inputFileContents.pop(0).rstrip()
+          
+          if indexStr.startswith("index: "):
+            indexStr = indexStr[7:]
+            
+            if indexStr.isdigit(): indexArg = int(indexStr)
+            else: raise IOError("non-numeric index value: %s" % indexStr)
+          else: raise IOError("malformed index argument: %s"% indexStr)
+          
+          option = inputFileContents.pop(0).rstrip()
+          argument = inputFileContents.pop(0).rstrip()
+          
+          description, loadedLine = "", inputFileContents.pop(0)
+          while loadedLine != PERSIST_ENTRY_DIVIDER:
+            description += loadedLine
+            
+            if inputFileContents: loadedLine = inputFileContents.pop(0)
+            else: break
+          
+          CONFIG_DESCRIPTIONS[option.lower()] = ManPageEntry(option, indexArg, category, argument, description.rstrip())
+      except IndexError:
+        CONFIG_DESCRIPTIONS.clear()
+        raise IOError("input file format is invalid")
+    else:
+      manCallResults = system.call("man tor")
+      
+      if not manCallResults:
+        raise IOError("man page not found")
+      
+      # Fetches all options available with this tor instance. This isn't
+      # vital, and the validOptions are left empty if the call fails.
+      conn, validOptions = torTools.getConn(), []
+      configOptionQuery = conn.getInfo("config/names", None)
+      if configOptionQuery:
+        for line in configOptionQuery.strip().split("\n"):
+          validOptions.append(line[:line.find(" ")].lower())
+      
+      optionCount, lastOption, lastArg = 0, None, None
+      lastCategory, lastDescription = Category.GENERAL, ""
+      for line in manCallResults:
+        line = uiTools.getPrintable(line)
+        strippedLine = line.strip()
+        
+        # we have content, but an indent less than an option (ignore line)
+        #if strippedLine and not line.startswith(" " * MAN_OPT_INDENT): continue
+        
+        # line starts with an indent equivilant to a new config option
+        isOptIndent = line.startswith(" " * MAN_OPT_INDENT) and line[MAN_OPT_INDENT] != " "
+        
+        isCategoryLine = not line.startswith(" ") and "OPTIONS" in line
+        
+        # if this is a category header or a new option, add an entry using the
+        # buffered results
+        if isOptIndent or isCategoryLine:
+          # Filters the line based on if the option is recognized by tor or
+          # not. This isn't necessary for arm, so if unable to make the check
+          # then we skip filtering (no loss, the map will just have some extra
+          # noise).
+          strippedDescription = lastDescription.strip()
+          if lastOption and (not validOptions or lastOption.lower() in validOptions):
+            CONFIG_DESCRIPTIONS[lastOption.lower()] = ManPageEntry(lastOption, optionCount, lastCategory, lastArg, strippedDescription)
+            optionCount += 1
+          lastDescription = ""
+          
+          # parses the option and argument
+          line = line.strip()
+          divIndex = line.find(" ")
+          if divIndex != -1:
+            lastOption, lastArg = line[:divIndex], line[divIndex + 1:]
+          
+          # if this is a category header then switch it
+          if isCategoryLine:
+            if line.startswith("OPTIONS"): lastCategory = Category.GENERAL
+            elif line.startswith("CLIENT"): lastCategory = Category.CLIENT
+            elif line.startswith("SERVER"): lastCategory = Category.RELAY
+            elif line.startswith("DIRECTORY SERVER"): lastCategory = Category.DIRECTORY
+            elif line.startswith("DIRECTORY AUTHORITY SERVER"): lastCategory = Category.AUTHORITY
+            elif line.startswith("HIDDEN SERVICE"): lastCategory = Category.HIDDEN_SERVICE
+            elif line.startswith("TESTING NETWORK"): lastCategory = Category.TESTING
+            else:
+              log.notice("Unrecognized category in the man page: %s" % line.strip())
+        else:
+          # Appends the text to the running description. Empty lines and lines
+          # starting with a specific indentation are used for formatting, for
+          # instance the ExitPolicy and TestingTorNetwork entries.
+          if lastDescription and lastDescription[-1] != "\n":
+            lastDescription += " "
+          
+          if not strippedLine:
+            lastDescription += "\n\n"
+          elif line.startswith(" " * MAN_EX_INDENT):
+            lastDescription += "    %s\n" % strippedLine
+          else: lastDescription += strippedLine
+  except IOError, exc:
+    raisedExc = exc
+  
+  CONFIG_DESCRIPTIONS_LOCK.release()
+  if raisedExc: raise raisedExc
+  else: return loadedVersion
+
+def saveOptionDescriptions(path):
+  """
+  Preserves the current configuration descriptors to the given path. This
+  raises an IOError or OSError if unable to do so.
+  
+  Arguments:
+    path - location to persist configuration descriptors
+  """
+  
+  # make dir if the path doesn't already exist
+  baseDir = os.path.dirname(path)
+  if not os.path.exists(baseDir): os.makedirs(baseDir)
+  outputFile = open(path, "w")
+  
+  CONFIG_DESCRIPTIONS_LOCK.acquire()
+  sortedOptions = CONFIG_DESCRIPTIONS.keys()
+  sortedOptions.sort()
+  
+  torVersion = torTools.getConn().getInfo("version", "")
+  outputFile.write("Tor Version %s\n" % torVersion)
+  for i in range(len(sortedOptions)):
+    manEntry = getConfigDescription(sortedOptions[i])
+    outputFile.write("%s\nindex: %i\n%s\n%s\n%s\n" % (manEntry.category, manEntry.index, manEntry.option, manEntry.argUsage, manEntry.description))
+    if i != len(sortedOptions) - 1: outputFile.write(PERSIST_ENTRY_DIVIDER)
+  
+  outputFile.close()
+  CONFIG_DESCRIPTIONS_LOCK.release()
+
+def getConfigSummary(option):
+  """
+  Provides a short summary description of the configuration option. If none is
+  known then this proivdes None.
+  
+  Arguments:
+    option - tor config option
+  """
+  
+  return CONFIG.get("config.summary.%s" % option.lower())
+
+def isImportant(option):
+  """
+  Provides True if the option has the 'important' flag in the configuration,
+  False otherwise.
+  
+  Arguments:
+    option - tor config option
+  """
+  
+  return option.lower() in CONFIG["config.important"]
+
+def getConfigDescription(option):
+  """
+  Provides ManPageEntry instances populated with information fetched from the
+  tor man page. This provides None if no such option has been loaded. If the
+  man page is in the process of being loaded then this call blocks until it
+  finishes.
+  
+  Arguments:
+    option - tor config option
+  """
+  
+  CONFIG_DESCRIPTIONS_LOCK.acquire()
+  
+  if option.lower() in CONFIG_DESCRIPTIONS:
+    returnVal = CONFIG_DESCRIPTIONS[option.lower()]
+  else: returnVal = None
+  
+  CONFIG_DESCRIPTIONS_LOCK.release()
+  return returnVal
+
+def getConfigOptions():
+  """
+  Provides the configuration options from the loaded man page. This is an empty
+  list if no man page has been loaded.
+  """
+  
+  CONFIG_DESCRIPTIONS_LOCK.acquire()
+  
+  returnVal = [CONFIG_DESCRIPTIONS[opt].option for opt in CONFIG_DESCRIPTIONS]
+  
+  CONFIG_DESCRIPTIONS_LOCK.release()
+  return returnVal
+
+def getConfigLocation():
+  """
+  Provides the location of the torrc, raising an IOError with the reason if the
+  path can't be determined.
+  """
+  
+  conn = torTools.getConn()
+  configLocation = conn.getInfo("config-file", None)
+  torPid, torPrefix = conn.controller.get_pid(None), conn.getPathPrefix()
+  if not configLocation: raise IOError("unable to query the torrc location")
+  
+  try:
+    torCwd = system.get_cwd(torPid)
+    return torPrefix + system.expand_path(configLocation, torCwd)
+  except IOError, exc:
+    raise IOError("querying tor's pwd failed because %s" % exc)
+
+def getMultilineParameters():
+  """
+  Provides parameters that can be defined multiple times in the torrc without
+  overwriting the value.
+  """
+  
+  # fetches config options with the LINELIST (aka 'LineList'), LINELIST_S (aka
+  # 'Dependent'), and LINELIST_V (aka 'Virtual') types
+  global MULTILINE_PARAM
+  if MULTILINE_PARAM == None:
+    conn, multilineEntries = torTools.getConn(), []
+    
+    configOptionQuery = conn.getInfo("config/names", None)
+    if configOptionQuery:
+      for line in configOptionQuery.strip().split("\n"):
+        confOption, confType = line.strip().split(" ", 1)
+        if confType in ("LineList", "Dependant", "Virtual"):
+          multilineEntries.append(confOption)
+    else:
+      # unable to query tor connection, so not caching results
+      return ()
+    
+    MULTILINE_PARAM = multilineEntries
+  
+  return tuple(MULTILINE_PARAM)
+
+def getCustomOptions(includeValue = False):
+  """
+  Provides the torrc parameters that differ from their defaults.
+  
+  Arguments:
+    includeValue - provides the current value with results if true, otherwise
+                   this just contains the options
+  """
+  
+  configText = torTools.getConn().getInfo("config-text", "").strip()
+  configLines = configText.split("\n")
+  
+  # removes any duplicates
+  configLines = list(set(configLines))
+  
+  # The "GETINFO config-text" query only provides options that differ
+  # from Tor's defaults with the exception of its Log and Nickname entries
+  # which, even if undefined, returns "Log notice stdout" as per:
+  # https://trac.torproject.org/projects/tor/ticket/2362
+  #
+  # If this is from the deb then it will be "Log notice file /var/log/tor/log"
+  # due to special patching applied to it, as per:
+  # https://trac.torproject.org/projects/tor/ticket/4602
+  
+  try: configLines.remove("Log notice stdout")
+  except ValueError: pass
+  
+  try: configLines.remove("Log notice file /var/log/tor/log")
+  except ValueError: pass
+  
+  try: configLines.remove("Nickname %s" % socket.gethostname())
+  except ValueError: pass
+  
+  if includeValue: return configLines
+  else: return [line[:line.find(" ")] for line in configLines]
+
+def saveConf(destination = None, contents = None):
+  """
+  Saves the configuration to the given path. If this is equivilant to
+  issuing a SAVECONF (the contents and destination match what tor's using)
+  then that's done. Otherwise, this writes the contents directly. This raises
+  an IOError if unsuccessful.
+  
+  Arguments:
+    destination - path to be saved to, the current config location if None
+    contents    - configuration to be saved, the current config if None
+  """
+  
+  if destination:
+    destination = os.path.abspath(destination)
+  
+  # fills default config values, and sets isSaveconf to false if they differ
+  # from the arguments
+  isSaveconf, startTime = True, time.time()
+  
+  currentConfig = getCustomOptions(True)
+  if not contents: contents = currentConfig
+  else: isSaveconf &= contents == currentConfig
+  
+  # The "GETINFO config-text" option was introduced in Tor version 0.2.2.7. If
+  # we're writing custom contents then this is fine, but if we're trying to
+  # save the current configuration then we need to fail if it's unavailable.
+  # Otherwise we'd write a blank torrc as per...
+  # https://trac.torproject.org/projects/tor/ticket/3614
+  
+  if contents == ['']:
+    # double check that "GETINFO config-text" is unavailable rather than just
+    # giving an empty result
+    
+    if torTools.getConn().getInfo("config-text", None) == None:
+      raise IOError("determining the torrc requires Tor version 0.2.2.7")
+  
+  currentLocation = None
+  try:
+    currentLocation = getConfigLocation()
+    if not destination: destination = currentLocation
+    else: isSaveconf &= destination == currentLocation
+  except IOError: pass
+  
+  if not destination: raise IOError("unable to determine the torrc's path")
+  logMsg = "Saved config by %%s to %s (runtime: %%0.4f)" % destination
+  
+  # attempts SAVECONF if we're updating our torrc with the current state
+  if isSaveconf:
+    try:
+      torTools.getConn().saveConf()
+      
+      try: getTorrc().load()
+      except IOError: pass
+      
+      log.debug(logMsg % ("SAVECONF", time.time() - startTime))
+      return # if successful then we're done
+    except:
+      pass
+  
+  # if the SAVECONF fails or this is a custom save then write contents directly
+  try:
+    # make dir if the path doesn't already exist
+    baseDir = os.path.dirname(destination)
+    if not os.path.exists(baseDir): os.makedirs(baseDir)
+    
+    # saves the configuration to the file
+    configFile = open(destination, "w")
+    configFile.write("\n".join(contents))
+    configFile.close()
+  except (IOError, OSError), exc:
+    raise IOError(exc)
+  
+  # reloads the cached torrc if overwriting it
+  if destination == currentLocation:
+    try: getTorrc().load()
+    except IOError: pass
+  
+  log.debug(logMsg % ("directly writing", time.time() - startTime))
+
+def validate(contents = None):
+  """
+  Performs validation on the given torrc contents, providing back a listing of
+  (line number, issue, msg) tuples for issues found. If the issue occures on a
+  multiline torrc entry then the line number is for the last line of the entry.
+  
+  Arguments:
+    contents - torrc contents
+  """
+  
+  conn = torTools.getConn()
+  customOptions = getCustomOptions()
+  issuesFound, seenOptions = [], []
+  
+  # Strips comments and collapses multiline multi-line entries, for more
+  # information see:
+  # https://trac.torproject.org/projects/tor/ticket/1929
+  strippedContents, multilineBuffer = [], ""
+  for line in _stripComments(contents):
+    if not line: strippedContents.append("")
+    else:
+      line = multilineBuffer + line
+      multilineBuffer = ""
+      
+      if line.endswith("\\"):
+        multilineBuffer = line[:-1]
+        strippedContents.append("")
+      else:
+        strippedContents.append(line.strip())
+  
+  for lineNumber in range(len(strippedContents) - 1, -1, -1):
+    lineText = strippedContents[lineNumber]
+    if not lineText: continue
+    
+    lineComp = lineText.split(None, 1)
+    if len(lineComp) == 2: option, value = lineComp
+    else: option, value = lineText, ""
+    
+    # Tor is case insensetive when parsing its torrc. This poses a bit of an
+    # issue for us because we want all of our checks to be case insensetive
+    # too but also want messages to match the normal camel-case conventions.
+    #
+    # Using the customOptions to account for this. It contains the tor reported
+    # options (camel case) and is either a matching set or the following defaut
+    # value check will fail. Hence using that hash to correct the case.
+    #
+    # TODO: when refactoring for stem make this less confusing...
+    
+    for customOpt in customOptions:
+      if customOpt.lower() == option.lower():
+        option = customOpt
+        break
+    
+    # if an aliased option then use its real name
+    if option in CONFIG["torrc.alias"]:
+      option = CONFIG["torrc.alias"][option]
+    
+    # most parameters are overwritten if defined multiple times
+    if option in seenOptions and not option in getMultilineParameters():
+      issuesFound.append((lineNumber, ValidationError.DUPLICATE, option))
+      continue
+    else: seenOptions.append(option)
+    
+    # checks if the value isn't necessary due to matching the defaults
+    if not option in customOptions:
+      issuesFound.append((lineNumber, ValidationError.IS_DEFAULT, option))
+    
+    # replace aliases with their recognized representation
+    if option in CONFIG["torrc.alias"]:
+      option = CONFIG["torrc.alias"][option]
+    
+    # tor appears to replace tabs with a space, for instance:
+    # "accept\t*:563" is read back as "accept *:563"
+    value = value.replace("\t", " ")
+    
+    # parse value if it's a size or time, expanding the units
+    value, valueType = _parseConfValue(value)
+    
+    # issues GETCONF to get the values tor's currently configured to use
+    torValues = conn.getOption(option, [], True)
+    
+    # multiline entries can be comma separated values (for both tor and conf)
+    valueList = [value]
+    if option in getMultilineParameters():
+      valueList = [val.strip() for val in value.split(",")]
+      
+      fetchedValues, torValues = torValues, []
+      for fetchedValue in fetchedValues:
+        for fetchedEntry in fetchedValue.split(","):
+          fetchedEntry = fetchedEntry.strip()
+          if not fetchedEntry in torValues:
+            torValues.append(fetchedEntry)
+    
+    for val in valueList:
+      # checks if both the argument and tor's value are empty
+      isBlankMatch = not val and not torValues
+      
+      if not isBlankMatch and not val in torValues:
+        # converts corrections to reader friedly size values
+        displayValues = torValues
+        if valueType == ValueType.SIZE:
+          displayValues = [str_tools.get_size_label(int(val)) for val in torValues]
+        elif valueType == ValueType.TIME:
+          displayValues = [str_tools.get_time_label(int(val)) for val in torValues]
+        
+        issuesFound.append((lineNumber, ValidationError.MISMATCH, ", ".join(displayValues)))
+  
+  # checks if any custom options are missing from the torrc
+  for option in customOptions:
+    # In new versions the 'DirReqStatistics' option is true by default and
+    # disabled on startup if geoip lookups are unavailable. If this option is
+    # missing then that's most likely the reason.
+    #
+    # https://trac.torproject.org/projects/tor/ticket/4237
+    
+    if option == "DirReqStatistics": continue
+    
+    if not option in seenOptions:
+      issuesFound.append((None, ValidationError.MISSING, option))
+  
+  return issuesFound
+
+def _parseConfValue(confArg):
+  """
+  Converts size or time values to their lowest units (bytes or seconds) which
+  is what GETCONF calls provide. The returned is a tuple of the value and unit
+  type.
+  
+  Arguments:
+    confArg - torrc argument
+  """
+  
+  if confArg.count(" ") == 1:
+    val, unit = confArg.lower().split(" ", 1)
+    if not val.isdigit(): return confArg, ValueType.UNRECOGNIZED
+    mult, multType = _getUnitType(unit)
+    
+    if mult != None:
+      return str(int(val) * mult), multType
+  
+  return confArg, ValueType.UNRECOGNIZED
+
+def _getUnitType(unit):
+  """
+  Provides the type and multiplier for an argument's unit. The multiplier is
+  None if the unit isn't recognized.
+  
+  Arguments:
+    unit - string representation of a unit
+  """
+  
+  for label in SIZE_MULT:
+    if unit in CONFIG["torrc.label.size." + label]:
+      return SIZE_MULT[label], ValueType.SIZE
+  
+  for label in TIME_MULT:
+    if unit in CONFIG["torrc.label.time." + label]:
+      return TIME_MULT[label], ValueType.TIME
+  
+  return None, ValueType.UNRECOGNIZED
+
+def _stripComments(contents):
+  """
+  Removes comments and extra whitespace from the given torrc contents.
+  
+  Arguments:
+    contents - torrc contents
+  """
+  
+  strippedContents = []
+  for line in contents:
+    if line and "#" in line: line = line[:line.find("#")]
+    strippedContents.append(line.strip())
+  return strippedContents
+
+class Torrc():
+  """
+  Wrapper for the torrc. All getters provide None if the contents are unloaded.
+  """
+  
+  def __init__(self):
+    self.contents = None
+    self.configLocation = None
+    self.valsLock = threading.RLock()
+    
+    # cached results for the current contents
+    self.displayableContents = None
+    self.strippedContents = None
+    self.corrections = None
+    
+    # flag to indicate if we've given a load failure warning before
+    self.isLoadFailWarned = False
+  
+  def load(self, logFailure = False):
+    """
+    Loads or reloads the torrc contents, raising an IOError if there's a
+    problem.
+    
+    Arguments:
+      logFailure - if the torrc fails to load and we've never provided a
+                   warning for this before then logs a warning
+    """
+    
+    self.valsLock.acquire()
+    
+    # clears contents and caches
+    self.contents, self.configLocation = None, None
+    self.displayableContents = None
+    self.strippedContents = None
+    self.corrections = None
+    
+    try:
+      self.configLocation = getConfigLocation()
+      configFile = open(self.configLocation, "r")
+      self.contents = configFile.readlines()
+      configFile.close()
+    except IOError, exc:
+      if logFailure and not self.isLoadFailWarned:
+        log.warn("Unable to load torrc (%s)" % sysTools.getFileErrorMsg(exc))
+        self.isLoadFailWarned = True
+      
+      self.valsLock.release()
+      raise exc
+    
+    self.valsLock.release()
+  
+  def isLoaded(self):
+    """
+    Provides true if there's loaded contents, false otherwise.
+    """
+    
+    return self.contents != None
+  
+  def getConfigLocation(self):
+    """
+    Provides the location of the loaded configuration contents. This may be
+    available, even if the torrc failed to be loaded.
+    """
+    
+    return self.configLocation
+  
+  def getContents(self):
+    """
+    Provides the contents of the configuration file.
+    """
+    
+    self.valsLock.acquire()
+    returnVal = list(self.contents) if self.contents else None
+    self.valsLock.release()
+    return returnVal
+  
+  def getDisplayContents(self, strip = False):
+    """
+    Provides the contents of the configuration file, formatted in a rendering
+    frindly fashion:
+    - Tabs print as three spaces. Keeping them as tabs is problematic for
+      layouts since it's counted as a single character, but occupies several
+      cells.
+    - Strips control and unprintable characters.
+    
+    Arguments:
+      strip - removes comments and extra whitespace if true
+    """
+    
+    self.valsLock.acquire()
+    
+    if not self.isLoaded(): returnVal = None
+    else:
+      if self.displayableContents == None:
+        # restricts contents to displayable characters
+        self.displayableContents = []
+        
+        for lineNum in range(len(self.contents)):
+          lineText = self.contents[lineNum]
+          lineText = lineText.replace("\t", "   ")
+          lineText = uiTools.getPrintable(lineText)
+          self.displayableContents.append(lineText)
+      
+      if strip:
+        if self.strippedContents == None:
+          self.strippedContents = _stripComments(self.displayableContents)
+        
+        returnVal = list(self.strippedContents)
+      else: returnVal = list(self.displayableContents)
+    
+    self.valsLock.release()
+    return returnVal
+  
+  def getCorrections(self):
+    """
+    Performs validation on the loaded contents and provides back the
+    corrections. If validation is disabled then this won't provide any
+    results.
+    """
+    
+    self.valsLock.acquire()
+    
+    if not self.isLoaded(): returnVal = None
+    else:
+      torVersion = torTools.getConn().getVersion()
+      skipValidation = not CONFIG["features.torrc.validate"]
+      skipValidation |= (torVersion is None or not torVersion >= stem.version.Requirement.GETINFO_CONFIG_TEXT)
+      
+      if skipValidation:
+        log.info("Skipping torrc validation (requires tor 0.2.2.7-alpha)")
+        returnVal = {}
+      else:
+        if self.corrections == None:
+          self.corrections = validate(self.contents)
+        
+        returnVal = list(self.corrections)
+    
+    self.valsLock.release()
+    return returnVal
+  
+  def getLock(self):
+    """
+    Provides the lock governing concurrent access to the contents.
+    """
+    
+    return self.valsLock
+  
+  def logValidationIssues(self):
+    """
+    Performs validation on the loaded contents, and logs warnings for issues
+    that are found.
+    """
+    
+    corrections = self.getCorrections()
+    
+    if corrections:
+      duplicateOptions, defaultOptions, mismatchLines, missingOptions = [], [], [], []
+      
+      for lineNum, issue, msg in corrections:
+        if issue == ValidationError.DUPLICATE:
+          duplicateOptions.append("%s (line %i)" % (msg, lineNum + 1))
+        elif issue == ValidationError.IS_DEFAULT:
+          defaultOptions.append("%s (line %i)" % (msg, lineNum + 1))
+        elif issue == ValidationError.MISMATCH: mismatchLines.append(lineNum + 1)
+        elif issue == ValidationError.MISSING: missingOptions.append(msg)
+      
+      if duplicateOptions or defaultOptions:
+        msg = "Unneeded torrc entries found. They've been highlighted in blue on the torrc page."
+        
+        if duplicateOptions:
+          if len(duplicateOptions) > 1:
+            msg += "\n- entries ignored due to having duplicates: "
+          else:
+            msg += "\n- entry ignored due to having a duplicate: "
+          
+          duplicateOptions.sort()
+          msg += ", ".join(duplicateOptions)
+        
+        if defaultOptions:
+          if len(defaultOptions) > 1:
+            msg += "\n- entries match their default values: "
+          else:
+            msg += "\n- entry matches its default value: "
+          
+          defaultOptions.sort()
+          msg += ", ".join(defaultOptions)
+        
+        log.notice(msg)
+      
+      if mismatchLines or missingOptions:
+        msg = "The torrc differs from what tor's using. You can issue a sighup to reload the torrc values by pressing x."
+        
+        if mismatchLines:
+          if len(mismatchLines) > 1:
+            msg += "\n- torrc values differ on lines: "
+          else:
+            msg += "\n- torrc value differs on line: "
+          
+          mismatchLines.sort()
+          msg += ", ".join([str(val + 1) for val in mismatchLines])
+          
+        if missingOptions:
+          if len(missingOptions) > 1:
+            msg += "\n- configuration values are missing from the torrc: "
+          else:
+            msg += "\n- configuration value is missing from the torrc: "
+          
+          missingOptions.sort()
+          msg += ", ".join(missingOptions)
+        
+        log.warn(msg)
+
+def _testConfigDescriptions():
+  """
+  Tester for the loadOptionDescriptions function, fetching the man page
+  contents and dumping its parsed results.
+  """
+  
+  loadOptionDescriptions()
+  sortedOptions = CONFIG_DESCRIPTIONS.keys()
+  sortedOptions.sort()
+  
+  for i in range(len(sortedOptions)):
+    option = sortedOptions[i]
+    argument, description = getConfigDescription(option)
+    optLabel = "OPTION: \"%s\"" % option
+    argLabel = "ARGUMENT: \"%s\"" % argument
+    
+    print "     %-45s %s" % (optLabel, argLabel)
+    print "\"%s\"" % description
+    if i != len(sortedOptions) - 1: print "-" * 80
+
+def isRootNeeded(torrcPath):
+  """
+  Returns True if the given torrc needs root permissions to be ran, False
+  otherwise. This raises an IOError if the torrc can't be read.
+  
+  Arguments:
+    torrcPath - torrc to be checked
+  """
+  
+  try:
+    torrcFile = open(torrcPath, "r")
+    torrcLines = torrcFile.readlines()
+    torrcFile.close()
+    
+    for line in torrcLines:
+      line = line.strip()
+      
+      isPortOpt = False
+      for opt in PORT_OPT:
+        if line.startswith(opt):
+          isPortOpt = True
+          break
+      
+      if isPortOpt and " " in line:
+        arg = line.split(" ")[1]
+        
+        if arg.isdigit() and int(arg) <= 1024 and int(arg) != 0:
+          return True
+    
+    return False
+  except Exception, exc:
+    raise IOError(exc)
+
+def renderTorrc(template, options, commentIndent = 30):
+  """
+  Uses the given template to generate a nicely formatted torrc with the given
+  options. The tempating language this recognizes is a simple one, recognizing
+  the following options:
+    [IF <option>]         # if <option> maps to true or a non-empty string
+    [IF NOT <option>]     # logical inverse
+    [IF <opt1> | <opt2>]  # logical or of the options
+    [ELSE]          # if the prior conditional evaluated to false
+    [END IF]        # ends the control block
+    
+    [<option>]      # inputs the option value, omitting the line if it maps
+                    # to a boolean or empty string
+    [NEWLINE]       # empty line, otherwise templating white space is ignored
+  
+  Arguments:
+    template      - torrc template lines used to generate the results
+    options       - mapping of keywords to their given values, with values
+                    being booleans or strings (possibly multi-line)
+    commentIndent - minimum column that comments align on
+  """
+  
+  results = []
+  templateIter = iter(template)
+  commentLineFormat = "%%-%is%%s" % commentIndent
+  
+  try:
+    while True:
+      line = templateIter.next().strip()
+      
+      if line.startswith("[IF ") and line.endswith("]"):
+        # checks if any of the conditional options are true or a non-empty string
+        evaluatesTrue = False
+        for cond in line[4:-1].split("|"):
+          isInverse = False
+          if cond.startswith("NOT "):
+            isInverse = True
+            cond = cond[4:]
+          
+          if isInverse != bool(options.get(cond.strip())):
+            evaluatesTrue = True
+            break
+        
+        if evaluatesTrue:
+          continue
+        else:
+          # skips lines until we come to an else or the end of the block
+          depth = 0
+          
+          while depth != -1:
+            line = templateIter.next().strip()
+            
+            if line.startswith("[IF ") and line.endswith("]"): depth += 1
+            elif line == "[END IF]": depth -= 1
+            elif depth == 0 and line == "[ELSE]": depth -= 1
+      elif line == "[ELSE]":
+        # an else block we aren't using - skip to the end of it
+        depth = 0
+        
+        while depth != -1:
+          line = templateIter.next().strip()
+          
+          if line.startswith("[IF "): depth += 1
+          elif line == "[END IF]": depth -= 1
+      elif line == "[NEWLINE]":
+        # explicit newline
+        results.append("")
+      elif line.startswith("#"):
+        # comment only
+        results.append(line)
+      elif line.startswith("[") and line.endswith("]"):
+        # completely dynamic entry
+        optValue = options.get(line[1:-1])
+        if optValue: results.append(optValue)
+      else:
+        # torrc option line
+        option, arg, comment = "", "", ""
+        parsedLine = line
+        
+        if "#" in parsedLine:
+          parsedLine, comment = parsedLine.split("#", 1)
+          parsedLine = parsedLine.strip()
+          comment = "# %s" % comment.strip()
+        
+        # parses the argument from the option
+        if " " in parsedLine.strip():
+          option, arg = parsedLine.split(" ", 1)
+          option = option.strip()
+        else:
+          log.info("torrc template option lacks an argument: '%s'" % line)
+          continue
+        
+        # inputs dynamic arguments
+        if arg.startswith("[") and arg.endswith("]"):
+          arg = options.get(arg[1:-1])
+        
+        # skips argument if it's false or an empty string
+        if not arg: continue
+        
+        torrcEntry = "%s %s" % (option, arg)
+        if comment: results.append(commentLineFormat % (torrcEntry + " ", comment))
+        else: results.append(torrcEntry)
+  except StopIteration: pass
+  
+  return "\n".join(results)
+
diff --git a/arm/util/torTools.py b/arm/util/torTools.py
new file mode 100644
index 0000000..21f94b4
--- /dev/null
+++ b/arm/util/torTools.py
@@ -0,0 +1,1020 @@
+"""
+Helper for working with an active tor process. This both provides a wrapper for
+accessing stem and notifications of state changes to subscribers.
+"""
+
+import os
+import time
+import math
+import threading
+
+import stem
+import stem.control
+import stem.descriptor
+import stem.util.system
+
+from util import connections
+
+from stem.util import conf, enum, log, proc, str_tools, system
+
+CONTROLLER = None # singleton Controller instance
+
+UNDEFINED = "<Undefined_ >"
+
+CONFIG = conf.config_dict("arm", {
+  "features.pathPrefix": "",
+})
+
+# events used for controller functionality:
+# NEWDESC, NS, and NEWCONSENSUS - used for cache invalidation
+REQ_EVENTS = {"NEWDESC": "information related to descriptors will grow stale",
+              "NS": "information related to the consensus will grow stale",
+              "NEWCONSENSUS": "information related to the consensus will grow stale"}
+
+def getConn():
+  """
+  Singleton constructor for a Controller. Be aware that this starts as being
+  uninitialized, needing a stem Controller before it's fully functional.
+  """
+  
+  global CONTROLLER
+  if CONTROLLER == None: CONTROLLER = Controller()
+  return CONTROLLER
+
+class Controller:
+  """
+  Stem wrapper providing convenience functions (mostly from the days of using
+  TorCtl), listener functionality for tor's state, and the capability for
+  controller connections to be restarted if closed.
+  """
+  
+  def __init__(self):
+    self.controller = None
+    self.connLock = threading.RLock()
+    self._fingerprintMappings = None    # mappings of ip -> [(port, fingerprint), ...]
+    self._fingerprintLookupCache = {}   # lookup cache with (ip, port) -> fingerprint mappings
+    self._nicknameLookupCache = {}      # lookup cache with fingerprint -> nickname mappings
+    self._addressLookupCache = {}       # lookup cache with fingerprint -> (ip address, or port) mappings
+    self._consensusLookupCache = {}     # lookup cache with network status entries
+    self._descriptorLookupCache = {}    # lookup cache with relay descriptors
+    self._lastNewnym = 0                # time we last sent a NEWNYM signal
+    
+    # Logs issues and notices when fetching the path prefix if true. This is
+    # only done once for the duration of the application to avoid pointless
+    # messages.
+    self._pathPrefixLogging = True
+  
+  def init(self, controller):
+    """
+    Uses the given stem instance for future operations, notifying listeners
+    about the change.
+    
+    Arguments:
+      controller - stem based Controller instance
+    """
+    
+    # TODO: We should reuse our controller instance so event listeners will be
+    # re-attached. This is a point of regression until we do... :(
+    
+    if controller.is_alive() and controller != self.controller:
+      self.connLock.acquire()
+      
+      if self.controller: self.close() # shut down current connection
+      self.controller = controller
+      log.info("Stem connected to tor version %s" % self.controller.get_version())
+      
+      self.controller.add_event_listener(self.ns_event, stem.control.EventType.NS)
+      self.controller.add_event_listener(self.new_consensus_event, stem.control.EventType.NEWCONSENSUS)
+      self.controller.add_event_listener(self.new_desc_event, stem.control.EventType.NEWDESC)
+      
+      # reset caches for ip -> fingerprint lookups
+      self._fingerprintMappings = None
+      self._fingerprintLookupCache = {}
+      self._nicknameLookupCache = {}
+      self._addressLookupCache = {}
+      self._consensusLookupCache = {}
+      self._descriptorLookupCache = {}
+      
+      # time that we sent our last newnym signal
+      self._lastNewnym = 0
+      
+      self.connLock.release()
+  
+  def close(self):
+    """
+    Closes the current stem instance and notifies listeners.
+    """
+    
+    self.connLock.acquire()
+    if self.controller:
+      self.controller.close()
+    self.connLock.release()
+  
+  def getController(self):
+    return self.controller
+
+  def isAlive(self):
+    """
+    Returns True if this has been initialized with a working stem instance,
+    False otherwise.
+    """
+    
+    self.connLock.acquire()
+    
+    result = False
+    if self.controller:
+      if self.controller.is_alive(): result = True
+      else: self.close()
+    
+    self.connLock.release()
+    return result
+  
+  def getInfo(self, param, default = UNDEFINED):
+    """
+    Queries the control port for the given GETINFO option, providing the
+    default if the response is undefined or fails for any reason (error
+    response, control port closed, initiated, etc).
+    
+    Arguments:
+      param   - GETINFO option to be queried
+      default - result if the query fails
+    """
+    
+    self.connLock.acquire()
+    
+    try:
+      if not self.isAlive():
+        if default != UNDEFINED:
+          return default
+        else:
+          raise stem.SocketClosed()
+      
+      if default != UNDEFINED:
+        return self.controller.get_info(param, default)
+      else:
+        return self.controller.get_info(param)
+    except stem.SocketClosed, exc:
+      self.close()
+      raise exc
+    finally:
+      self.connLock.release()
+  
+  def getOption(self, param, default = UNDEFINED, multiple = False):
+    """
+    Queries the control port for the given configuration option, providing the
+    default if the response is undefined or fails for any reason. If multiple
+    values exist then this arbitrarily returns the first unless the multiple
+    flag is set.
+    
+    Arguments:
+      param     - configuration option to be queried
+      default   - result if the query fails
+      multiple  - provides a list with all returned values if true, otherwise
+                  this just provides the first result
+    """
+    
+    self.connLock.acquire()
+    
+    try:
+      if not self.isAlive():
+        if default != UNDEFINED:
+          return default
+        else:
+          raise stem.SocketClosed()
+      
+      if default != UNDEFINED:
+        return self.controller.get_conf(param, default, multiple)
+      else:
+        return self.controller.get_conf(param, multiple = multiple)
+    except stem.SocketClosed, exc:
+      self.close()
+      raise exc
+    finally:
+      self.connLock.release()
+  
+  def setOption(self, param, value = None):
+    """
+    Issues a SETCONF to set the given option/value pair. An exeptions raised
+    if it fails to be set. If no value is provided then this sets the option to
+    0 or NULL.
+    
+    Arguments:
+      param - configuration option to be set
+      value - value to set the parameter to (this can be either a string or a
+              list of strings)
+    """
+    
+    self.connLock.acquire()
+    
+    try:
+      if not self.isAlive():
+        raise stem.SocketClosed()
+      
+      self.controller.set_conf(param, value)
+    except stem.SocketClosed, exc:
+      self.close()
+      raise exc
+    finally:
+      self.connLock.release()
+  
+  def saveConf(self):
+    """
+    Calls tor's SAVECONF method.
+    """
+    
+    self.connLock.acquire()
+    
+    if self.isAlive():
+      self.controller.save_conf()
+    
+    self.connLock.release()
+  
+  def sendNewnym(self):
+    """
+    Sends a newnym request to Tor. These are rate limited so if it occures
+    more than once within a ten second window then the second is delayed.
+    """
+    
+    self.connLock.acquire()
+    
+    if self.isAlive():
+      self._lastNewnym = time.time()
+      self.controller.signal(stem.Signal.NEWNYM)
+    
+    self.connLock.release()
+  
+  def isNewnymAvailable(self):
+    """
+    True if Tor will immediately respect a newnym request, false otherwise.
+    """
+    
+    if self.isAlive():
+      return self.getNewnymWait() == 0
+    else: return False
+  
+  def getNewnymWait(self):
+    """
+    Provides the number of seconds until a newnym signal would be respected.
+    """
+    
+    # newnym signals can occure at the rate of one every ten seconds
+    # TODO: this can't take other controllers into account :(
+    return max(0, math.ceil(self._lastNewnym + 10 - time.time()))
+  
+  def getCircuits(self, default = []):
+    """
+    This provides a list with tuples of the form:
+    (circuitID, status, purpose, (fingerprint1, fingerprint2...))
+    
+    Arguments:
+      default - value provided back if unable to query the circuit-status
+    """
+    
+    # TODO: We're losing caching around this. We should check to see the call
+    # volume of this and probably add it to stem.
+    
+    results = []
+    
+    for entry in self.controller.get_circuits():
+      fingerprints = []
+      
+      for fp, nickname in entry.path:
+        if not fp:
+          consensusEntry = self.controller.get_network_status(nickname, None)
+          
+          if consensusEntry:
+            fp = consensusEntry.fingerprint
+          
+          # It shouldn't be possible for this lookup to fail, but we
+          # need to fill something (callers won't expect our own client
+          # paths to have unknown relays). If this turns out to be wrong
+          # then log a warning.
+          
+          if not fp:
+            log.warn("Unable to determine the fingerprint for a relay in our own circuit: %s" % nickname)
+            fp = "0" * 40
+        
+        fingerprints.append(fp)
+      
+      results.append((int(entry.id), entry.status, entry.purpose, fingerprints))
+    
+    if results:
+      return results
+    else:
+      return default
+  
+  def getHiddenServicePorts(self, default = []):
+    """
+    Provides the target ports hidden services are configured to use.
+    
+    Arguments:
+      default - value provided back if unable to query the hidden service ports
+    """
+    
+    result = []
+    hs_options = self.controller.get_conf_map("HiddenServiceOptions", {})
+    
+    for entry in hs_options.get("HiddenServicePort", []):
+      # HiddenServicePort entries are of the form...
+      #
+      #   VIRTPORT [TARGET]
+      #
+      # ... with the TARGET being an address, port, or address:port. If the
+      # target port isn't defined then uses the VIRTPORT.
+      
+      hs_port = None
+      
+      if ' ' in entry:
+        virtport, target = entry.split(' ', 1)
+        
+        if ':' in target:
+          hs_port = target.split(':', 1)[1]  # target is an address:port
+        elif target.isdigit():
+          hs_port = target  # target is a port
+        else:
+          hs_port = virtport  # target is an address
+      else:
+        hs_port = entry  # just has the virtual port
+      
+      if hs_port.isdigit():
+        result.append(hsPort)
+    
+    if result:
+      return result
+    else:
+      return default
+  
+  def getMyBandwidthRate(self, default = None):
+    """
+    Provides the effective relaying bandwidth rate of this relay. Currently
+    this doesn't account for SETCONF events.
+    
+    Arguments:
+      default - result if the query fails
+    """
+    
+    # effective relayed bandwidth is the minimum of BandwidthRate,
+    # MaxAdvertisedBandwidth, and RelayBandwidthRate (if set)
+    effectiveRate = int(self.getOption("BandwidthRate", None))
+    
+    relayRate = self.getOption("RelayBandwidthRate", None)
+    if relayRate and relayRate != "0":
+      effectiveRate = min(effectiveRate, int(relayRate))
+    
+    maxAdvertised = self.getOption("MaxAdvertisedBandwidth", None)
+    if maxAdvertised: effectiveRate = min(effectiveRate, int(maxAdvertised))
+    
+    if effectiveRate is not None:
+      return effectiveRate
+    else:
+      return default
+  
+  def getMyBandwidthBurst(self, default = None):
+    """
+    Provides the effective bandwidth burst rate of this relay. Currently this
+    doesn't account for SETCONF events.
+    
+    Arguments:
+      default - result if the query fails
+    """
+    
+    # effective burst (same for BandwidthBurst and RelayBandwidthBurst)
+    effectiveBurst = int(self.getOption("BandwidthBurst", None))
+    
+    relayBurst = self.getOption("RelayBandwidthBurst", None)
+    
+    if relayBurst and relayBurst != "0":
+      effectiveBurst = min(effectiveBurst, int(relayBurst))
+    
+    if effectiveBurst is not None:
+      return effectiveBurst
+    else:
+      return default
+  
+  def getMyBandwidthObserved(self, default = None):
+    """
+    Provides the relay's current observed bandwidth (the throughput determined
+    from historical measurements on the client side). This is used in the
+    heuristic used for path selection if the measured bandwidth is undefined.
+    This is fetched from the descriptors and hence will get stale if
+    descriptors aren't periodically updated.
+    
+    Arguments:
+      default - result if the query fails
+    """
+    
+    myFingerprint = self.getInfo("fingerprint", None)
+    
+    if myFingerprint:
+      myDescriptor = self.controller.get_server_descriptor(myFingerprint)
+      
+      if myDescriptor:
+        result = myDescriptor.observed_bandwidth
+    
+    return default
+  
+  def getMyBandwidthMeasured(self, default = None):
+    """
+    Provides the relay's current measured bandwidth (the throughput as noted by
+    the directory authorities and used by clients for relay selection). This is
+    undefined if not in the consensus or with older versions of Tor. Depending
+    on the circumstances this can be from a variety of things (observed,
+    measured, weighted measured, etc) as described by:
+    https://trac.torproject.org/projects/tor/ticket/1566
+    
+    Arguments:
+      default - result if the query fails
+    """
+    
+    # TODO: Tor is documented as providing v2 router status entries but
+    # actually looks to be v3. This needs to be sorted out between stem
+    # and tor.
+    
+    myFingerprint = self.getInfo("fingerprint", None)
+    
+    if myFingerprint:
+      myStatusEntry = self.controller.get_network_status(myFingerprint)
+      
+      if myStatusEntry and hasattr(myStatusEntry, 'bandwidth'):
+        return myStatusEntry.bandwidth
+    
+    return default
+  
+  def getMyFlags(self, default = None):
+    """
+    Provides the flags held by this relay.
+    
+    Arguments:
+      default - result if the query fails or this relay isn't a part of the consensus yet
+    """
+    
+    myFingerprint = self.getInfo("fingerprint", None)
+    
+    if myFingerprint:
+      myStatusEntry = self.controller.get_network_status(myFingerprint)
+      
+      if myStatusEntry:
+        return myStatusEntry.flags
+
+    return default
+  
+  def getVersion(self):
+    """
+    Provides the version of our tor instance, this is None if we don't have a
+    connection.
+    """
+    
+    self.connLock.acquire()
+    
+    try:
+      return self.controller.get_version()
+    except stem.SocketClosed, exc:
+      self.close()
+      return None
+    except:
+      return None
+    finally:
+      self.connLock.release()
+  
+  def isGeoipUnavailable(self):
+    """
+    Provides true if we've concluded that our geoip database is unavailable,
+    false otherwise.
+    """
+    
+    if self.isAlive():
+      return self.controller.is_geoip_unavailable()
+    else:
+      return False
+  
+  def getMyUser(self):
+    """
+    Provides the user this process is running under. If unavailable this
+    provides None.
+    """
+    
+    return self.controller.get_user(None)
+  
+  def getMyFileDescriptorUsage(self):
+    """
+    Provides the number of file descriptors currently being used by this
+    process. This returns None if this can't be determined.
+    """
+    
+    # The file descriptor usage is the size of the '/proc/<pid>/fd' contents
+    # http://linuxshellaccount.blogspot.com/2008/06/finding-number-of-open-file-descriptors.html
+    # I'm not sure about other platforms (like BSD) so erroring out there.
+    
+    self.connLock.acquire()
+    
+    result = None
+    if self.isAlive() and proc.is_available():
+      myPid = self.controller.get_pid(None)
+      
+      if myPid:
+        try: result = len(os.listdir("/proc/%s/fd" % myPid))
+        except: pass
+    
+    self.connLock.release()
+    
+    return result
+  
+  def getMyFileDescriptorLimit(self):
+    """
+    Provides the maximum number of file descriptors this process can have.
+    Only the Tor process itself reliably knows this value, and the option for
+    getting this was added in Tor 0.2.3.x-final. If that's unavailable then
+    we can only estimate the file descriptor limit based on other factors.
+    
+    The return result is a tuple of the form:
+    (fileDescLimit, isEstimate)
+    and if all methods fail then both values are None.
+    """
+    
+    # provides -1 if the query fails
+    queriedLimit = self.getInfo("process/descriptor-limit", None)
+    
+    if queriedLimit != None and queriedLimit != "-1":
+      return (int(queriedLimit), False)
+    
+    torUser = self.getMyUser()
+    
+    # This is guessing the open file limit. Unfortunately there's no way
+    # (other than "/usr/proc/bin/pfiles pid | grep rlimit" under Solaris)
+    # to get the file descriptor limit for an arbitrary process.
+    
+    if torUser == "debian-tor":
+      # probably loaded via /etc/init.d/tor which changes descriptor limit
+      return (8192, True)
+    else:
+      # uses ulimit to estimate (-H is for hard limit, which is what tor uses)
+      ulimitResults = system.call("ulimit -Hn")
+      
+      if ulimitResults:
+        ulimit = ulimitResults[0].strip()
+        
+        if ulimit.isdigit():
+          return (int(ulimit), True)
+
+    return (None, None)
+  
+  def getPathPrefix(self):
+    """
+    Provides the path prefix that should be used for fetching tor resources.
+    If undefined and Tor is inside a jail under FreeBsd then this provides the
+    jail's path.
+    """
+    
+    # make sure the path prefix is valid and exists (providing a notice if not)
+    prefixPath = CONFIG["features.pathPrefix"].strip()
+    
+    if not prefixPath and os.uname()[0] == "FreeBSD":
+      prefixPath = system.get_bsd_jail_path(getConn().controller.get_pid(0))
+      
+      if prefixPath and self._pathPrefixLogging:
+        log.info("Adjusting paths to account for Tor running in a jail at: %s" % prefixPath)
+    
+    if prefixPath:
+      # strips off ending slash from the path
+      if prefixPath.endswith("/"): prefixPath = prefixPath[:-1]
+      
+      # avoid using paths that don't exist
+      if self._pathPrefixLogging and prefixPath and not os.path.exists(prefixPath):
+        log.notice("The prefix path set in your config (%s) doesn't exist." % prefixPath)
+        prefixPath = ""
+    
+    self._pathPrefixLogging = False # prevents logging if fetched again
+    return prefixPath
+  
+  def getStartTime(self):
+    """
+    Provides the unix time for when the tor process first started. If this
+    can't be determined then this provides None.
+    """
+    
+    try:
+      return system.get_start_time(self.controller.get_pid())
+    except:
+      return None
+  
+  def isExitingAllowed(self, ipAddress, port):
+    """
+    Checks if the given destination can be exited to by this relay, returning
+    True if so and False otherwise.
+    """
+    
+    self.connLock.acquire()
+    
+    result = False
+    if self.isAlive():
+      # If we allow any exiting then this could be relayed DNS queries,
+      # otherwise the policy is checked. Tor still makes DNS connections to
+      # test when exiting isn't allowed, but nothing is relayed over them.
+      # I'm registering these as non-exiting to avoid likely user confusion:
+      # https://trac.torproject.org/projects/tor/ticket/965
+      
+      our_policy = self.getExitPolicy()
+      
+      if our_policy and our_policy.is_exiting_allowed() and port == "53": result = True
+      else: result = our_policy and our_policy.can_exit_to(ipAddress, port)
+    
+    self.connLock.release()
+    
+    return result
+  
+  def getExitPolicy(self):
+    """
+    Provides an ExitPolicy instance for the head of this relay's exit policy
+    chain. If there's no active connection then this provides None.
+    """
+    
+    self.connLock.acquire()
+    
+    result = None
+    if self.isAlive():
+      try:
+        result = self.controller.get_exit_policy(param)
+      except:
+        pass
+    
+    self.connLock.release()
+    
+    return result
+  
+  def getConsensusEntry(self, relayFingerprint):
+    """
+    Provides the most recently available consensus information for the given
+    relay. This is none if no such information exists.
+    
+    Arguments:
+      relayFingerprint - fingerprint of the relay
+    """
+    
+    self.connLock.acquire()
+    
+    result = None
+    if self.isAlive():
+      if not relayFingerprint in self._consensusLookupCache:
+        nsEntry = self.getInfo("ns/id/%s" % relayFingerprint, None)
+        self._consensusLookupCache[relayFingerprint] = nsEntry
+      
+      result = self._consensusLookupCache[relayFingerprint]
+    
+    self.connLock.release()
+    
+    return result
+  
+  def getDescriptorEntry(self, relayFingerprint):
+    """
+    Provides the most recently available descriptor information for the given
+    relay. Unless FetchUselessDescriptors is set this may frequently be
+    unavailable. If no such descriptor is available then this returns None.
+    
+    Arguments:
+      relayFingerprint - fingerprint of the relay
+    """
+    
+    self.connLock.acquire()
+    
+    result = None
+    if self.isAlive():
+      if not relayFingerprint in self._descriptorLookupCache:
+        descEntry = self.getInfo("desc/id/%s" % relayFingerprint, None)
+        self._descriptorLookupCache[relayFingerprint] = descEntry
+      
+      result = self._descriptorLookupCache[relayFingerprint]
+    
+    self.connLock.release()
+    
+    return result
+  
+  def getRelayFingerprint(self, relayAddress, relayPort = None, getAllMatches = False):
+    """
+    Provides the fingerprint associated with the given address. If there's
+    multiple potential matches or the mapping is unknown then this returns
+    None. This disambiguates the fingerprint if there's multiple relays on
+    the same ip address by several methods, one of them being to pick relays
+    we have a connection with.
+    
+    Arguments:
+      relayAddress  - address of relay to be returned
+      relayPort     - orport of relay (to further narrow the results)
+      getAllMatches - ignores the relayPort and provides all of the
+                      (port, fingerprint) tuples matching the given
+                      address
+    """
+    
+    self.connLock.acquire()
+    
+    result = None
+    if self.isAlive():
+      if getAllMatches:
+        # populates the ip -> fingerprint mappings if not yet available
+        if self._fingerprintMappings == None:
+          self._fingerprintMappings = self._getFingerprintMappings()
+        
+        if relayAddress in self._fingerprintMappings:
+          result = self._fingerprintMappings[relayAddress]
+        else: result = []
+      else:
+        # query the fingerprint if it isn't yet cached
+        if not (relayAddress, relayPort) in self._fingerprintLookupCache:
+          relayFingerprint = self._getRelayFingerprint(relayAddress, relayPort)
+          self._fingerprintLookupCache[(relayAddress, relayPort)] = relayFingerprint
+        
+        result = self._fingerprintLookupCache[(relayAddress, relayPort)]
+    
+    self.connLock.release()
+    
+    return result
+  
+  def getRelayNickname(self, relayFingerprint):
+    """
+    Provides the nickname associated with the given relay. This provides None
+    if no such relay exists, and "Unnamed" if the name hasn't been set.
+    
+    Arguments:
+      relayFingerprint - fingerprint of the relay
+    """
+    
+    self.connLock.acquire()
+    
+    result = None
+    if self.isAlive():
+      # query the nickname if it isn't yet cached
+      if not relayFingerprint in self._nicknameLookupCache:
+        if relayFingerprint == self.getInfo("fingerprint", None):
+          # this is us, simply check the config
+          myNickname = self.getOption("Nickname", "Unnamed")
+          self._nicknameLookupCache[relayFingerprint] = myNickname
+        else:
+          nsEntry = self.controller.get_network_status(relayFingerprint, None)
+          
+          if nsEntry:
+            self._nicknameLookupCache[relayFingerprint] = nsEntry.nickname
+      
+      result = self._nicknameLookupCache[relayFingerprint]
+    
+    self.connLock.release()
+    
+    return result
+  
+  def getRelayExitPolicy(self, relayFingerprint):
+    """
+    Provides the ExitPolicy instance associated with the given relay. The tor
+    consensus entries don't indicate if private addresses are rejected or
+    address-specific policies, so this is only used as a fallback if a recent
+    descriptor is unavailable. This returns None if unable to determine the
+    policy.
+    
+    Arguments:
+      relayFingerprint - fingerprint of the relay
+    """
+    
+    self.connLock.acquire()
+    
+    result = None
+    if self.isAlive():
+      # attempts to fetch the policy via the descriptor
+      descriptor = self.controller.get_server_descriptor(relayFingerprint, None)
+      
+      if descriptor:
+        result = descriptor.exit_policy
+    
+    self.connLock.release()
+    
+    return result
+  
+  def getRelayAddress(self, relayFingerprint, default = None):
+    """
+    Provides the (IP Address, ORPort) tuple for a given relay. If the lookup
+    fails then this returns the default.
+    
+    Arguments:
+      relayFingerprint - fingerprint of the relay
+    """
+    
+    self.connLock.acquire()
+    
+    result = default
+    if self.isAlive():
+      # query the address if it isn't yet cached
+      if not relayFingerprint in self._addressLookupCache:
+        if relayFingerprint == self.getInfo("fingerprint", None):
+          # this is us, simply check the config
+          myAddress = self.getInfo("address", None)
+          myOrPort = self.getOption("ORPort", None)
+          
+          if myAddress and myOrPort:
+            self._addressLookupCache[relayFingerprint] = (myAddress, myOrPort)
+        else:
+          # check the consensus for the relay
+          nsEntry = self.getConsensusEntry(relayFingerprint)
+          
+          if nsEntry:
+            nsLineComp = nsEntry.split("\n")[0].split(" ")
+            
+            if len(nsLineComp) >= 8:
+              self._addressLookupCache[relayFingerprint] = (nsLineComp[6], nsLineComp[7])
+      
+      result = self._addressLookupCache.get(relayFingerprint, default)
+    
+    self.connLock.release()
+    
+    return result
+  
+  def addEventListener(self, listener, *eventTypes):
+    """
+    Directs further tor controller events to callback functions of the
+    listener. If a new control connection is initialized then this listener is
+    reattached.
+    """
+    
+    self.connLock.acquire()
+    if self.isAlive(): self.controller.add_event_listener(listener, *eventTypes)
+    self.connLock.release()
+  
+  def removeEventListener(self, listener):
+    """
+    Stops the given event listener from being notified of further events.
+    """
+    
+    self.connLock.acquire()
+    if self.isAlive(): self.controller.remove_event_listener(listener)
+    self.connLock.release()
+  
+  def addStatusListener(self, callback):
+    """
+    Directs further events related to tor's controller status to the callback
+    function.
+    
+    Arguments:
+      callback - functor that'll accept the events, expected to be of the form:
+                 myFunction(controller, eventType)
+    """
+    
+    self.controller.add_status_listener(callback)
+  
+  def reload(self):
+    """
+    This resets tor (sending a RELOAD signal to the control port) causing tor's
+    internal state to be reset and the torrc reloaded.
+    """
+    
+    self.connLock.acquire()
+    
+    try:
+      if self.isAlive():
+        try:
+          self.controller.signal(stem.Signal.RELOAD)
+        except Exception, exc:
+          # new torrc parameters caused an error (tor's likely shut down)
+          raise IOError(str(exc))
+    finally:
+      self.connLock.release()
+  
+  def shutdown(self, force = False):
+    """
+    Sends a shutdown signal to the attached tor instance. For relays the
+    actual shutdown is delayed for thirty seconds unless the force flag is
+    given. This raises an IOError if a signal is sent but fails.
+    
+    Arguments:
+      force - triggers an immediate shutdown for relays if True
+    """
+    
+    self.connLock.acquire()
+    
+    raisedException = None
+    if self.isAlive():
+      try:
+        isRelay = self.getOption("ORPort", None) != None
+        
+        if force:
+          self.controller.signal(stem.Signal.HALT)
+        else:
+          self.controller.signal(stem.Signal.SHUTDOWN)
+        
+        # shuts down control connection if we aren't making a delayed shutdown
+        if force or not isRelay: self.close()
+      except Exception, exc:
+        raisedException = IOError(str(exc))
+    
+    self.connLock.release()
+    
+    if raisedException: raise raisedException
+  
+  def ns_event(self, event):
+    self._consensusLookupCache = {}
+  
+  def new_consensus_event(self, event):
+    self.connLock.acquire()
+    
+    # reconstructs consensus based mappings
+    self._fingerprintLookupCache = {}
+    self._nicknameLookupCache = {}
+    self._addressLookupCache = {}
+    self._consensusLookupCache = {}
+    
+    if self._fingerprintMappings != None:
+      self._fingerprintMappings = self._getFingerprintMappings(event.desc)
+    
+    self.connLock.release()
+  
+  def new_desc_event(self, event):
+    self.connLock.acquire()
+    
+    myFingerprint = self.getInfo("fingerprint", None)
+    desc_fingerprints = [fingerprint for (fingerprint, nickname) in event.relays]
+    
+    # If we're tracking ip address -> fingerprint mappings then update with
+    # the new relays.
+    self._fingerprintLookupCache = {}
+    self._descriptorLookupCache = {}
+    
+    if self._fingerprintMappings != None:
+      for fingerprint in desc_fingerprints:
+        # gets consensus data for the new descriptor
+        try: desc = self.controller.get_network_status(fingerprint)
+        except stem.ControllerError: continue
+        
+        # updates fingerprintMappings with new data
+        if desc.address in self._fingerprintMappings:
+          # if entry already exists with the same orport, remove it
+          orportMatch = None
+          for entryPort, entryFingerprint in self._fingerprintMappings[desc.address]:
+            if entryPort == desc.or_port:
+              orportMatch = (entryPort, entryFingerprint)
+              break
+          
+          if orportMatch: self._fingerprintMappings[desc.address].remove(orportMatch)
+          
+          # add the new entry
+          self._fingerprintMappings[desc.address].append((desc.or_port, desc.fingerprint))
+        else:
+          self._fingerprintMappings[desc.address] = [(desc.or_port, desc.fingerprint)]
+    
+    self.connLock.release()
+  
+  def _getFingerprintMappings(self, descriptors = None):
+    """
+    Provides IP address to (port, fingerprint) tuple mappings for all of the
+    currently cached relays.
+    
+    Arguments:
+      descriptors - router status entries (fetched if not provided)
+    """
+    
+    results = {}
+    if self.isAlive():
+      # fetch the current network status if not provided
+      if not descriptors:
+        try: descriptors = self.controller.get_network_statuses()
+        except stem.ControllerError: descriptors = []
+      
+      # construct mappings of ips to relay data
+      for desc in descriptors:
+        results.setdefault(desc.address, []).append((desc.or_port, desc.fingerprint))
+    
+    return results
+  
+  def _getRelayFingerprint(self, relayAddress, relayPort):
+    """
+    Provides the fingerprint associated with the address/port combination.
+    
+    Arguments:
+      relayAddress - address of relay to be returned
+      relayPort    - orport of relay (to further narrow the results)
+    """
+    
+    # If we were provided with a string port then convert to an int (so
+    # lookups won't mismatch based on type).
+    if isinstance(relayPort, str): relayPort = int(relayPort)
+    
+    # checks if this matches us
+    if relayAddress == self.getInfo("address", None):
+      if not relayPort or relayPort == self.getOption("ORPort", None):
+        return self.getInfo("fingerprint", None)
+    
+    # if we haven't yet populated the ip -> fingerprint mappings then do so
+    if self._fingerprintMappings == None:
+      self._fingerprintMappings = self._getFingerprintMappings()
+    
+    potentialMatches = self._fingerprintMappings.get(relayAddress)
+    if not potentialMatches: return None # no relay matches this ip address
+    
+    if len(potentialMatches) == 1:
+      # There's only one relay belonging to this ip address. If the port
+      # matches then we're done.
+      match = potentialMatches[0]
+      
+      if relayPort and match[0] != relayPort: return None
+      else: return match[1]
+    elif relayPort:
+      # Multiple potential matches, so trying to match based on the port.
+      for entryPort, entryFingerprint in potentialMatches:
+        if entryPort == relayPort:
+          return entryFingerprint
+    
+    return None
+
diff --git a/arm/util/uiTools.py b/arm/util/uiTools.py
new file mode 100644
index 0000000..2aac55a
--- /dev/null
+++ b/arm/util/uiTools.py
@@ -0,0 +1,541 @@
+"""
+Toolkit for common ui tasks when working with curses. This provides a quick and
+easy method of providing the following interface components:
+- preinitialized curses color attributes
+- unit conversion for labels
+"""
+
+import os
+import sys
+import curses
+
+from curses.ascii import isprint
+
+from stem.util import conf, enum, log, system
+
+# colors curses can handle
+COLOR_LIST = {"red": curses.COLOR_RED,        "green": curses.COLOR_GREEN,
+              "yellow": curses.COLOR_YELLOW,  "blue": curses.COLOR_BLUE,
+              "cyan": curses.COLOR_CYAN,      "magenta": curses.COLOR_MAGENTA,
+              "black": curses.COLOR_BLACK,    "white": curses.COLOR_WHITE}
+
+# boolean for if we have color support enabled, None not yet determined
+COLOR_IS_SUPPORTED = None
+
+# mappings for getColor() - this uses the default terminal color scheme if
+# color support is unavailable
+COLOR_ATTR_INITIALIZED = False
+COLOR_ATTR = dict([(color, 0) for color in COLOR_LIST])
+
+Ending = enum.Enum("ELLIPSE", "HYPHEN")
+SCROLL_KEYS = (curses.KEY_UP, curses.KEY_DOWN, curses.KEY_PPAGE, curses.KEY_NPAGE, curses.KEY_HOME, curses.KEY_END)
+
+def conf_handler(key, value):
+  if key == "features.colorOverride" and value != "none":
+    try: setColorOverride(value)
+    except ValueError, exc:
+      log.notice(exc)
+
+CONFIG = conf.config_dict("arm", {
+  "features.colorOverride": "none",
+  "features.colorInterface": True,
+  "features.acsSupport": True,
+  "features.printUnicode": True,
+}, conf_handler)
+
+# Flag indicating if unicode is supported by curses. If None then this has yet
+# to be determined.
+IS_UNICODE_SUPPORTED = None
+
+def demoGlyphs():
+  """
+  Displays all ACS options with their corresponding representation. These are
+  undocumented in the pydocs. For more information see the following man page:
+  http://www.mkssoftware.com/docs/man5/terminfo.5.asp
+  """
+  
+  try: curses.wrapper(_showGlyphs)
+  except KeyboardInterrupt: pass # quit
+
+def _showGlyphs(stdscr):
+  """
+  Renders a chart with the ACS glyphs.
+  """
+  
+  # allows things like semi-transparent backgrounds
+  try: curses.use_default_colors()
+  except curses.error: pass
+  
+  # attempts to make the cursor invisible
+  try: curses.curs_set(0)
+  except curses.error: pass
+  
+  acsOptions = [item for item in curses.__dict__.items() if item[0].startswith("ACS_")]
+  acsOptions.sort(key=lambda i: (i[1])) # order by character codes
+  
+  # displays a chart with all the glyphs and their representations
+  height, width = stdscr.getmaxyx()
+  if width < 30: return # not enough room to show a column
+  columns = width / 30
+  
+  # display title
+  stdscr.addstr(0, 0, "Curses Glyphs:", curses.A_STANDOUT)
+  
+  x, y = 0, 1
+  while acsOptions:
+    name, keycode = acsOptions.pop(0)
+    stdscr.addstr(y, x * 30, "%s (%i)" % (name, keycode))
+    stdscr.addch(y, (x * 30) + 25, keycode)
+    
+    x += 1
+    if x >= columns:
+      x, y = 0, y + 1
+      if y >= height: break
+  
+  stdscr.getch() # quit on keyboard input
+
+def isUnicodeAvailable():
+  """
+  True if curses has wide character support, false otherwise or if it can't be
+  determined.
+  """
+  
+  global IS_UNICODE_SUPPORTED
+  if IS_UNICODE_SUPPORTED == None:
+    if CONFIG["features.printUnicode"]:
+      # Checks if our LANG variable is unicode. This is what will be respected
+      # when printing multi-byte characters after calling...
+      # locale.setlocale(locale.LC_ALL, '')
+      # 
+      # so if the LANG isn't unicode then setting this would be pointless.
+      
+      isLangUnicode = "utf-" in os.environ.get("LANG", "").lower()
+      IS_UNICODE_SUPPORTED = isLangUnicode and _isWideCharactersAvailable()
+    else: IS_UNICODE_SUPPORTED = False
+  
+  return IS_UNICODE_SUPPORTED
+
+def getPrintable(line, keepNewlines = True):
+  """
+  Provides the line back with non-printable characters stripped.
+  
+  Arguments:
+    line          - string to be processed
+    stripNewlines - retains newlines if true, stripped otherwise
+  """
+  
+  line = line.replace('\xc2', "'")
+  line = "".join([char for char in line if (isprint(char) or (keepNewlines and char == "\n"))])
+  return line
+
+def isColorSupported():
+  """
+  True if the display supports showing color, false otherwise.
+  """
+  
+  if COLOR_IS_SUPPORTED == None: _initColors()
+  return COLOR_IS_SUPPORTED
+
+def getColor(color):
+  """
+  Provides attribute corresponding to a given text color. Supported colors
+  include:
+  red       green     yellow    blue
+  cyan      magenta   black     white
+  
+  If color support isn't available or colors can't be initialized then this uses the 
+  terminal's default coloring scheme.
+  
+  Arguments:
+    color - name of the foreground color to be returned
+  """
+  
+  colorOverride = getColorOverride()
+  if colorOverride: color = colorOverride
+  if not COLOR_ATTR_INITIALIZED: _initColors()
+  return COLOR_ATTR[color]
+
+def setColorOverride(color = None):
+  """
+  Overwrites all requests for color with the given color instead. This raises
+  a ValueError if the color is invalid.
+  
+  Arguments:
+    color - name of the color to overwrite requests with, None to use normal
+            coloring
+  """
+  
+  if color == None:
+    CONFIG["features.colorOverride"] = "none"
+  elif color in COLOR_LIST.keys():
+    CONFIG["features.colorOverride"] = color
+  else: raise ValueError("\"%s\" isn't a valid color" % color)
+
+def getColorOverride():
+  """
+  Provides the override color used by the interface, None if it isn't set.
+  """
+  
+  colorOverride = CONFIG.get("features.colorOverride", "none")
+  if colorOverride == "none": return None
+  else: return colorOverride
+
+def cropStr(msg, size, minWordLen = 4, minCrop = 0, endType = Ending.ELLIPSE, getRemainder = False):
+  """
+  Provides the msg constrained to the given length, truncating on word breaks.
+  If the last words is long this truncates mid-word with an ellipse. If there
+  isn't room for even a truncated single word (or one word plus the ellipse if
+  including those) then this provides an empty string. If a cropped string ends
+  with a comma or period then it's stripped (unless we're providing the
+  remainder back). Examples:
+  
+  cropStr("This is a looooong message", 17)
+  "This is a looo..."
+  
+  cropStr("This is a looooong message", 12)
+  "This is a..."
+  
+  cropStr("This is a looooong message", 3)
+  ""
+  
+  Arguments:
+    msg          - source text
+    size         - room available for text
+    minWordLen   - minimum characters before which a word is dropped, requires
+                   whole word if None
+    minCrop      - minimum characters that must be dropped if a word's cropped
+    endType      - type of ending used when truncating:
+                   None - blank ending
+                   Ending.ELLIPSE - includes an ellipse
+                   Ending.HYPHEN - adds hyphen when breaking words
+    getRemainder - returns a tuple instead, with the second part being the
+                   cropped portion of the message
+  """
+  
+  # checks if there's room for the whole message
+  if len(msg) <= size:
+    if getRemainder: return (msg, "")
+    else: return msg
+  
+  # avoids negative input
+  size = max(0, size)
+  if minWordLen != None: minWordLen = max(0, minWordLen)
+  minCrop = max(0, minCrop)
+  
+  # since we're cropping, the effective space available is less with an
+  # ellipse, and cropping words requires an extra space for hyphens
+  if endType == Ending.ELLIPSE: size -= 3
+  elif endType == Ending.HYPHEN and minWordLen != None: minWordLen += 1
+  
+  # checks if there isn't the minimum space needed to include anything
+  lastWordbreak = msg.rfind(" ", 0, size + 1)
+  
+  if lastWordbreak == -1:
+    # we're splitting the first word
+    if minWordLen == None or size < minWordLen:
+      if getRemainder: return ("", msg)
+      else: return ""
+    
+    includeCrop = True
+  else:
+    lastWordbreak = len(msg[:lastWordbreak].rstrip()) # drops extra ending whitespaces
+    if (minWordLen != None and size < minWordLen) or (minWordLen == None and lastWordbreak < 1):
+      if getRemainder: return ("", msg)
+      else: return ""
+    
+    if minWordLen == None: minWordLen = sys.maxint
+    includeCrop = size - lastWordbreak - 1 >= minWordLen
+  
+  # if there's a max crop size then make sure we're cropping at least that many characters
+  if includeCrop and minCrop:
+    nextWordbreak = msg.find(" ", size)
+    if nextWordbreak == -1: nextWordbreak = len(msg)
+    includeCrop = nextWordbreak - size + 1 >= minCrop
+  
+  if includeCrop:
+    returnMsg, remainder = msg[:size], msg[size:]
+    if endType == Ending.HYPHEN:
+      remainder = returnMsg[-1] + remainder
+      returnMsg = returnMsg[:-1].rstrip() + "-"
+  else: returnMsg, remainder = msg[:lastWordbreak], msg[lastWordbreak:]
+  
+  # if this is ending with a comma or period then strip it off
+  if not getRemainder and returnMsg and returnMsg[-1] in (",", "."):
+    returnMsg = returnMsg[:-1]
+  
+  if endType == Ending.ELLIPSE:
+    returnMsg = returnMsg.rstrip() + "..."
+  
+  if getRemainder: return (returnMsg, remainder)
+  else: return returnMsg
+
+def padStr(msg, size, cropExtra = False):
+  """
+  Provides the string padded with whitespace to the given length.
+  
+  Arguments:
+    msg       - string to be padded
+    size      - length to be padded to
+    cropExtra - crops string if it's longer than the size if true
+  """
+  
+  if cropExtra: msg = msg[:size]
+  return ("%%-%is" % size) % msg
+
+def drawBox(panel, top, left, width, height, attr=curses.A_NORMAL):
+  """
+  Draws a box in the panel with the given bounds.
+  
+  Arguments:
+    panel  - panel in which to draw
+    top    - vertical position of the box's top
+    left   - horizontal position of the box's left side
+    width  - width of the drawn box
+    height - height of the drawn box
+    attr   - text attributes
+  """
+  
+  # draws the top and bottom
+  panel.hline(top, left + 1, width - 2, attr)
+  panel.hline(top + height - 1, left + 1, width - 2, attr)
+  
+  # draws the left and right sides
+  panel.vline(top + 1, left, height - 2, attr)
+  panel.vline(top + 1, left + width - 1, height - 2, attr)
+  
+  # draws the corners
+  panel.addch(top, left, curses.ACS_ULCORNER, attr)
+  panel.addch(top, left + width - 1, curses.ACS_URCORNER, attr)
+  panel.addch(top + height - 1, left, curses.ACS_LLCORNER, attr)
+
+def isSelectionKey(key):
+  """
+  Returns true if the keycode matches the enter or space keys.
+  
+  Argument:
+    key - keycode to be checked
+  """
+  
+  return key in (curses.KEY_ENTER, 10, ord(' '))
+
+def isScrollKey(key):
+  """
+  Returns true if the keycode is recognized by the getScrollPosition function
+  for scrolling.
+  
+  Argument:
+    key - keycode to be checked
+  """
+  
+  return key in SCROLL_KEYS
+
+def getScrollPosition(key, position, pageHeight, contentHeight, isCursor = False):
+  """
+  Parses navigation keys, providing the new scroll possition the panel should
+  use. Position is always between zero and (contentHeight - pageHeight). This
+  handles the following keys:
+  Up / Down - scrolls a position up or down
+  Page Up / Page Down - scrolls by the pageHeight
+  Home - top of the content
+  End - bottom of the content
+  
+  This provides the input position if the key doesn't correspond to the above.
+  
+  Arguments:
+    key           - keycode for the user's input
+    position      - starting position
+    pageHeight    - size of a single screen's worth of content
+    contentHeight - total lines of content that can be scrolled
+    isCursor      - tracks a cursor position rather than scroll if true
+  """
+  
+  if isScrollKey(key):
+    shift = 0
+    if key == curses.KEY_UP: shift = -1
+    elif key == curses.KEY_DOWN: shift = 1
+    elif key == curses.KEY_PPAGE: shift = -pageHeight + 1 if isCursor else -pageHeight
+    elif key == curses.KEY_NPAGE: shift = pageHeight - 1 if isCursor else pageHeight
+    elif key == curses.KEY_HOME: shift = -contentHeight
+    elif key == curses.KEY_END: shift = contentHeight
+    
+    # returns the shift, restricted to valid bounds
+    maxLoc = contentHeight - 1 if isCursor else contentHeight - pageHeight
+    return max(0, min(position + shift, maxLoc))
+  else: return position
+
+class Scroller:
+  """
+  Tracks the scrolling position when there might be a visible cursor. This
+  expects that there is a single line displayed per an entry in the contents.
+  """
+  
+  def __init__(self, isCursorEnabled):
+    self.scrollLoc, self.cursorLoc = 0, 0
+    self.cursorSelection = None
+    self.isCursorEnabled = isCursorEnabled
+  
+  def getScrollLoc(self, content, pageHeight):
+    """
+    Provides the scrolling location, taking into account its cursor's location
+    content size, and page height.
+    
+    Arguments:
+      content    - displayed content
+      pageHeight - height of the display area for the content
+    """
+    
+    if content and pageHeight:
+      self.scrollLoc = max(0, min(self.scrollLoc, len(content) - pageHeight + 1))
+      
+      if self.isCursorEnabled:
+        self.getCursorSelection(content) # resets the cursor location
+        
+        # makes sure the cursor is visible
+        if self.cursorLoc < self.scrollLoc:
+          self.scrollLoc = self.cursorLoc
+        elif self.cursorLoc > self.scrollLoc + pageHeight - 1:
+          self.scrollLoc = self.cursorLoc - pageHeight + 1
+      
+      # checks if the bottom would run off the content (this could be the
+      # case when the content's size is dynamic and entries are removed)
+      if len(content) > pageHeight:
+        self.scrollLoc = min(self.scrollLoc, len(content) - pageHeight)
+    
+    return self.scrollLoc
+  
+  def getCursorSelection(self, content):
+    """
+    Provides the selected item in the content. This is the same entry until
+    the cursor moves or it's no longer available (in which case it moves on to
+    the next entry).
+    
+    Arguments:
+      content - displayed content
+    """
+    
+    # TODO: needs to handle duplicate entries when using this for the
+    # connection panel
+    
+    if not self.isCursorEnabled: return None
+    elif not content:
+      self.cursorLoc, self.cursorSelection = 0, None
+      return None
+    
+    self.cursorLoc = min(self.cursorLoc, len(content) - 1)
+    if self.cursorSelection != None and self.cursorSelection in content:
+      # moves cursor location to track the selection
+      self.cursorLoc = content.index(self.cursorSelection)
+    else:
+      # select the next closest entry
+      self.cursorSelection = content[self.cursorLoc]
+    
+    return self.cursorSelection
+  
+  def handleKey(self, key, content, pageHeight):
+    """
+    Moves either the scroll or cursor according to the given input.
+    
+    Arguments:
+      key        - key code of user input
+      content    - displayed content
+      pageHeight - height of the display area for the content
+    """
+    
+    if self.isCursorEnabled:
+      self.getCursorSelection(content) # resets the cursor location
+      startLoc = self.cursorLoc
+    else: startLoc = self.scrollLoc
+    
+    newLoc = getScrollPosition(key, startLoc, pageHeight, len(content), self.isCursorEnabled)
+    if startLoc != newLoc:
+      if self.isCursorEnabled: self.cursorSelection = content[newLoc]
+      else: self.scrollLoc = newLoc
+      return True
+    else: return False
+
+def _isWideCharactersAvailable():
+  """
+  True if curses has wide character support (which is required to print
+  unicode). False otherwise.
+  """
+  
+  try:
+    # gets the dynamic library used by the interpretor for curses
+    
+    import _curses
+    cursesLib = _curses.__file__
+    
+    # Uses 'ldd' (Linux) or 'otool -L' (Mac) to determine the curses
+    # library dependencies.
+    # 
+    # atagar at fenrir:~/Desktop$ ldd /usr/lib/python2.6/lib-dynload/_curses.so
+    #   linux-gate.so.1 =>  (0x00a51000)
+    #   libncursesw.so.5 => /lib/libncursesw.so.5 (0x00faa000)
+    #   libpthread.so.0 => /lib/tls/i686/cmov/libpthread.so.0 (0x002f1000)
+    #   libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0x00158000)
+    #   libdl.so.2 => /lib/tls/i686/cmov/libdl.so.2 (0x00398000)
+    #   /lib/ld-linux.so.2 (0x00ca8000)
+    # 
+    # atagar$ otool -L /System/Library/Frameworks/Python.framework/Versions/2.5/lib/python2.5/lib-dynload/_curses.so
+    # /System/Library/Frameworks/Python.framework/Versions/2.5/lib/python2.5/lib-dynload/_curses.so:
+    #   /usr/lib/libncurses.5.4.dylib (compatibility version 5.4.0, current version 5.4.0)
+    #   /usr/lib/libgcc_s.1.dylib (compatibility version 1.0.0, current version 1.0.0)
+    #   /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 111.1.6)
+    
+    libDependencyLines = None
+    if system.is_available("ldd"):
+      libDependencyLines = system.call("ldd %s" % cursesLib)
+    elif system.is_available("otool"):
+      libDependencyLines = system.call("otool -L %s" % cursesLib)
+    
+    if libDependencyLines:
+      for line in libDependencyLines:
+        if "libncursesw" in line: return True
+  except: pass
+  
+  return False
+
+def _initColors():
+  """
+  Initializes color mappings usable by curses. This can only be done after
+  calling curses.initscr().
+  """
+  
+  global COLOR_ATTR_INITIALIZED, COLOR_IS_SUPPORTED
+  if not COLOR_ATTR_INITIALIZED:
+    # hack to replace all ACS characters with '+' if ACS support has been
+    # manually disabled
+    if not CONFIG["features.acsSupport"]:
+      for item in curses.__dict__:
+        if item.startswith("ACS_"):
+          curses.__dict__[item] = ord('+')
+      
+      # replace a few common border pipes that are better rendered as '|' or
+      # '-' instead
+      
+      curses.ACS_SBSB = ord('|')
+      curses.ACS_VLINE = ord('|')
+      curses.ACS_BSBS = ord('-')
+      curses.ACS_HLINE = ord('-')
+    
+    COLOR_ATTR_INITIALIZED = True
+    COLOR_IS_SUPPORTED = False
+    if not CONFIG["features.colorInterface"]: return
+    
+    try: COLOR_IS_SUPPORTED = curses.has_colors()
+    except curses.error: return # initscr hasn't been called yet
+    
+    # initializes color mappings if color support is available
+    if COLOR_IS_SUPPORTED:
+      colorpair = 0
+      log.info("Terminal color support detected and enabled")
+      
+      for colorName in COLOR_LIST:
+        fgColor = COLOR_LIST[colorName]
+        bgColor = -1 # allows for default (possibly transparent) background
+        colorpair += 1
+        curses.init_pair(colorpair, fgColor, bgColor)
+        COLOR_ATTR[colorName] = curses.color_pair(colorpair)
+    else:
+      log.info("Terminal color support unavailable")
+
diff --git a/arm/version.py b/arm/version.py
new file mode 100644
index 0000000..bbbd5bb
--- /dev/null
+++ b/arm/version.py
@@ -0,0 +1,7 @@
+"""
+Provides arm's version and release date.
+"""
+
+VERSION = '1.4.6_dev'
+LAST_MODIFIED = "April 28, 2011"
+
diff --git a/run_arm b/run_arm
new file mode 100755
index 0000000..0603abb
--- /dev/null
+++ b/run_arm
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+# Also looking in /bin/arm because of the UsrMove feature on Fedora...
+# https://trac.torproject.org/5973
+
+if [ "$0" = /usr/bin/arm ] || [ "$0" = /bin/arm ]; then
+  arm_base=/usr/share/arm/
+else
+  arm_base=$( dirname "$0" )/arm/
+fi
+
+python "${arm_base}prereq.py" $*
+
+if [ $? = 0 ]; then
+  exec python -W ignore::DeprecationWarning "${arm_base}starter.py" $*
+fi
+
diff --git a/setup.py b/setup.py
index 7d035ed..64d4986 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@ import os
 import sys
 import gzip
 import tempfile
-from src.version import VERSION
+from arm.version import VERSION
 from distutils.core import setup
 
 def getResources(dst, sourceDir):
@@ -11,13 +11,13 @@ def getResources(dst, sourceDir):
   Provides a list of tuples of the form...
   [(destination, (file1, file2...)), ...]
   
-  for the given contents of the src directory (that's right, distutils isn't
+  for the given contents of the arm directory (that's right, distutils isn't
   smart enough to know how to copy directories).
   """
   
   results = []
   
-  for root, _, files in os.walk(os.path.join("src", sourceDir)):
+  for root, _, files in os.walk(os.path.join("arm", sourceDir)):
     if files:
       fileListing = tuple([os.path.join(root, file) for file in files])
       results.append((os.path.join(dst, root[4:]), fileListing))
@@ -59,7 +59,7 @@ except ValueError: pass # --docPath flag not found
 #   install-purelib=/usr/share
 # which would mean a bit more unnecessary clutter.
 
-manFilename = "src/resoureces/arm.1"
+manFilename = "arm/resoureces/arm.1"
 if "install" in sys.argv:
   sys.argv += ["--install-purelib", "/usr/share"]
   
@@ -68,7 +68,7 @@ if "install" in sys.argv:
   # page instead.
   
   try:
-    manInputFile = open('src/resources/arm.1', 'r')
+    manInputFile = open('arm/resources/arm.1', 'r')
     manContents = manInputFile.read()
     manInputFile.close()
     
@@ -100,17 +100,17 @@ setup(name='arm',
       author_email='atagar at torproject.org',
       url='http://www.atagar.com/arm/',
       packages=installPackages,
-      package_dir={'arm': 'src'},
-      data_files=[("/usr/bin", ["arm"]),
+      package_dir={'arm': 'arm'},
+      data_files=[("/usr/bin", ["run_arm"]),
                   ("/usr/share/man/man1", [manFilename]),
                   (docPath, ["armrc.sample"]),
-                  ("/usr/share/arm/gui", ["src/gui/arm.xml"]),
-                  ("/usr/share/arm", ["src/settings.cfg", "src/uninstall"])] + 
+                  ("/usr/share/arm/gui", ["arm/gui/arm.xml"]),
+                  ("/usr/share/arm", ["arm/settings.cfg", "arm/uninstall"])] + 
                   getResources("/usr/share/arm", "resources"),
      )
 
 # Cleans up the temporary compressed man page.
-if manFilename != 'src/resoureces/arm.1' and os.path.isfile(manFilename):
+if manFilename != 'arm/resoureces/arm.1' and os.path.isfile(manFilename):
   if "-q" not in sys.argv: print "Removing %s" % manFilename
   os.remove(manFilename)
 
diff --git a/src/__init__.py b/src/__init__.py
deleted file mode 100644
index 461b0ce..0000000
--- a/src/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""
-Scripts involved in validating user input, system state, and initializing arm.
-"""
-
-__all__ = ["starter", "prereq", "version"]
-
diff --git a/src/cli/__init__.py b/src/cli/__init__.py
deleted file mode 100644
index 052e06c..0000000
--- a/src/cli/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""
-Panels, popups, and handlers comprising the arm user interface.
-"""
-
-__all__ = ["configPanel", "controller", "headerPanel", "logPanel", "popups", "torrcPanel"]
-
diff --git a/src/cli/configPanel.py b/src/cli/configPanel.py
deleted file mode 100644
index f37c1d1..0000000
--- a/src/cli/configPanel.py
+++ /dev/null
@@ -1,614 +0,0 @@
-"""
-Panel presenting the configuration state for tor or arm. Options can be edited
-and the resulting configuration files saved.
-"""
-
-import curses
-import threading
-
-import cli.controller
-import popups
-
-from util import panel, sysTools, torConfig, torTools, uiTools
-
-import stem.control
-
-from stem.util import conf, enum, str_tools
-
-# TODO: The arm use cases are incomplete since they currently can't be
-# modified, have their descriptions fetched, or even get a complete listing
-# of what's available.
-State = enum.Enum("TOR", "ARM") # state to be presented
-
-# mappings of option categories to the color for their entries
-CATEGORY_COLOR = {torConfig.Category.GENERAL: "green",
-                  torConfig.Category.CLIENT: "blue",
-                  torConfig.Category.RELAY: "yellow",
-                  torConfig.Category.DIRECTORY: "magenta",
-                  torConfig.Category.AUTHORITY: "red",
-                  torConfig.Category.HIDDEN_SERVICE: "cyan",
-                  torConfig.Category.TESTING: "white",
-                  torConfig.Category.UNKNOWN: "white"}
-
-# attributes of a ConfigEntry
-Field = enum.Enum("CATEGORY", "OPTION", "VALUE", "TYPE", "ARG_USAGE",
-                  "SUMMARY", "DESCRIPTION", "MAN_ENTRY", "IS_DEFAULT")
-
-FIELD_ATTR = {Field.CATEGORY: ("Category", "red"),
-              Field.OPTION: ("Option Name", "blue"),
-              Field.VALUE: ("Value", "cyan"),
-              Field.TYPE: ("Arg Type", "green"),
-              Field.ARG_USAGE: ("Arg Usage", "yellow"),
-              Field.SUMMARY: ("Summary", "green"),
-              Field.DESCRIPTION: ("Description", "white"),
-              Field.MAN_ENTRY: ("Man Page Entry", "blue"),
-              Field.IS_DEFAULT: ("Is Default", "magenta")}
-
-def conf_handler(key, value):
-  if key == "features.config.selectionDetails.height":
-    return max(0, value)
-  elif key == "features.config.state.colWidth.option":
-    return max(5, value)
-  elif key == "features.config.state.colWidth.value":
-    return max(5, value)
-  elif key == "features.config.order":
-    return conf.parse_enum_csv(key, value[0], Field, 3)
-
-CONFIG = conf.config_dict("arm", {
-  "features.config.order": [Field.MAN_ENTRY, Field.OPTION, Field.IS_DEFAULT],
-  "features.config.selectionDetails.height": 6,
-  "features.config.prepopulateEditValues": True,
-  "features.config.state.showPrivateOptions": False,
-  "features.config.state.showVirtualOptions": False,
-  "features.config.state.colWidth.option": 25,
-  "features.config.state.colWidth.value": 15,
-}, conf_handler)
-
-def getFieldFromLabel(fieldLabel):
-  """
-  Converts field labels back to their enumeration, raising a ValueError if it
-  doesn't exist.
-  """
-  
-  for entryEnum in FIELD_ATTR:
-    if fieldLabel == FIELD_ATTR[entryEnum][0]:
-      return entryEnum
-
-class ConfigEntry():
-  """
-  Configuration option in the panel.
-  """
-  
-  def __init__(self, option, type, isDefault):
-    self.fields = {}
-    self.fields[Field.OPTION] = option
-    self.fields[Field.TYPE] = type
-    self.fields[Field.IS_DEFAULT] = isDefault
-    
-    # Fetches extra infromation from external sources (the arm config and tor
-    # man page). These are None if unavailable for this config option.
-    summary = torConfig.getConfigSummary(option)
-    manEntry = torConfig.getConfigDescription(option)
-    
-    if manEntry:
-      self.fields[Field.MAN_ENTRY] = manEntry.index
-      self.fields[Field.CATEGORY] = manEntry.category
-      self.fields[Field.ARG_USAGE] = manEntry.argUsage
-      self.fields[Field.DESCRIPTION] = manEntry.description
-    else:
-      self.fields[Field.MAN_ENTRY] = 99999 # sorts non-man entries last
-      self.fields[Field.CATEGORY] = torConfig.Category.UNKNOWN
-      self.fields[Field.ARG_USAGE] = ""
-      self.fields[Field.DESCRIPTION] = ""
-    
-    # uses the full man page description if a summary is unavailable
-    self.fields[Field.SUMMARY] = summary if summary != None else self.fields[Field.DESCRIPTION]
-    
-    # cache of what's displayed for this configuration option
-    self.labelCache = None
-    self.labelCacheArgs = None
-  
-  def get(self, field):
-    """
-    Provides back the value in the given field.
-    
-    Arguments:
-      field - enum for the field to be provided back
-    """
-    
-    if field == Field.VALUE: return self._getValue()
-    else: return self.fields[field]
-  
-  def getAll(self, fields):
-    """
-    Provides back a list with the given field values.
-    
-    Arguments:
-      field - enums for the fields to be provided back
-    """
-    
-    return [self.get(field) for field in fields]
-  
-  def getLabel(self, optionWidth, valueWidth, summaryWidth):
-    """
-    Provides display string of the configuration entry with the given
-    constraints on the width of the contents.
-    
-    Arguments:
-      optionWidth  - width of the option column
-      valueWidth   - width of the value column
-      summaryWidth - width of the summary column
-    """
-    
-    # Fetching the display entries is very common so this caches the values.
-    # Doing this substantially drops cpu usage when scrolling (by around 40%).
-    
-    argSet = (optionWidth, valueWidth, summaryWidth)
-    if not self.labelCache or self.labelCacheArgs != argSet:
-      optionLabel = uiTools.cropStr(self.get(Field.OPTION), optionWidth)
-      valueLabel = uiTools.cropStr(self.get(Field.VALUE), valueWidth)
-      summaryLabel = uiTools.cropStr(self.get(Field.SUMMARY), summaryWidth, None)
-      lineTextLayout = "%%-%is %%-%is %%-%is" % (optionWidth, valueWidth, summaryWidth)
-      self.labelCache = lineTextLayout % (optionLabel, valueLabel, summaryLabel)
-      self.labelCacheArgs = argSet
-    
-    return self.labelCache
-  
-  def isUnset(self):
-    """
-    True if we have no value, false otherwise.
-    """
-    
-    confValue = torTools.getConn().getOption(self.get(Field.OPTION), [], True)
-    return not bool(confValue)
-  
-  def _getValue(self):
-    """
-    Provides the current value of the configuration entry, taking advantage of
-    the torTools caching to effectively query the accurate value. This uses the
-    value's type to provide a user friendly representation if able.
-    """
-    
-    confValue = ", ".join(torTools.getConn().getOption(self.get(Field.OPTION), [], True))
-    
-    # provides nicer values for recognized types
-    if not confValue: confValue = "<none>"
-    elif self.get(Field.TYPE) == "Boolean" and confValue in ("0", "1"):
-      confValue = "False" if confValue == "0" else "True"
-    elif self.get(Field.TYPE) == "DataSize" and confValue.isdigit():
-      confValue = str_tools.get_size_label(int(confValue))
-    elif self.get(Field.TYPE) == "TimeInterval" and confValue.isdigit():
-      confValue = str_tools.get_time_label(int(confValue), is_long = True)
-    
-    return confValue
-
-class ConfigPanel(panel.Panel):
-  """
-  Renders a listing of the tor or arm configuration state, allowing options to
-  be selected and edited.
-  """
-  
-  def __init__(self, stdscr, configType):
-    panel.Panel.__init__(self, stdscr, "configuration", 0)
-    
-    self.configType = configType
-    self.confContents = []
-    self.confImportantContents = []
-    self.scroller = uiTools.Scroller(True)
-    self.valsLock = threading.RLock()
-    
-    # shows all configuration options if true, otherwise only the ones with
-    # the 'important' flag are shown
-    self.showAll = False
-    
-    # initializes config contents if we're connected
-    conn = torTools.getConn()
-    conn.addStatusListener(self.resetListener)
-    if conn.isAlive(): self.resetListener(None, stem.control.State.INIT, None)
-  
-  def resetListener(self, controller, eventType, _):
-    # fetches configuration options if a new instance, otherewise keeps our
-    # current contents
-    
-    if eventType == stem.control.State.INIT:
-      self._loadConfigOptions()
-  
-  def _loadConfigOptions(self):
-    """
-    Fetches the configuration options available from tor or arm.
-    """
-    
-    self.confContents = []
-    self.confImportantContents = []
-    
-    if self.configType == State.TOR:
-      conn, configOptionLines = torTools.getConn(), []
-      customOptions = torConfig.getCustomOptions()
-      configOptionQuery = conn.getInfo("config/names", None)
-      
-      if configOptionQuery:
-        configOptionLines = configOptionQuery.strip().split("\n")
-      
-      for line in configOptionLines:
-        # lines are of the form "<option> <type>[ <documentation>]", like:
-        # UseEntryGuards Boolean
-        # documentation is aparently only in older versions (for instance,
-        # 0.2.1.25)
-        lineComp = line.strip().split(" ")
-        confOption, confType = lineComp[0], lineComp[1]
-        
-        # skips private and virtual entries if not configured to show them
-        if not CONFIG["features.config.state.showPrivateOptions"] and confOption.startswith("__"):
-          continue
-        elif not CONFIG["features.config.state.showVirtualOptions"] and confType == "Virtual":
-          continue
-        
-        self.confContents.append(ConfigEntry(confOption, confType, not confOption in customOptions))
-    elif self.configType == State.ARM:
-      # loaded via the conf utility
-      armConf = conf.get_config("arm")
-      for key in armConf.keys():
-        pass # TODO: implement
-    
-    # mirror listing with only the important configuration options
-    self.confImportantContents = []
-    for entry in self.confContents:
-      if torConfig.isImportant(entry.get(Field.OPTION)):
-        self.confImportantContents.append(entry)
-    
-    # if there aren't any important options then show everything
-    if not self.confImportantContents:
-      self.confImportantContents = self.confContents
-    
-    self.setSortOrder() # initial sorting of the contents
-  
-  def getSelection(self):
-    """
-    Provides the currently selected entry.
-    """
-    
-    return self.scroller.getCursorSelection(self._getConfigOptions())
-  
-  def setFiltering(self, isFiltered):
-    """
-    Sets if configuration options are filtered or not.
-    
-    Arguments:
-      isFiltered - if true then only relatively important options will be
-                   shown, otherwise everything is shown
-    """
-    
-    self.showAll = not isFiltered
-  
-  def setSortOrder(self, ordering = None):
-    """
-    Sets the configuration attributes we're sorting by and resorts the
-    contents.
-    
-    Arguments:
-      ordering - new ordering, if undefined then this resorts with the last
-                 set ordering
-    """
-    
-    self.valsLock.acquire()
-    if ordering: CONFIG["features.config.order"] = ordering
-    self.confContents.sort(key=lambda i: (i.getAll(CONFIG["features.config.order"])))
-    self.confImportantContents.sort(key=lambda i: (i.getAll(CONFIG["features.config.order"])))
-    self.valsLock.release()
-  
-  def showSortDialog(self):
-    """
-    Provides the sort dialog for our configuration options.
-    """
-    
-    # set ordering for config options
-    titleLabel = "Config Option Ordering:"
-    options = [FIELD_ATTR[field][0] for field in Field]
-    oldSelection = [FIELD_ATTR[field][0] for field in CONFIG["features.config.order"]]
-    optionColors = dict([FIELD_ATTR[field] for field in Field])
-    results = popups.showSortDialog(titleLabel, options, oldSelection, optionColors)
-    
-    if results:
-      # converts labels back to enums
-      resultEnums = [getFieldFromLabel(label) for label in results]
-      self.setSortOrder(resultEnums)
-  
-  def handleKey(self, key):
-    self.valsLock.acquire()
-    isKeystrokeConsumed = True
-    if uiTools.isScrollKey(key):
-      pageHeight = self.getPreferredSize()[0] - 1
-      detailPanelHeight = CONFIG["features.config.selectionDetails.height"]
-      if detailPanelHeight > 0 and detailPanelHeight + 2 <= pageHeight:
-        pageHeight -= (detailPanelHeight + 1)
-      
-      isChanged = self.scroller.handleKey(key, self._getConfigOptions(), pageHeight)
-      if isChanged: self.redraw(True)
-    elif uiTools.isSelectionKey(key) and self._getConfigOptions():
-      # Prompts the user to edit the selected configuration value. The
-      # interface is locked to prevent updates between setting the value
-      # and showing any errors.
-      
-      panel.CURSES_LOCK.acquire()
-      try:
-        selection = self.getSelection()
-        configOption = selection.get(Field.OPTION)
-        if selection.isUnset(): initialValue = ""
-        else: initialValue = selection.get(Field.VALUE)
-        
-        promptMsg = "%s Value (esc to cancel): " % configOption
-        isPrepopulated = CONFIG["features.config.prepopulateEditValues"]
-        newValue = popups.inputPrompt(promptMsg, initialValue if isPrepopulated else "")
-        
-        if newValue != None and newValue != initialValue:
-          try:
-            if selection.get(Field.TYPE) == "Boolean":
-              # if the value's a boolean then allow for 'true' and 'false' inputs
-              if newValue.lower() == "true": newValue = "1"
-              elif newValue.lower() == "false": newValue = "0"
-            elif selection.get(Field.TYPE) == "LineList":
-              # setOption accepts list inputs when there's multiple values
-              newValue = newValue.split(",")
-            
-            torTools.getConn().setOption(configOption, newValue)
-            
-            # forces the label to be remade with the new value
-            selection.labelCache = None
-            
-            # resets the isDefault flag
-            customOptions = torConfig.getCustomOptions()
-            selection.fields[Field.IS_DEFAULT] = not configOption in customOptions
-            
-            self.redraw(True)
-          except Exception, exc:
-            popups.showMsg("%s (press any key)" % exc)
-      finally:
-        panel.CURSES_LOCK.release()
-    elif key == ord('a') or key == ord('A'):
-      self.showAll = not self.showAll
-      self.redraw(True)
-    elif key == ord('s') or key == ord('S'):
-      self.showSortDialog()
-    elif key == ord('v') or key == ord('V'):
-      self.showWriteDialog()
-    else: isKeystrokeConsumed = False
-    
-    self.valsLock.release()
-    return isKeystrokeConsumed
-  
-  def showWriteDialog(self):
-    """
-    Provies an interface to confirm if the configuration is saved and, if so,
-    where.
-    """
-    
-    # display a popup for saving the current configuration
-    configLines = torConfig.getCustomOptions(True)
-    popup, width, height = popups.init(len(configLines) + 2)
-    if not popup: return
-    
-    try:
-      # displayed options (truncating the labels if there's limited room)
-      if width >= 30: selectionOptions = ("Save", "Save As...", "Cancel")
-      else: selectionOptions = ("Save", "Save As", "X")
-      
-      # checks if we can show options beside the last line of visible content
-      isOptionLineSeparate = False
-      lastIndex = min(height - 2, len(configLines) - 1)
-      
-      # if we don't have room to display the selection options and room to
-      # grow then display the selection options on its own line
-      if width < (30 + len(configLines[lastIndex])):
-        popup.setHeight(height + 1)
-        popup.redraw(True) # recreates the window instance
-        newHeight, _ = popup.getPreferredSize()
-        
-        if newHeight > height:
-          height = newHeight
-          isOptionLineSeparate = True
-      
-      key, selection = 0, 2
-      while not uiTools.isSelectionKey(key):
-        # if the popup has been resized then recreate it (needed for the
-        # proper border height)
-        newHeight, newWidth = popup.getPreferredSize()
-        if (height, width) != (newHeight, newWidth):
-          height, width = newHeight, newWidth
-          popup.redraw(True)
-        
-        # if there isn't room to display the popup then cancel it
-        if height <= 2:
-          selection = 2
-          break
-        
-        popup.win.erase()
-        popup.win.box()
-        popup.addstr(0, 0, "Configuration being saved:", curses.A_STANDOUT)
-        
-        visibleConfigLines = height - 3 if isOptionLineSeparate else height - 2
-        for i in range(visibleConfigLines):
-          line = uiTools.cropStr(configLines[i], width - 2)
-          
-          if " " in line:
-            option, arg = line.split(" ", 1)
-            popup.addstr(i + 1, 1, option, curses.A_BOLD | uiTools.getColor("green"))
-            popup.addstr(i + 1, len(option) + 2, arg, curses.A_BOLD | uiTools.getColor("cyan"))
-          else:
-            popup.addstr(i + 1, 1, line, curses.A_BOLD | uiTools.getColor("green"))
-        
-        # draws selection options (drawn right to left)
-        drawX = width - 1
-        for i in range(len(selectionOptions) - 1, -1, -1):
-          optionLabel = selectionOptions[i]
-          drawX -= (len(optionLabel) + 2)
-          
-          # if we've run out of room then drop the option (this will only
-          # occure on tiny displays)
-          if drawX < 1: break
-          
-          selectionFormat = curses.A_STANDOUT if i == selection else curses.A_NORMAL
-          popup.addstr(height - 2, drawX, "[")
-          popup.addstr(height - 2, drawX + 1, optionLabel, selectionFormat | curses.A_BOLD)
-          popup.addstr(height - 2, drawX + len(optionLabel) + 1, "]")
-          
-          drawX -= 1 # space gap between the options
-        
-        popup.win.refresh()
-        
-        key = cli.controller.getController().getScreen().getch()
-        if key == curses.KEY_LEFT: selection = max(0, selection - 1)
-        elif key == curses.KEY_RIGHT: selection = min(len(selectionOptions) - 1, selection + 1)
-      
-      if selection in (0, 1):
-        loadedTorrc, promptCanceled = torConfig.getTorrc(), False
-        try: configLocation = loadedTorrc.getConfigLocation()
-        except IOError: configLocation = ""
-        
-        if selection == 1:
-          # prompts user for a configuration location
-          configLocation = popups.inputPrompt("Save to (esc to cancel): ", configLocation)
-          if not configLocation: promptCanceled = True
-        
-        if not promptCanceled:
-          try:
-            torConfig.saveConf(configLocation, configLines)
-            msg = "Saved configuration to %s" % configLocation
-          except IOError, exc:
-            msg = "Unable to save configuration (%s)" % sysTools.getFileErrorMsg(exc)
-          
-          popups.showMsg(msg, 2)
-    finally: popups.finalize()
-  
-  def getHelp(self):
-    options = []
-    options.append(("up arrow", "scroll up a line", None))
-    options.append(("down arrow", "scroll down a line", None))
-    options.append(("page up", "scroll up a page", None))
-    options.append(("page down", "scroll down a page", None))
-    options.append(("enter", "edit configuration option", None))
-    options.append(("v", "save configuration", None))
-    options.append(("a", "toggle option filtering", None))
-    options.append(("s", "sort ordering", None))
-    return options
-  
-  def draw(self, width, height):
-    self.valsLock.acquire()
-    
-    # panel with details for the current selection
-    detailPanelHeight = CONFIG["features.config.selectionDetails.height"]
-    isScrollbarVisible = False
-    if detailPanelHeight == 0 or detailPanelHeight + 2 >= height:
-      # no detail panel
-      detailPanelHeight = 0
-      scrollLoc = self.scroller.getScrollLoc(self._getConfigOptions(), height - 1)
-      cursorSelection = self.getSelection()
-      isScrollbarVisible = len(self._getConfigOptions()) > height - 1
-    else:
-      # Shrink detail panel if there isn't sufficient room for the whole
-      # thing. The extra line is for the bottom border.
-      detailPanelHeight = min(height - 1, detailPanelHeight + 1)
-      scrollLoc = self.scroller.getScrollLoc(self._getConfigOptions(), height - 1 - detailPanelHeight)
-      cursorSelection = self.getSelection()
-      isScrollbarVisible = len(self._getConfigOptions()) > height - detailPanelHeight - 1
-      
-      if cursorSelection != None:
-        self._drawSelectionPanel(cursorSelection, width, detailPanelHeight, isScrollbarVisible)
-    
-    # draws the top label
-    if self.isTitleVisible():
-      configType = "Tor" if self.configType == State.TOR else "Arm"
-      hiddenMsg = "press 'a' to hide most options" if self.showAll else "press 'a' to show all options"
-      titleLabel = "%s Configuration (%s):" % (configType, hiddenMsg)
-      self.addstr(0, 0, titleLabel, curses.A_STANDOUT)
-    
-    # draws left-hand scroll bar if content's longer than the height
-    scrollOffset = 1
-    if isScrollbarVisible:
-      scrollOffset = 3
-      self.addScrollBar(scrollLoc, scrollLoc + height - detailPanelHeight - 1, len(self._getConfigOptions()), 1 + detailPanelHeight)
-    
-    optionWidth = CONFIG["features.config.state.colWidth.option"]
-    valueWidth = CONFIG["features.config.state.colWidth.value"]
-    descriptionWidth = max(0, width - scrollOffset - optionWidth - valueWidth - 2)
-    
-    # if the description column is overly long then use its space for the
-    # value instead
-    if descriptionWidth > 80:
-      valueWidth += descriptionWidth - 80
-      descriptionWidth = 80
-    
-    for lineNum in range(scrollLoc, len(self._getConfigOptions())):
-      entry = self._getConfigOptions()[lineNum]
-      drawLine = lineNum + detailPanelHeight + 1 - scrollLoc
-      
-      lineFormat = curses.A_NORMAL if entry.get(Field.IS_DEFAULT) else curses.A_BOLD
-      if entry.get(Field.CATEGORY): lineFormat |= uiTools.getColor(CATEGORY_COLOR[entry.get(Field.CATEGORY)])
-      if entry == cursorSelection: lineFormat |= curses.A_STANDOUT
-      
-      lineText = entry.getLabel(optionWidth, valueWidth, descriptionWidth)
-      self.addstr(drawLine, scrollOffset, lineText, lineFormat)
-      
-      if drawLine >= height: break
-    
-    self.valsLock.release()
-  
-  def _getConfigOptions(self):
-    return self.confContents if self.showAll else self.confImportantContents
-  
-  def _drawSelectionPanel(self, selection, width, detailPanelHeight, isScrollbarVisible):
-    """
-    Renders a panel for the selected configuration option.
-    """
-    
-    # This is a solid border unless the scrollbar is visible, in which case a
-    # 'T' pipe connects the border to the bar.
-    uiTools.drawBox(self, 0, 0, width, detailPanelHeight + 1)
-    if isScrollbarVisible: self.addch(detailPanelHeight, 1, curses.ACS_TTEE)
-    
-    selectionFormat = curses.A_BOLD | uiTools.getColor(CATEGORY_COLOR[selection.get(Field.CATEGORY)])
-    
-    # first entry:
-    # <option> (<category> Option)
-    optionLabel =" (%s Option)" % selection.get(Field.CATEGORY)
-    self.addstr(1, 2, selection.get(Field.OPTION) + optionLabel, selectionFormat)
-    
-    # second entry:
-    # Value: <value> ([default|custom], <type>, usage: <argument usage>)
-    if detailPanelHeight >= 3:
-      valueAttr = []
-      valueAttr.append("default" if selection.get(Field.IS_DEFAULT) else "custom")
-      valueAttr.append(selection.get(Field.TYPE))
-      valueAttr.append("usage: %s" % (selection.get(Field.ARG_USAGE)))
-      valueAttrLabel = ", ".join(valueAttr)
-      
-      valueLabelWidth = width - 12 - len(valueAttrLabel)
-      valueLabel = uiTools.cropStr(selection.get(Field.VALUE), valueLabelWidth)
-      
-      self.addstr(2, 2, "Value: %s (%s)" % (valueLabel, valueAttrLabel), selectionFormat)
-    
-    # remainder is filled with the man page description
-    descriptionHeight = max(0, detailPanelHeight - 3)
-    descriptionContent = "Description: " + selection.get(Field.DESCRIPTION)
-    
-    for i in range(descriptionHeight):
-      # checks if we're done writing the description
-      if not descriptionContent: break
-      
-      # there's a leading indent after the first line
-      if i > 0: descriptionContent = "  " + descriptionContent
-      
-      # we only want to work with content up until the next newline
-      if "\n" in descriptionContent:
-        lineContent, descriptionContent = descriptionContent.split("\n", 1)
-      else: lineContent, descriptionContent = descriptionContent, ""
-      
-      if i != descriptionHeight - 1:
-        # there's more lines to display
-        msg, remainder = uiTools.cropStr(lineContent, width - 3, 4, 4, uiTools.Ending.HYPHEN, True)
-        descriptionContent = remainder.strip() + descriptionContent
-      else:
-        # this is the last line, end it with an ellipse
-        msg = uiTools.cropStr(lineContent, width - 3, 4, 4)
-      
-      self.addstr(3 + i, 2, msg, selectionFormat)
-
diff --git a/src/cli/connections/__init__.py b/src/cli/connections/__init__.py
deleted file mode 100644
index abd3410..0000000
--- a/src/cli/connections/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""
-Connection panel related resources.
-"""
-
-__all__ = ["circEntry", "connEntry", "connPanel", "countPopup", "descriptorPopup", "entries"]
-
diff --git a/src/cli/connections/circEntry.py b/src/cli/connections/circEntry.py
deleted file mode 100644
index 25966df..0000000
--- a/src/cli/connections/circEntry.py
+++ /dev/null
@@ -1,196 +0,0 @@
-"""
-Connection panel entries for client circuits. This includes a header entry
-followed by an entry for each hop in the circuit. For instance:
-
-89.188.20.246:42667    -->  217.172.182.26 (de)       General / Built     8.6m (CIRCUIT)
-|  85.8.28.4 (se)               98FBC3B2B93897A78CDD797EF549E6B62C9A8523    1 / Guard
-|  91.121.204.76 (fr)           546387D93F8D40CFF8842BB9D3A8EC477CEDA984    2 / Middle
-+- 217.172.182.26 (de)          5CFA9EA136C0EA0AC096E5CEA7EB674F1207CF86    3 / Exit
-"""
-
-import curses
-
-from cli.connections import entries, connEntry
-from util import torTools, uiTools
-
-class CircEntry(connEntry.ConnectionEntry):
-  def __init__(self, circuitID, status, purpose, path):
-    connEntry.ConnectionEntry.__init__(self, "127.0.0.1", "0", "127.0.0.1", "0")
-    
-    self.circuitID = circuitID
-    self.status = status
-    
-    # drops to lowercase except the first letter
-    if len(purpose) >= 2:
-      purpose = purpose[0].upper() + purpose[1:].lower()
-    
-    self.lines = [CircHeaderLine(self.circuitID, purpose)]
-    
-    # Overwrites attributes of the initial line to make it more fitting as the
-    # header for our listing.
-    
-    self.lines[0].baseType = connEntry.Category.CIRCUIT
-    
-    self.update(status, path)
-  
-  def update(self, status, path):
-    """
-    Our status and path can change over time if the circuit is still in the
-    process of being built. Updates these attributes of our relay.
-    
-    Arguments:
-      status - new status of the circuit
-      path   - list of fingerprints for the series of relays involved in the
-               circuit
-    """
-    
-    self.status = status
-    self.lines = [self.lines[0]]
-    conn = torTools.getConn()
-    
-    if status == "BUILT" and not self.lines[0].isBuilt:
-      exitIp, exitORPort = conn.getRelayAddress(path[-1], ("192.168.0.1", "0"))
-      self.lines[0].setExit(exitIp, exitORPort, path[-1])
-    
-    for i in range(len(path)):
-      relayFingerprint = path[i]
-      relayIp, relayOrPort = conn.getRelayAddress(relayFingerprint, ("192.168.0.1", "0"))
-      
-      if i == len(path) - 1:
-        if status == "BUILT": placementType = "Exit"
-        else: placementType = "Extending"
-      elif i == 0: placementType = "Guard"
-      else: placementType = "Middle"
-      
-      placementLabel = "%i / %s" % (i + 1, placementType)
-      
-      self.lines.append(CircLine(relayIp, relayOrPort, relayFingerprint, placementLabel))
-    
-    self.lines[-1].isLast = True
-
-class CircHeaderLine(connEntry.ConnectionLine):
-  """
-  Initial line of a client entry. This has the same basic format as connection
-  lines except that its etc field has circuit attributes.
-  """
-  
-  def __init__(self, circuitID, purpose):
-    connEntry.ConnectionLine.__init__(self, "127.0.0.1", "0", "0.0.0.0", "0", False, False)
-    self.circuitID = circuitID
-    self.purpose = purpose
-    self.isBuilt = False
-  
-  def setExit(self, exitIpAddr, exitPort, exitFingerprint):
-    connEntry.ConnectionLine.__init__(self, "127.0.0.1", "0", exitIpAddr, exitPort, False, False)
-    self.isBuilt = True
-    self.foreign.fingerprintOverwrite = exitFingerprint
-  
-  def getType(self):
-    return connEntry.Category.CIRCUIT
-  
-  def getDestinationLabel(self, maxLength, includeLocale=False, includeHostname=False):
-    if not self.isBuilt: return "Building..."
-    return connEntry.ConnectionLine.getDestinationLabel(self, maxLength, includeLocale, includeHostname)
-  
-  def getEtcContent(self, width, listingType):
-    """
-    Attempts to provide all circuit related stats. Anything that can't be
-    shown completely (not enough room) is dropped.
-    """
-    
-    etcAttr = ["Purpose: %s" % self.purpose, "Circuit ID: %i" % self.circuitID]
-    
-    for i in range(len(etcAttr), -1, -1):
-      etcLabel = ", ".join(etcAttr[:i])
-      if len(etcLabel) <= width:
-        return ("%%-%is" % width) % etcLabel
-    
-    return ""
-  
-  def getDetails(self, width):
-    if not self.isBuilt:
-      detailFormat = curses.A_BOLD | uiTools.getColor(connEntry.CATEGORY_COLOR[self.getType()])
-      return [("Building Circuit...", detailFormat)]
-    else: return connEntry.ConnectionLine.getDetails(self, width)
-
-class CircLine(connEntry.ConnectionLine):
-  """
-  An individual hop in a circuit. This overwrites the displayed listing, but
-  otherwise makes use of the ConnectionLine attributes (for the detail display,
-  caching, etc).
-  """
-  
-  def __init__(self, fIpAddr, fPort, fFingerprint, placementLabel):
-    connEntry.ConnectionLine.__init__(self, "127.0.0.1", "0", fIpAddr, fPort)
-    self.foreign.fingerprintOverwrite = fFingerprint
-    self.placementLabel = placementLabel
-    self.includePort = False
-    
-    # determines the sort of left hand bracketing we use
-    self.isLast = False
-  
-  def getType(self):
-    return connEntry.Category.CIRCUIT
-  
-  def getListingPrefix(self):
-    if self.isLast: return (ord(' '), curses.ACS_LLCORNER, curses.ACS_HLINE, ord(' '))
-    else: return (ord(' '), curses.ACS_VLINE, ord(' '), ord(' '))
-  
-  def getListingEntry(self, width, currentTime, listingType):
-    """
-    Provides the [(msg, attr)...] listing for this relay in the circuilt
-    listing. Lines are composed of the following components:
-      <bracket> <dst> <etc> <placement label>
-    
-    The dst and etc entries largely match their ConnectionEntry counterparts.
-    
-    Arguments:
-      width       - maximum length of the line
-      currentTime - the current unix time (ignored)
-      listingType - primary attribute we're listing connections by
-    """
-    
-    return entries.ConnectionPanelLine.getListingEntry(self, width, currentTime, listingType)
-  
-  def _getListingEntry(self, width, currentTime, listingType):
-    lineFormat = uiTools.getColor(connEntry.CATEGORY_COLOR[self.getType()])
-    
-    # The required widths are the sum of the following:
-    # initial space (1 character)
-    # bracketing (3 characters)
-    # placementLabel (14 characters)
-    # gap between etc and placement label (5 characters)
-    
-    baselineSpace = 14 + 5
-    
-    dst, etc = "", ""
-    if listingType == entries.ListingType.IP_ADDRESS:
-      # TODO: include hostname when that's available
-      # dst width is derived as:
-      # src (21) + dst (26) + divider (7) + right gap (2) - bracket (3) = 53 char
-      dst = "%-53s" % self.getDestinationLabel(53, includeLocale = True)
-      
-      # fills the nickname into the empty space here
-      dst = "%s%-25s   " % (dst[:25], uiTools.cropStr(self.foreign.getNickname(), 25, 0))
-      
-      etc = self.getEtcContent(width - baselineSpace - len(dst), listingType)
-    elif listingType == entries.ListingType.HOSTNAME:
-      # min space for the hostname is 40 characters
-      etc = self.getEtcContent(width - baselineSpace - 40, listingType)
-      dstLayout = "%%-%is" % (width - baselineSpace - len(etc))
-      dst = dstLayout % self.foreign.getHostname(self.foreign.getIpAddr())
-    elif listingType == entries.ListingType.FINGERPRINT:
-      # dst width is derived as:
-      # src (9) + dst (40) + divider (7) + right gap (2) - bracket (3) = 55 char
-      dst = "%-55s" % self.foreign.getFingerprint()
-      etc = self.getEtcContent(width - baselineSpace - len(dst), listingType)
-    else:
-      # min space for the nickname is 56 characters
-      etc = self.getEtcContent(width - baselineSpace - 56, listingType)
-      dstLayout = "%%-%is" % (width - baselineSpace - len(etc))
-      dst = dstLayout % self.foreign.getNickname()
-    
-    return ((dst + etc, lineFormat),
-            (" " * (width - baselineSpace - len(dst) - len(etc) + 5), lineFormat),
-            ("%-14s" % self.placementLabel, lineFormat))
-
diff --git a/src/cli/connections/connEntry.py b/src/cli/connections/connEntry.py
deleted file mode 100644
index 5b2eda5..0000000
--- a/src/cli/connections/connEntry.py
+++ /dev/null
@@ -1,849 +0,0 @@
-"""
-Connection panel entries related to actual connections to or from the system
-(ie, results seen by netstat, lsof, etc).
-"""
-
-import time
-import curses
-
-from util import connections, torTools, uiTools
-from cli.connections import entries
-
-from stem.util import conf, enum, str_tools
-
-# Connection Categories:
-#   Inbound      Relay connection, coming to us.
-#   Outbound     Relay connection, leaving us.
-#   Exit         Outbound relay connection leaving the Tor network.
-#   Hidden       Connections to a hidden service we're providing.
-#   Socks        Socks connections for applications using Tor.
-#   Circuit      Circuits our tor client has created.
-#   Directory    Fetching tor consensus information.
-#   Control      Tor controller (arm, vidalia, etc).
-
-Category = enum.Enum("INBOUND", "OUTBOUND", "EXIT", "HIDDEN", "SOCKS", "CIRCUIT", "DIRECTORY", "CONTROL")
-CATEGORY_COLOR = {Category.INBOUND: "green",      Category.OUTBOUND: "blue",
-                  Category.EXIT: "red",           Category.HIDDEN: "magenta",
-                  Category.SOCKS: "yellow",       Category.CIRCUIT: "cyan",
-                  Category.DIRECTORY: "magenta",  Category.CONTROL: "red"}
-
-# static data for listing format
-# <src>  -->  <dst>  <etc><padding>
-LABEL_FORMAT = "%s  -->  %s  %s%s"
-LABEL_MIN_PADDING = 2 # min space between listing label and following data
-
-# sort value for scrubbed ip addresses
-SCRUBBED_IP_VAL = 255 ** 4
-
-CONFIG = conf.config_dict("arm", {
-  "features.connection.markInitialConnections": True,
-  "features.connection.showIps": True,
-  "features.connection.showExitPort": True,
-  "features.connection.showColumn.fingerprint": True,
-  "features.connection.showColumn.nickname": True,
-  "features.connection.showColumn.destination": True,
-  "features.connection.showColumn.expandedIp": True,
-})
-
-class Endpoint:
-  """
-  Collection of attributes associated with a connection endpoint. This is a
-  thin wrapper for torUtil functions, making use of its caching for
-  performance.
-  """
-  
-  def __init__(self, ipAddr, port):
-    self.ipAddr = ipAddr
-    self.port = port
-    
-    # if true, we treat the port as an definitely not being an ORPort when
-    # searching for matching fingerprints (otherwise we use it to possably
-    # narrow results when unknown)
-    self.isNotORPort = True
-    
-    # if set then this overwrites fingerprint lookups
-    self.fingerprintOverwrite = None
-  
-  def getIpAddr(self):
-    """
-    Provides the IP address of the endpoint.
-    """
-    
-    return self.ipAddr
-  
-  def getPort(self):
-    """
-    Provides the port of the endpoint.
-    """
-    
-    return self.port
-  
-  def getHostname(self, default = None):
-    """
-    Provides the hostname associated with the relay's address. This is a
-    non-blocking call and returns None if the address either can't be resolved
-    or hasn't been resolved yet.
-    
-    Arguments:
-      default - return value if no hostname is available
-    """
-    
-    # TODO: skipping all hostname resolution to be safe for now
-    #try:
-    #  myHostname = hostnames.resolve(self.ipAddr)
-    #except:
-    #  # either a ValueError or IOError depending on the source of the lookup failure
-    #  myHostname = None
-    #
-    #if not myHostname: return default
-    #else: return myHostname
-    
-    return default
-  
-  def getLocale(self, default=None):
-    """
-    Provides the two letter country code for the IP address' locale.
-    
-    Arguments:
-      default - return value if no locale information is available
-    """
-    
-    conn = torTools.getConn()
-    return conn.getInfo("ip-to-country/%s" % self.ipAddr, default)
-  
-  def getFingerprint(self):
-    """
-    Provides the fingerprint of the relay, returning "UNKNOWN" if it can't be
-    determined.
-    """
-    
-    if self.fingerprintOverwrite:
-      return self.fingerprintOverwrite
-    
-    conn = torTools.getConn()
-    myFingerprint = conn.getRelayFingerprint(self.ipAddr)
-    
-    # If there were multiple matches and our port is likely the ORPort then
-    # try again with that to narrow the results.
-    if not myFingerprint and not self.isNotORPort:
-      myFingerprint = conn.getRelayFingerprint(self.ipAddr, self.port)
-    
-    if myFingerprint: return myFingerprint
-    else: return "UNKNOWN"
-  
-  def getNickname(self):
-    """
-    Provides the nickname of the relay, retuning "UNKNOWN" if it can't be
-    determined.
-    """
-    
-    myFingerprint = self.getFingerprint()
-    
-    if myFingerprint != "UNKNOWN":
-      conn = torTools.getConn()
-      myNickname = conn.getRelayNickname(myFingerprint)
-      
-      if myNickname: return myNickname
-      else: return "UNKNOWN"
-    else: return "UNKNOWN"
-
-class ConnectionEntry(entries.ConnectionPanelEntry):
-  """
-  Represents a connection being made to or from this system. These only
-  concern real connections so it includes the inbound, outbound, directory,
-  application, and controller categories.
-  """
-  
-  def __init__(self, lIpAddr, lPort, fIpAddr, fPort):
-    entries.ConnectionPanelEntry.__init__(self)
-    self.lines = [ConnectionLine(lIpAddr, lPort, fIpAddr, fPort)]
-  
-  def getSortValue(self, attr, listingType):
-    """
-    Provides the value of a single attribute used for sorting purposes.
-    """
-    
-    connLine = self.lines[0]
-    if attr == entries.SortAttr.IP_ADDRESS:
-      if connLine.isPrivate(): return SCRUBBED_IP_VAL # orders at the end
-      return connLine.sortIpAddr
-    elif attr == entries.SortAttr.PORT:
-      return connLine.sortPort
-    elif attr == entries.SortAttr.HOSTNAME:
-      if connLine.isPrivate(): return ""
-      return connLine.foreign.getHostname("")
-    elif attr == entries.SortAttr.FINGERPRINT:
-      return connLine.foreign.getFingerprint()
-    elif attr == entries.SortAttr.NICKNAME:
-      myNickname = connLine.foreign.getNickname()
-      if myNickname == "UNKNOWN": return "z" * 20 # orders at the end
-      else: return myNickname.lower()
-    elif attr == entries.SortAttr.CATEGORY:
-      return Category.index_of(connLine.getType())
-    elif attr == entries.SortAttr.UPTIME:
-      return connLine.startTime
-    elif attr == entries.SortAttr.COUNTRY:
-      if connections.isIpAddressPrivate(self.lines[0].foreign.getIpAddr()): return ""
-      else: return connLine.foreign.getLocale("")
-    else:
-      return entries.ConnectionPanelEntry.getSortValue(self, attr, listingType)
-
-class ConnectionLine(entries.ConnectionPanelLine):
-  """
-  Display component of the ConnectionEntry.
-  """
-  
-  def __init__(self, lIpAddr, lPort, fIpAddr, fPort, includePort=True, includeExpandedIpAddr=True):
-    entries.ConnectionPanelLine.__init__(self)
-    
-    self.local = Endpoint(lIpAddr, lPort)
-    self.foreign = Endpoint(fIpAddr, fPort)
-    self.startTime = time.time()
-    self.isInitialConnection = False
-    
-    # overwrite the local fingerprint with ours
-    conn = torTools.getConn()
-    self.local.fingerprintOverwrite = conn.getInfo("fingerprint", None)
-    
-    # True if the connection has matched the properties of a client/directory
-    # connection every time we've checked. The criteria we check is...
-    #   client    - first hop in an established circuit
-    #   directory - matches an established single-hop circuit (probably a
-    #               directory mirror)
-    
-    self._possibleClient = True
-    self._possibleDirectory = True
-    
-    # attributes for SOCKS, HIDDEN, and CONTROL connections
-    self.appName = None
-    self.appPid = None
-    self.isAppResolving = False
-    
-    myOrPort = conn.getOption("ORPort", None)
-    myDirPort = conn.getOption("DirPort", None)
-    mySocksPort = conn.getOption("SocksPort", "9050")
-    myCtlPort = conn.getOption("ControlPort", None)
-    myHiddenServicePorts = conn.getHiddenServicePorts()
-    
-    # the ORListenAddress can overwrite the ORPort
-    listenAddr = conn.getOption("ORListenAddress", None)
-    if listenAddr and ":" in listenAddr:
-      myOrPort = listenAddr[listenAddr.find(":") + 1:]
-    
-    if lPort in (myOrPort, myDirPort):
-      self.baseType = Category.INBOUND
-      self.local.isNotORPort = False
-    elif lPort == mySocksPort:
-      self.baseType = Category.SOCKS
-    elif fPort in myHiddenServicePorts:
-      self.baseType = Category.HIDDEN
-    elif lPort == myCtlPort:
-      self.baseType = Category.CONTROL
-    else:
-      self.baseType = Category.OUTBOUND
-      self.foreign.isNotORPort = False
-    
-    self.cachedType = None
-    
-    # includes the port or expanded ip address field when displaying listing
-    # information if true
-    self.includePort = includePort
-    self.includeExpandedIpAddr = includeExpandedIpAddr
-    
-    # cached immutable values used for sorting
-    self.sortIpAddr = connections.ipToInt(self.foreign.getIpAddr())
-    self.sortPort = int(self.foreign.getPort())
-  
-  def getListingEntry(self, width, currentTime, listingType):
-    """
-    Provides the tuple list for this connection's listing. Lines are composed
-    of the following components:
-      <src>  -->  <dst>     <etc>     <uptime> (<type>)
-    
-    ListingType.IP_ADDRESS:
-      src - <internal addr:port> --> <external addr:port>
-      dst - <destination addr:port>
-      etc - <fingerprint> <nickname>
-    
-    ListingType.HOSTNAME:
-      src - localhost:<port>
-      dst - <destination hostname:port>
-      etc - <destination addr:port> <fingerprint> <nickname>
-    
-    ListingType.FINGERPRINT:
-      src - localhost
-      dst - <destination fingerprint>
-      etc - <nickname> <destination addr:port>
-    
-    ListingType.NICKNAME:
-      src - <source nickname>
-      dst - <destination nickname>
-      etc - <fingerprint> <destination addr:port>
-    
-    Arguments:
-      width       - maximum length of the line
-      currentTime - unix timestamp for what the results should consider to be
-                    the current time
-      listingType - primary attribute we're listing connections by
-    """
-    
-    # fetch our (most likely cached) display entry for the listing
-    myListing = entries.ConnectionPanelLine.getListingEntry(self, width, currentTime, listingType)
-    
-    # fill in the current uptime and return the results
-    if CONFIG["features.connection.markInitialConnections"]:
-      timePrefix = "+" if self.isInitialConnection else " "
-    else: timePrefix = ""
-    
-    timeLabel = timePrefix + "%5s" % str_tools.get_time_label(currentTime - self.startTime, 1)
-    myListing[2] = (timeLabel, myListing[2][1])
-    
-    return myListing
-  
-  def isUnresolvedApp(self):
-    """
-    True if our display uses application information that hasn't yet been resolved.
-    """
-    
-    return self.appName == None and self.getType() in (Category.SOCKS, Category.HIDDEN, Category.CONTROL)
-  
-  def _getListingEntry(self, width, currentTime, listingType):
-    entryType = self.getType()
-    
-    # Lines are split into the following components in reverse:
-    # init gap - " "
-    # content  - "<src>  -->  <dst>     <etc>     "
-    # time     - "<uptime>"
-    # preType  - " ("
-    # category - "<type>"
-    # postType - ")   "
-    
-    lineFormat = uiTools.getColor(CATEGORY_COLOR[entryType])
-    timeWidth = 6 if CONFIG["features.connection.markInitialConnections"] else 5
-    
-    drawEntry = [(" ", lineFormat),
-                 (self._getListingContent(width - (12 + timeWidth) - 1, listingType), lineFormat),
-                 (" " * timeWidth, lineFormat),
-                 (" (", lineFormat),
-                 (entryType.upper(), lineFormat | curses.A_BOLD),
-                 (")" + " " * (9 - len(entryType)), lineFormat)]
-    return drawEntry
-  
-  def _getDetails(self, width):
-    """
-    Provides details on the connection, correlated against available consensus
-    data.
-    
-    Arguments:
-      width - available space to display in
-    """
-    
-    detailFormat = curses.A_BOLD | uiTools.getColor(CATEGORY_COLOR[self.getType()])
-    return [(line, detailFormat) for line in self._getDetailContent(width)]
-  
-  def resetDisplay(self):
-    entries.ConnectionPanelLine.resetDisplay(self)
-    self.cachedType = None
-  
-  def isPrivate(self):
-    """
-    Returns true if the endpoint is private, possibly belonging to a client
-    connection or exit traffic.
-    """
-    
-    if not CONFIG["features.connection.showIps"]: return True
-    
-    # This is used to scrub private information from the interface. Relaying
-    # etiquette (and wiretapping laws) say these are bad things to look at so
-    # DON'T CHANGE THIS UNLESS YOU HAVE A DAMN GOOD REASON!
-    
-    myType = self.getType()
-    
-    if myType == Category.INBOUND:
-      # if we're a guard or bridge and the connection doesn't belong to a
-      # known relay then it might be client traffic
-      
-      conn = torTools.getConn()
-      if "Guard" in conn.getMyFlags([]) or conn.getOption("BridgeRelay", None) == "1":
-        allMatches = conn.getRelayFingerprint(self.foreign.getIpAddr(), getAllMatches = True)
-        return allMatches == []
-    elif myType == Category.EXIT:
-      # DNS connections exiting us aren't private (since they're hitting our
-      # resolvers). Everything else, however, is.
-      
-      # TODO: Ideally this would also double check that it's a UDP connection
-      # (since DNS is the only UDP connections Tor will relay), however this
-      # will take a bit more work to propagate the information up from the
-      # connection resolver.
-      return self.foreign.getPort() != "53"
-    
-    # for everything else this isn't a concern
-    return False
-  
-  def getType(self):
-    """
-    Provides our best guess at the current type of the connection. This
-    depends on consensus results, our current client circuits, etc. Results
-    are cached until this entry's display is reset.
-    """
-    
-    # caches both to simplify the calls and to keep the type consistent until
-    # we want to reflect changes
-    if not self.cachedType:
-      if self.baseType == Category.OUTBOUND:
-        # Currently the only non-static categories are OUTBOUND vs...
-        # - EXIT since this depends on the current consensus
-        # - CIRCUIT if this is likely to belong to our guard usage
-        # - DIRECTORY if this is a single-hop circuit (directory mirror?)
-        # 
-        # The exitability, circuits, and fingerprints are all cached by the
-        # torTools util keeping this a quick lookup.
-        
-        conn = torTools.getConn()
-        destFingerprint = self.foreign.getFingerprint()
-        
-        if destFingerprint == "UNKNOWN":
-          # Not a known relay. This might be an exit connection.
-          
-          if conn.isExitingAllowed(self.foreign.getIpAddr(), self.foreign.getPort()):
-            self.cachedType = Category.EXIT
-        elif self._possibleClient or self._possibleDirectory:
-          # This belongs to a known relay. If we haven't eliminated ourselves as
-          # a possible client or directory connection then check if it still
-          # holds true.
-          
-          myCircuits = conn.getCircuits()
-          
-          if self._possibleClient:
-            # Checks that this belongs to the first hop in a circuit that's
-            # either unestablished or longer than a single hop (ie, anything but
-            # a built 1-hop connection since those are most likely a directory
-            # mirror).
-            
-            for _, status, _, path in myCircuits:
-              if path[0] == destFingerprint and (status != "BUILT" or len(path) > 1):
-                self.cachedType = Category.CIRCUIT # matched a probable guard connection
-            
-            # if we fell through, we can eliminate ourselves as a guard in the future
-            if not self.cachedType:
-              self._possibleClient = False
-          
-          if self._possibleDirectory:
-            # Checks if we match a built, single hop circuit.
-            
-            for _, status, _, path in myCircuits:
-              if path[0] == destFingerprint and status == "BUILT" and len(path) == 1:
-                self.cachedType = Category.DIRECTORY
-            
-            # if we fell through, eliminate ourselves as a directory connection
-            if not self.cachedType:
-              self._possibleDirectory = False
-      
-      if not self.cachedType:
-        self.cachedType = self.baseType
-    
-    return self.cachedType
-  
-  def getEtcContent(self, width, listingType):
-    """
-    Provides the optional content for the connection.
-    
-    Arguments:
-      width       - maximum length of the line
-      listingType - primary attribute we're listing connections by
-    """
-    
-    # for applications show the command/pid
-    if self.getType() in (Category.SOCKS, Category.HIDDEN, Category.CONTROL):
-      displayLabel = ""
-      
-      if self.appName:
-        if self.appPid: displayLabel = "%s (%s)" % (self.appName, self.appPid)
-        else: displayLabel = self.appName
-      elif self.isAppResolving:
-        displayLabel = "resolving..."
-      else: displayLabel = "UNKNOWN"
-      
-      if len(displayLabel) < width:
-        return ("%%-%is" % width) % displayLabel
-      else: return ""
-    
-    # for everything else display connection/consensus information
-    dstAddress = self.getDestinationLabel(26, includeLocale = True)
-    etc, usedSpace = "", 0
-    if listingType == entries.ListingType.IP_ADDRESS:
-      if width > usedSpace + 42 and CONFIG["features.connection.showColumn.fingerprint"]:
-        # show fingerprint (column width: 42 characters)
-        etc += "%-40s  " % self.foreign.getFingerprint()
-        usedSpace += 42
-      
-      if width > usedSpace + 10 and CONFIG["features.connection.showColumn.nickname"]:
-        # show nickname (column width: remainder)
-        nicknameSpace = width - usedSpace
-        nicknameLabel = uiTools.cropStr(self.foreign.getNickname(), nicknameSpace, 0)
-        etc += ("%%-%is  " % nicknameSpace) % nicknameLabel
-        usedSpace += nicknameSpace + 2
-    elif listingType == entries.ListingType.HOSTNAME:
-      if width > usedSpace + 28 and CONFIG["features.connection.showColumn.destination"]:
-        # show destination ip/port/locale (column width: 28 characters)
-        etc += "%-26s  " % dstAddress
-        usedSpace += 28
-      
-      if width > usedSpace + 42 and CONFIG["features.connection.showColumn.fingerprint"]:
-        # show fingerprint (column width: 42 characters)
-        etc += "%-40s  " % self.foreign.getFingerprint()
-        usedSpace += 42
-      
-      if width > usedSpace + 17 and CONFIG["features.connection.showColumn.nickname"]:
-        # show nickname (column width: min 17 characters, uses half of the remainder)
-        nicknameSpace = 15 + (width - (usedSpace + 17)) / 2
-        nicknameLabel = uiTools.cropStr(self.foreign.getNickname(), nicknameSpace, 0)
-        etc += ("%%-%is  " % nicknameSpace) % nicknameLabel
-        usedSpace += (nicknameSpace + 2)
-    elif listingType == entries.ListingType.FINGERPRINT:
-      if width > usedSpace + 17:
-        # show nickname (column width: min 17 characters, consumes any remaining space)
-        nicknameSpace = width - usedSpace - 2
-        
-        # if there's room then also show a column with the destination
-        # ip/port/locale (column width: 28 characters)
-        isIpLocaleIncluded = width > usedSpace + 45
-        isIpLocaleIncluded &= CONFIG["features.connection.showColumn.destination"]
-        if isIpLocaleIncluded: nicknameSpace -= 28
-        
-        if CONFIG["features.connection.showColumn.nickname"]:
-          nicknameLabel = uiTools.cropStr(self.foreign.getNickname(), nicknameSpace, 0)
-          etc += ("%%-%is  " % nicknameSpace) % nicknameLabel
-          usedSpace += nicknameSpace + 2
-        
-        if isIpLocaleIncluded:
-          etc += "%-26s  " % dstAddress
-          usedSpace += 28
-    else:
-      if width > usedSpace + 42 and CONFIG["features.connection.showColumn.fingerprint"]:
-        # show fingerprint (column width: 42 characters)
-        etc += "%-40s  " % self.foreign.getFingerprint()
-        usedSpace += 42
-      
-      if width > usedSpace + 28 and CONFIG["features.connection.showColumn.destination"]:
-        # show destination ip/port/locale (column width: 28 characters)
-        etc += "%-26s  " % dstAddress
-        usedSpace += 28
-    
-    return ("%%-%is" % width) % etc
-  
-  def _getListingContent(self, width, listingType):
-    """
-    Provides the source, destination, and extra info for our listing.
-    
-    Arguments:
-      width       - maximum length of the line
-      listingType - primary attribute we're listing connections by
-    """
-    
-    conn = torTools.getConn()
-    myType = self.getType()
-    dstAddress = self.getDestinationLabel(26, includeLocale = True)
-    
-    # The required widths are the sum of the following:
-    # - room for LABEL_FORMAT and LABEL_MIN_PADDING (11 characters)
-    # - base data for the listing
-    # - that extra field plus any previous
-    
-    usedSpace = len(LABEL_FORMAT % tuple([""] * 4)) + LABEL_MIN_PADDING
-    localPort = ":%s" % self.local.getPort() if self.includePort else ""
-    
-    src, dst, etc = "", "", ""
-    if listingType == entries.ListingType.IP_ADDRESS:
-      myExternalIpAddr = conn.getInfo("address", self.local.getIpAddr())
-      addrDiffer = myExternalIpAddr != self.local.getIpAddr()
-      
-      # Expanding doesn't make sense, if the connection isn't actually
-      # going through Tor's external IP address. As there isn't a known
-      # method for checking if it is, we're checking the type instead.
-      #
-      # This isn't entirely correct. It might be a better idea to check if
-      # the source and destination addresses are both private, but that might
-      # not be perfectly reliable either.
-      
-      isExpansionType = not myType in (Category.SOCKS, Category.HIDDEN, Category.CONTROL)
-      
-      if isExpansionType: srcAddress = myExternalIpAddr + localPort
-      else: srcAddress = self.local.getIpAddr() + localPort
-      
-      if myType in (Category.SOCKS, Category.CONTROL):
-        # Like inbound connections these need their source and destination to
-        # be swapped. However, this only applies when listing by IP or hostname
-        # (their fingerprint and nickname are both for us). Reversing the
-        # fields here to keep the same column alignments.
-        
-        src = "%-21s" % dstAddress
-        dst = "%-26s" % srcAddress
-      else:
-        src = "%-21s" % srcAddress # ip:port = max of 21 characters
-        dst = "%-26s" % dstAddress # ip:port (xx) = max of 26 characters
-      
-      usedSpace += len(src) + len(dst) # base data requires 47 characters
-      
-      # Showing the fingerprint (which has the width of 42) has priority over
-      # an expanded address field. Hence check if we either have space for
-      # both or wouldn't be showing the fingerprint regardless.
-      
-      isExpandedAddrVisible = width > usedSpace + 28
-      if isExpandedAddrVisible and CONFIG["features.connection.showColumn.fingerprint"]:
-        isExpandedAddrVisible = width < usedSpace + 42 or width > usedSpace + 70
-      
-      if addrDiffer and isExpansionType and isExpandedAddrVisible and self.includeExpandedIpAddr and CONFIG["features.connection.showColumn.expandedIp"]:
-        # include the internal address in the src (extra 28 characters)
-        internalAddress = self.local.getIpAddr() + localPort
-        
-        # If this is an inbound connection then reverse ordering so it's:
-        # <foreign> --> <external> --> <internal>
-        # when the src and dst are swapped later
-        
-        if myType == Category.INBOUND: src = "%-21s  -->  %s" % (src, internalAddress)
-        else: src = "%-21s  -->  %s" % (internalAddress, src)
-        
-        usedSpace += 28
-      
-      etc = self.getEtcContent(width - usedSpace, listingType)
-      usedSpace += len(etc)
-    elif listingType == entries.ListingType.HOSTNAME:
-      # 15 characters for source, and a min of 40 reserved for the destination
-      # TODO: when actually functional the src and dst need to be swapped for
-      # SOCKS and CONTROL connections
-      src = "localhost%-6s" % localPort
-      usedSpace += len(src)
-      minHostnameSpace = 40
-      
-      etc = self.getEtcContent(width - usedSpace - minHostnameSpace, listingType)
-      usedSpace += len(etc)
-      
-      hostnameSpace = width - usedSpace
-      usedSpace = width # prevents padding at the end
-      if self.isPrivate():
-        dst = ("%%-%is" % hostnameSpace) % "<scrubbed>"
-      else:
-        hostname = self.foreign.getHostname(self.foreign.getIpAddr())
-        portLabel = ":%-5s" % self.foreign.getPort() if self.includePort else ""
-        
-        # truncates long hostnames and sets dst to <hostname>:<port>
-        hostname = uiTools.cropStr(hostname, hostnameSpace, 0)
-        dst = ("%%-%is" % hostnameSpace) % (hostname + portLabel)
-    elif listingType == entries.ListingType.FINGERPRINT:
-      src = "localhost"
-      if myType == Category.CONTROL: dst = "localhost"
-      else: dst = self.foreign.getFingerprint()
-      dst = "%-40s" % dst
-      
-      usedSpace += len(src) + len(dst) # base data requires 49 characters
-      
-      etc = self.getEtcContent(width - usedSpace, listingType)
-      usedSpace += len(etc)
-    else:
-      # base data requires 50 min characters
-      src = self.local.getNickname()
-      if myType == Category.CONTROL: dst = self.local.getNickname()
-      else: dst = self.foreign.getNickname()
-      minBaseSpace = 50
-      
-      etc = self.getEtcContent(width - usedSpace - minBaseSpace, listingType)
-      usedSpace += len(etc)
-      
-      baseSpace = width - usedSpace
-      usedSpace = width # prevents padding at the end
-      
-      if len(src) + len(dst) > baseSpace:
-        src = uiTools.cropStr(src, baseSpace / 3)
-        dst = uiTools.cropStr(dst, baseSpace - len(src))
-      
-      # pads dst entry to its max space
-      dst = ("%%-%is" % (baseSpace - len(src))) % dst
-    
-    if myType == Category.INBOUND: src, dst = dst, src
-    padding = " " * (width - usedSpace + LABEL_MIN_PADDING)
-    return LABEL_FORMAT % (src, dst, etc, padding)
-  
-  def _getDetailContent(self, width):
-    """
-    Provides a list with detailed information for this connection.
-    
-    Arguments:
-      width - max length of lines
-    """
-    
-    lines = [""] * 7
-    lines[0] = "address: %s" % self.getDestinationLabel(width - 11)
-    lines[1] = "locale: %s" % ("??" if self.isPrivate() else self.foreign.getLocale("??"))
-    
-    # Remaining data concerns the consensus results, with three possible cases:
-    # - if there's a single match then display its details
-    # - if there's multiple potential relays then list all of the combinations
-    #   of ORPorts / Fingerprints
-    # - if no consensus data is available then say so (probably a client or
-    #   exit connection)
-    
-    fingerprint = self.foreign.getFingerprint()
-    conn = torTools.getConn()
-    
-    if fingerprint != "UNKNOWN":
-      # single match - display information available about it
-      nsEntry = conn.getConsensusEntry(fingerprint)
-      descEntry = conn.getDescriptorEntry(fingerprint)
-      
-      # append the fingerprint to the second line
-      lines[1] = "%-13sfingerprint: %s" % (lines[1], fingerprint)
-      
-      if nsEntry:
-        # example consensus entry:
-        # r murble R8sCM1ar1sS2GulQYFVmvN95xsk RJr6q+wkTFG+ng5v2bdCbVVFfA4 2011-02-21 00:25:32 195.43.157.85 443 0
-        # s Exit Fast Guard Named Running Stable Valid
-        # w Bandwidth=2540
-        # p accept 20-23,43,53,79-81,88,110,143,194,443
-        
-        nsLines = nsEntry.split("\n")
-        
-        firstLineComp = nsLines[0].split(" ")
-        if len(firstLineComp) >= 9:
-          _, nickname, _, _, pubDate, pubTime, _, orPort, dirPort = firstLineComp[:9]
-        else: nickname, pubDate, pubTime, orPort, dirPort = "", "", "", "", ""
-        
-        flags = "unknown"
-        if len(nsLines) >= 2 and nsLines[1].startswith("s "):
-          flags = nsLines[1][2:]
-        
-        exitPolicy = conn.getRelayExitPolicy(fingerprint)
-        
-        if exitPolicy: policyLabel = exitPolicy.summary()
-        else: policyLabel = "unknown"
-        
-        dirPortLabel = "" if dirPort == "0" else "dirport: %s" % dirPort
-        lines[2] = "nickname: %-25s orport: %-10s %s" % (nickname, orPort, dirPortLabel)
-        lines[3] = "published: %s %s" % (pubTime, pubDate)
-        lines[4] = "flags: %s" % flags.replace(" ", ", ")
-        lines[5] = "exit policy: %s" % policyLabel
-      
-      if descEntry:
-        torVersion, platform, contact = "", "", ""
-        
-        for descLine in descEntry.split("\n"):
-          if descLine.startswith("platform"):
-            # has the tor version and platform, ex:
-            # platform Tor 0.2.1.29 (r318f470bc5f2ad43) on Linux x86_64
-            
-            torVersion = descLine[13:descLine.find(" ", 13)]
-            platform = descLine[descLine.rfind(" on ") + 4:]
-          elif descLine.startswith("contact"):
-            contact = descLine[8:]
-            
-            # clears up some highly common obscuring
-            for alias in (" at ", " AT "): contact = contact.replace(alias, "@")
-            for alias in (" dot ", " DOT "): contact = contact.replace(alias, ".")
-            
-            break # contact lines come after the platform
-        
-        lines[3] = "%-35s os: %-14s version: %s" % (lines[3], platform, torVersion)
-        
-        # contact information is an optional field
-        if contact: lines[6] = "contact: %s" % contact
-    else:
-      allMatches = conn.getRelayFingerprint(self.foreign.getIpAddr(), getAllMatches = True)
-      
-      if allMatches:
-        # multiple matches
-        lines[2] = "Multiple matches, possible fingerprints are:"
-        
-        for i in range(len(allMatches)):
-          isLastLine = i == 3
-          
-          relayPort, relayFingerprint = allMatches[i]
-          lineText = "%i. or port: %-5s fingerprint: %s" % (i, relayPort, relayFingerprint)
-          
-          # if there's multiple lines remaining at the end then give a count
-          remainingRelays = len(allMatches) - i
-          if isLastLine and remainingRelays > 1:
-            lineText = "... %i more" % remainingRelays
-          
-          lines[3 + i] = lineText
-          
-          if isLastLine: break
-      else:
-        # no consensus entry for this ip address
-        lines[2] = "No consensus data found"
-    
-    # crops any lines that are too long
-    for i in range(len(lines)):
-      lines[i] = uiTools.cropStr(lines[i], width - 2)
-    
-    return lines
-  
-  def getDestinationLabel(self, maxLength, includeLocale=False, includeHostname=False):
-    """
-    Provides a short description of the destination. This is made up of two
-    components, the base <ip addr>:<port> and an extra piece of information in
-    parentheses. The IP address is scrubbed from private connections.
-    
-    Extra information is...
-    - the port's purpose for exit connections
-    - the locale and/or hostname if set to do so, the address isn't private,
-      and isn't on the local network
-    - nothing otherwise
-    
-    Arguments:
-      maxLength       - maximum length of the string returned
-      includeLocale   - possibly includes the locale
-      includeHostname - possibly includes the hostname
-    """
-    
-    # the port and port derived data can be hidden by config or without includePort
-    includePort = self.includePort and (CONFIG["features.connection.showExitPort"] or self.getType() != Category.EXIT)
-    
-    # destination of the connection
-    ipLabel = "<scrubbed>" if self.isPrivate() else self.foreign.getIpAddr()
-    portLabel = ":%s" % self.foreign.getPort() if includePort else ""
-    dstAddress = ipLabel + portLabel
-    
-    # Only append the extra info if there's at least a couple characters of
-    # space (this is what's needed for the country codes).
-    if len(dstAddress) + 5 <= maxLength:
-      spaceAvailable = maxLength - len(dstAddress) - 3
-      
-      if self.getType() == Category.EXIT and includePort:
-        purpose = connections.getPortUsage(self.foreign.getPort())
-        
-        if purpose:
-          # BitTorrent is a common protocol to truncate, so just use "Torrent"
-          # if there's not enough room.
-          if len(purpose) > spaceAvailable and purpose == "BitTorrent":
-            purpose = "Torrent"
-          
-          # crops with a hyphen if too long
-          purpose = uiTools.cropStr(purpose, spaceAvailable, endType = uiTools.Ending.HYPHEN)
-          
-          dstAddress += " (%s)" % purpose
-      elif not connections.isIpAddressPrivate(self.foreign.getIpAddr()):
-        extraInfo = []
-        conn = torTools.getConn()
-        
-        if includeLocale and not conn.isGeoipUnavailable():
-          foreignLocale = self.foreign.getLocale("??")
-          extraInfo.append(foreignLocale)
-          spaceAvailable -= len(foreignLocale) + 2
-        
-        if includeHostname:
-          dstHostname = self.foreign.getHostname()
-          
-          if dstHostname:
-            # determines the full space available, taking into account the ", "
-            # dividers if there's multiple pieces of extra data
-            
-            maxHostnameSpace = spaceAvailable - 2 * len(extraInfo)
-            dstHostname = uiTools.cropStr(dstHostname, maxHostnameSpace)
-            extraInfo.append(dstHostname)
-            spaceAvailable -= len(dstHostname)
-        
-        if extraInfo:
-          dstAddress += " (%s)" % ", ".join(extraInfo)
-    
-    return dstAddress[:maxLength]
-
diff --git a/src/cli/connections/connPanel.py b/src/cli/connections/connPanel.py
deleted file mode 100644
index ec11944..0000000
--- a/src/cli/connections/connPanel.py
+++ /dev/null
@@ -1,587 +0,0 @@
-"""
-Listing of the currently established connections tor has made.
-"""
-
-import re
-import time
-import curses
-import threading
-
-import cli.popups
-
-from cli.connections import countPopup, descriptorPopup, entries, connEntry, circEntry
-from util import connections, panel, torTools, uiTools
-
-from stem.control import State
-from stem.util import conf, enum
-
-# height of the detail panel content, not counting top and bottom border
-DETAILS_HEIGHT = 7
-
-# listing types
-Listing = enum.Enum(("IP_ADDRESS", "IP Address"), "HOSTNAME", "FINGERPRINT", "NICKNAME")
-
-def conf_handler(key, value):
-  if key == "features.connection.listingType":
-    return conf.parse_enum(key, value, Listing)
-  elif key == "features.connection.refreshRate":
-    return max(1, value)
-  elif key == "features.connection.order":
-    return conf.parse_enum_csv(key, value[0], entries.SortAttr, 3)
-
-CONFIG = conf.config_dict("arm", {
-  "features.connection.resolveApps": True,
-  "features.connection.listingType": Listing.IP_ADDRESS,
-  "features.connection.order": [
-    entries.SortAttr.CATEGORY,
-    entries.SortAttr.LISTING,
-    entries.SortAttr.UPTIME],
-  "features.connection.refreshRate": 5,
-  "features.connection.showIps": True,
-}, conf_handler)
-
-class ConnectionPanel(panel.Panel, threading.Thread):
-  """
-  Listing of connections tor is making, with information correlated against
-  the current consensus and other data sources.
-  """
-  
-  def __init__(self, stdscr):
-    panel.Panel.__init__(self, stdscr, "connections", 0)
-    threading.Thread.__init__(self)
-    self.setDaemon(True)
-    
-    # defaults our listing selection to fingerprints if ip address
-    # displaying is disabled
-    #
-    # TODO: This is a little sucky in that it won't work if showIps changes
-    # while we're running (... but arm doesn't allow for that atm)
-    
-    if not CONFIG["features.connection.showIps"] and CONFIG["features.connection.listingType"] == 0:
-      armConf = conf.get_config("arm")
-      armConf.set("features.connection.listingType", enumeration.keys()[Listing.index_of(Listing.FINGERPRINT)])
-    
-    self._scroller = uiTools.Scroller(True)
-    self._title = "Connections:" # title line of the panel
-    self._entries = []          # last fetched display entries
-    self._entryLines = []       # individual lines rendered from the entries listing
-    self._showDetails = False   # presents the details panel if true
-    
-    self._lastUpdate = -1       # time the content was last revised
-    self._isTorRunning = True   # indicates if tor is currently running or not
-    self._haltTime = None       # time when tor was stopped
-    self._halt = False          # terminates thread if true
-    self._cond = threading.Condition()  # used for pausing the thread
-    self.valsLock = threading.RLock()
-    
-    # Tracks exiting port and client country statistics
-    self._clientLocaleUsage = {}
-    self._exitPortUsage = {}
-    
-    # If we're a bridge and been running over a day then prepopulates with the
-    # last day's clients.
-    
-    conn = torTools.getConn()
-    bridgeClients = conn.getInfo("status/clients-seen", None)
-    
-    if bridgeClients:
-      # Response has a couple arguments...
-      # TimeStarted="2011-08-17 15:50:49" CountrySummary=us=16,de=8,uk=8
-      
-      countrySummary = None
-      for arg in bridgeClients.split():
-        if arg.startswith("CountrySummary="):
-          countrySummary = arg[15:]
-          break
-      
-      if countrySummary:
-        for entry in countrySummary.split(","):
-          if re.match("^..=[0-9]+$", entry):
-            locale, count = entry.split("=", 1)
-            self._clientLocaleUsage[locale] = int(count)
-    
-    # Last sampling received from the ConnectionResolver, used to detect when
-    # it changes.
-    self._lastResourceFetch = -1
-    
-    # resolver for the command/pid associated with SOCKS, HIDDEN, and CONTROL connections
-    self._appResolver = connections.AppResolver("arm")
-    
-    # rate limits appResolver queries to once per update
-    self.appResolveSinceUpdate = False
-    
-    # mark the initially exitsing connection uptimes as being estimates
-    for entry in self._entries:
-      if isinstance(entry, connEntry.ConnectionEntry):
-        entry.getLines()[0].isInitialConnection = True
-    
-    # listens for when tor stops so we know to stop reflecting changes
-    conn.addStatusListener(self.torStateListener)
-  
-  def torStateListener(self, controller, eventType, _):
-    """
-    Freezes the connection contents when Tor stops.
-    """
-    
-    self._isTorRunning = eventType in (State.INIT, State.RESET)
-    
-    if self._isTorRunning: self._haltTime = None
-    else: self._haltTime = time.time()
-    
-    self.redraw(True)
-  
-  def getPauseTime(self):
-    """
-    Provides the time Tor stopped if it isn't running. Otherwise this is the
-    time we were last paused.
-    """
-    
-    if self._haltTime: return self._haltTime
-    else: return panel.Panel.getPauseTime(self)
-  
-  def setSortOrder(self, ordering = None):
-    """
-    Sets the connection attributes we're sorting by and resorts the contents.
-    
-    Arguments:
-      ordering - new ordering, if undefined then this resorts with the last
-                 set ordering
-    """
-    
-    self.valsLock.acquire()
-    
-    if ordering:
-      armConf = conf.get_config("arm")
-      
-      ordering_keys = [entries.SortAttr.keys()[entries.SortAttr.index_of(v)] for v in ordering]
-      armConf.set("features.connection.order", ", ".join(ordering_keys))
-    
-    self._entries.sort(key=lambda i: (i.getSortValues(CONFIG["features.connection.order"], self.getListingType())))
-    
-    self._entryLines = []
-    for entry in self._entries:
-      self._entryLines += entry.getLines()
-    self.valsLock.release()
-  
-  def getListingType(self):
-    """
-    Provides the priority content we list connections by.
-    """
-    
-    return CONFIG["features.connection.listingType"]
-  
-  def setListingType(self, listingType):
-    """
-    Sets the priority information presented by the panel.
-    
-    Arguments:
-      listingType - Listing instance for the primary information to be shown
-    """
-    
-    if self.getListingType() == listingType: return
-    
-    self.valsLock.acquire()
-    
-    armConf = conf.get_config("arm")
-    armConf.set("features.connection.listingType", Listing.keys()[Listing.index_of(listingType)])
-    
-    # if we're sorting by the listing then we need to resort
-    if entries.SortAttr.LISTING in CONFIG["features.connection.order"]:
-      self.setSortOrder()
-    
-    self.valsLock.release()
-  
-  def isClientsAllowed(self):
-    """
-    True if client connections are permissable, false otherwise.
-    """
-    
-    conn = torTools.getConn()
-    return "Guard" in conn.getMyFlags([]) or conn.getOption("BridgeRelay", None) == "1"
-  
-  def isExitsAllowed(self):
-    """
-    True if exit connections are permissable, false otherwise.
-    """
-    
-    if not torTools.getConn().getOption("ORPort", None):
-      return False # no ORPort
-    
-    policy = torTools.getConn().getExitPolicy()
-    return policy and policy.is_exiting_allowed()
-  
-  def showSortDialog(self):
-    """
-    Provides the sort dialog for our connections.
-    """
-    
-    # set ordering for connection options
-    titleLabel = "Connection Ordering:"
-    options = list(entries.SortAttr)
-    oldSelection = CONFIG["features.connection.order"]
-    optionColors = dict([(attr, entries.SORT_COLORS[attr]) for attr in options])
-    results = cli.popups.showSortDialog(titleLabel, options, oldSelection, optionColors)
-    if results: self.setSortOrder(results)
-  
-  def handleKey(self, key):
-    self.valsLock.acquire()
-    
-    isKeystrokeConsumed = True
-    if uiTools.isScrollKey(key):
-      pageHeight = self.getPreferredSize()[0] - 1
-      if self._showDetails: pageHeight -= (DETAILS_HEIGHT + 1)
-      isChanged = self._scroller.handleKey(key, self._entryLines, pageHeight)
-      if isChanged: self.redraw(True)
-    elif uiTools.isSelectionKey(key):
-      self._showDetails = not self._showDetails
-      self.redraw(True)
-    elif key == ord('s') or key == ord('S'):
-      self.showSortDialog()
-    elif key == ord('u') or key == ord('U'):
-      # provides a menu to pick the connection resolver
-      title = "Resolver Util:"
-      options = ["auto"] + list(connections.Resolver)
-      connResolver = connections.getResolver("tor")
-      
-      currentOverwrite = connResolver.overwriteResolver
-      if currentOverwrite == None: oldSelection = 0
-      else: oldSelection = options.index(currentOverwrite)
-      
-      selection = cli.popups.showMenu(title, options, oldSelection)
-      
-      # applies new setting
-      if selection != -1:
-        selectedOption = options[selection] if selection != 0 else None
-        connResolver.overwriteResolver = selectedOption
-    elif key == ord('l') or key == ord('L'):
-      # provides a menu to pick the primary information we list connections by
-      title = "List By:"
-      options = list(entries.ListingType)
-      
-      # dropping the HOSTNAME listing type until we support displaying that content
-      options.remove(cli.connections.entries.ListingType.HOSTNAME)
-      
-      oldSelection = options.index(self.getListingType())
-      selection = cli.popups.showMenu(title, options, oldSelection)
-      
-      # applies new setting
-      if selection != -1: self.setListingType(options[selection])
-    elif key == ord('d') or key == ord('D'):
-      # presents popup for raw consensus data
-      descriptorPopup.showDescriptorPopup(self)
-    elif (key == ord('c') or key == ord('C')) and self.isClientsAllowed():
-      countPopup.showCountDialog(countPopup.CountType.CLIENT_LOCALE, self._clientLocaleUsage)
-    elif (key == ord('e') or key == ord('E')) and self.isExitsAllowed():
-      countPopup.showCountDialog(countPopup.CountType.EXIT_PORT, self._exitPortUsage)
-    else: isKeystrokeConsumed = False
-    
-    self.valsLock.release()
-    return isKeystrokeConsumed
-  
-  def run(self):
-    """
-    Keeps connections listing updated, checking for new entries at a set rate.
-    """
-    
-    lastDraw = time.time() - 1
-    
-    # Fetches out initial connection results. The wait is so this doesn't
-    # run during arm's interface initialization (otherwise there's a
-    # noticeable pause before the first redraw).
-    self._cond.acquire()
-    self._cond.wait(0.2)
-    self._cond.release()
-    self._update()            # populates initial entries
-    self._resolveApps(False)  # resolves initial applications
-    
-    while not self._halt:
-      currentTime = time.time()
-      
-      if self.isPaused() or not self._isTorRunning or currentTime - lastDraw < CONFIG["features.connection.refreshRate"]:
-        self._cond.acquire()
-        if not self._halt: self._cond.wait(0.2)
-        self._cond.release()
-      else:
-        # updates content if their's new results, otherwise just redraws
-        self._update()
-        self.redraw(True)
-        
-        # we may have missed multiple updates due to being paused, showing
-        # another panel, etc so lastDraw might need to jump multiple ticks
-        drawTicks = (time.time() - lastDraw) / CONFIG["features.connection.refreshRate"]
-        lastDraw += CONFIG["features.connection.refreshRate"] * drawTicks
-  
-  def getHelp(self):
-    resolverUtil = connections.getResolver("tor").overwriteResolver
-    if resolverUtil == None: resolverUtil = "auto"
-    
-    options = []
-    options.append(("up arrow", "scroll up a line", None))
-    options.append(("down arrow", "scroll down a line", None))
-    options.append(("page up", "scroll up a page", None))
-    options.append(("page down", "scroll down a page", None))
-    options.append(("enter", "show connection details", None))
-    options.append(("d", "raw consensus descriptor", None))
-    
-    if self.isClientsAllowed():
-      options.append(("c", "client locale usage summary", None))
-    
-    if self.isExitsAllowed():
-      options.append(("e", "exit port usage summary", None))
-    
-    options.append(("l", "listed identity", self.getListingType().lower()))
-    options.append(("s", "sort ordering", None))
-    options.append(("u", "resolving utility", resolverUtil))
-    return options
-  
-  def getSelection(self):
-    """
-    Provides the currently selected connection entry.
-    """
-    
-    return self._scroller.getCursorSelection(self._entryLines)
-  
-  def draw(self, width, height):
-    self.valsLock.acquire()
-    
-    # if we don't have any contents then refuse to show details
-    if not self._entries: self._showDetails = False
-    
-    # extra line when showing the detail panel is for the bottom border
-    detailPanelOffset = DETAILS_HEIGHT + 1 if self._showDetails else 0
-    isScrollbarVisible = len(self._entryLines) > height - detailPanelOffset - 1
-    
-    scrollLoc = self._scroller.getScrollLoc(self._entryLines, height - detailPanelOffset - 1)
-    cursorSelection = self.getSelection()
-    
-    # draws the detail panel if currently displaying it
-    if self._showDetails and cursorSelection:
-      # This is a solid border unless the scrollbar is visible, in which case a
-      # 'T' pipe connects the border to the bar.
-      uiTools.drawBox(self, 0, 0, width, DETAILS_HEIGHT + 2)
-      if isScrollbarVisible: self.addch(DETAILS_HEIGHT + 1, 1, curses.ACS_TTEE)
-      
-      drawEntries = cursorSelection.getDetails(width)
-      for i in range(min(len(drawEntries), DETAILS_HEIGHT)):
-        self.addstr(1 + i, 2, drawEntries[i][0], drawEntries[i][1])
-    
-    # title label with connection counts
-    if self.isTitleVisible():
-      title = "Connection Details:" if self._showDetails else self._title
-      self.addstr(0, 0, title, curses.A_STANDOUT)
-    
-    scrollOffset = 0
-    if isScrollbarVisible:
-      scrollOffset = 2
-      self.addScrollBar(scrollLoc, scrollLoc + height - detailPanelOffset - 1, len(self._entryLines), 1 + detailPanelOffset)
-    
-    if self.isPaused() or not self._isTorRunning:
-      currentTime = self.getPauseTime()
-    else: currentTime = time.time()
-    
-    for lineNum in range(scrollLoc, len(self._entryLines)):
-      entryLine = self._entryLines[lineNum]
-      
-      # if this is an unresolved SOCKS, HIDDEN, or CONTROL entry then queue up
-      # resolution for the applicaitions they belong to
-      if isinstance(entryLine, connEntry.ConnectionLine) and entryLine.isUnresolvedApp():
-        self._resolveApps()
-      
-      # hilighting if this is the selected line
-      extraFormat = curses.A_STANDOUT if entryLine == cursorSelection else curses.A_NORMAL
-      
-      drawLine = lineNum + detailPanelOffset + 1 - scrollLoc
-      
-      prefix = entryLine.getListingPrefix()
-      for i in range(len(prefix)):
-        self.addch(drawLine, scrollOffset + i, prefix[i])
-      
-      xOffset = scrollOffset + len(prefix)
-      drawEntry = entryLine.getListingEntry(width - scrollOffset - len(prefix), currentTime, self.getListingType())
-      
-      for msg, attr in drawEntry:
-        attr |= extraFormat
-        self.addstr(drawLine, xOffset, msg, attr)
-        xOffset += len(msg)
-      
-      if drawLine >= height: break
-    
-    self.valsLock.release()
-  
-  def stop(self):
-    """
-    Halts further resolutions and terminates the thread.
-    """
-    
-    self._cond.acquire()
-    self._halt = True
-    self._cond.notifyAll()
-    self._cond.release()
-  
-  def _update(self):
-    """
-    Fetches the newest resolved connections.
-    """
-    
-    self.appResolveSinceUpdate = False
-    
-    # if we don't have an initialized resolver then this is a no-op
-    if not connections.isResolverAlive("tor"): return
-    
-    connResolver = connections.getResolver("tor")
-    currentResolutionCount = connResolver.getResolutionCount()
-    
-    self.valsLock.acquire()
-    
-    newEntries = [] # the new results we'll display
-    
-    # Fetches new connections and client circuits...
-    # newConnections  [(local ip, local port, foreign ip, foreign port)...]
-    # newCircuits     {circuitID => (status, purpose, path)...}
-    
-    newConnections = connResolver.getConnections()
-    newCircuits = {}
-    
-    for circuitID, status, purpose, path in torTools.getConn().getCircuits():
-      # Skips established single-hop circuits (these are for directory
-      # fetches, not client circuits)
-      if not (status == "BUILT" and len(path) == 1):
-        newCircuits[circuitID] = (status, purpose, path)
-    
-    # Populates newEntries with any of our old entries that still exist.
-    # This is both for performance and to keep from resetting the uptime
-    # attributes. Note that CircEntries are a ConnectionEntry subclass so
-    # we need to check for them first.
-    
-    for oldEntry in self._entries:
-      if isinstance(oldEntry, circEntry.CircEntry):
-        newEntry = newCircuits.get(oldEntry.circuitID)
-        
-        if newEntry:
-          oldEntry.update(newEntry[0], newEntry[2])
-          newEntries.append(oldEntry)
-          del newCircuits[oldEntry.circuitID]
-      elif isinstance(oldEntry, connEntry.ConnectionEntry):
-        connLine = oldEntry.getLines()[0]
-        connAttr = (connLine.local.getIpAddr(), connLine.local.getPort(),
-                    connLine.foreign.getIpAddr(), connLine.foreign.getPort())
-        
-        if connAttr in newConnections:
-          newEntries.append(oldEntry)
-          newConnections.remove(connAttr)
-    
-    # Reset any display attributes for the entries we're keeping
-    for entry in newEntries: entry.resetDisplay()
-    
-    # Adds any new connection and circuit entries.
-    for lIp, lPort, fIp, fPort in newConnections:
-      newConnEntry = connEntry.ConnectionEntry(lIp, lPort, fIp, fPort)
-      newConnLine = newConnEntry.getLines()[0]
-      
-      if newConnLine.getType() != connEntry.Category.CIRCUIT:
-        newEntries.append(newConnEntry)
-        
-        # updates exit port and client locale usage information
-        if newConnLine.isPrivate():
-          if newConnLine.getType() == connEntry.Category.INBOUND:
-            # client connection, update locale information
-            clientLocale = newConnLine.foreign.getLocale()
-            
-            if clientLocale:
-              self._clientLocaleUsage[clientLocale] = self._clientLocaleUsage.get(clientLocale, 0) + 1
-          elif newConnLine.getType() == connEntry.Category.EXIT:
-            exitPort = newConnLine.foreign.getPort()
-            self._exitPortUsage[exitPort] = self._exitPortUsage.get(exitPort, 0) + 1
-    
-    for circuitID in newCircuits:
-      status, purpose, path = newCircuits[circuitID]
-      newEntries.append(circEntry.CircEntry(circuitID, status, purpose, path))
-    
-    # Counts the relays in each of the categories. This also flushes the
-    # type cache for all of the connections (in case its changed since last
-    # fetched).
-    
-    categoryTypes = list(connEntry.Category)
-    typeCounts = dict((type, 0) for type in categoryTypes)
-    for entry in newEntries:
-      if isinstance(entry, connEntry.ConnectionEntry):
-        typeCounts[entry.getLines()[0].getType()] += 1
-      elif isinstance(entry, circEntry.CircEntry):
-        typeCounts[connEntry.Category.CIRCUIT] += 1
-    
-    # makes labels for all the categories with connections (ie,
-    # "21 outbound", "1 control", etc)
-    countLabels = []
-    
-    for category in categoryTypes:
-      if typeCounts[category] > 0:
-        countLabels.append("%i %s" % (typeCounts[category], category.lower()))
-    
-    if countLabels: self._title = "Connections (%s):" % ", ".join(countLabels)
-    else: self._title = "Connections:"
-    
-    self._entries = newEntries
-    
-    self._entryLines = []
-    for entry in self._entries:
-      self._entryLines += entry.getLines()
-    
-    self.setSortOrder()
-    self._lastResourceFetch = currentResolutionCount
-    self.valsLock.release()
-  
-  def _resolveApps(self, flagQuery = True):
-    """
-    Triggers an asynchronous query for all unresolved SOCKS, HIDDEN, and
-    CONTROL entries.
-    
-    Arguments:
-      flagQuery - sets a flag to prevent further call from being respected
-                  until the next update if true
-    """
-    
-    if self.appResolveSinceUpdate or not CONFIG["features.connection.resolveApps"]: return
-    unresolvedLines = [l for l in self._entryLines if isinstance(l, connEntry.ConnectionLine) and l.isUnresolvedApp()]
-    
-    # get the ports used for unresolved applications
-    appPorts = []
-    
-    for line in unresolvedLines:
-      appConn = line.local if line.getType() == connEntry.Category.HIDDEN else line.foreign
-      appPorts.append(appConn.getPort())
-    
-    # Queue up resolution for the unresolved ports (skips if it's still working
-    # on the last query).
-    if appPorts and not self._appResolver.isResolving:
-      self._appResolver.resolve(appPorts)
-    
-    # Fetches results. If the query finishes quickly then this is what we just
-    # asked for, otherwise these belong to an earlier resolution.
-    #
-    # The application resolver might have given up querying (for instance, if
-    # the lsof lookups aren't working on this platform or lacks permissions).
-    # The isAppResolving flag lets the unresolved entries indicate if there's
-    # a lookup in progress for them or not.
-    
-    appResults = self._appResolver.getResults(0.2)
-    
-    for line in unresolvedLines:
-      isLocal = line.getType() == connEntry.Category.HIDDEN
-      linePort = line.local.getPort() if isLocal else line.foreign.getPort()
-      
-      if linePort in appResults:
-        # sets application attributes if there's a result with this as the
-        # inbound port
-        for inboundPort, outboundPort, cmd, pid in appResults[linePort]:
-          appPort = outboundPort if isLocal else inboundPort
-          
-          if linePort == appPort:
-            line.appName = cmd
-            line.appPid = pid
-            line.isAppResolving = False
-      else:
-        line.isAppResolving = self._appResolver.isResolving
-    
-    if flagQuery:
-      self.appResolveSinceUpdate = True
-
diff --git a/src/cli/connections/countPopup.py b/src/cli/connections/countPopup.py
deleted file mode 100644
index d2818ed..0000000
--- a/src/cli/connections/countPopup.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""
-Provides a dialog with client locale or exiting port counts.
-"""
-
-import curses
-import operator
-
-import cli.controller
-import cli.popups
-
-from util import connections, uiTools
-
-from stem.util import enum, log
-
-CountType = enum.Enum("CLIENT_LOCALE", "EXIT_PORT")
-EXIT_USAGE_WIDTH = 15
-
-def showCountDialog(countType, counts):
-  """
-  Provides a dialog with bar graphs and percentages for the given set of
-  counts. Pressing any key closes the dialog.
-  
-  Arguments:
-    countType - type of counts being presented
-    counts    - mapping of labels to counts
-  """
-  
-  isNoStats = not counts
-  noStatsMsg = "Usage stats aren't available yet, press any key..."
-  
-  if isNoStats:
-    popup, width, height = cli.popups.init(3, len(noStatsMsg) + 4)
-  else:
-    popup, width, height = cli.popups.init(4 + max(1, len(counts)), 80)
-  if not popup: return
-  
-  try:
-    control = cli.controller.getController()
-    
-    popup.win.box()
-    
-    # dialog title
-    if countType == CountType.CLIENT_LOCALE:
-      title = "Client Locales"
-    elif countType == CountType.EXIT_PORT:
-      title = "Exiting Port Usage"
-    else:
-      title = ""
-      log.warn("Unrecognized count type: %s" % countType)
-    
-    popup.addstr(0, 0, title, curses.A_STANDOUT)
-    
-    if isNoStats:
-      popup.addstr(1, 2, noStatsMsg, curses.A_BOLD | uiTools.getColor("cyan"))
-    else:
-      sortedCounts = sorted(counts.iteritems(), key=operator.itemgetter(1))
-      sortedCounts.reverse()
-      
-      # constructs string formatting for the max key and value display width
-      keyWidth, valWidth, valueTotal = 3, 1, 0
-      for k, v in sortedCounts:
-        keyWidth = max(keyWidth, len(k))
-        valWidth = max(valWidth, len(str(v)))
-        valueTotal += v
-      
-      # extra space since we're adding usage informaion
-      if countType == CountType.EXIT_PORT:
-        keyWidth += EXIT_USAGE_WIDTH
-      
-      labelFormat = "%%-%is %%%ii (%%%%%%-2i)" % (keyWidth, valWidth)
-      
-      for i in range(height - 4):
-        k, v = sortedCounts[i]
-        
-        # includes a port usage column
-        if countType == CountType.EXIT_PORT:
-          usage = connections.getPortUsage(k)
-          
-          if usage:
-            keyFormat = "%%-%is   %%s" % (keyWidth - EXIT_USAGE_WIDTH)
-            k = keyFormat % (k, usage[:EXIT_USAGE_WIDTH - 3])
-        
-        label = labelFormat % (k, v, v * 100 / valueTotal)
-        popup.addstr(i + 1, 2, label, curses.A_BOLD | uiTools.getColor("green"))
-        
-        # All labels have the same size since they're based on the max widths.
-        # If this changes then this'll need to be the max label width.
-        labelWidth = len(label)
-        
-        # draws simple bar graph for percentages
-        fillWidth = v * (width - 4 - labelWidth) / valueTotal
-        for j in range(fillWidth):
-          popup.addstr(i + 1, 3 + labelWidth + j, " ", curses.A_STANDOUT | uiTools.getColor("red"))
-      
-      popup.addstr(height - 2, 2, "Press any key...")
-    
-    popup.win.refresh()
-    
-    curses.cbreak()
-    control.getScreen().getch()
-  finally: cli.popups.finalize()
-
diff --git a/src/cli/connections/descriptorPopup.py b/src/cli/connections/descriptorPopup.py
deleted file mode 100644
index eed213f..0000000
--- a/src/cli/connections/descriptorPopup.py
+++ /dev/null
@@ -1,229 +0,0 @@
-"""
-Popup providing the raw descriptor and consensus information for a relay.
-"""
-
-import math
-import curses
-
-import cli.popups
-import cli.connections.connEntry
-
-from util import panel, torTools, uiTools
-
-# field keywords used to identify areas for coloring
-LINE_NUM_COLOR = "yellow"
-HEADER_COLOR = "cyan"
-HEADER_PREFIX = ["ns/id/", "desc/id/"]
-
-SIG_COLOR = "red"
-SIG_START_KEYS = ["-----BEGIN RSA PUBLIC KEY-----", "-----BEGIN SIGNATURE-----"]
-SIG_END_KEYS = ["-----END RSA PUBLIC KEY-----", "-----END SIGNATURE-----"]
-
-UNRESOLVED_MSG = "No consensus data available"
-ERROR_MSG = "Unable to retrieve data"
-
-def showDescriptorPopup(connPanel):
-  """
-  Presents consensus descriptor in popup window with the following controls:
-  Up, Down, Page Up, Page Down - scroll descriptor
-  Right, Left - next / previous connection
-  Enter, Space, d, D - close popup
-  
-  Arguments:
-    connPanel - connection panel providing the dialog
-  """
-  
-  # hides the title of the connection panel
-  connPanel.setTitleVisible(False)
-  connPanel.redraw(True)
-  
-  control = cli.controller.getController()
-  panel.CURSES_LOCK.acquire()
-  isDone = False
-  
-  try:
-    while not isDone:
-      selection = connPanel.getSelection()
-      if not selection: break
-      
-      fingerprint = selection.foreign.getFingerprint()
-      if fingerprint == "UNKNOWN": fingerprint = None
-      
-      displayText = getDisplayText(fingerprint)
-      displayColor = cli.connections.connEntry.CATEGORY_COLOR[selection.getType()]
-      showLineNumber = fingerprint != None
-      
-      # determines the maximum popup size the displayText can fill
-      pHeight, pWidth = getPreferredSize(displayText, connPanel.maxX, showLineNumber)
-      
-      popup, _, height = cli.popups.init(pHeight, pWidth)
-      if not popup: break
-      scroll, isChanged = 0, True
-      
-      try:
-        while not isDone:
-          if isChanged:
-            draw(popup, fingerprint, displayText, displayColor, scroll, showLineNumber)
-            isChanged = False
-          
-          key = control.getScreen().getch()
-          
-          if uiTools.isScrollKey(key):
-            # TODO: This is a bit buggy in that scrolling is by displayText
-            # lines rather than the displayed lines, causing issues when
-            # content wraps. The result is that we can't have a scrollbar and
-            # can't scroll to the bottom if there's a multi-line being
-            # displayed. However, trying to correct this introduces a big can
-            # of worms and after hours decided that this isn't worth the
-            # effort...
-            
-            newScroll = uiTools.getScrollPosition(key, scroll, height - 2, len(displayText))
-            
-            if scroll != newScroll:
-              scroll, isChanged = newScroll, True
-          elif uiTools.isSelectionKey(key) or key in (ord('d'), ord('D')):
-            isDone = True # closes popup
-          elif key in (curses.KEY_LEFT, curses.KEY_RIGHT):
-            # navigation - pass on to connPanel and recreate popup
-            connPanel.handleKey(curses.KEY_UP if key == curses.KEY_LEFT else curses.KEY_DOWN)
-            break
-      finally: cli.popups.finalize()
-  finally:
-    connPanel.setTitleVisible(True)
-    connPanel.redraw(True)
-    panel.CURSES_LOCK.release()
-
-def getDisplayText(fingerprint):
-  """
-  Provides the descriptor and consensus entry for a relay. This is a list of
-  lines to be displayed by the dialog.
-  """
-  
-  if not fingerprint: return [UNRESOLVED_MSG]
-  conn, description = torTools.getConn(), []
-  
-  description.append("ns/id/%s" % fingerprint)
-  consensusEntry = conn.getConsensusEntry(fingerprint)
-  
-  if consensusEntry: description += consensusEntry.split("\n")
-  else: description += [ERROR_MSG, ""]
-  
-  description.append("desc/id/%s" % fingerprint)
-  descriptorEntry = conn.getDescriptorEntry(fingerprint)
-  
-  if descriptorEntry: description += descriptorEntry.split("\n")
-  else: description += [ERROR_MSG]
-  
-  return description
-
-def getPreferredSize(text, maxWidth, showLineNumber):
-  """
-  Provides the (height, width) tuple for the preferred size of the given text.
-  """
-  
-  width, height = 0, len(text) + 2
-  lineNumWidth = int(math.log10(len(text))) + 1
-  for line in text:
-    # width includes content, line number field, and border
-    lineWidth = len(line) + 5
-    if showLineNumber: lineWidth += lineNumWidth
-    width = max(width, lineWidth)
-    
-    # tracks number of extra lines that will be taken due to text wrap
-    height += (lineWidth - 2) / maxWidth
-  
-  return (height, width)
-
-def draw(popup, fingerprint, displayText, displayColor, scroll, showLineNumber):
-  popup.win.erase()
-  popup.win.box()
-  xOffset = 2
-  
-  if fingerprint: title = "Consensus Descriptor (%s):" % fingerprint
-  else: title = "Consensus Descriptor:"
-  popup.addstr(0, 0, title, curses.A_STANDOUT)
-  
-  lineNumWidth = int(math.log10(len(displayText))) + 1
-  isEncryptionBlock = False   # flag indicating if we're currently displaying a key
-  
-  # checks if first line is in an encryption block
-  for i in range(0, scroll):
-    lineText = displayText[i].strip()
-    if lineText in SIG_START_KEYS: isEncryptionBlock = True
-    elif lineText in SIG_END_KEYS: isEncryptionBlock = False
-  
-  drawLine, pageHeight = 1, popup.maxY - 2
-  for i in range(scroll, scroll + pageHeight):
-    lineText = displayText[i].strip()
-    xOffset = 2
-    
-    if showLineNumber:
-      lineNumLabel = ("%%%ii" % lineNumWidth) % (i + 1)
-      lineNumFormat = curses.A_BOLD | uiTools.getColor(LINE_NUM_COLOR)
-      
-      popup.addstr(drawLine, xOffset, lineNumLabel, lineNumFormat)
-      xOffset += lineNumWidth + 1
-    
-    # Most consensus and descriptor lines are keyword/value pairs. Both are
-    # shown with the same color, but the keyword is bolded.
-    
-    keyword, value = lineText, ""
-    drawFormat = uiTools.getColor(displayColor)
-    
-    if lineText.startswith(HEADER_PREFIX[0]) or lineText.startswith(HEADER_PREFIX[1]):
-      keyword, value = lineText, ""
-      drawFormat = uiTools.getColor(HEADER_COLOR)
-    elif lineText == UNRESOLVED_MSG or lineText == ERROR_MSG:
-      keyword, value = lineText, ""
-    elif lineText in SIG_START_KEYS:
-      keyword, value = lineText, ""
-      isEncryptionBlock = True
-      drawFormat = uiTools.getColor(SIG_COLOR)
-    elif lineText in SIG_END_KEYS:
-      keyword, value = lineText, ""
-      isEncryptionBlock = False
-      drawFormat = uiTools.getColor(SIG_COLOR)
-    elif isEncryptionBlock:
-      keyword, value = "", lineText
-      drawFormat = uiTools.getColor(SIG_COLOR)
-    elif " " in lineText:
-      divIndex = lineText.find(" ")
-      keyword, value = lineText[:divIndex], lineText[divIndex:]
-    
-    displayQueue = [(keyword, drawFormat | curses.A_BOLD), (value, drawFormat)]
-    cursorLoc = xOffset
-    
-    while displayQueue:
-      msg, format = displayQueue.pop(0)
-      if not msg: continue
-      
-      maxMsgSize = popup.maxX - 1 - cursorLoc
-      if len(msg) >= maxMsgSize:
-        # needs to split up the line
-        msg, remainder = uiTools.cropStr(msg, maxMsgSize, None, endType = None, getRemainder = True)
-        
-        if xOffset == cursorLoc and msg == "":
-          # first word is longer than the line
-          msg = uiTools.cropStr(remainder, maxMsgSize)
-          
-          if " " in remainder:
-            remainder = remainder.split(" ", 1)[1]
-          else: remainder = ""
-        
-        popup.addstr(drawLine, cursorLoc, msg, format)
-        cursorLoc = xOffset
-        
-        if remainder:
-          displayQueue.insert(0, (remainder.strip(), format))
-          drawLine += 1
-      else:
-        popup.addstr(drawLine, cursorLoc, msg, format)
-        cursorLoc += len(msg)
-      
-      if drawLine > pageHeight: break
-    
-    drawLine += 1
-    if drawLine > pageHeight: break
-  
-  popup.win.refresh()
-
diff --git a/src/cli/connections/entries.py b/src/cli/connections/entries.py
deleted file mode 100644
index d5085aa..0000000
--- a/src/cli/connections/entries.py
+++ /dev/null
@@ -1,171 +0,0 @@
-"""
-Interface for entries in the connection panel. These consist of two parts: the
-entry itself (ie, Tor connection, client circuit, etc) and the lines it
-consists of in the listing.
-"""
-
-from stem.util import enum
-
-# attributes we can list entries by
-ListingType = enum.Enum(("IP_ADDRESS", "IP Address"), "HOSTNAME", "FINGERPRINT", "NICKNAME")
-
-SortAttr = enum.Enum("CATEGORY", "UPTIME", "LISTING", "IP_ADDRESS", "PORT",
-                     "HOSTNAME", "FINGERPRINT", "NICKNAME", "COUNTRY")
-
-SORT_COLORS = {SortAttr.CATEGORY: "red",      SortAttr.UPTIME: "yellow",
-               SortAttr.LISTING: "green",     SortAttr.IP_ADDRESS: "blue",
-               SortAttr.PORT: "blue",         SortAttr.HOSTNAME: "magenta",
-               SortAttr.FINGERPRINT: "cyan",  SortAttr.NICKNAME: "cyan",
-               SortAttr.COUNTRY: "blue"}
-
-# maximum number of ports a system can have
-PORT_COUNT = 65536
-
-class ConnectionPanelEntry:
-  """
-  Common parent for connection panel entries. This consists of a list of lines
-  in the panel listing. This caches results until the display indicates that
-  they should be flushed.
-  """
-  
-  def __init__(self):
-    self.lines = []
-    self.flushCache = True
-  
-  def getLines(self):
-    """
-    Provides the individual lines in the connection listing.
-    """
-    
-    if self.flushCache:
-      self.lines = self._getLines(self.lines)
-      self.flushCache = False
-    
-    return self.lines
-  
-  def _getLines(self, oldResults):
-    # implementation of getLines
-    
-    for line in oldResults:
-      line.resetDisplay()
-    
-    return oldResults
-  
-  def getSortValues(self, sortAttrs, listingType):
-    """
-    Provides the value used in comparisons to sort based on the given
-    attribute.
-    
-    Arguments:
-      sortAttrs   - list of SortAttr values for the field being sorted on
-      listingType - ListingType enumeration for the attribute we're listing
-                    entries by
-    """
-    
-    return [self.getSortValue(attr, listingType) for attr in sortAttrs]
-  
-  def getSortValue(self, attr, listingType):
-    """
-    Provides the value of a single attribute used for sorting purposes.
-    
-    Arguments:
-      attr        - list of SortAttr values for the field being sorted on
-      listingType - ListingType enumeration for the attribute we're listing
-                    entries by
-    """
-    
-    if attr == SortAttr.LISTING:
-      if listingType == ListingType.IP_ADDRESS:
-        # uses the IP address as the primary value, and port as secondary
-        sortValue = self.getSortValue(SortAttr.IP_ADDRESS, listingType) * PORT_COUNT
-        sortValue += self.getSortValue(SortAttr.PORT, listingType)
-        return sortValue
-      elif listingType == ListingType.HOSTNAME:
-        return self.getSortValue(SortAttr.HOSTNAME, listingType)
-      elif listingType == ListingType.FINGERPRINT:
-        return self.getSortValue(SortAttr.FINGERPRINT, listingType)
-      elif listingType == ListingType.NICKNAME:
-        return self.getSortValue(SortAttr.NICKNAME, listingType)
-    
-    return ""
-  
-  def resetDisplay(self):
-    """
-    Flushes cached display results.
-    """
-    
-    self.flushCache = True
-
-class ConnectionPanelLine:
-  """
-  Individual line in the connection panel listing.
-  """
-  
-  def __init__(self):
-    # cache for displayed information
-    self._listingCache = None
-    self._listingCacheArgs = (None, None)
-    
-    self._detailsCache = None
-    self._detailsCacheArgs = None
-    
-    self._descriptorCache = None
-    self._descriptorCacheArgs = None
-  
-  def getListingPrefix(self):
-    """
-    Provides a list of characters to be appended before the listing entry.
-    """
-    
-    return ()
-  
-  def getListingEntry(self, width, currentTime, listingType):
-    """
-    Provides a [(msg, attr)...] tuple list for contents to be displayed in the
-    connection panel listing.
-    
-    Arguments:
-      width       - available space to display in
-      currentTime - unix timestamp for what the results should consider to be
-                    the current time (this may be ignored due to caching)
-      listingType - ListingType enumeration for the highest priority content
-                    to be displayed
-    """
-    
-    if self._listingCacheArgs != (width, listingType):
-      self._listingCache = self._getListingEntry(width, currentTime, listingType)
-      self._listingCacheArgs = (width, listingType)
-    
-    return self._listingCache
-  
-  def _getListingEntry(self, width, currentTime, listingType):
-    # implementation of getListingEntry
-    return None
-  
-  def getDetails(self, width):
-    """
-    Provides a list of [(msg, attr)...] tuple listings with detailed
-    information for this connection.
-    
-    Arguments:
-      width - available space to display in
-    """
-    
-    if self._detailsCacheArgs != width:
-      self._detailsCache = self._getDetails(width)
-      self._detailsCacheArgs = width
-    
-    return self._detailsCache
-  
-  def _getDetails(self, width):
-    # implementation of getDetails
-    return []
-  
-  def resetDisplay(self):
-    """
-    Flushes cached display results.
-    """
-    
-    self._listingCacheArgs = (None, None)
-    self._detailsCacheArgs = None
-
diff --git a/src/cli/controller.py b/src/cli/controller.py
deleted file mode 100644
index 0ef07d8..0000000
--- a/src/cli/controller.py
+++ /dev/null
@@ -1,676 +0,0 @@
-"""
-Main interface loop for arm, periodically redrawing the screen and issuing
-user input to the proper panels.
-"""
-
-import os
-import time
-import curses
-import sys
-import threading
-
-import cli.menu.menu
-import cli.popups
-import cli.headerPanel
-import cli.logPanel
-import cli.configPanel
-import cli.torrcPanel
-import cli.graphing.graphPanel
-import cli.graphing.bandwidthStats
-import cli.graphing.connStats
-import cli.graphing.resourceStats
-import cli.connections.connPanel
-
-from stem.control import State, Controller
-
-from util import connections, hostnames, panel, sysTools, torConfig, torTools
-
-from stem.util import conf, enum, log
-
-ARM_CONTROLLER = None
-
-def conf_handler(key, value):
-  if key == "features.redrawRate":
-    return max(1, value)
-  elif key == "features.refreshRate":
-    return max(0, value)
-
-CONFIG = conf.config_dict("arm", {
-  "startup.events": "N3",
-  "startup.dataDirectory": "~/.arm",
-  "startup.blindModeEnabled": False,
-  "features.panels.show.graph": True,
-  "features.panels.show.log": True,
-  "features.panels.show.connection": True,
-  "features.panels.show.config": True,
-  "features.panels.show.torrc": True,
-  "features.redrawRate": 5,
-  "features.refreshRate": 5,
-  "features.confirmQuit": True,
-  "features.graph.type": 1,
-  "features.graph.bw.prepopulate": True,
-}, conf_handler)
-
-GraphStat = enum.Enum("BANDWIDTH", "CONNECTIONS", "SYSTEM_RESOURCES")
-
-# maps 'features.graph.type' config values to the initial types
-GRAPH_INIT_STATS = {1: GraphStat.BANDWIDTH, 2: GraphStat.CONNECTIONS, 3: GraphStat.SYSTEM_RESOURCES}
-
-def getController():
-  """
-  Provides the arm controller instance.
-  """
-  
-  return ARM_CONTROLLER
-
-def initController(stdscr, startTime):
-  """
-  Spawns the controller, and related panels for it.
-  
-  Arguments:
-    stdscr - curses window
-  """
-  
-  global ARM_CONTROLLER
-  
-  # initializes the panels
-  stickyPanels = [cli.headerPanel.HeaderPanel(stdscr, startTime),
-                  LabelPanel(stdscr)]
-  pagePanels, firstPagePanels = [], []
-  
-  # first page: graph and log
-  if CONFIG["features.panels.show.graph"]:
-    firstPagePanels.append(cli.graphing.graphPanel.GraphPanel(stdscr))
-  
-  if CONFIG["features.panels.show.log"]:
-    expandedEvents = cli.logPanel.expandEvents(CONFIG["startup.events"])
-    firstPagePanels.append(cli.logPanel.LogPanel(stdscr, expandedEvents))
-  
-  if firstPagePanels: pagePanels.append(firstPagePanels)
-  
-  # second page: connections
-  if not CONFIG["startup.blindModeEnabled"] and CONFIG["features.panels.show.connection"]:
-    pagePanels.append([cli.connections.connPanel.ConnectionPanel(stdscr)])
-  
-  # third page: config
-  if CONFIG["features.panels.show.config"]:
-    pagePanels.append([cli.configPanel.ConfigPanel(stdscr, cli.configPanel.State.TOR)])
-  
-  # fourth page: torrc
-  if CONFIG["features.panels.show.torrc"]:
-    pagePanels.append([cli.torrcPanel.TorrcPanel(stdscr, cli.torrcPanel.Config.TORRC)])
-  
-  # initializes the controller
-  ARM_CONTROLLER = Controller(stdscr, stickyPanels, pagePanels)
-  
-  # additional configuration for the graph panel
-  graphPanel = ARM_CONTROLLER.getPanel("graph")
-  
-  if graphPanel:
-    # statistical monitors for graph
-    bwStats = cli.graphing.bandwidthStats.BandwidthStats()
-    graphPanel.addStats(GraphStat.BANDWIDTH, bwStats)
-    graphPanel.addStats(GraphStat.SYSTEM_RESOURCES, cli.graphing.resourceStats.ResourceStats())
-    if not CONFIG["startup.blindModeEnabled"]:
-      graphPanel.addStats(GraphStat.CONNECTIONS, cli.graphing.connStats.ConnStats())
-    
-    # sets graph based on config parameter
-    try:
-      initialStats = GRAPH_INIT_STATS.get(CONFIG["features.graph.type"])
-      graphPanel.setStats(initialStats)
-    except ValueError: pass # invalid stats, maybe connections when in blind mode
-    
-    # prepopulates bandwidth values from state file
-    if CONFIG["features.graph.bw.prepopulate"] and torTools.getConn().isAlive():
-      isSuccessful = bwStats.prepopulateFromState()
-      if isSuccessful: graphPanel.updateInterval = 4
-
-class LabelPanel(panel.Panel):
-  """
-  Panel that just displays a single line of text.
-  """
-  
-  def __init__(self, stdscr):
-    panel.Panel.__init__(self, stdscr, "msg", 0, height=1)
-    self.msgText = ""
-    self.msgAttr = curses.A_NORMAL
-  
-  def setMessage(self, msg, attr = None):
-    """
-    Sets the message being displayed by the panel.
-    
-    Arguments:
-      msg  - string to be displayed
-      attr - attribute for the label, normal text if undefined
-    """
-    
-    if attr == None: attr = curses.A_NORMAL
-    self.msgText = msg
-    self.msgAttr = attr
-  
-  def draw(self, width, height):
-    self.addstr(0, 0, self.msgText, self.msgAttr)
-
-class Controller:
-  """
-  Tracks the global state of the interface
-  """
-  
-  def __init__(self, stdscr, stickyPanels, pagePanels):
-    """
-    Creates a new controller instance. Panel lists are ordered as they appear,
-    top to bottom on the page.
-    
-    Arguments:
-      stdscr       - curses window
-      stickyPanels - panels shown at the top of each page
-      pagePanels   - list of pages, each being a list of the panels on it
-    """
-    
-    self._screen = stdscr
-    self._stickyPanels = stickyPanels
-    self._pagePanels = pagePanels
-    self._page = 0
-    self._isPaused = False
-    self._forceRedraw = False
-    self._isDone = False
-    self._lastDrawn = 0
-    self.setMsg() # initializes our control message
-  
-  def getScreen(self):
-    """
-    Provides our curses window.
-    """
-    
-    return self._screen
-  
-  def getPageCount(self):
-    """
-    Provides the number of pages the interface has. This may be zero if all
-    page panels have been disabled.
-    """
-    
-    return len(self._pagePanels)
-  
-  def getPage(self):
-    """
-    Provides the number belonging to this page. Page numbers start at zero.
-    """
-    
-    return self._page
-  
-  def setPage(self, pageNumber):
-    """
-    Sets the selected page, raising a ValueError if the page number is invalid.
-    
-    Arguments:
-      pageNumber - page number to be selected
-    """
-    
-    if pageNumber < 0 or pageNumber >= self.getPageCount():
-      raise ValueError("Invalid page number: %i" % pageNumber)
-    
-    if pageNumber != self._page:
-      self._page = pageNumber
-      self._forceRedraw = True
-      self.setMsg()
-  
-  def nextPage(self):
-    """
-    Increments the page number.
-    """
-    
-    self.setPage((self._page + 1) % len(self._pagePanels))
-  
-  def prevPage(self):
-    """
-    Decrements the page number.
-    """
-    
-    self.setPage((self._page - 1) % len(self._pagePanels))
-  
-  def isPaused(self):
-    """
-    True if the interface is paused, false otherwise.
-    """
-    
-    return self._isPaused
-  
-  def setPaused(self, isPause):
-    """
-    Sets the interface to be paused or unpaused.
-    """
-    
-    if isPause != self._isPaused:
-      self._isPaused = isPause
-      self._forceRedraw = True
-      self.setMsg()
-      
-      for panelImpl in self.getAllPanels():
-        panelImpl.setPaused(isPause)
-  
-  def getPanel(self, name):
-    """
-    Provides the panel with the given identifier. This returns None if no such
-    panel exists.
-    
-    Arguments:
-      name - name of the panel to be fetched
-    """
-    
-    for panelImpl in self.getAllPanels():
-      if panelImpl.getName() == name:
-        return panelImpl
-    
-    return None
-  
-  def getStickyPanels(self):
-    """
-    Provides the panels visibile at the top of every page.
-    """
-    
-    return list(self._stickyPanels)
-  
-  def getDisplayPanels(self, pageNumber = None, includeSticky = True):
-    """
-    Provides all panels belonging to a page and sticky content above it. This
-    is ordered they way they are presented (top to bottom) on the page.
-    
-    Arguments:
-      pageNumber    - page number of the panels to be returned, the current
-                      page if None
-      includeSticky - includes sticky panels in the results if true
-    """
-    
-    returnPage = self._page if pageNumber == None else pageNumber
-    
-    if self._pagePanels:
-      if includeSticky:
-        return self._stickyPanels + self._pagePanels[returnPage]
-      else: return list(self._pagePanels[returnPage])
-    else: return self._stickyPanels if includeSticky else []
-  
-  def getDaemonPanels(self):
-    """
-    Provides thread panels.
-    """
-    
-    threadPanels = []
-    for panelImpl in self.getAllPanels():
-      if isinstance(panelImpl, threading.Thread):
-        threadPanels.append(panelImpl)
-    
-    return threadPanels
-  
-  def getAllPanels(self):
-    """
-    Provides all panels in the interface.
-    """
-    
-    allPanels = list(self._stickyPanels)
-    
-    for page in self._pagePanels:
-      allPanels += list(page)
-    
-    return allPanels
-  
-  def redraw(self, force = True):
-    """
-    Redraws the displayed panel content.
-    
-    Arguments:
-      force - redraws reguardless of if it's needed if true, otherwise ignores
-              the request when there arne't changes to be displayed
-    """
-    
-    force |= self._forceRedraw
-    self._forceRedraw = False
-    
-    currentTime = time.time()
-    if CONFIG["features.refreshRate"] != 0:
-      if self._lastDrawn + CONFIG["features.refreshRate"] <= currentTime:
-        force = True
-    
-    displayPanels = self.getDisplayPanels()
-    
-    occupiedContent = 0
-    for panelImpl in displayPanels:
-      panelImpl.setTop(occupiedContent)
-      occupiedContent += panelImpl.getHeight()
-    
-    # apparently curses may cache display contents unless we explicitely
-    # request a redraw here...
-    # https://trac.torproject.org/projects/tor/ticket/2830#comment:9
-    if force: self._screen.clear()
-    
-    for panelImpl in displayPanels:
-      panelImpl.redraw(force)
-    
-    if force: self._lastDrawn = currentTime
-  
-  def requestRedraw(self):
-    """
-    Requests that all content is redrawn when the interface is next rendered.
-    """
-    
-    self._forceRedraw = True
-  
-  def getLastRedrawTime(self):
-    """
-    Provides the time when the content was last redrawn, zero if the content
-    has never been drawn.
-    """
-    
-    return self._lastDrawn
-  
-  def setMsg(self, msg = None, attr = None, redraw = False):
-    """
-    Sets the message displayed in the interfaces control panel. This uses our
-    default prompt if no arguments are provided.
-    
-    Arguments:
-      msg    - string to be displayed
-      attr   - attribute for the label, normal text if undefined
-      redraw - redraws right away if true, otherwise redraws when display
-               content is next normally drawn
-    """
-    
-    if msg == None:
-      msg = ""
-      
-      if attr == None:
-        if not self._isPaused:
-          msg = "page %i / %i - m: menu, p: pause, h: page help, q: quit" % (self._page + 1, len(self._pagePanels))
-          attr = curses.A_NORMAL
-        else:
-          msg = "Paused"
-          attr = curses.A_STANDOUT
-    
-    controlPanel = self.getPanel("msg")
-    controlPanel.setMessage(msg, attr)
-    
-    if redraw: controlPanel.redraw(True)
-    else: self._forceRedraw = True
-  
-  def getDataDirectory(self):
-    """
-    Provides the path where arm's resources are being placed. The path ends
-    with a slash and is created if it doesn't already exist.
-    """
-    
-    dataDir = os.path.expanduser(CONFIG["startup.dataDirectory"])
-    if not dataDir.endswith("/"): dataDir += "/"
-    if not os.path.exists(dataDir): os.makedirs(dataDir)
-    return dataDir
-  
-  def isDone(self):
-    """
-    True if arm should be terminated, false otherwise.
-    """
-    
-    return self._isDone
-  
-  def quit(self):
-    """
-    Terminates arm after the input is processed. Optionally if we're connected
-    to a arm generated tor instance then this may check if that should be shut
-    down too.
-    """
-    
-    self._isDone = True
-    
-    # check if the torrc has a "ARM_SHUTDOWN" comment flag, if so then shut
-    # down the instance
-    
-    isShutdownFlagPresent = False
-    torrcContents = torConfig.getTorrc().getContents()
-    
-    if torrcContents:
-      for line in torrcContents:
-        if "# ARM_SHUTDOWN" in line:
-          isShutdownFlagPresent = True
-          break
-    
-    if isShutdownFlagPresent:
-      try: torTools.getConn().shutdown()
-      except IOError, exc: cli.popups.showMsg(str(exc), 3, curses.A_BOLD)
-
-def shutdownDaemons():
-  """
-  Stops and joins on worker threads.
-  """
-  
-  # prevents further worker threads from being spawned
-  torTools.NO_SPAWN = True
-  
-  # stops panel daemons
-  control = getController()
-
-  if control:
-    for panelImpl in control.getDaemonPanels(): panelImpl.stop()
-    for panelImpl in control.getDaemonPanels(): panelImpl.join()
-  
-  # joins on stem threads
-  torTools.getConn().close()
-  
-  # joins on utility daemon threads - this might take a moment since the
-  # internal threadpools being joined might be sleeping
-  hostnames.stop()
-  resourceTrackers = sysTools.RESOURCE_TRACKERS.values()
-  resolver = connections.getResolver("tor") if connections.isResolverAlive("tor") else None
-  for tracker in resourceTrackers: tracker.stop()
-  if resolver: resolver.stop()  # sets halt flag (returning immediately)
-  for tracker in resourceTrackers: tracker.join()
-  if resolver: resolver.join()  # joins on halted resolver
-
-def heartbeatCheck(isUnresponsive):
-  """
-  Logs if its been ten seconds since the last BW event.
-  
-  Arguments:
-    isUnresponsive - flag for if we've indicated to be responsive or not
-  """
-  
-  conn = torTools.getConn()
-  lastHeartbeat = conn.controller.get_latest_heartbeat()
-  if conn.isAlive():
-    if not isUnresponsive and (time.time() - lastHeartbeat) >= 10:
-      isUnresponsive = True
-      log.notice("Relay unresponsive (last heartbeat: %s)" % time.ctime(lastHeartbeat))
-    elif isUnresponsive and (time.time() - lastHeartbeat) < 10:
-      # really shouldn't happen (meant Tor froze for a bit)
-      isUnresponsive = False
-      log.notice("Relay resumed")
-  
-  return isUnresponsive
-
-def connResetListener(controller, eventType, _):
-  """
-  Pauses connection resolution when tor's shut down, and resumes with the new
-  pid if started again.
-  """
-  
-  if connections.isResolverAlive("tor"):
-    resolver = connections.getResolver("tor")
-    resolver.setPaused(eventType == State.CLOSED)
-    
-    if eventType in (State.INIT, State.RESET):
-      # Reload the torrc contents. If the torrc panel is present then it will
-      # do this instead since it wants to do validation and redraw _after_ the
-      # new contents are loaded.
-      
-      if getController().getPanel("torrc") == None:
-        torConfig.getTorrc().load(True)
-      
-      try:
-        resolver.setPid(controller.get_pid())
-      except ValueError:
-        pass
-
-def startTorMonitor(startTime):
-  """
-  Initializes the interface and starts the main draw loop.
-  
-  Arguments:
-    startTime - unix time for when arm was started
-  """
-  
-  # attempts to fetch the tor pid, warning if unsuccessful (this is needed for
-  # checking its resource usage, among other things)
-  conn = torTools.getConn()
-  torPid = conn.controller.get_pid(None)
-  
-  if not torPid and conn.isAlive():
-    log.warn("Unable to determine Tor's pid. Some information, like its resource usage will be unavailable.")
-  
-  # adds events needed for arm functionality to the torTools REQ_EVENTS
-  # mapping (they're then included with any setControllerEvents call, and log
-  # a more helpful error if unavailable)
-  
-  torTools.REQ_EVENTS["BW"] = "bandwidth graph won't function"
-  
-  if not CONFIG["startup.blindModeEnabled"]:
-    # The DisableDebuggerAttachment will prevent our connection panel from really
-    # functioning. It'll have circuits, but little else. If this is the case then
-    # notify the user and tell them what they can do to fix it.
-    
-    if conn.getOption("DisableDebuggerAttachment", None) == "1":
-      log.notice("Tor is preventing system utilities like netstat and lsof from working. This means that arm can't provide you with connection information. You can change this by adding 'DisableDebuggerAttachment 0' to your torrc and restarting tor. For more information see...\nhttps://trac.torproject.org/3313")
-      connections.getResolver("tor").setPaused(True)
-    else:
-      torTools.REQ_EVENTS["CIRC"] = "may cause issues in identifying client connections"
-      
-      # Configures connection resoultions. This is paused/unpaused according to
-      # if Tor's connected or not.
-      conn.addStatusListener(connResetListener)
-      
-      if torPid:
-        # use the tor pid to help narrow connection results
-        torCmdName = sysTools.getProcessName(torPid, "tor")
-        connections.getResolver(torCmdName, torPid, "tor")
-      else:
-        # constructs singleton resolver and, if tor isn't connected, initizes
-        # it to be paused
-        connections.getResolver("tor").setPaused(not conn.isAlive())
-      
-      # hack to display a better (arm specific) notice if all resolvers fail
-      connections.RESOLVER_FINAL_FAILURE_MSG = "We were unable to use any of your system's resolvers to get tor's connections. This is fine, but means that the connections page will be empty. This is usually permissions related so if you would like to fix this then run arm with the same user as tor (ie, \"sudo -u <tor user> arm\")."
-  
-  # provides a notice about any event types tor supports but arm doesn't
-  missingEventTypes = cli.logPanel.getMissingEventTypes()
-  
-  if missingEventTypes:
-    pluralLabel = "s" if len(missingEventTypes) > 1 else ""
-    log.info("arm doesn't recognize the following event type%s: %s (log 'UNKNOWN' events to see them)" % (pluralLabel, ", ".join(missingEventTypes)))
-  
-  try:
-    curses.wrapper(drawTorMonitor, startTime)
-  except UnboundLocalError, exc:
-    if os.environ['TERM'] != 'xterm':
-      shutdownDaemons()
-      print 'Unknown $TERM: (%s)' % os.environ['TERM']
-      print 'Either update your terminfo database or run arm using "TERM=xterm arm".'
-      print
-    else:
-      raise exc
-  except KeyboardInterrupt:
-    # Skip printing stack trace in case of keyboard interrupt. The
-    # HALT_ACTIVITY attempts to prevent daemons from triggering a curses redraw
-    # (which would leave the user's terminal in a screwed up state). There is
-    # still a tiny timing issue here (after the exception but before the flag
-    # is set) but I've never seen it happen in practice.
-    
-    panel.HALT_ACTIVITY = True
-    shutdownDaemons()
-
-def drawTorMonitor(stdscr, startTime):
-  """
-  Main draw loop context.
-  
-  Arguments:
-    stdscr    - curses window
-    startTime - unix time for when arm was started
-  """
-  
-  initController(stdscr, startTime)
-  control = getController()
-  
-  # provides notice about any unused config keys
-  for key in conf.get_config("arm").unused_keys():
-    log.notice("Unused configuration entry: %s" % key)
-  
-  # tells daemon panels to start
-  for panelImpl in control.getDaemonPanels(): panelImpl.start()
-  
-  # allows for background transparency
-  try: curses.use_default_colors()
-  except curses.error: pass
-  
-  # makes the cursor invisible
-  try: curses.curs_set(0)
-  except curses.error: pass
-  
-  # logs the initialization time
-  log.info("arm started (initialization took %0.3f seconds)" % (time.time() - startTime))
-  
-  # main draw loop
-  overrideKey = None     # uses this rather than waiting on user input
-  isUnresponsive = False # flag for heartbeat responsiveness check
-  
-  while not control.isDone():
-    displayPanels = control.getDisplayPanels()
-    isUnresponsive = heartbeatCheck(isUnresponsive)
-    
-    # sets panel visability
-    for panelImpl in control.getAllPanels():
-      panelImpl.setVisible(panelImpl in displayPanels)
-    
-    # redraws the interface if it's needed
-    control.redraw(False)
-    stdscr.refresh()
-    
-    # wait for user keyboard input until timeout, unless an override was set
-    if overrideKey:
-      key, overrideKey = overrideKey, None
-    else:
-      curses.halfdelay(CONFIG["features.redrawRate"] * 10)
-      key = stdscr.getch()
-    
-    if key == curses.KEY_RIGHT:
-      control.nextPage()
-    elif key == curses.KEY_LEFT:
-      control.prevPage()
-    elif key == ord('p') or key == ord('P'):
-      control.setPaused(not control.isPaused())
-    elif key == ord('m') or key == ord('M'):
-      cli.menu.menu.showMenu()
-    elif key == ord('q') or key == ord('Q'):
-      # provides prompt to confirm that arm should exit
-      if CONFIG["features.confirmQuit"]:
-        msg = "Are you sure (q again to confirm)?"
-        confirmationKey = cli.popups.showMsg(msg, attr = curses.A_BOLD)
-        quitConfirmed = confirmationKey in (ord('q'), ord('Q'))
-      else: quitConfirmed = True
-      
-      if quitConfirmed: control.quit()
-    elif key == ord('x') or key == ord('X'):
-      # provides prompt to confirm that arm should issue a sighup
-      msg = "This will reset Tor's internal state. Are you sure (x again to confirm)?"
-      confirmationKey = cli.popups.showMsg(msg, attr = curses.A_BOLD)
-      
-      if confirmationKey in (ord('x'), ord('X')):
-        try: torTools.getConn().reload()
-        except IOError, exc:
-          log.error("Error detected when reloading tor: %s" % sysTools.getFileErrorMsg(exc))
-    elif key == ord('h') or key == ord('H'):
-      overrideKey = cli.popups.showHelpPopup()
-    elif key == ord('l') - 96:
-      # force redraw when ctrl+l is pressed
-      control.redraw(True)
-    else:
-      for panelImpl in displayPanels:
-        isKeystrokeConsumed = panelImpl.handleKey(key)
-        if isKeystrokeConsumed: break
-  
-  shutdownDaemons()
-
diff --git a/src/cli/graphing/__init__.py b/src/cli/graphing/__init__.py
deleted file mode 100644
index 2dddaa3..0000000
--- a/src/cli/graphing/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""
-Graphing panel resources.
-"""
-
-__all__ = ["graphPanel", "bandwidthStats", "connStats", "resourceStats"]
-
diff --git a/src/cli/graphing/bandwidthStats.py b/src/cli/graphing/bandwidthStats.py
deleted file mode 100644
index 935e23d..0000000
--- a/src/cli/graphing/bandwidthStats.py
+++ /dev/null
@@ -1,430 +0,0 @@
-"""
-Tracks bandwidth usage of the tor process, expanding to include accounting
-stats if they're set.
-"""
-
-import time
-import curses
-
-import cli.controller
-
-from cli.graphing import graphPanel
-from util import torTools, uiTools
-
-from stem.control import State
-from stem.util import conf, log, str_tools, system
-
-def conf_handler(key, value):
-  if key == "features.graph.bw.accounting.rate":
-    return max(1, value)
-
-CONFIG = conf.config_dict("arm", {
-  "features.graph.bw.transferInBytes": False,
-  "features.graph.bw.accounting.show": True,
-  "features.graph.bw.accounting.rate": 10,
-  "features.graph.bw.accounting.isTimeLong": False,
-}, conf_handler)
-
-DL_COLOR, UL_COLOR = "green", "cyan"
-
-# width at which panel abandons placing optional stats (avg and total) with
-# header in favor of replacing the x-axis label
-COLLAPSE_WIDTH = 135
-
-# valid keys for the accountingInfo mapping
-ACCOUNTING_ARGS = ("status", "resetTime", "read", "written", "readLimit", "writtenLimit")
-
-PREPOPULATE_SUCCESS_MSG = "Read the last day of bandwidth history from the state file"
-PREPOPULATE_FAILURE_MSG = "Unable to prepopulate bandwidth information (%s)"
-
-class BandwidthStats(graphPanel.GraphStats):
-  """
-  Uses tor BW events to generate bandwidth usage graph.
-  """
-  
-  def __init__(self, isPauseBuffer=False):
-    graphPanel.GraphStats.__init__(self)
-    
-    # stats prepopulated from tor's state file
-    self.prepopulatePrimaryTotal = 0
-    self.prepopulateSecondaryTotal = 0
-    self.prepopulateTicks = 0
-    
-    # accounting data (set by _updateAccountingInfo method)
-    self.accountingLastUpdated = 0
-    self.accountingInfo = dict([(arg, "") for arg in ACCOUNTING_ARGS])
-    
-    # listens for tor reload (sighup) events which can reset the bandwidth
-    # rate/burst and if tor's using accounting
-    conn = torTools.getConn()
-    self._titleStats, self.isAccounting = [], False
-    if not isPauseBuffer: self.resetListener(conn.getController(), State.INIT, None) # initializes values
-    conn.addStatusListener(self.resetListener)
-    
-    # Initialized the bandwidth totals to the values reported by Tor. This
-    # uses a controller options introduced in ticket 2345:
-    # https://trac.torproject.org/projects/tor/ticket/2345
-    # 
-    # further updates are still handled via BW events to avoid unnecessary
-    # GETINFO requests.
-    
-    self.initialPrimaryTotal = 0
-    self.initialSecondaryTotal = 0
-    
-    readTotal = conn.getInfo("traffic/read", None)
-    if readTotal and readTotal.isdigit():
-      self.initialPrimaryTotal = int(readTotal) / 1024 # Bytes -> KB
-    
-    writeTotal = conn.getInfo("traffic/written", None)
-    if writeTotal and writeTotal.isdigit():
-      self.initialSecondaryTotal = int(writeTotal) / 1024 # Bytes -> KB
-  
-  def clone(self, newCopy=None):
-    if not newCopy: newCopy = BandwidthStats(True)
-    newCopy.accountingLastUpdated = self.accountingLastUpdated
-    newCopy.accountingInfo = self.accountingInfo
-    
-    # attributes that would have been initialized from calling the resetListener
-    newCopy.isAccounting = self.isAccounting
-    newCopy._titleStats = self._titleStats
-    
-    return graphPanel.GraphStats.clone(self, newCopy)
-  
-  def resetListener(self, controller, eventType, _):
-    # updates title parameters and accounting status if they changed
-    self._titleStats = []     # force reset of title
-    self.new_desc_event(None) # updates title params
-    
-    if eventType in (State.INIT, State.RESET) and CONFIG["features.graph.bw.accounting.show"]:
-      isAccountingEnabled = controller.get_info('accounting/enabled', None) == '1'
-      
-      if isAccountingEnabled != self.isAccounting:
-        self.isAccounting = isAccountingEnabled
-        
-        # redraws the whole screen since our height changed
-        cli.controller.getController().redraw()
-    
-    # redraws to reflect changes (this especially noticeable when we have
-    # accounting and shut down since it then gives notice of the shutdown)
-    if self._graphPanel and self.isSelected: self._graphPanel.redraw(True)
-  
-  def prepopulateFromState(self):
-    """
-    Attempts to use tor's state file to prepopulate values for the 15 minute
-    interval via the BWHistoryReadValues/BWHistoryWriteValues values. This
-    returns True if successful and False otherwise.
-    """
-    
-    # checks that this is a relay (if ORPort is unset, then skip)
-    conn = torTools.getConn()
-    orPort = conn.getOption("ORPort", None)
-    if orPort == "0": return
-    
-    # gets the uptime (using the same parameters as the header panel to take
-    # advantage of caching)
-    # TODO: stem dropped system caching support so we'll need to think of
-    # something else
-    uptime = None
-    queryPid = conn.controller.get_pid(None)
-    if queryPid:
-      queryParam = ["%cpu", "rss", "%mem", "etime"]
-      queryCmd = "ps -p %s -o %s" % (queryPid, ",".join(queryParam))
-      psCall = system.call(queryCmd, None)
-      
-      if psCall and len(psCall) == 2:
-        stats = psCall[1].strip().split()
-        if len(stats) == 4: uptime = stats[3]
-    
-    # checks if tor has been running for at least a day, the reason being that
-    # the state tracks a day's worth of data and this should only prepopulate
-    # results associated with this tor instance
-    if not uptime or not "-" in uptime:
-      msg = PREPOPULATE_FAILURE_MSG % "insufficient uptime"
-      log.notice(msg)
-      return False
-    
-    # get the user's data directory (usually '~/.tor')
-    dataDir = conn.getOption("DataDirectory", None)
-    if not dataDir:
-      msg = PREPOPULATE_FAILURE_MSG % "data directory not found"
-      log.notice(msg)
-      return False
-    
-    # attempt to open the state file
-    try: stateFile = open("%s%s/state" % (conn.getPathPrefix(), dataDir), "r")
-    except IOError:
-      msg = PREPOPULATE_FAILURE_MSG % "unable to read the state file"
-      log.notice(msg)
-      return False
-    
-    # get the BWHistory entries (ordered oldest to newest) and number of
-    # intervals since last recorded
-    bwReadEntries, bwWriteEntries = None, None
-    missingReadEntries, missingWriteEntries = None, None
-    
-    # converts from gmt to local with respect to DST
-    tz_offset = time.altzone if time.localtime()[8] else time.timezone
-    
-    for line in stateFile:
-      line = line.strip()
-      
-      # According to the rep_hist_update_state() function the BWHistory*Ends
-      # correspond to the start of the following sampling period. Also, the
-      # most recent values of BWHistory*Values appear to be an incremental
-      # counter for the current sampling period. Hence, offsets are added to
-      # account for both.
-      
-      if line.startswith("BWHistoryReadValues"):
-        bwReadEntries = line[20:].split(",")
-        bwReadEntries = [int(entry) / 1024.0 / 900 for entry in bwReadEntries]
-        bwReadEntries.pop()
-      elif line.startswith("BWHistoryWriteValues"):
-        bwWriteEntries = line[21:].split(",")
-        bwWriteEntries = [int(entry) / 1024.0 / 900 for entry in bwWriteEntries]
-        bwWriteEntries.pop()
-      elif line.startswith("BWHistoryReadEnds"):
-        lastReadTime = time.mktime(time.strptime(line[18:], "%Y-%m-%d %H:%M:%S")) - tz_offset
-        lastReadTime -= 900
-        missingReadEntries = int((time.time() - lastReadTime) / 900)
-      elif line.startswith("BWHistoryWriteEnds"):
-        lastWriteTime = time.mktime(time.strptime(line[19:], "%Y-%m-%d %H:%M:%S")) - tz_offset
-        lastWriteTime -= 900
-        missingWriteEntries = int((time.time() - lastWriteTime) / 900)
-    
-    if not bwReadEntries or not bwWriteEntries or not lastReadTime or not lastWriteTime:
-      msg = PREPOPULATE_FAILURE_MSG % "bandwidth stats missing from state file"
-      log.notice(msg)
-      return False
-    
-    # fills missing entries with the last value
-    bwReadEntries += [bwReadEntries[-1]] * missingReadEntries
-    bwWriteEntries += [bwWriteEntries[-1]] * missingWriteEntries
-    
-    # crops starting entries so they're the same size
-    entryCount = min(len(bwReadEntries), len(bwWriteEntries), self.maxCol)
-    bwReadEntries = bwReadEntries[len(bwReadEntries) - entryCount:]
-    bwWriteEntries = bwWriteEntries[len(bwWriteEntries) - entryCount:]
-    
-    # gets index for 15-minute interval
-    intervalIndex = 0
-    for indexEntry in graphPanel.UPDATE_INTERVALS:
-      if indexEntry[1] == 900: break
-      else: intervalIndex += 1
-    
-    # fills the graphing parameters with state information
-    for i in range(entryCount):
-      readVal, writeVal = bwReadEntries[i], bwWriteEntries[i]
-      
-      self.lastPrimary, self.lastSecondary = readVal, writeVal
-      
-      self.prepopulatePrimaryTotal += readVal * 900
-      self.prepopulateSecondaryTotal += writeVal * 900
-      self.prepopulateTicks += 900
-      
-      self.primaryCounts[intervalIndex].insert(0, readVal)
-      self.secondaryCounts[intervalIndex].insert(0, writeVal)
-    
-    self.maxPrimary[intervalIndex] = max(self.primaryCounts)
-    self.maxSecondary[intervalIndex] = max(self.secondaryCounts)
-    del self.primaryCounts[intervalIndex][self.maxCol + 1:]
-    del self.secondaryCounts[intervalIndex][self.maxCol + 1:]
-    
-    msg = PREPOPULATE_SUCCESS_MSG
-    missingSec = time.time() - min(lastReadTime, lastWriteTime)
-    if missingSec: msg += " (%s is missing)" % str_tools.get_time_label(missingSec, 0, True)
-    log.notice(msg)
-    
-    return True
-  
-  def bandwidth_event(self, event):
-    if self.isAccounting and self.isNextTickRedraw():
-      if time.time() - self.accountingLastUpdated >= CONFIG["features.graph.bw.accounting.rate"]:
-        self._updateAccountingInfo()
-    
-    # scales units from B to KB for graphing
-    self._processEvent(event.read / 1024.0, event.written / 1024.0)
-  
-  def draw(self, panel, width, height):
-    # line of the graph's x-axis labeling
-    labelingLine = graphPanel.GraphStats.getContentHeight(self) + panel.graphHeight - 2
-    
-    # if display is narrow, overwrites x-axis labels with avg / total stats
-    if width <= COLLAPSE_WIDTH:
-      # clears line
-      panel.addstr(labelingLine, 0, " " * width)
-      graphCol = min((width - 10) / 2, self.maxCol)
-      
-      primaryFooter = "%s, %s" % (self._getAvgLabel(True), self._getTotalLabel(True))
-      secondaryFooter = "%s, %s" % (self._getAvgLabel(False), self._getTotalLabel(False))
-      
-      panel.addstr(labelingLine, 1, primaryFooter, uiTools.getColor(self.getColor(True)))
-      panel.addstr(labelingLine, graphCol + 6, secondaryFooter, uiTools.getColor(self.getColor(False)))
-    
-    # provides accounting stats if enabled
-    if self.isAccounting:
-      if torTools.getConn().isAlive():
-        status = self.accountingInfo["status"]
-        
-        hibernateColor = "green"
-        if status == "soft": hibernateColor = "yellow"
-        elif status == "hard": hibernateColor = "red"
-        elif status == "":
-          # failed to be queried
-          status, hibernateColor = "unknown", "red"
-        
-        panel.addstr(labelingLine + 2, 0, "Accounting (", curses.A_BOLD)
-        panel.addstr(labelingLine + 2, 12, status, curses.A_BOLD | uiTools.getColor(hibernateColor))
-        panel.addstr(labelingLine + 2, 12 + len(status), ")", curses.A_BOLD)
-        
-        resetTime = self.accountingInfo["resetTime"]
-        if not resetTime: resetTime = "unknown"
-        panel.addstr(labelingLine + 2, 35, "Time to reset: %s" % resetTime)
-        
-        used, total = self.accountingInfo["read"], self.accountingInfo["readLimit"]
-        if used and total:
-          panel.addstr(labelingLine + 3, 2, "%s / %s" % (used, total), uiTools.getColor(self.getColor(True)))
-        
-        used, total = self.accountingInfo["written"], self.accountingInfo["writtenLimit"]
-        if used and total:
-          panel.addstr(labelingLine + 3, 37, "%s / %s" % (used, total), uiTools.getColor(self.getColor(False)))
-      else:
-        panel.addstr(labelingLine + 2, 0, "Accounting:", curses.A_BOLD)
-        panel.addstr(labelingLine + 2, 12, "Connection Closed...")
-  
-  def getTitle(self, width):
-    stats = list(self._titleStats)
-    
-    while True:
-      if not stats: return "Bandwidth:"
-      else:
-        label = "Bandwidth (%s):" % ", ".join(stats)
-        
-        if len(label) > width: del stats[-1]
-        else: return label
-  
-  def getHeaderLabel(self, width, isPrimary):
-    graphType = "Download" if isPrimary else "Upload"
-    stats = [""]
-    
-    # if wide then avg and total are part of the header, otherwise they're on
-    # the x-axis
-    if width * 2 > COLLAPSE_WIDTH:
-      stats = [""] * 3
-      stats[1] = "- %s" % self._getAvgLabel(isPrimary)
-      stats[2] = ", %s" % self._getTotalLabel(isPrimary)
-    
-    stats[0] = "%-14s" % ("%s/sec" % str_tools.get_size_label((self.lastPrimary if isPrimary else self.lastSecondary) * 1024, 1, False, CONFIG["features.graph.bw.transferInBytes"]))
-    
-    # drops label's components if there's not enough space
-    labeling = graphType + " (" + "".join(stats).strip() + "):"
-    while len(labeling) >= width:
-      if len(stats) > 1:
-        del stats[-1]
-        labeling = graphType + " (" + "".join(stats).strip() + "):"
-      else:
-        labeling = graphType + ":"
-        break
-    
-    return labeling
-  
-  def getColor(self, isPrimary):
-    return DL_COLOR if isPrimary else UL_COLOR
-  
-  def getContentHeight(self):
-    baseHeight = graphPanel.GraphStats.getContentHeight(self)
-    return baseHeight + 3 if self.isAccounting else baseHeight
-  
-  def new_desc_event(self, event):
-    # updates self._titleStats with updated values
-    conn = torTools.getConn()
-    if not conn.isAlive(): return # keep old values
-    
-    myFingerprint = conn.getInfo("fingerprint", None)
-    if not self._titleStats or not myFingerprint or (event and myFingerprint in event.idlist):
-      stats = []
-      bwRate = conn.getMyBandwidthRate()
-      bwBurst = conn.getMyBandwidthBurst()
-      bwObserved = conn.getMyBandwidthObserved()
-      bwMeasured = conn.getMyBandwidthMeasured()
-      labelInBytes = CONFIG["features.graph.bw.transferInBytes"]
-      
-      if bwRate and bwBurst:
-        bwRateLabel = str_tools.get_size_label(bwRate, 1, False, labelInBytes)
-        bwBurstLabel = str_tools.get_size_label(bwBurst, 1, False, labelInBytes)
-        
-        # if both are using rounded values then strip off the ".0" decimal
-        if ".0" in bwRateLabel and ".0" in bwBurstLabel:
-          bwRateLabel = bwRateLabel.replace(".0", "")
-          bwBurstLabel = bwBurstLabel.replace(".0", "")
-        
-        stats.append("limit: %s/s" % bwRateLabel)
-        stats.append("burst: %s/s" % bwBurstLabel)
-      
-      # Provide the observed bandwidth either if the measured bandwidth isn't
-      # available or if the measured bandwidth is the observed (this happens
-      # if there isn't yet enough bandwidth measurements).
-      if bwObserved and (not bwMeasured or bwMeasured == bwObserved):
-        stats.append("observed: %s/s" % str_tools.get_size_label(bwObserved, 1, False, labelInBytes))
-      elif bwMeasured:
-        stats.append("measured: %s/s" % str_tools.get_size_label(bwMeasured, 1, False, labelInBytes))
-      
-      self._titleStats = stats
-  
-  def _getAvgLabel(self, isPrimary):
-    total = self.primaryTotal if isPrimary else self.secondaryTotal
-    total += self.prepopulatePrimaryTotal if isPrimary else self.prepopulateSecondaryTotal
-    return "avg: %s/sec" % str_tools.get_size_label((total / max(1, self.tick + self.prepopulateTicks)) * 1024, 1, False, CONFIG["features.graph.bw.transferInBytes"])
-  
-  def _getTotalLabel(self, isPrimary):
-    total = self.primaryTotal if isPrimary else self.secondaryTotal
-    total += self.initialPrimaryTotal if isPrimary else self.initialSecondaryTotal
-    return "total: %s" % str_tools.get_size_label(total * 1024, 1)
-  
-  def _updateAccountingInfo(self):
-    """
-    Updates mapping used for accounting info. This includes the following keys:
-    status, resetTime, read, written, readLimit, writtenLimit
-    
-    Any failed lookups result in a mapping to an empty string.
-    """
-    
-    conn = torTools.getConn()
-    queried = dict([(arg, "") for arg in ACCOUNTING_ARGS])
-    queried["status"] = conn.getInfo("accounting/hibernating", None)
-    
-    # provides a nicely formatted reset time
-    endInterval = conn.getInfo("accounting/interval-end", None)
-    if endInterval:
-      # converts from gmt to local with respect to DST
-      if time.localtime()[8]: tz_offset = time.altzone
-      else: tz_offset = time.timezone
-      
-      sec = time.mktime(time.strptime(endInterval, "%Y-%m-%d %H:%M:%S")) - time.time() - tz_offset
-      if CONFIG["features.graph.bw.accounting.isTimeLong"]:
-        queried["resetTime"] = ", ".join(str_tools.get_time_labels(sec, True))
-      else:
-        days = sec / 86400
-        sec %= 86400
-        hours = sec / 3600
-        sec %= 3600
-        minutes = sec / 60
-        sec %= 60
-        queried["resetTime"] = "%i:%02i:%02i:%02i" % (days, hours, minutes, sec)
-    
-    # number of bytes used and in total for the accounting period
-    used = conn.getInfo("accounting/bytes", None)
-    left = conn.getInfo("accounting/bytes-left", None)
-    
-    if used and left:
-      usedComp, leftComp = used.split(" "), left.split(" ")
-      read, written = int(usedComp[0]), int(usedComp[1])
-      readLeft, writtenLeft = int(leftComp[0]), int(leftComp[1])
-      
-      queried["read"] = str_tools.get_size_label(read)
-      queried["written"] = str_tools.get_size_label(written)
-      queried["readLimit"] = str_tools.get_size_label(read + readLeft)
-      queried["writtenLimit"] = str_tools.get_size_label(written + writtenLeft)
-    
-    self.accountingInfo = queried
-    self.accountingLastUpdated = time.time()
-
diff --git a/src/cli/graphing/connStats.py b/src/cli/graphing/connStats.py
deleted file mode 100644
index 88ed44a..0000000
--- a/src/cli/graphing/connStats.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""
-Tracks stats concerning tor's current connections.
-"""
-
-from cli.graphing import graphPanel
-from util import connections, torTools
-
-from stem.control import State
-
-class ConnStats(graphPanel.GraphStats):
-  """
-  Tracks number of connections, counting client and directory connections as 
-  outbound. Control connections are excluded from counts.
-  """
-  
-  def __init__(self):
-    graphPanel.GraphStats.__init__(self)
-    
-    # listens for tor reload (sighup) events which can reset the ports tor uses
-    conn = torTools.getConn()
-    self.orPort, self.dirPort, self.controlPort = "0", "0", "0"
-    self.resetListener(conn.getController(), State.INIT, None) # initialize port values
-    conn.addStatusListener(self.resetListener)
-  
-  def clone(self, newCopy=None):
-    if not newCopy: newCopy = ConnStats()
-    return graphPanel.GraphStats.clone(self, newCopy)
-  
-  def resetListener(self, controller, eventType, _):
-    if eventType in (State.INIT, State.RESET):
-      self.orPort = controller.get_conf("ORPort", "0")
-      self.dirPort = controller.get_conf("DirPort", "0")
-      self.controlPort = controller.get_conf("ControlPort", "0")
-  
-  def eventTick(self):
-    """
-    Fetches connection stats from cached information.
-    """
-    
-    inboundCount, outboundCount = 0, 0
-    
-    for entry in connections.getResolver("tor").getConnections():
-      localPort = entry[1]
-      if localPort in (self.orPort, self.dirPort): inboundCount += 1
-      elif localPort == self.controlPort: pass # control connection
-      else: outboundCount += 1
-    
-    self._processEvent(inboundCount, outboundCount)
-  
-  def getTitle(self, width):
-    return "Connection Count:"
-  
-  def getHeaderLabel(self, width, isPrimary):
-    avg = (self.primaryTotal if isPrimary else self.secondaryTotal) / max(1, self.tick)
-    if isPrimary: return "Inbound (%s, avg: %s):" % (self.lastPrimary, avg)
-    else: return "Outbound (%s, avg: %s):" % (self.lastSecondary, avg)
-  
-  def getRefreshRate(self):
-    return 5
-
diff --git a/src/cli/graphing/graphPanel.py b/src/cli/graphing/graphPanel.py
deleted file mode 100644
index a0a348a..0000000
--- a/src/cli/graphing/graphPanel.py
+++ /dev/null
@@ -1,518 +0,0 @@
-"""
-Flexible panel for presenting bar graphs for a variety of stats. This panel is
-just concerned with the rendering of information, which is actually collected
-and stored by implementations of the GraphStats interface. Panels are made up
-of a title, followed by headers and graphs for two sets of stats. For
-instance...
-
-Bandwidth (cap: 5 MB, burst: 10 MB):
-Downloaded (0.0 B/sec):           Uploaded (0.0 B/sec):
-  34                                30
-                            *                                 *
-                    **  *   *                          *      **
-      *   *  *      ** **   **          ***  **       ** **   **
-     *********      ******  ******     *********      ******  ******
-   0 ************ ****************   0 ************ ****************
-         25s  50   1m   1.6  2.0           25s  50   1m   1.6  2.0
-"""
-
-import copy
-import curses
-
-import cli.popups
-import cli.controller
-
-import stem.control
-
-from util import panel, torTools, uiTools
-
-from stem.util import conf, enum, str_tools
-
-# time intervals at which graphs can be updated
-UPDATE_INTERVALS = [("each second", 1), ("5 seconds", 5),   ("30 seconds", 30),
-                    ("minutely", 60),   ("15 minute", 900), ("30 minute", 1800),
-                    ("hourly", 3600),   ("daily", 86400)]
-
-DEFAULT_CONTENT_HEIGHT = 4 # space needed for labeling above and below the graph
-DEFAULT_COLOR_PRIMARY, DEFAULT_COLOR_SECONDARY = "green", "cyan"
-MIN_GRAPH_HEIGHT = 1
-
-# enums for graph bounds:
-#   Bounds.GLOBAL_MAX - global maximum (highest value ever seen)
-#   Bounds.LOCAL_MAX - local maximum (highest value currently on the graph)
-#   Bounds.TIGHT - local maximum and minimum
-Bounds = enum.Enum("GLOBAL_MAX", "LOCAL_MAX", "TIGHT")
-
-WIDE_LABELING_GRAPH_COL = 50  # minimum graph columns to use wide spacing for x-axis labels
-
-def conf_handler(key, value):
-  if key == "features.graph.height":
-    return max(MIN_GRAPH_HEIGHT, value)
-  elif key == "features.graph.maxWidth":
-    return max(1, value)
-  elif key == "features.graph.interval":
-    return max(0, min(len(UPDATE_INTERVALS) - 1, value))
-  elif key == "features.graph.bound":
-    return max(0, min(2, value))
-
-# used for setting defaults when initializing GraphStats and GraphPanel instances
-CONFIG = conf.config_dict("arm", {
-  "features.graph.height": 7,
-  "features.graph.interval": 0,
-  "features.graph.bound": 1,
-  "features.graph.maxWidth": 150,
-  "features.graph.showIntermediateBounds": True,
-}, conf_handler)
-
-class GraphStats:
-  """
-  Module that's expected to update dynamically and provide attributes to be
-  graphed. Up to two graphs (a 'primary' and 'secondary') can be displayed at a
-  time and timescale parameters use the labels defined in UPDATE_INTERVALS.
-  """
-  
-  def __init__(self):
-    """
-    Initializes parameters needed to present a graph.
-    """
-    
-    # panel to be redrawn when updated (set when added to GraphPanel)
-    self._graphPanel = None
-    self.isSelected = False
-    self.isPauseBuffer = False
-    
-    # tracked stats
-    self.tick = 0                                 # number of processed events
-    self.lastPrimary, self.lastSecondary = 0, 0   # most recent registered stats
-    self.primaryTotal, self.secondaryTotal = 0, 0 # sum of all stats seen
-    
-    # timescale dependent stats
-    self.maxCol = CONFIG["features.graph.maxWidth"]
-    self.maxPrimary, self.maxSecondary = {}, {}
-    self.primaryCounts, self.secondaryCounts = {}, {}
-    
-    for i in range(len(UPDATE_INTERVALS)):
-      # recent rates for graph
-      self.maxPrimary[i] = 0
-      self.maxSecondary[i] = 0
-      
-      # historic stats for graph, first is accumulator
-      # iterative insert needed to avoid making shallow copies (nasty, nasty gotcha)
-      self.primaryCounts[i] = (self.maxCol + 1) * [0]
-      self.secondaryCounts[i] = (self.maxCol + 1) * [0]
-    
-    # tracks BW events
-    torTools.getConn().addEventListener(self.bandwidth_event, stem.control.EventType.BW)
-  
-  def clone(self, newCopy=None):
-    """
-    Provides a deep copy of this instance.
-    
-    Arguments:
-      newCopy - base instance to build copy off of
-    """
-    
-    if not newCopy: newCopy = GraphStats()
-    newCopy.tick = self.tick
-    newCopy.lastPrimary = self.lastPrimary
-    newCopy.lastSecondary = self.lastSecondary
-    newCopy.primaryTotal = self.primaryTotal
-    newCopy.secondaryTotal = self.secondaryTotal
-    newCopy.maxPrimary = dict(self.maxPrimary)
-    newCopy.maxSecondary = dict(self.maxSecondary)
-    newCopy.primaryCounts = copy.deepcopy(self.primaryCounts)
-    newCopy.secondaryCounts = copy.deepcopy(self.secondaryCounts)
-    newCopy.isPauseBuffer = True
-    return newCopy
-  
-  def eventTick(self):
-    """
-    Called when it's time to process another event. All graphs use tor BW
-    events to keep in sync with each other (this happens once a second).
-    """
-    
-    pass
-  
-  def isNextTickRedraw(self):
-    """
-    Provides true if the following tick (call to _processEvent) will result in
-    being redrawn.
-    """
-    
-    if self._graphPanel and self.isSelected and not self._graphPanel.isPaused():
-      # use the minimum of the current refresh rate and the panel's
-      updateRate = UPDATE_INTERVALS[self._graphPanel.updateInterval][1]
-      return (self.tick + 1) % min(updateRate, self.getRefreshRate()) == 0
-    else: return False
-  
-  def getTitle(self, width):
-    """
-    Provides top label.
-    """
-    
-    return ""
-  
-  def getHeaderLabel(self, width, isPrimary):
-    """
-    Provides labeling presented at the top of the graph.
-    """
-    
-    return ""
-  
-  def getColor(self, isPrimary):
-    """
-    Provides the color to be used for the graph and stats.
-    """
-    
-    return DEFAULT_COLOR_PRIMARY if isPrimary else DEFAULT_COLOR_SECONDARY
-  
-  def getContentHeight(self):
-    """
-    Provides the height content should take up (not including the graph).
-    """
-    
-    return DEFAULT_CONTENT_HEIGHT
-  
-  def getRefreshRate(self):
-    """
-    Provides the number of ticks between when the stats have new values to be
-    redrawn.
-    """
-    
-    return 1
-  
-  def isVisible(self):
-    """
-    True if the stat has content to present, false if it should be hidden.
-    """
-    
-    return True
-  
-  def draw(self, panel, width, height):
-    """
-    Allows for any custom drawing monitor wishes to append.
-    """
-    
-    pass
-  
-  def bandwidth_event(self, event):
-    if not self.isPauseBuffer: self.eventTick()
-  
-  def _processEvent(self, primary, secondary):
-    """
-    Includes new stats in graphs and notifies associated GraphPanel of changes.
-    """
-    
-    isRedraw = self.isNextTickRedraw()
-    
-    self.lastPrimary, self.lastSecondary = primary, secondary
-    self.primaryTotal += primary
-    self.secondaryTotal += secondary
-    
-    # updates for all time intervals
-    self.tick += 1
-    for i in range(len(UPDATE_INTERVALS)):
-      lable, timescale = UPDATE_INTERVALS[i]
-      
-      self.primaryCounts[i][0] += primary
-      self.secondaryCounts[i][0] += secondary
-      
-      if self.tick % timescale == 0:
-        self.maxPrimary[i] = max(self.maxPrimary[i], self.primaryCounts[i][0] / timescale)
-        self.primaryCounts[i][0] /= timescale
-        self.primaryCounts[i].insert(0, 0)
-        del self.primaryCounts[i][self.maxCol + 1:]
-        
-        self.maxSecondary[i] = max(self.maxSecondary[i], self.secondaryCounts[i][0] / timescale)
-        self.secondaryCounts[i][0] /= timescale
-        self.secondaryCounts[i].insert(0, 0)
-        del self.secondaryCounts[i][self.maxCol + 1:]
-    
-    if isRedraw and self._graphPanel: self._graphPanel.redraw(True)
-
-class GraphPanel(panel.Panel):
-  """
-  Panel displaying a graph, drawing statistics from custom GraphStats
-  implementations.
-  """
-  
-  def __init__(self, stdscr):
-    panel.Panel.__init__(self, stdscr, "graph", 0)
-    self.updateInterval = CONFIG["features.graph.interval"]
-    self.bounds = list(Bounds)[CONFIG["features.graph.bound"]]
-    self.graphHeight = CONFIG["features.graph.height"]
-    self.currentDisplay = None    # label of the stats currently being displayed
-    self.stats = {}               # available stats (mappings of label -> instance)
-    self.setPauseAttr("stats")
-  
-  def getUpdateInterval(self):
-    """
-    Provides the rate that we update the graph at.
-    """
-    
-    return self.updateInterval
-  
-  def setUpdateInterval(self, updateInterval):
-    """
-    Sets the rate that we update the graph at.
-    
-    Arguments:
-      updateInterval - update time enum
-    """
-    
-    self.updateInterval = updateInterval
-  
-  def getBoundsType(self):
-    """
-    Provides the type of graph bounds used.
-    """
-    
-    return self.bounds
-  
-  def setBoundsType(self, boundsType):
-    """
-    Sets the type of graph boundaries we use.
-    
-    Arguments:
-      boundsType - graph bounds enum
-    """
-    
-    self.bounds = boundsType
-  
-  def getHeight(self):
-    """
-    Provides the height requested by the currently displayed GraphStats (zero
-    if hidden).
-    """
-    
-    if self.currentDisplay and self.stats[self.currentDisplay].isVisible():
-      return self.stats[self.currentDisplay].getContentHeight() + self.graphHeight
-    else: return 0
-  
-  def setGraphHeight(self, newGraphHeight):
-    """
-    Sets the preferred height used for the graph (restricted to the
-    MIN_GRAPH_HEIGHT minimum).
-    
-    Arguments:
-      newGraphHeight - new height for the graph
-    """
-    
-    self.graphHeight = max(MIN_GRAPH_HEIGHT, newGraphHeight)
-  
-  def resizeGraph(self):
-    """
-    Prompts for user input to resize the graph panel. Options include...
-      down arrow - grow graph
-      up arrow - shrink graph
-      enter / space - set size
-    """
-    
-    control = cli.controller.getController()
-    
-    panel.CURSES_LOCK.acquire()
-    try:
-      while True:
-        msg = "press the down/up to resize the graph, and enter when done"
-        control.setMsg(msg, curses.A_BOLD, True)
-        curses.cbreak()
-        key = control.getScreen().getch()
-        
-        if key == curses.KEY_DOWN:
-          # don't grow the graph if it's already consuming the whole display
-          # (plus an extra line for the graph/log gap)
-          maxHeight = self.parent.getmaxyx()[0] - self.top
-          currentHeight = self.getHeight()
-          
-          if currentHeight < maxHeight + 1:
-            self.setGraphHeight(self.graphHeight + 1)
-        elif key == curses.KEY_UP:
-          self.setGraphHeight(self.graphHeight - 1)
-        elif uiTools.isSelectionKey(key): break
-        
-        control.redraw()
-    finally:
-      control.setMsg()
-      panel.CURSES_LOCK.release()
-  
-  def handleKey(self, key):
-    isKeystrokeConsumed = True
-    if key == ord('r') or key == ord('R'):
-      self.resizeGraph()
-    elif key == ord('b') or key == ord('B'):
-      # uses the next boundary type
-      self.bounds = Bounds.next(self.bounds)
-      self.redraw(True)
-    elif key == ord('s') or key == ord('S'):
-      # provides a menu to pick the graphed stats
-      availableStats = self.stats.keys()
-      availableStats.sort()
-      
-      # uses sorted, camel cased labels for the options
-      options = ["None"]
-      for label in availableStats:
-        words = label.split()
-        options.append(" ".join(word[0].upper() + word[1:] for word in words))
-      
-      if self.currentDisplay:
-        initialSelection = availableStats.index(self.currentDisplay) + 1
-      else: initialSelection = 0
-      
-      selection = cli.popups.showMenu("Graphed Stats:", options, initialSelection)
-      
-      # applies new setting
-      if selection == 0: self.setStats(None)
-      elif selection != -1: self.setStats(availableStats[selection - 1])
-    elif key == ord('i') or key == ord('I'):
-      # provides menu to pick graph panel update interval
-      options = [label for (label, _) in UPDATE_INTERVALS]
-      selection = cli.popups.showMenu("Update Interval:", options, self.updateInterval)
-      if selection != -1: self.updateInterval = selection
-    else: isKeystrokeConsumed = False
-    
-    return isKeystrokeConsumed
-  
-  def getHelp(self):
-    if self.currentDisplay: graphedStats = self.currentDisplay
-    else: graphedStats = "none"
-    
-    options = []
-    options.append(("r", "resize graph", None))
-    options.append(("s", "graphed stats", graphedStats))
-    options.append(("b", "graph bounds", self.bounds.lower()))
-    options.append(("i", "graph update interval", UPDATE_INTERVALS[self.updateInterval][0]))
-    return options
-  
-  def draw(self, width, height):
-    """ Redraws graph panel """
-    
-    if self.currentDisplay:
-      param = self.getAttr("stats")[self.currentDisplay]
-      graphCol = min((width - 10) / 2, param.maxCol)
-      
-      primaryColor = uiTools.getColor(param.getColor(True))
-      secondaryColor = uiTools.getColor(param.getColor(False))
-      
-      if self.isTitleVisible(): self.addstr(0, 0, param.getTitle(width), curses.A_STANDOUT)
-      
-      # top labels
-      left, right = param.getHeaderLabel(width / 2, True), param.getHeaderLabel(width / 2, False)
-      if left: self.addstr(1, 0, left, curses.A_BOLD | primaryColor)
-      if right: self.addstr(1, graphCol + 5, right, curses.A_BOLD | secondaryColor)
-      
-      # determines max/min value on the graph
-      if self.bounds == Bounds.GLOBAL_MAX:
-        primaryMaxBound = int(param.maxPrimary[self.updateInterval])
-        secondaryMaxBound = int(param.maxSecondary[self.updateInterval])
-      else:
-        # both Bounds.LOCAL_MAX and Bounds.TIGHT use local maxima
-        if graphCol < 2:
-          # nothing being displayed
-          primaryMaxBound, secondaryMaxBound = 0, 0
-        else:
-          primaryMaxBound = int(max(param.primaryCounts[self.updateInterval][1:graphCol + 1]))
-          secondaryMaxBound = int(max(param.secondaryCounts[self.updateInterval][1:graphCol + 1]))
-      
-      primaryMinBound = secondaryMinBound = 0
-      if self.bounds == Bounds.TIGHT:
-        primaryMinBound = int(min(param.primaryCounts[self.updateInterval][1:graphCol + 1]))
-        secondaryMinBound = int(min(param.secondaryCounts[self.updateInterval][1:graphCol + 1]))
-        
-        # if the max = min (ie, all values are the same) then use zero lower
-        # bound so a graph is still displayed
-        if primaryMinBound == primaryMaxBound: primaryMinBound = 0
-        if secondaryMinBound == secondaryMaxBound: secondaryMinBound = 0
-      
-      # displays upper and lower bounds
-      self.addstr(2, 0, "%4i" % primaryMaxBound, primaryColor)
-      self.addstr(self.graphHeight + 1, 0, "%4i" % primaryMinBound, primaryColor)
-      
-      self.addstr(2, graphCol + 5, "%4i" % secondaryMaxBound, secondaryColor)
-      self.addstr(self.graphHeight + 1, graphCol + 5, "%4i" % secondaryMinBound, secondaryColor)
-      
-      # displays intermediate bounds on every other row
-      if CONFIG["features.graph.showIntermediateBounds"]:
-        ticks = (self.graphHeight - 3) / 2
-        for i in range(ticks):
-          row = self.graphHeight - (2 * i) - 3
-          if self.graphHeight % 2 == 0 and i >= (ticks / 2): row -= 1
-          
-          if primaryMinBound != primaryMaxBound:
-            primaryVal = (primaryMaxBound - primaryMinBound) * (self.graphHeight - row - 1) / (self.graphHeight - 1)
-            if not primaryVal in (primaryMinBound, primaryMaxBound): self.addstr(row + 2, 0, "%4i" % primaryVal, primaryColor)
-          
-          if secondaryMinBound != secondaryMaxBound:
-            secondaryVal = (secondaryMaxBound - secondaryMinBound) * (self.graphHeight - row - 1) / (self.graphHeight - 1)
-            if not secondaryVal in (secondaryMinBound, secondaryMaxBound): self.addstr(row + 2, graphCol + 5, "%4i" % secondaryVal, secondaryColor)
-      
-      # creates bar graph (both primary and secondary)
-      for col in range(graphCol):
-        colCount = int(param.primaryCounts[self.updateInterval][col + 1]) - primaryMinBound
-        colHeight = min(self.graphHeight, self.graphHeight * colCount / (max(1, primaryMaxBound) - primaryMinBound))
-        for row in range(colHeight): self.addstr(self.graphHeight + 1 - row, col + 5, " ", curses.A_STANDOUT | primaryColor)
-        
-        colCount = int(param.secondaryCounts[self.updateInterval][col + 1]) - secondaryMinBound
-        colHeight = min(self.graphHeight, self.graphHeight * colCount / (max(1, secondaryMaxBound) - secondaryMinBound))
-        for row in range(colHeight): self.addstr(self.graphHeight + 1 - row, col + graphCol + 10, " ", curses.A_STANDOUT | secondaryColor)
-      
-      # bottom labeling of x-axis
-      intervalSec = 1 # seconds per labeling
-      for i in range(len(UPDATE_INTERVALS)):
-        if i == self.updateInterval: intervalSec = UPDATE_INTERVALS[i][1]
-      
-      intervalSpacing = 10 if graphCol >= WIDE_LABELING_GRAPH_COL else 5
-      unitsLabel, decimalPrecision = None, 0
-      for i in range((graphCol - 4) / intervalSpacing):
-        loc = (i + 1) * intervalSpacing
-        timeLabel = str_tools.get_time_label(loc * intervalSec, decimalPrecision)
-        
-        if not unitsLabel: unitsLabel = timeLabel[-1]
-        elif unitsLabel != timeLabel[-1]:
-          # upped scale so also up precision of future measurements
-          unitsLabel = timeLabel[-1]
-          decimalPrecision += 1
-        else:
-          # if constrained on space then strips labeling since already provided
-          timeLabel = timeLabel[:-1]
-        
-        self.addstr(self.graphHeight + 2, 4 + loc, timeLabel, primaryColor)
-        self.addstr(self.graphHeight + 2, graphCol + 10 + loc, timeLabel, secondaryColor)
-        
-      param.draw(self, width, height) # allows current stats to modify the display
-  
-  def addStats(self, label, stats):
-    """
-    Makes GraphStats instance available in the panel.
-    """
-    
-    stats._graphPanel = self
-    self.stats[label] = stats
-  
-  def getStats(self):
-    """
-    Provides the currently selected stats label.
-    """
-    
-    return self.currentDisplay
-  
-  def setStats(self, label):
-    """
-    Sets the currently displayed stats instance, hiding panel if None.
-    """
-    
-    if label != self.currentDisplay:
-      if self.currentDisplay: self.stats[self.currentDisplay].isSelected = False
-      
-      if not label:
-        self.currentDisplay = None
-      elif label in self.stats.keys():
-        self.currentDisplay = label
-        self.stats[self.currentDisplay].isSelected = True
-      else: raise ValueError("Unrecognized stats label: %s" % label)
-  
-  def copyAttr(self, attr):
-    if attr == "stats":
-      # uses custom clone method to copy GraphStats instances
-      return dict([(key, self.stats[key].clone()) for key in self.stats])
-    else: return panel.Panel.copyAttr(self, attr)
-
diff --git a/src/cli/graphing/resourceStats.py b/src/cli/graphing/resourceStats.py
deleted file mode 100644
index c0f18c9..0000000
--- a/src/cli/graphing/resourceStats.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""
-Tracks the system resource usage (cpu and memory) of the tor process.
-"""
-
-from cli.graphing import graphPanel
-from util import sysTools, torTools
-
-from stem.util import str_tools
-
-class ResourceStats(graphPanel.GraphStats):
-  """
-  System resource usage tracker.
-  """
-  
-  def __init__(self):
-    graphPanel.GraphStats.__init__(self)
-    self.queryPid = torTools.getConn().controller.get_pid(None)
-  
-  def clone(self, newCopy=None):
-    if not newCopy: newCopy = ResourceStats()
-    return graphPanel.GraphStats.clone(self, newCopy)
-  
-  def getTitle(self, width):
-    return "System Resources:"
-  
-  def getHeaderLabel(self, width, isPrimary):
-    avg = (self.primaryTotal if isPrimary else self.secondaryTotal) / max(1, self.tick)
-    lastAmount = self.lastPrimary if isPrimary else self.lastSecondary
-    
-    if isPrimary:
-      return "CPU (%0.1f%%, avg: %0.1f%%):" % (lastAmount, avg)
-    else:
-      # memory sizes are converted from MB to B before generating labels
-      usageLabel = str_tools.get_size_label(lastAmount * 1048576, 1)
-      avgLabel = str_tools.get_size_label(avg * 1048576, 1)
-      return "Memory (%s, avg: %s):" % (usageLabel, avgLabel)
-  
-  def eventTick(self):
-    """
-    Fetch the cached measurement of resource usage from the ResourceTracker.
-    """
-    
-    primary, secondary = 0, 0
-    if self.queryPid:
-      resourceTracker = sysTools.getResourceTracker(self.queryPid, True)
-      
-      if resourceTracker and not resourceTracker.lastQueryFailed():
-        primary, _, secondary, _ = resourceTracker.getResourceUsage()
-        primary *= 100        # decimal percentage to whole numbers
-        secondary /= 1048576  # translate size to MB so axis labels are short
-    
-    self._processEvent(primary, secondary)
-
diff --git a/src/cli/headerPanel.py b/src/cli/headerPanel.py
deleted file mode 100644
index f1704dc..0000000
--- a/src/cli/headerPanel.py
+++ /dev/null
@@ -1,590 +0,0 @@
-"""
-Top panel for every page, containing basic system and tor related information.
-If there's room available then this expands to present its information in two
-columns, otherwise it's laid out as follows:
-  arm - <hostname> (<os> <sys/version>)         Tor <tor/version> (<new, old, recommended, etc>)
-  <nickname> - <address>:<orPort>, [Dir Port: <dirPort>, ]Control Port (<open, password, cookie>): <controlPort>
-  cpu: <cpu%> mem: <mem> (<mem%>) uid: <uid> uptime: <upmin>:<upsec>
-  fingerprint: <fingerprint>
-
-Example:
-  arm - odin (Linux 2.6.24-24-generic)         Tor 0.2.1.19 (recommended)
-  odin - 76.104.132.98:9001, Dir Port: 9030, Control Port (cookie): 9051
-  cpu: 14.6%    mem: 42 MB (4.2%)    pid: 20060   uptime: 48:27
-  fingerprint: BDAD31F6F318E0413833E8EBDA956F76E4D66788
-"""
-
-import os
-import time
-import curses
-import threading
-
-import stem
-import stem.connection
-
-from stem.control import State, Controller
-from stem.util import conf, str_tools
-
-import starter
-import cli.popups
-import cli.controller
-
-from util import panel, sysTools, torTools, uiTools
-
-from stem.util import log, str_tools
-
-# minimum width for which panel attempts to double up contents (two columns to
-# better use screen real estate)
-MIN_DUAL_COL_WIDTH = 141
-
-FLAG_COLORS = {"Authority": "white",  "BadExit": "red",     "BadDirectory": "red",    "Exit": "cyan",
-               "Fast": "yellow",      "Guard": "green",     "HSDir": "magenta",       "Named": "blue",
-               "Stable": "blue",      "Running": "yellow",  "Unnamed": "magenta",     "Valid": "green",
-               "V2Dir": "cyan",       "V3Dir": "white"}
-
-VERSION_STATUS_COLORS = {"new": "blue", "new in series": "blue", "obsolete": "red", "recommended": "green",  
-                         "old": "red",  "unrecommended": "red",  "unknown": "cyan"}
-
-CONFIG = conf.config_dict("arm", {
-  "startup.interface.ipAddress": "127.0.0.1",
-  "startup.interface.port": 9051,
-  "startup.interface.socket": "/var/run/tor/control",
-  "features.showFdUsage": False,
-})
-
-class HeaderPanel(panel.Panel, threading.Thread):
-  """
-  Top area contenting tor settings and system information. Stats are stored in
-  the vals mapping, keys including:
-    tor/  version, versionStatus, nickname, orPort, dirPort, controlPort,
-          socketPath, exitPolicy, isAuthPassword (bool), isAuthCookie (bool),
-          orListenAddr, *address, *fingerprint, *flags, pid, startTime,
-          *fdUsed, fdLimit, isFdLimitEstimate
-    sys/  hostname, os, version
-    stat/ *%torCpu, *%armCpu, *rss, *%mem
-  
-  * volatile parameter that'll be reset on each update
-  """
-  
-  def __init__(self, stdscr, startTime):
-    panel.Panel.__init__(self, stdscr, "header", 0)
-    threading.Thread.__init__(self)
-    self.setDaemon(True)
-    
-    self._isTorConnected = torTools.getConn().isAlive()
-    self._lastUpdate = -1       # time the content was last revised
-    self._halt = False          # terminates thread if true
-    self._cond = threading.Condition()  # used for pausing the thread
-    
-    # Time when the panel was paused or tor was stopped. This is used to
-    # freeze the uptime statistic (uptime increments normally when None).
-    self._haltTime = None
-    
-    # The last arm cpu usage sampling taken. This is a tuple of the form:
-    # (total arm cpu time, sampling timestamp)
-    # 
-    # The initial cpu total should be zero. However, at startup the cpu time
-    # in practice is often greater than the real time causing the initially
-    # reported cpu usage to be over 100% (which shouldn't be possible on
-    # single core systems).
-    # 
-    # Setting the initial cpu total to the value at this panel's init tends to
-    # give smoother results (staying in the same ballpark as the second
-    # sampling) so fudging the numbers this way for now.
-    
-    self._armCpuSampling = (sum(os.times()[:3]), startTime)
-    
-    # Last sampling received from the ResourceTracker, used to detect when it
-    # changes.
-    self._lastResourceFetch = -1
-    
-    # flag to indicate if we've already given file descriptor warnings
-    self._isFdSixtyPercentWarned = False
-    self._isFdNinetyPercentWarned = False
-    
-    self.vals = {}
-    self.valsLock = threading.RLock()
-    self._update(True)
-    
-    # listens for tor reload (sighup) events
-    torTools.getConn().addStatusListener(self.resetListener)
-  
-  def getHeight(self):
-    """
-    Provides the height of the content, which is dynamically determined by the
-    panel's maximum width.
-    """
-    
-    isWide = self.getParent().getmaxyx()[1] >= MIN_DUAL_COL_WIDTH
-    if self.vals["tor/orPort"]: return 4 if isWide else 6
-    else: return 3 if isWide else 4
-  
-  def sendNewnym(self):
-    """
-    Requests a new identity and provides a visual queue.
-    """
-    
-    torTools.getConn().sendNewnym()
-    
-    # If we're wide then the newnym label in this panel will give an
-    # indication that the signal was sent. Otherwise use a msg.
-    isWide = self.getParent().getmaxyx()[1] >= MIN_DUAL_COL_WIDTH
-    if not isWide: cli.popups.showMsg("Requesting a new identity", 1)
-  
-  def handleKey(self, key):
-    isKeystrokeConsumed = True
-    
-    if key in (ord('n'), ord('N')) and torTools.getConn().isNewnymAvailable():
-      self.sendNewnym()
-    elif key in (ord('r'), ord('R')) and not self._isTorConnected:
-      controller = None
-      allowPortConnection, allowSocketConnection, _ = starter.allowConnectionTypes()
-      
-      if os.path.exists(CONFIG["startup.interface.socket"]) and allowSocketConnection:
-        try:
-          # TODO: um... what about passwords?
-          controller = Controller.from_socket_file(CONFIG["startup.interface.socket"])
-          controller.authenticate()
-        except (IOError, stem.SocketError), exc:
-          controller = None
-          
-          if not allowPortConnection:
-            cli.popups.showMsg("Unable to reconnect (%s)" % exc, 3)
-      elif not allowPortConnection:
-        cli.popups.showMsg("Unable to reconnect (socket '%s' doesn't exist)" % CONFIG["startup.interface.socket"], 3)
-      
-      if not controller and allowPortConnection:
-        # TODO: This has diverged from starter.py's connection, for instance it
-        # doesn't account for relative cookie paths or multiple authentication
-        # methods. We can't use the starter.py's connection function directly
-        # due to password prompts, but we could certainly make this mess more
-        # manageable.
-        
-        try:
-          ctlAddr, ctlPort = CONFIG["startup.interface.ipAddress"], CONFIG["startup.interface.port"]
-          controller = Controller.from_port(ctlAddr, ctlPort)
-          
-          try:
-            controller.authenticate()
-          except stem.connection.MissingPassword:
-            controller.authenticate(authValue) # already got the password above
-        except Exception, exc:
-          controller = None
-      
-      if controller:
-        torTools.getConn().init(controller)
-        log.notice("Reconnected to Tor's control port")
-        cli.popups.showMsg("Tor reconnected", 1)
-    else: isKeystrokeConsumed = False
-    
-    return isKeystrokeConsumed
-  
-  def draw(self, width, height):
-    self.valsLock.acquire()
-    isWide = width + 1 >= MIN_DUAL_COL_WIDTH
-    
-    # space available for content
-    if isWide:
-      leftWidth = max(width / 2, 77)
-      rightWidth = width - leftWidth
-    else: leftWidth = rightWidth = width
-    
-    # Line 1 / Line 1 Left (system and tor version information)
-    sysNameLabel = "arm - %s" % self.vals["sys/hostname"]
-    contentSpace = min(leftWidth, 40)
-    
-    if len(sysNameLabel) + 10 <= contentSpace:
-      sysTypeLabel = "%s %s" % (self.vals["sys/os"], self.vals["sys/version"])
-      sysTypeLabel = uiTools.cropStr(sysTypeLabel, contentSpace - len(sysNameLabel) - 3, 4)
-      self.addstr(0, 0, "%s (%s)" % (sysNameLabel, sysTypeLabel))
-    else:
-      self.addstr(0, 0, uiTools.cropStr(sysNameLabel, contentSpace))
-    
-    contentSpace = leftWidth - 43
-    if 7 + len(self.vals["tor/version"]) + len(self.vals["tor/versionStatus"]) <= contentSpace:
-      if self.vals["tor/version"] != "Unknown":
-        versionColor = VERSION_STATUS_COLORS[self.vals["tor/versionStatus"]] if \
-            self.vals["tor/versionStatus"] in VERSION_STATUS_COLORS else "white"
-        labelPrefix = "Tor %s (" % self.vals["tor/version"]
-        self.addstr(0, 43, labelPrefix)
-        self.addstr(0, 43 + len(labelPrefix), self.vals["tor/versionStatus"], uiTools.getColor(versionColor))
-        self.addstr(0, 43 + len(labelPrefix) + len(self.vals["tor/versionStatus"]), ")")
-    elif 11 <= contentSpace:
-      self.addstr(0, 43, uiTools.cropStr("Tor %s" % self.vals["tor/version"], contentSpace, 4))
-    
-    # Line 2 / Line 2 Left (tor ip/port information)
-    x, includeControlPort = 0, True
-    if self.vals["tor/orPort"]:
-      myAddress = "Unknown"
-      if self.vals["tor/orListenAddr"]: myAddress = self.vals["tor/orListenAddr"]
-      elif self.vals["tor/address"]: myAddress = self.vals["tor/address"]
-      
-      # acting as a relay (we can assume certain parameters are set
-      dirPortLabel = ", Dir Port: %s" % self.vals["tor/dirPort"] if self.vals["tor/dirPort"] != "0" else ""
-      for label in (self.vals["tor/nickname"], " - " + myAddress, ":" + self.vals["tor/orPort"], dirPortLabel):
-        if x + len(label) <= leftWidth:
-          self.addstr(1, x, label)
-          x += len(label)
-        else: break
-    else:
-      # non-relay (client only)
-      if self._isTorConnected:
-        self.addstr(1, x, "Relaying Disabled", uiTools.getColor("cyan"))
-        x += 17
-      else:
-        statusTime = torTools.getConn().controller.get_latest_heartbeat()
-        
-        if statusTime:
-          statusTimeLabel = time.strftime("%H:%M %m/%d/%Y, ", time.localtime(statusTime))
-        else: statusTimeLabel = "" # never connected to tor
-        
-        self.addstr(1, x, "Tor Disconnected", curses.A_BOLD | uiTools.getColor("red"))
-        self.addstr(1, x + 16, " (%spress r to reconnect)" % statusTimeLabel)
-        x += 39 + len(statusTimeLabel)
-        includeControlPort = False
-    
-    if includeControlPort:
-      if self.vals["tor/controlPort"] == "0":
-        # connected via a control socket
-        self.addstr(1, x, ", Control Socket: %s" % self.vals["tor/socketPath"])
-      else:
-        if self.vals["tor/isAuthPassword"]: authType = "password"
-        elif self.vals["tor/isAuthCookie"]: authType = "cookie"
-        else: authType = "open"
-        
-        if x + 19 + len(self.vals["tor/controlPort"]) + len(authType) <= leftWidth:
-          authColor = "red" if authType == "open" else "green"
-          self.addstr(1, x, ", Control Port (")
-          self.addstr(1, x + 16, authType, uiTools.getColor(authColor))
-          self.addstr(1, x + 16 + len(authType), "): %s" % self.vals["tor/controlPort"])
-        elif x + 16 + len(self.vals["tor/controlPort"]) <= leftWidth:
-          self.addstr(1, 0, ", Control Port: %s" % self.vals["tor/controlPort"])
-    
-    # Line 3 / Line 1 Right (system usage info)
-    y, x = (0, leftWidth) if isWide else (2, 0)
-    if self.vals["stat/rss"] != "0": memoryLabel = str_tools.get_size_label(int(self.vals["stat/rss"]))
-    else: memoryLabel = "0"
-    
-    uptimeLabel = ""
-    if self.vals["tor/startTime"]:
-      if self.isPaused() or not self._isTorConnected:
-        # freeze the uptime when paused or the tor process is stopped
-        uptimeLabel = str_tools.get_short_time_label(self.getPauseTime() - self.vals["tor/startTime"])
-      else:
-        uptimeLabel = str_tools.get_short_time_label(time.time() - self.vals["tor/startTime"])
-    
-    sysFields = ((0, "cpu: %s%% tor, %s%% arm" % (self.vals["stat/%torCpu"], self.vals["stat/%armCpu"])),
-                 (27, "mem: %s (%s%%)" % (memoryLabel, self.vals["stat/%mem"])),
-                 (47, "pid: %s" % (self.vals["tor/pid"] if self._isTorConnected else "")),
-                 (59, "uptime: %s" % uptimeLabel))
-    
-    for (start, label) in sysFields:
-      if start + len(label) <= rightWidth: self.addstr(y, x + start, label)
-      else: break
-    
-    if self.vals["tor/orPort"]:
-      # Line 4 / Line 2 Right (fingerprint, and possibly file descriptor usage)
-      y, x = (1, leftWidth) if isWide else (3, 0)
-      
-      fingerprintLabel = uiTools.cropStr("fingerprint: %s" % self.vals["tor/fingerprint"], width)
-      self.addstr(y, x, fingerprintLabel)
-      
-      # if there's room and we're able to retrieve both the file descriptor
-      # usage and limit then it might be presented
-      if width - x - 59 >= 20 and self.vals["tor/fdUsed"] and self.vals["tor/fdLimit"]:
-        # display file descriptor usage if we're either configured to do so or
-        # running out
-        
-        fdPercent = 100 * self.vals["tor/fdUsed"] / self.vals["tor/fdLimit"]
-        
-        if fdPercent >= 60 or CONFIG["features.showFdUsage"]:
-          fdPercentLabel, fdPercentFormat = "%i%%" % fdPercent, curses.A_NORMAL
-          if fdPercent >= 95:
-            fdPercentFormat = curses.A_BOLD | uiTools.getColor("red")
-          elif fdPercent >= 90:
-            fdPercentFormat = uiTools.getColor("red")
-          elif fdPercent >= 60:
-            fdPercentFormat = uiTools.getColor("yellow")
-          
-          estimateChar = "?" if self.vals["tor/isFdLimitEstimate"] else ""
-          baseLabel = "file desc: %i / %i%s (" % (self.vals["tor/fdUsed"], self.vals["tor/fdLimit"], estimateChar)
-          
-          self.addstr(y, x + 59, baseLabel)
-          self.addstr(y, x + 59 + len(baseLabel), fdPercentLabel, fdPercentFormat)
-          self.addstr(y, x + 59 + len(baseLabel) + len(fdPercentLabel), ")")
-      
-      # Line 5 / Line 3 Left (flags)
-      if self._isTorConnected:
-        y, x = (2 if isWide else 4, 0)
-        self.addstr(y, x, "flags: ")
-        x += 7
-        
-        if len(self.vals["tor/flags"]) > 0:
-          for i in range(len(self.vals["tor/flags"])):
-            flag = self.vals["tor/flags"][i]
-            flagColor = FLAG_COLORS[flag] if flag in FLAG_COLORS.keys() else "white"
-            
-            self.addstr(y, x, flag, curses.A_BOLD | uiTools.getColor(flagColor))
-            x += len(flag)
-            
-            if i < len(self.vals["tor/flags"]) - 1:
-              self.addstr(y, x, ", ")
-              x += 2
-        else:
-          self.addstr(y, x, "none", curses.A_BOLD | uiTools.getColor("cyan"))
-      else:
-        y = 2 if isWide else 4
-        statusTime = torTools.getConn().controller.get_latest_heartbeat()
-        statusTimeLabel = time.strftime("%H:%M %m/%d/%Y", time.localtime(statusTime))
-        self.addstr(y, 0, "Tor Disconnected", curses.A_BOLD | uiTools.getColor("red"))
-        self.addstr(y, 16, " (%s) - press r to reconnect" % statusTimeLabel)
-      
-      # Undisplayed / Line 3 Right (exit policy)
-      if isWide:
-        exitPolicy = self.vals["tor/exitPolicy"]
-        
-        # adds note when default exit policy is appended
-        if exitPolicy == "": exitPolicy = "<default>"
-        elif not exitPolicy.endswith((" *:*", " *")): exitPolicy += ", <default>"
-        
-        self.addstr(2, leftWidth, "exit policy: ")
-        x = leftWidth + 13
-        
-        # color codes accepts to be green, rejects to be red, and default marker to be cyan
-        isSimple = len(exitPolicy) > rightWidth - 13
-        policies = exitPolicy.split(", ")
-        for i in range(len(policies)):
-          policy = policies[i].strip()
-          policyLabel = policy.replace("accept", "").replace("reject", "").strip() if isSimple else policy
-          
-          policyColor = "white"
-          if policy.startswith("accept"): policyColor = "green"
-          elif policy.startswith("reject"): policyColor = "red"
-          elif policy.startswith("<default>"): policyColor = "cyan"
-          
-          self.addstr(2, x, policyLabel, curses.A_BOLD | uiTools.getColor(policyColor))
-          x += len(policyLabel)
-          
-          if i < len(policies) - 1:
-            self.addstr(2, x, ", ")
-            x += 2
-    else:
-      # (Client only) Undisplayed / Line 2 Right (new identity option)
-      if isWide:
-        conn = torTools.getConn()
-        newnymWait = conn.getNewnymWait()
-        
-        msg = "press 'n' for a new identity"
-        if newnymWait > 0:
-          pluralLabel = "s" if newnymWait > 1 else ""
-          msg = "building circuits, available again in %i second%s" % (newnymWait, pluralLabel)
-        
-        self.addstr(1, leftWidth, msg)
-    
-    self.valsLock.release()
-  
-  def getPauseTime(self):
-    """
-    Provides the time Tor stopped if it isn't running. Otherwise this is the
-    time we were last paused.
-    """
-    
-    if self._haltTime: return self._haltTime
-    else: return panel.Panel.getPauseTime(self)
-  
-  def run(self):
-    """
-    Keeps stats updated, checking for new information at a set rate.
-    """
-    
-    lastDraw = time.time() - 1
-    while not self._halt:
-      currentTime = time.time()
-      
-      if self.isPaused() or currentTime - lastDraw < 1 or not self._isTorConnected:
-        self._cond.acquire()
-        if not self._halt: self._cond.wait(0.2)
-        self._cond.release()
-      else:
-        # Update the volatile attributes (cpu, memory, flags, etc) if we have
-        # a new resource usage sampling (the most dynamic stat) or its been
-        # twenty seconds since last fetched (so we still refresh occasionally
-        # when resource fetches fail).
-        # 
-        # Otherwise, just redraw the panel to change the uptime field.
-        
-        isChanged = False
-        if self.vals["tor/pid"]:
-          resourceTracker = sysTools.getResourceTracker(self.vals["tor/pid"])
-          isChanged = self._lastResourceFetch != resourceTracker.getRunCount()
-        
-        if isChanged or currentTime - self._lastUpdate >= 20:
-          self._update()
-        
-        self.redraw(True)
-        lastDraw += 1
-  
-  def stop(self):
-    """
-    Halts further resolutions and terminates the thread.
-    """
-    
-    self._cond.acquire()
-    self._halt = True
-    self._cond.notifyAll()
-    self._cond.release()
-  
-  def resetListener(self, controller, eventType, _):
-    """
-    Updates static parameters on tor reload (sighup) events.
-    """
-    
-    if eventType in (State.INIT, State.RESET):
-      initialHeight = self.getHeight()
-      self._isTorConnected = True
-      self._haltTime = None
-      self._update(True)
-      
-      if self.getHeight() != initialHeight:
-        # We're toggling between being a relay and client, causing the height
-        # of this panel to change. Redraw all content so we don't get
-        # overlapping content.
-        cli.controller.getController().redraw()
-      else:
-        # just need to redraw ourselves
-        self.redraw(True)
-    elif eventType == State.CLOSED:
-      self._isTorConnected = False
-      self._haltTime = time.time()
-      self._update()
-      self.redraw(True)
-  
-  def _update(self, setStatic=False):
-    """
-    Updates stats in the vals mapping. By default this just revises volatile
-    attributes.
-    
-    Arguments:
-      setStatic - resets all parameters, including relatively static values
-    """
-    
-    self.valsLock.acquire()
-    conn = torTools.getConn()
-    
-    if setStatic:
-      # version is truncated to first part, for instance:
-      # 0.2.2.13-alpha (git-feb8c1b5f67f2c6f) -> 0.2.2.13-alpha
-      self.vals["tor/version"] = conn.getInfo("version", "Unknown").split()[0]
-      self.vals["tor/versionStatus"] = conn.getInfo("status/version/current", "Unknown")
-      self.vals["tor/nickname"] = conn.getOption("Nickname", "")
-      self.vals["tor/orPort"] = conn.getOption("ORPort", "0")
-      self.vals["tor/dirPort"] = conn.getOption("DirPort", "0")
-      self.vals["tor/controlPort"] = conn.getOption("ControlPort", "0")
-      self.vals["tor/socketPath"] = conn.getOption("ControlSocket", "")
-      self.vals["tor/isAuthPassword"] = conn.getOption("HashedControlPassword", None) != None
-      self.vals["tor/isAuthCookie"] = conn.getOption("CookieAuthentication", None) == "1"
-      
-      # orport is reported as zero if unset
-      if self.vals["tor/orPort"] == "0": self.vals["tor/orPort"] = ""
-      
-      # overwrite address if ORListenAddress is set (and possibly orPort too)
-      self.vals["tor/orListenAddr"] = ""
-      listenAddr = conn.getOption("ORListenAddress", None)
-      if listenAddr:
-        if ":" in listenAddr:
-          # both ip and port overwritten
-          self.vals["tor/orListenAddr"] = listenAddr[:listenAddr.find(":")]
-          self.vals["tor/orPort"] = listenAddr[listenAddr.find(":") + 1:]
-        else:
-          self.vals["tor/orListenAddr"] = listenAddr
-      
-      # fetch exit policy (might span over multiple lines)
-      policyEntries = []
-      for exitPolicy in conn.getOption("ExitPolicy", [], True):
-        policyEntries += [policy.strip() for policy in exitPolicy.split(",")]
-      self.vals["tor/exitPolicy"] = ", ".join(policyEntries)
-      
-      # file descriptor limit for the process, if this can't be determined
-      # then the limit is None
-      fdLimit, fdIsEstimate = conn.getMyFileDescriptorLimit()
-      self.vals["tor/fdLimit"] = fdLimit
-      self.vals["tor/isFdLimitEstimate"] = fdIsEstimate
-      
-      # system information
-      unameVals = os.uname()
-      self.vals["sys/hostname"] = unameVals[1]
-      self.vals["sys/os"] = unameVals[0]
-      self.vals["sys/version"] = unameVals[2]
-      
-      self.vals["tor/pid"] = conn.controller.get_pid("")
-      
-      startTime = conn.getStartTime()
-      self.vals["tor/startTime"] = startTime if startTime else ""
-      
-      # reverts volatile parameters to defaults
-      self.vals["tor/fingerprint"] = "Unknown"
-      self.vals["tor/flags"] = []
-      self.vals["tor/fdUsed"] = 0
-      self.vals["stat/%torCpu"] = "0"
-      self.vals["stat/%armCpu"] = "0"
-      self.vals["stat/rss"] = "0"
-      self.vals["stat/%mem"] = "0"
-    
-    # sets volatile parameters
-    # TODO: This can change, being reported by STATUS_SERVER -> EXTERNAL_ADDRESS
-    # events. Introduce caching via torTools?
-    self.vals["tor/address"] = conn.getInfo("address", "")
-    
-    self.vals["tor/fingerprint"] = conn.getInfo("fingerprint", self.vals["tor/fingerprint"])
-    self.vals["tor/flags"] = conn.getMyFlags(self.vals["tor/flags"])
-    
-    # Updates file descriptor usage and logs if the usage is high. If we don't
-    # have a known limit or it's obviously faulty (being lower than our
-    # current usage) then omit file descriptor functionality.
-    if self.vals["tor/fdLimit"]:
-      fdUsed = conn.getMyFileDescriptorUsage()
-      if fdUsed and fdUsed <= self.vals["tor/fdLimit"]: self.vals["tor/fdUsed"] = fdUsed
-      else: self.vals["tor/fdUsed"] = 0
-    
-    if self.vals["tor/fdUsed"] and self.vals["tor/fdLimit"]:
-      fdPercent = 100 * self.vals["tor/fdUsed"] / self.vals["tor/fdLimit"]
-      estimatedLabel = " estimated" if self.vals["tor/isFdLimitEstimate"] else ""
-      msg = "Tor's%s file descriptor usage is at %i%%." % (estimatedLabel, fdPercent)
-      
-      if fdPercent >= 90 and not self._isFdNinetyPercentWarned:
-        self._isFdSixtyPercentWarned, self._isFdNinetyPercentWarned = True, True
-        msg += " If you run out Tor will be unable to continue functioning."
-        log.warn(msg)
-      elif fdPercent >= 60 and not self._isFdSixtyPercentWarned:
-        self._isFdSixtyPercentWarned = True
-        log.notice(msg)
-    
-    # ps or proc derived resource usage stats
-    if self.vals["tor/pid"]:
-      resourceTracker = sysTools.getResourceTracker(self.vals["tor/pid"])
-      
-      if resourceTracker.lastQueryFailed():
-        self.vals["stat/%torCpu"] = "0"
-        self.vals["stat/rss"] = "0"
-        self.vals["stat/%mem"] = "0"
-      else:
-        cpuUsage, _, memUsage, memUsagePercent = resourceTracker.getResourceUsage()
-        self._lastResourceFetch = resourceTracker.getRunCount()
-        self.vals["stat/%torCpu"] = "%0.1f" % (100 * cpuUsage)
-        self.vals["stat/rss"] = str(memUsage)
-        self.vals["stat/%mem"] = "%0.1f" % (100 * memUsagePercent)
-    
-    # determines the cpu time for the arm process (including user and system
-    # time of both the primary and child processes)
-    
-    totalArmCpuTime, currentTime = sum(os.times()[:3]), time.time()
-    armCpuDelta = totalArmCpuTime - self._armCpuSampling[0]
-    armTimeDelta = currentTime - self._armCpuSampling[1]
-    pythonCpuTime = armCpuDelta / armTimeDelta
-    sysCallCpuTime = sysTools.getSysCpuUsage()
-    self.vals["stat/%armCpu"] = "%0.1f" % (100 * (pythonCpuTime + sysCallCpuTime))
-    self._armCpuSampling = (totalArmCpuTime, currentTime)
-    
-    self._lastUpdate = currentTime
-    self.valsLock.release()
-
diff --git a/src/cli/logPanel.py b/src/cli/logPanel.py
deleted file mode 100644
index d85144c..0000000
--- a/src/cli/logPanel.py
+++ /dev/null
@@ -1,1270 +0,0 @@
-"""
-Panel providing a chronological log of events its been configured to listen
-for. This provides prepopulation from the log file and supports filtering by
-regular expressions.
-"""
-
-import re
-import os
-import time
-import curses
-import logging
-import threading
-
-import stem
-from stem.control import State
-from stem.response import events
-from stem.util import conf, log, system
-
-import popups
-from version import VERSION
-from util import panel, sysTools, torTools, uiTools
-
-TOR_EVENT_TYPES = {
-  "d": "DEBUG",   "a": "ADDRMAP",          "k": "DESCCHANGED",  "s": "STREAM",
-  "i": "INFO",    "f": "AUTHDIR_NEWDESCS", "g": "GUARD",        "r": "STREAM_BW",
-  "n": "NOTICE",  "h": "BUILDTIMEOUT_SET", "l": "NEWCONSENSUS", "t": "STATUS_CLIENT",
-  "w": "WARN",    "b": "BW",               "m": "NEWDESC",      "u": "STATUS_GENERAL",
-  "e": "ERR",     "c": "CIRC",             "p": "NS",           "v": "STATUS_SERVER",
-                  "j": "CLIENTS_SEEN",     "q": "ORCONN"}
-
-EVENT_LISTING = """        d DEBUG      a ADDRMAP           k DESCCHANGED   s STREAM
-        i INFO       f AUTHDIR_NEWDESCS  g GUARD         r STREAM_BW
-        n NOTICE     h BUILDTIMEOUT_SET  l NEWCONSENSUS  t STATUS_CLIENT
-        w WARN       b BW                m NEWDESC       u STATUS_GENERAL
-        e ERR        c CIRC              p NS            v STATUS_SERVER
-                     j CLIENTS_SEEN      q ORCONN
-          DINWE tor runlevel+            A All Events
-          12345 arm runlevel+            X No Events
-                                         U Unknown Events"""
-
-RUNLEVEL_EVENT_COLOR = {log.DEBUG: "magenta", log.INFO: "blue", log.NOTICE: "green",
-                        log.WARN: "yellow", log.ERR: "red"}
-DAYBREAK_EVENT = "DAYBREAK" # special event for marking when the date changes
-TIMEZONE_OFFSET = time.altzone if time.localtime()[8] else time.timezone
-
-ENTRY_INDENT = 2 # spaces an entry's message is indented after the first line
-
-def conf_handler(key, value):
-  if key == "features.log.maxLinesPerEntry":
-    return max(1, value)
-  elif key == "features.log.prepopulateReadLimit":
-    return max(0, value)
-  elif key == "features.log.maxRefreshRate":
-    return max(10, value)
-  elif key == "cache.logPanel.size":
-    return max(1000, value)
-
-CONFIG = conf.config_dict("arm", {
-  "features.logFile": "",
-  "features.log.showDateDividers": True,
-  "features.log.showDuplicateEntries": False,
-  "features.log.entryDuration": 7,
-  "features.log.maxLinesPerEntry": 6,
-  "features.log.prepopulate": True,
-  "features.log.prepopulateReadLimit": 5000,
-  "features.log.maxRefreshRate": 300,
-  "features.log.regex": [],
-  "cache.logPanel.size": 1000,
-}, conf_handler)
-
-DUPLICATE_MSG = " [%i duplicate%s hidden]"
-
-# The height of the drawn content is estimated based on the last time we redrew
-# the panel. It's chiefly used for scrolling and the bar indicating its
-# position. Letting the estimate be too inaccurate results in a display bug, so
-# redraws the display if it's off by this threshold.
-CONTENT_HEIGHT_REDRAW_THRESHOLD = 3
-
-# static starting portion of common log entries, fetched from the config when
-# needed if None
-COMMON_LOG_MESSAGES = None
-
-# cached values and the arguments that generated it for the getDaybreaks and
-# getDuplicates functions
-CACHED_DAYBREAKS_ARGUMENTS = (None, None) # events, current day
-CACHED_DAYBREAKS_RESULT = None
-CACHED_DUPLICATES_ARGUMENTS = None # events
-CACHED_DUPLICATES_RESULT = None
-
-# duration we'll wait for the deduplication function before giving up (in ms)
-DEDUPLICATION_TIMEOUT = 100
-
-# maximum number of regex filters we'll remember
-MAX_REGEX_FILTERS = 5
-
-def daysSince(timestamp=None):
-  """
-  Provides the number of days since the epoch converted to local time (rounded
-  down).
-  
-  Arguments:
-    timestamp - unix timestamp to convert, current time if undefined
-  """
-  
-  if timestamp == None: timestamp = time.time()
-  return int((timestamp - TIMEZONE_OFFSET) / 86400)
-
-def expandEvents(eventAbbr):
-  """
-  Expands event abbreviations to their full names. Beside mappings provided in
-  TOR_EVENT_TYPES this recognizes the following special events and aliases:
-  U - UKNOWN events
-  A - all events
-  X - no events
-  DINWE - runlevel and higher
-  12345 - arm/stem runlevel and higher (ARM_DEBUG - ARM_ERR)
-  Raises ValueError with invalid input if any part isn't recognized.
-  
-  Examples:
-  "inUt" -> ["INFO", "NOTICE", "UNKNOWN", "STREAM_BW"]
-  "N4" -> ["NOTICE", "WARN", "ERR", "ARM_WARN", "ARM_ERR"]
-  "cfX" -> []
-  
-  Arguments:
-    eventAbbr - flags to be parsed to event types
-  """
-  
-  expandedEvents, invalidFlags = set(), ""
-  
-  for flag in eventAbbr:
-    if flag == "A":
-      armRunlevels = ["ARM_" + runlevel for runlevel in log.Runlevel]
-      expandedEvents = set(list(TOR_EVENT_TYPES) + armRunlevels + ["UNKNOWN"])
-      break
-    elif flag == "X":
-      expandedEvents = set()
-      break
-    elif flag in "DINWE12345":
-      # all events for a runlevel and higher
-      if flag in "D1": runlevelIndex = 1
-      elif flag in "I2": runlevelIndex = 2
-      elif flag in "N3": runlevelIndex = 3
-      elif flag in "W4": runlevelIndex = 4
-      elif flag in "E5": runlevelIndex = 5
-      
-      if flag in "DINWE":
-        runlevelSet = [runlevel for runlevel in list(log.Runlevel)[runlevelIndex:]]
-        expandedEvents = expandedEvents.union(set(runlevelSet))
-      elif flag in "12345":
-        runlevelSet = ["ARM_" + runlevel for runlevel in list(log.Runlevel)[runlevelIndex:]]
-        expandedEvents = expandedEvents.union(set(runlevelSet))
-    elif flag == "U":
-      expandedEvents.add("UNKNOWN")
-    elif flag in TOR_EVENT_TYPES:
-      expandedEvents.add(TOR_EVENT_TYPES[flag])
-    else:
-      invalidFlags += flag
-  
-  if invalidFlags: raise ValueError(invalidFlags)
-  else: return expandedEvents
-
-def getMissingEventTypes():
-  """
-  Provides the event types the current tor connection supports but arm
-  doesn't. This provides an empty list if no event types are missing, and None
-  if the GETINFO query fails.
-  """
-  
-  torEventTypes = torTools.getConn().getInfo("events/names", None)
-  
-  if torEventTypes:
-    torEventTypes = torEventTypes.split(" ")
-    armEventTypes = TOR_EVENT_TYPES.values()
-    return [event for event in torEventTypes if not event in armEventTypes]
-  else: return None # GETINFO call failed
-
-def loadLogMessages():
-  """
-  Fetches a mapping of common log messages to their runlevels from the config.
-  """
-  
-  global COMMON_LOG_MESSAGES
-  armConf = conf.get_config("arm")
-  
-  COMMON_LOG_MESSAGES = {}
-  for confKey in armConf.keys():
-    if confKey.startswith("msg."):
-      eventType = confKey[4:].upper()
-      messages = armConf.get(confKey, [])
-      COMMON_LOG_MESSAGES[eventType] = messages
-
-def getLogFileEntries(runlevels, readLimit = None, addLimit = None):
-  """
-  Parses tor's log file for past events matching the given runlevels, providing
-  a list of log entries (ordered newest to oldest). Limiting the number of read
-  entries is suggested to avoid parsing everything from logs in the GB and TB
-  range.
-  
-  Arguments:
-    runlevels - event types (DEBUG - ERR) to be returned
-    readLimit - max lines of the log file that'll be read (unlimited if None)
-    addLimit  - maximum entries to provide back (unlimited if None)
-  """
-  
-  startTime = time.time()
-  if not runlevels: return []
-  
-  # checks tor's configuration for the log file's location (if any exists)
-  loggingTypes, loggingLocation = None, None
-  for loggingEntry in torTools.getConn().getOption("Log", [], True):
-    # looks for an entry like: notice file /var/log/tor/notices.log
-    entryComp = loggingEntry.split()
-    
-    if entryComp[1] == "file":
-      loggingTypes, loggingLocation = entryComp[0], entryComp[2]
-      break
-  
-  if not loggingLocation: return []
-  
-  # includes the prefix for tor paths
-  loggingLocation = torTools.getConn().getPathPrefix() + loggingLocation
-  
-  # if the runlevels argument is a superset of the log file then we can
-  # limit the read contents to the addLimit
-  runlevels = list(log.Runlevel)
-  loggingTypes = loggingTypes.upper()
-  if addLimit and (not readLimit or readLimit > addLimit):
-    if "-" in loggingTypes:
-      divIndex = loggingTypes.find("-")
-      sIndex = runlevels.index(loggingTypes[:divIndex])
-      eIndex = runlevels.index(loggingTypes[divIndex+1:])
-      logFileRunlevels = runlevels[sIndex:eIndex+1]
-    else:
-      sIndex = runlevels.index(loggingTypes)
-      logFileRunlevels = runlevels[sIndex:]
-    
-    # checks if runlevels we're reporting are a superset of the file's contents
-    isFileSubset = True
-    for runlevelType in logFileRunlevels:
-      if runlevelType not in runlevels:
-        isFileSubset = False
-        break
-    
-    if isFileSubset: readLimit = addLimit
-  
-  # tries opening the log file, cropping results to avoid choking on huge logs
-  lines = []
-  try:
-    if readLimit:
-      lines = system.call("tail -n %i %s" % (readLimit, loggingLocation))
-      if not lines: raise IOError()
-    else:
-      logFile = open(loggingLocation, "r")
-      lines = logFile.readlines()
-      logFile.close()
-  except IOError:
-    log.warn("Unable to read tor's log file: %s" % loggingLocation)
-  
-  if not lines: return []
-  
-  loggedEvents = []
-  currentUnixTime, currentLocalTime = time.time(), time.localtime()
-  for i in range(len(lines) - 1, -1, -1):
-    line = lines[i]
-    
-    # entries look like:
-    # Jul 15 18:29:48.806 [notice] Parsing GEOIP file.
-    lineComp = line.split()
-    
-    # Checks that we have all the components we expect. This could happen if
-    # we're either not parsing a tor log or in weird edge cases (like being
-    # out of disk space)
-    
-    if len(lineComp) < 4: continue
-    
-    eventType = lineComp[3][1:-1].upper()
-    
-    if eventType in runlevels:
-      # converts timestamp to unix time
-      timestamp = " ".join(lineComp[:3])
-      
-      # strips the decimal seconds
-      if "." in timestamp: timestamp = timestamp[:timestamp.find(".")]
-      
-      # Ignoring wday and yday since they aren't used.
-      #
-      # Pretend the year is 2012, because 2012 is a leap year, and parsing a
-      # date with strptime fails if Feb 29th is passed without a year that's
-      # actually a leap year. We can't just use the current year, because we
-      # might be parsing old logs which didn't get rotated.
-      #
-      # https://trac.torproject.org/projects/tor/ticket/5265
-      
-      timestamp = "2012 " + timestamp
-      eventTimeComp = list(time.strptime(timestamp, "%Y %b %d %H:%M:%S"))
-      eventTimeComp[8] = currentLocalTime.tm_isdst
-      eventTime = time.mktime(eventTimeComp) # converts local to unix time
-      
-      # The above is gonna be wrong if the logs are for the previous year. If
-      # the event's in the future then correct for this.
-      if eventTime > currentUnixTime + 60:
-        eventTimeComp[0] -= 1
-        eventTime = time.mktime(eventTimeComp)
-      
-      eventMsg = " ".join(lineComp[4:])
-      loggedEvents.append(LogEntry(eventTime, eventType, eventMsg, RUNLEVEL_EVENT_COLOR[eventType]))
-    
-    if "opening log file" in line:
-      break # this entry marks the start of this tor instance
-  
-  if addLimit: loggedEvents = loggedEvents[:addLimit]
-  log.info("Read %i entries from tor's log file: %s (read limit: %i, runtime: %0.3f)" % (len(loggedEvents), loggingLocation, readLimit, time.time() - startTime))
-  return loggedEvents
-
-def getDaybreaks(events, ignoreTimeForCache = False):
-  """
-  Provides the input events back with special 'DAYBREAK_EVENT' markers inserted
-  whenever the date changed between log entries (or since the most recent
-  event). The timestamp matches the beginning of the day for the following
-  entry.
-  
-  Arguments:
-    events             - chronologically ordered listing of events
-    ignoreTimeForCache - skips taking the day into consideration for providing
-                         cached results if true
-  """
-  
-  global CACHED_DAYBREAKS_ARGUMENTS, CACHED_DAYBREAKS_RESULT
-  if not events: return []
-  
-  newListing = []
-  currentDay = daysSince()
-  lastDay = currentDay
-  
-  if CACHED_DAYBREAKS_ARGUMENTS[0] == events and \
-    (ignoreTimeForCache or CACHED_DAYBREAKS_ARGUMENTS[1] == currentDay):
-    return list(CACHED_DAYBREAKS_RESULT)
-  
-  for entry in events:
-    eventDay = daysSince(entry.timestamp)
-    if eventDay != lastDay:
-      markerTimestamp = (eventDay * 86400) + TIMEZONE_OFFSET
-      newListing.append(LogEntry(markerTimestamp, DAYBREAK_EVENT, "", "white"))
-    
-    newListing.append(entry)
-    lastDay = eventDay
-  
-  CACHED_DAYBREAKS_ARGUMENTS = (list(events), currentDay)
-  CACHED_DAYBREAKS_RESULT = list(newListing)
-  
-  return newListing
-
-def getDuplicates(events):
-  """
-  Deduplicates a list of log entries, providing back a tuple listing with the
-  log entry and count of duplicates following it. Entries in different days are
-  not considered to be duplicates. This times out, returning None if it takes
-  longer than DEDUPLICATION_TIMEOUT.
-  
-  Arguments:
-    events - chronologically ordered listing of events
-  """
-  
-  global CACHED_DUPLICATES_ARGUMENTS, CACHED_DUPLICATES_RESULT
-  if CACHED_DUPLICATES_ARGUMENTS == events:
-    return list(CACHED_DUPLICATES_RESULT)
-  
-  # loads common log entries from the config if they haven't been
-  if COMMON_LOG_MESSAGES == None: loadLogMessages()
-  
-  startTime = time.time()
-  eventsRemaining = list(events)
-  returnEvents = []
-  
-  while eventsRemaining:
-    entry = eventsRemaining.pop(0)
-    duplicateIndices = isDuplicate(entry, eventsRemaining, True)
-    
-    # checks if the call timeout has been reached
-    if (time.time() - startTime) > DEDUPLICATION_TIMEOUT / 1000.0:
-      return None
-    
-    # drops duplicate entries
-    duplicateIndices.reverse()
-    for i in duplicateIndices: del eventsRemaining[i]
-    
-    returnEvents.append((entry, len(duplicateIndices)))
-  
-  CACHED_DUPLICATES_ARGUMENTS = list(events)
-  CACHED_DUPLICATES_RESULT = list(returnEvents)
-  
-  return returnEvents
-
-def isDuplicate(event, eventSet, getDuplicates = False):
-  """
-  True if the event is a duplicate for something in the eventSet, false
-  otherwise. If the getDuplicates flag is set this provides the indices of
-  the duplicates instead.
-  
-  Arguments:
-    event         - event to search for duplicates of
-    eventSet      - set to look for the event in
-    getDuplicates - instead of providing back a boolean this gives a list of
-                    the duplicate indices in the eventSet
-  """
-  
-  duplicateIndices = []
-  for i in range(len(eventSet)):
-    forwardEntry = eventSet[i]
-    
-    # if showing dates then do duplicate detection for each day, rather
-    # than globally
-    if forwardEntry.type == DAYBREAK_EVENT: break
-    
-    if event.type == forwardEntry.type:
-      isDuplicate = False
-      if event.msg == forwardEntry.msg: isDuplicate = True
-      elif event.type in COMMON_LOG_MESSAGES:
-        for commonMsg in COMMON_LOG_MESSAGES[event.type]:
-          # if it starts with an asterisk then check the whole message rather
-          # than just the start
-          if commonMsg[0] == "*":
-            isDuplicate = commonMsg[1:] in event.msg and commonMsg[1:] in forwardEntry.msg
-          else:
-            isDuplicate = event.msg.startswith(commonMsg) and forwardEntry.msg.startswith(commonMsg)
-          
-          if isDuplicate: break
-      
-      if isDuplicate:
-        if getDuplicates: duplicateIndices.append(i)
-        else: return True
-  
-  if getDuplicates: return duplicateIndices
-  else: return False
-
-class LogEntry():
-  """
-  Individual log file entry, having the following attributes:
-    timestamp - unix timestamp for when the event occurred
-    eventType - event type that occurred ("INFO", "BW", "ARM_WARN", etc)
-    msg       - message that was logged
-    color     - color of the log entry
-  """
-  
-  def __init__(self, timestamp, eventType, msg, color):
-    self.timestamp = timestamp
-    self.type = eventType
-    self.msg = msg
-    self.color = color
-    self._displayMessage = None
-  
-  def getDisplayMessage(self, includeDate = False):
-    """
-    Provides the entry's message for the log.
-    
-    Arguments:
-      includeDate - appends the event's date to the start of the message
-    """
-    
-    if includeDate:
-      # not the common case so skip caching
-      entryTime = time.localtime(self.timestamp)
-      timeLabel =  "%i/%i/%i %02i:%02i:%02i" % (entryTime[1], entryTime[2], entryTime[0], entryTime[3], entryTime[4], entryTime[5])
-      return "%s [%s] %s" % (timeLabel, self.type, self.msg)
-    
-    if not self._displayMessage:
-      entryTime = time.localtime(self.timestamp)
-      self._displayMessage = "%02i:%02i:%02i [%s] %s" % (entryTime[3], entryTime[4], entryTime[5], self.type, self.msg)
-    
-    return self._displayMessage
-
-class LogPanel(panel.Panel, threading.Thread, logging.Handler):
-  """
-  Listens for and displays tor, arm, and stem events. This can prepopulate
-  from tor's log file if it exists.
-  """
-  
-  def __init__(self, stdscr, loggedEvents):
-    panel.Panel.__init__(self, stdscr, "log", 0)
-    logging.Handler.__init__(self, level = log.logging_level(log.DEBUG))
-    
-    self.setFormatter(logging.Formatter(
-      fmt = '%(asctime)s [%(levelname)s] %(message)s',
-      datefmt = '%m/%d/%Y %H:%M:%S'),
-    )
-    
-    threading.Thread.__init__(self)
-    self.setDaemon(True)
-    
-    # Make sure that the msg.* messages are loaded. Lazy loading it later is
-    # fine, but this way we're sure it happens before warning about unused
-    # config options.
-    loadLogMessages()
-    
-    # regex filters the user has defined
-    self.filterOptions = []
-    
-    for filter in CONFIG["features.log.regex"]:
-      # checks if we can't have more filters
-      if len(self.filterOptions) >= MAX_REGEX_FILTERS: break
-      
-      try:
-        re.compile(filter)
-        self.filterOptions.append(filter)
-      except re.error, exc:
-        log.notice("Invalid regular expression pattern (%s): %s" % (exc, filter))
-    
-    self.loggedEvents = [] # needs to be set before we receive any events
-    
-    # restricts the input to the set of events we can listen to, and
-    # configures the controller to liten to them
-    self.loggedEvents = self.setEventListening(loggedEvents)
-    
-    self.setPauseAttr("msgLog")         # tracks the message log when we're paused
-    self.msgLog = []                    # log entries, sorted by the timestamp
-    self.regexFilter = None             # filter for presented log events (no filtering if None)
-    self.lastContentHeight = 0          # height of the rendered content when last drawn
-    self.logFile = None                 # file log messages are saved to (skipped if None)
-    self.scroll = 0
-    
-    self._lastUpdate = -1               # time the content was last revised
-    self._halt = False                  # terminates thread if true
-    self._cond = threading.Condition()  # used for pausing/resuming the thread
-    
-    # restricts concurrent write access to attributes used to draw the display
-    # and pausing:
-    # msgLog, loggedEvents, regexFilter, scroll
-    self.valsLock = threading.RLock()
-    
-    # cached parameters (invalidated if arguments for them change)
-    # last set of events we've drawn with
-    self._lastLoggedEvents = []
-    
-    # _getTitle (args: loggedEvents, regexFilter pattern, width)
-    self._titleCache = None
-    self._titleArgs = (None, None, None)
-    
-    self.reprepopulateEvents()
-    
-    # leaving lastContentHeight as being too low causes initialization problems
-    self.lastContentHeight = len(self.msgLog)
-    
-    # adds listeners for tor and stem events
-    conn = torTools.getConn()
-    conn.addStatusListener(self._resetListener)
-    
-    # opens log file if we'll be saving entries
-    if CONFIG["features.logFile"]:
-      logPath = CONFIG["features.logFile"]
-      
-      try:
-        # make dir if the path doesn't already exist
-        baseDir = os.path.dirname(logPath)
-        if not os.path.exists(baseDir): os.makedirs(baseDir)
-        
-        self.logFile = open(logPath, "a")
-        log.notice("arm %s opening log file (%s)" % (VERSION, logPath))
-      except (IOError, OSError), exc:
-        log.error("Unable to write to log file: %s" % sysTools.getFileErrorMsg(exc))
-        self.logFile = None
-    
-    stem_logger = log.get_logger()
-    stem_logger.addHandler(self)
-  
-  def emit(self, record):
-    if record.levelname == "ERROR":
-      record.levelname = "ERR"
-    elif record.levelname == "WARNING":
-      record.levelname = "WARN"
-    
-    eventColor = RUNLEVEL_EVENT_COLOR[record.levelname]
-    self.registerEvent(LogEntry(int(record.created), "ARM_%s" % record.levelname, record.msg, eventColor))
-  
-  def reprepopulateEvents(self):
-    """
-    Clears the event log and repopulates it from the arm and tor backlogs.
-    """
-    
-    self.valsLock.acquire()
-    
-    # clears the event log
-    self.msgLog = []
-    
-    # fetches past tor events from log file, if available
-    if CONFIG["features.log.prepopulate"]:
-      setRunlevels = list(set.intersection(set(self.loggedEvents), set(list(log.Runlevel))))
-      readLimit = CONFIG["features.log.prepopulateReadLimit"]
-      addLimit = CONFIG["cache.logPanel.size"]
-      for entry in getLogFileEntries(setRunlevels, readLimit, addLimit):
-        self.msgLog.append(entry)
-    
-    # crops events that are either too old, or more numerous than the caching size
-    self._trimEvents(self.msgLog)
-    
-    self.valsLock.release()
-  
-  def setDuplicateVisability(self, isVisible):
-    """
-    Sets if duplicate log entries are collaped or expanded.
-    
-    Arguments:
-      isVisible - if true all log entries are shown, otherwise they're
-                  deduplicated
-    """
-    
-    armConf = conf.get_config("arm")
-    armConf.set("features.log.showDuplicateEntries", str(isVisible))
-  
-  def registerTorEvent(self, event):
-    """
-    Translates a stem.response.event.Event instance into a LogEvent, and calls
-    registerEvent().
-    """
-    
-    msg, color = ' '.join(str(event).split(' ')[1:]), "white"
-    
-    if isinstance(event, events.CircuitEvent):
-      color = "yellow"
-    elif isinstance(event, events.BandwidthEvent):
-      color = "cyan"
-      msg = "READ: %i, WRITTEN: %i" % (event.read, event.written)
-    elif isinstance(event, events.LogEvent):
-      color = RUNLEVEL_EVENT_COLOR[event.runlevel]
-      msg = event.message
-    elif isinstance(event, events.NetworkStatusEvent):
-      color = "blue"
-    elif isinstance(event, events.NewConsensusEvent):
-      color = "magenta"
-    elif isinstance(event, events.GuardEvent):
-      color = "yellow"
-    elif not event.type in TOR_EVENT_TYPES.values():
-      color = "red" # unknown event type
-    
-    self.registerEvent(LogEntry(event.arrived_at, event.type, msg, color))
-  
-  def registerEvent(self, event):
-    """
-    Notes event and redraws log. If paused it's held in a temporary buffer.
-    
-    Arguments:
-      event - LogEntry for the event that occurred
-    """
-    
-    if not event.type in self.loggedEvents: return
-    
-    # strips control characters to avoid screwing up the terminal
-    event.msg = uiTools.getPrintable(event.msg)
-    
-    # note event in the log file if we're saving them
-    if self.logFile:
-      try:
-        self.logFile.write(event.getDisplayMessage(True) + "\n")
-        self.logFile.flush()
-      except IOError, exc:
-        log.error("Unable to write to log file: %s" % sysTools.getFileErrorMsg(exc))
-        self.logFile = None
-    
-    self.valsLock.acquire()
-    self.msgLog.insert(0, event)
-    self._trimEvents(self.msgLog)
-    
-    # notifies the display that it has new content
-    if not self.regexFilter or self.regexFilter.search(event.getDisplayMessage()):
-      self._cond.acquire()
-      self._cond.notifyAll()
-      self._cond.release()
-    
-    self.valsLock.release()
-  
-  def setLoggedEvents(self, eventTypes):
-    """
-    Sets the event types recognized by the panel.
-    
-    Arguments:
-      eventTypes - event types to be logged
-    """
-    
-    if eventTypes == self.loggedEvents: return
-    self.valsLock.acquire()
-    
-    # configures the controller to listen for these tor events, and provides
-    # back a subset without anything we're failing to listen to
-    setTypes = self.setEventListening(eventTypes)
-    self.loggedEvents = setTypes
-    self.redraw(True)
-    self.valsLock.release()
-  
-  def getFilter(self):
-    """
-    Provides our currently selected regex filter.
-    """
-    
-    return self.filterOptions[0] if self.regexFilter else None
-  
-  def setFilter(self, logFilter):
-    """
-    Filters log entries according to the given regular expression.
-    
-    Arguments:
-      logFilter - regular expression used to determine which messages are
-                  shown, None if no filter should be applied
-    """
-    
-    if logFilter == self.regexFilter: return
-    
-    self.valsLock.acquire()
-    self.regexFilter = logFilter
-    self.redraw(True)
-    self.valsLock.release()
-  
-  def makeFilterSelection(self, selectedOption):
-    """
-    Makes the given filter selection, applying it to the log and reorganizing
-    our filter selection.
-    
-    Arguments:
-      selectedOption - regex filter we've already added, None if no filter
-                       should be applied
-    """
-    
-    if selectedOption:
-      try:
-        self.setFilter(re.compile(selectedOption))
-        
-        # move selection to top
-        self.filterOptions.remove(selectedOption)
-        self.filterOptions.insert(0, selectedOption)
-      except re.error, exc:
-        # shouldn't happen since we've already checked validity
-        log.warn("Invalid regular expression ('%s': %s) - removing from listing" % (selectedOption, exc))
-        self.filterOptions.remove(selectedOption)
-    else: self.setFilter(None)
-  
-  def showFilterPrompt(self):
-    """
-    Prompts the user to add a new regex filter.
-    """
-    
-    regexInput = popups.inputPrompt("Regular expression: ")
-    
-    if regexInput:
-      try:
-        self.setFilter(re.compile(regexInput))
-        if regexInput in self.filterOptions: self.filterOptions.remove(regexInput)
-        self.filterOptions.insert(0, regexInput)
-      except re.error, exc:
-        popups.showMsg("Unable to compile expression: %s" % exc, 2)
-  
-  def showEventSelectionPrompt(self):
-    """
-    Prompts the user to select the events being listened for.
-    """
-    
-    # allow user to enter new types of events to log - unchanged if left blank
-    popup, width, height = popups.init(11, 80)
-    
-    if popup:
-      try:
-        # displays the available flags
-        popup.win.box()
-        popup.addstr(0, 0, "Event Types:", curses.A_STANDOUT)
-        eventLines = EVENT_LISTING.split("\n")
-        
-        for i in range(len(eventLines)):
-          popup.addstr(i + 1, 1, eventLines[i][6:])
-        
-        popup.win.refresh()
-        
-        userInput = popups.inputPrompt("Events to log: ")
-        if userInput:
-          userInput = userInput.replace(' ', '') # strips spaces
-          try: self.setLoggedEvents(expandEvents(userInput))
-          except ValueError, exc:
-            popups.showMsg("Invalid flags: %s" % str(exc), 2)
-      finally: popups.finalize()
-  
-  def showSnapshotPrompt(self):
-    """
-    Lets user enter a path to take a snapshot, canceling if left blank.
-    """
-    
-    pathInput = popups.inputPrompt("Path to save log snapshot: ")
-    
-    if pathInput:
-      try:
-        self.saveSnapshot(pathInput)
-        popups.showMsg("Saved: %s" % pathInput, 2)
-      except IOError, exc:
-        popups.showMsg("Unable to save snapshot: %s" % sysTools.getFileErrorMsg(exc), 2)
-  
-  def clear(self):
-    """
-    Clears the contents of the event log.
-    """
-    
-    self.valsLock.acquire()
-    self.msgLog = []
-    self.redraw(True)
-    self.valsLock.release()
-  
-  def saveSnapshot(self, path):
-    """
-    Saves the log events currently being displayed to the given path. This
-    takes filers into account. This overwrites the file if it already exists,
-    and raises an IOError if there's a problem.
-    
-    Arguments:
-      path - path where to save the log snapshot
-    """
-    
-    path = os.path.abspath(os.path.expanduser(path))
-    
-    # make dir if the path doesn't already exist
-    baseDir = os.path.dirname(path)
-    
-    try:
-      if not os.path.exists(baseDir): os.makedirs(baseDir)
-    except OSError, exc:
-      raise IOError("unable to make directory '%s'" % baseDir)
-    
-    snapshotFile = open(path, "w")
-    self.valsLock.acquire()
-    try:
-      for entry in self.msgLog:
-        isVisible = not self.regexFilter or self.regexFilter.search(entry.getDisplayMessage())
-        if isVisible: snapshotFile.write(entry.getDisplayMessage(True) + "\n")
-      
-      self.valsLock.release()
-    except Exception, exc:
-      self.valsLock.release()
-      raise exc
-  
-  def handleKey(self, key):
-    isKeystrokeConsumed = True
-    if uiTools.isScrollKey(key):
-      pageHeight = self.getPreferredSize()[0] - 1
-      newScroll = uiTools.getScrollPosition(key, self.scroll, pageHeight, self.lastContentHeight)
-      
-      if self.scroll != newScroll:
-        self.valsLock.acquire()
-        self.scroll = newScroll
-        self.redraw(True)
-        self.valsLock.release()
-    elif key in (ord('u'), ord('U')):
-      self.valsLock.acquire()
-      self.setDuplicateVisability(not CONFIG["features.log.showDuplicateEntries"])
-      self.redraw(True)
-      self.valsLock.release()
-    elif key == ord('c') or key == ord('C'):
-      msg = "This will clear the log. Are you sure (c again to confirm)?"
-      keyPress = popups.showMsg(msg, attr = curses.A_BOLD)
-      if keyPress in (ord('c'), ord('C')): self.clear()
-    elif key == ord('f') or key == ord('F'):
-      # Provides menu to pick regular expression filters or adding new ones:
-      # for syntax see: http://docs.python.org/library/re.html#regular-expression-syntax
-      options = ["None"] + self.filterOptions + ["New..."]
-      oldSelection = 0 if not self.regexFilter else 1
-      
-      # does all activity under a curses lock to prevent redraws when adding
-      # new filters
-      panel.CURSES_LOCK.acquire()
-      try:
-        selection = popups.showMenu("Log Filter:", options, oldSelection)
-        
-        # applies new setting
-        if selection == 0:
-          self.setFilter(None)
-        elif selection == len(options) - 1:
-          # selected 'New...' option - prompt user to input regular expression
-          self.showFilterPrompt()
-        elif selection != -1:
-          self.makeFilterSelection(self.filterOptions[selection - 1])
-      finally:
-        panel.CURSES_LOCK.release()
-      
-      if len(self.filterOptions) > MAX_REGEX_FILTERS: del self.filterOptions[MAX_REGEX_FILTERS:]
-    elif key == ord('e') or key == ord('E'):
-      self.showEventSelectionPrompt()
-    elif key == ord('a') or key == ord('A'):
-      self.showSnapshotPrompt()
-    else: isKeystrokeConsumed = False
-    
-    return isKeystrokeConsumed
-  
-  def getHelp(self):
-    options = []
-    options.append(("up arrow", "scroll log up a line", None))
-    options.append(("down arrow", "scroll log down a line", None))
-    options.append(("a", "save snapshot of the log", None))
-    options.append(("e", "change logged events", None))
-    options.append(("f", "log regex filter", "enabled" if self.regexFilter else "disabled"))
-    options.append(("u", "duplicate log entries", "visible" if CONFIG["features.log.showDuplicateEntries"] else "hidden"))
-    options.append(("c", "clear event log", None))
-    return options
-  
-  def draw(self, width, height):
-    """
-    Redraws message log. Entries stretch to use available space and may
-    contain up to two lines. Starts with newest entries.
-    """
-    
-    currentLog = self.getAttr("msgLog")
-    
-    self.valsLock.acquire()
-    self._lastLoggedEvents, self._lastUpdate = list(currentLog), time.time()
-    
-    # draws the top label
-    if self.isTitleVisible():
-      self.addstr(0, 0, self._getTitle(width), curses.A_STANDOUT)
-    
-    # restricts scroll location to valid bounds
-    self.scroll = max(0, min(self.scroll, self.lastContentHeight - height + 1))
-    
-    # draws left-hand scroll bar if content's longer than the height
-    msgIndent, dividerIndent = 1, 0 # offsets for scroll bar
-    isScrollBarVisible = self.lastContentHeight > height - 1
-    if isScrollBarVisible:
-      msgIndent, dividerIndent = 3, 2
-      self.addScrollBar(self.scroll, self.scroll + height - 1, self.lastContentHeight, 1)
-    
-    # draws log entries
-    lineCount = 1 - self.scroll
-    seenFirstDateDivider = False
-    dividerAttr, duplicateAttr = curses.A_BOLD | uiTools.getColor("yellow"), curses.A_BOLD | uiTools.getColor("green")
-    
-    isDatesShown = self.regexFilter == None and CONFIG["features.log.showDateDividers"]
-    eventLog = getDaybreaks(currentLog, self.isPaused()) if isDatesShown else list(currentLog)
-    if not CONFIG["features.log.showDuplicateEntries"]:
-      deduplicatedLog = getDuplicates(eventLog)
-      
-      if deduplicatedLog == None:
-        log.warn("Deduplication took too long. Its current implementation has difficulty handling large logs so disabling it to keep the interface responsive.")
-        self.setDuplicateVisability(True)
-        deduplicatedLog = [(entry, 0) for entry in eventLog]
-    else: deduplicatedLog = [(entry, 0) for entry in eventLog]
-    
-    # determines if we have the minimum width to show date dividers
-    showDaybreaks = width - dividerIndent >= 3
-    
-    while deduplicatedLog:
-      entry, duplicateCount = deduplicatedLog.pop(0)
-      
-      if self.regexFilter and not self.regexFilter.search(entry.getDisplayMessage()):
-        continue  # filter doesn't match log message - skip
-      
-      # checks if we should be showing a divider with the date
-      if entry.type == DAYBREAK_EVENT:
-        # bottom of the divider
-        if seenFirstDateDivider:
-          if lineCount >= 1 and lineCount < height and showDaybreaks:
-            self.addch(lineCount, dividerIndent, curses.ACS_LLCORNER,  dividerAttr)
-            self.hline(lineCount, dividerIndent + 1, width - dividerIndent - 2, dividerAttr)
-            self.addch(lineCount, width - 1, curses.ACS_LRCORNER, dividerAttr)
-          
-          lineCount += 1
-        
-        # top of the divider
-        if lineCount >= 1 and lineCount < height and showDaybreaks:
-          timeLabel = time.strftime(" %B %d, %Y ", time.localtime(entry.timestamp))
-          self.addch(lineCount, dividerIndent, curses.ACS_ULCORNER, dividerAttr)
-          self.addch(lineCount, dividerIndent + 1, curses.ACS_HLINE, dividerAttr)
-          self.addstr(lineCount, dividerIndent + 2, timeLabel, curses.A_BOLD | dividerAttr)
-          
-          lineLength = width - dividerIndent - len(timeLabel) - 3
-          self.hline(lineCount, dividerIndent + len(timeLabel) + 2, lineLength, dividerAttr)
-          self.addch(lineCount, dividerIndent + len(timeLabel) + 2 + lineLength, curses.ACS_URCORNER, dividerAttr)
-        
-        seenFirstDateDivider = True
-        lineCount += 1
-      else:
-        # entry contents to be displayed, tuples of the form:
-        # (msg, formatting, includeLinebreak)
-        displayQueue = []
-        
-        msgComp = entry.getDisplayMessage().split("\n")
-        for i in range(len(msgComp)):
-          font = curses.A_BOLD if "ERR" in entry.type else curses.A_NORMAL # emphasizes ERR messages
-          displayQueue.append((msgComp[i].strip(), font | uiTools.getColor(entry.color), i != len(msgComp) - 1))
-        
-        if duplicateCount:
-          pluralLabel = "s" if duplicateCount > 1 else ""
-          duplicateMsg = DUPLICATE_MSG % (duplicateCount, pluralLabel)
-          displayQueue.append((duplicateMsg, duplicateAttr, False))
-        
-        cursorLoc, lineOffset = msgIndent, 0
-        maxEntriesPerLine = CONFIG["features.log.maxLinesPerEntry"]
-        while displayQueue:
-          msg, format, includeBreak = displayQueue.pop(0)
-          drawLine = lineCount + lineOffset
-          if lineOffset == maxEntriesPerLine: break
-          
-          maxMsgSize = width - cursorLoc - 1
-          if len(msg) > maxMsgSize:
-            # message is too long - break it up
-            if lineOffset == maxEntriesPerLine - 1:
-              msg = uiTools.cropStr(msg, maxMsgSize)
-            else:
-              msg, remainder = uiTools.cropStr(msg, maxMsgSize, 4, 4, uiTools.Ending.HYPHEN, True)
-              displayQueue.insert(0, (remainder.strip(), format, includeBreak))
-            
-            includeBreak = True
-          
-          if drawLine < height and drawLine >= 1:
-            if seenFirstDateDivider and width - dividerIndent >= 3 and showDaybreaks:
-              self.addch(drawLine, dividerIndent, curses.ACS_VLINE, dividerAttr)
-              self.addch(drawLine, width - 1, curses.ACS_VLINE, dividerAttr)
-            
-            self.addstr(drawLine, cursorLoc, msg, format)
-          
-          cursorLoc += len(msg)
-          
-          if includeBreak or not displayQueue:
-            lineOffset += 1
-            cursorLoc = msgIndent + ENTRY_INDENT
-        
-        lineCount += lineOffset
-      
-      # if this is the last line and there's room, then draw the bottom of the divider
-      if not deduplicatedLog and seenFirstDateDivider:
-        if lineCount < height and showDaybreaks:
-          self.addch(lineCount, dividerIndent, curses.ACS_LLCORNER, dividerAttr)
-          self.hline(lineCount, dividerIndent + 1, width - dividerIndent - 2, dividerAttr)
-          self.addch(lineCount, width - 1, curses.ACS_LRCORNER, dividerAttr)
-        
-        lineCount += 1
-    
-    # redraw the display if...
-    # - lastContentHeight was off by too much
-    # - we're off the bottom of the page
-    newContentHeight = lineCount + self.scroll - 1
-    contentHeightDelta = abs(self.lastContentHeight - newContentHeight)
-    forceRedraw, forceRedrawReason = True, ""
-    
-    if contentHeightDelta >= CONTENT_HEIGHT_REDRAW_THRESHOLD:
-      forceRedrawReason = "estimate was off by %i" % contentHeightDelta
-    elif newContentHeight > height and self.scroll + height - 1 > newContentHeight:
-      forceRedrawReason = "scrolled off the bottom of the page"
-    elif not isScrollBarVisible and newContentHeight > height - 1:
-      forceRedrawReason = "scroll bar wasn't previously visible"
-    elif isScrollBarVisible and newContentHeight <= height - 1:
-      forceRedrawReason = "scroll bar shouldn't be visible"
-    else: forceRedraw = False
-    
-    self.lastContentHeight = newContentHeight
-    if forceRedraw:
-      log.debug("redrawing the log panel with the corrected content height (%s)" % forceRedrawReason)
-      self.redraw(True)
-    
-    self.valsLock.release()
-  
-  def redraw(self, forceRedraw=False, block=False):
-    # determines if the content needs to be redrawn or not
-    panel.Panel.redraw(self, forceRedraw, block)
-  
-  def run(self):
-    """
-    Redraws the display, coalescing updates if events are rapidly logged (for
-    instance running at the DEBUG runlevel) while also being immediately
-    responsive if additions are less frequent.
-    """
-    
-    lastDay = daysSince() # used to determine if the date has changed
-    while not self._halt:
-      currentDay = daysSince()
-      timeSinceReset = time.time() - self._lastUpdate
-      maxLogUpdateRate = CONFIG["features.log.maxRefreshRate"] / 1000.0
-      
-      sleepTime = 0
-      if (self.msgLog == self._lastLoggedEvents and lastDay == currentDay) or self.isPaused():
-        sleepTime = 5
-      elif timeSinceReset < maxLogUpdateRate:
-        sleepTime = max(0.05, maxLogUpdateRate - timeSinceReset)
-      
-      if sleepTime:
-        self._cond.acquire()
-        if not self._halt: self._cond.wait(sleepTime)
-        self._cond.release()
-      else:
-        lastDay = currentDay
-        self.redraw(True)
-        
-        # makes sure that we register this as an update, otherwise lacking the
-        # curses lock can cause a busy wait here
-        self._lastUpdate = time.time()
-  
-  def stop(self):
-    """
-    Halts further resolutions and terminates the thread.
-    """
-    
-    self._cond.acquire()
-    self._halt = True
-    self._cond.notifyAll()
-    self._cond.release()
-  
-  def setEventListening(self, events):
-    """
-    Configures the events Tor listens for, filtering non-tor events from what we
-    request from the controller. This returns a sorted list of the events we
-    successfully set.
-    
-    Arguments:
-      events - event types to attempt to set
-    """
-    
-    events = set(events) # drops duplicates
-    
-    # accounts for runlevel naming difference
-    if "ERROR" in events:
-      events.add("ERR")
-      events.remove("ERROR")
-    
-    if "WARNING" in events:
-      events.add("WARN")
-      events.remove("WARNING")
-    
-    torEvents = events.intersection(set(TOR_EVENT_TYPES.values()))
-    armEvents = events.intersection(set(["ARM_%s" % runlevel for runlevel in log.Runlevel.keys()]))
-    
-    # adds events unrecognized by arm if we're listening to the 'UNKNOWN' type
-    if "UNKNOWN" in events:
-      torEvents.update(set(getMissingEventTypes()))
-    
-    torConn = torTools.getConn()
-    torConn.removeEventListener(self.registerTorEvent)
-    
-    for eventType in list(torEvents):
-      try:
-        torConn.addEventListener(self.registerTorEvent, eventType)
-      except stem.ProtocolError:
-        torEvents.remove(eventType)
-    
-    # provides back the input set minus events we failed to set
-    return sorted(torEvents.union(armEvents))
-  
-  def _resetListener(self, controller, eventType, _):
-    # if we're attaching to a new tor instance then clears the log and
-    # prepopulates it with the content belonging to this instance
-    
-    if eventType == State.INIT:
-      self.reprepopulateEvents()
-      self.redraw(True)
-    elif eventType == State.CLOSED:
-      log.notice("Tor control port closed")
-  
-  def _getTitle(self, width):
-    """
-    Provides the label used for the panel, looking like:
-      Events (ARM NOTICE - ERR, BW - filter: prepopulate):
-    
-    This truncates the attributes (with an ellipse) if too long, and condenses
-    runlevel ranges if there's three or more in a row (for instance ARM_INFO,
-    ARM_NOTICE, and ARM_WARN becomes "ARM_INFO - WARN").
-    
-    Arguments:
-      width - width constraint the label needs to fix in
-    """
-    
-    # usually the attributes used to make the label are decently static, so
-    # provide cached results if they're unchanged
-    self.valsLock.acquire()
-    currentPattern = self.regexFilter.pattern if self.regexFilter else None
-    isUnchanged = self._titleArgs[0] == self.loggedEvents
-    isUnchanged &= self._titleArgs[1] == currentPattern
-    isUnchanged &= self._titleArgs[2] == width
-    if isUnchanged:
-      self.valsLock.release()
-      return self._titleCache
-    
-    eventsList = list(self.loggedEvents)
-    if not eventsList:
-      if not currentPattern:
-        panelLabel = "Events:"
-      else:
-        labelPattern = uiTools.cropStr(currentPattern, width - 18)
-        panelLabel = "Events (filter: %s):" % labelPattern
-    else:
-      # does the following with all runlevel types (tor, arm, and stem):
-      # - pulls to the start of the list
-      # - condenses range if there's three or more in a row (ex. "ARM_INFO - WARN")
-      # - condense further if there's identical runlevel ranges for multiple
-      #   types (ex. "NOTICE - ERR, ARM_NOTICE - ERR" becomes "TOR/ARM NOTICE - ERR")
-      tmpRunlevels = [] # runlevels pulled from the list (just the runlevel part)
-      runlevelRanges = [] # tuple of type, startLevel, endLevel for ranges to be consensed
-      
-      # reverses runlevels and types so they're appended in the right order
-      reversedRunlevels = list(log.Runlevel)
-      reversedRunlevels.reverse()
-      for prefix in ("ARM_", ""):
-        # blank ending runlevel forces the break condition to be reached at the end
-        for runlevel in reversedRunlevels + [""]:
-          eventType = prefix + runlevel
-          if runlevel and eventType in eventsList:
-            # runlevel event found, move to the tmp list
-            eventsList.remove(eventType)
-            tmpRunlevels.append(runlevel)
-          elif tmpRunlevels:
-            # adds all tmp list entries to the start of eventsList
-            if len(tmpRunlevels) >= 3:
-              # save condense sequential runlevels to be added later
-              runlevelRanges.append((prefix, tmpRunlevels[-1], tmpRunlevels[0]))
-            else:
-              # adds runlevels individaully
-              for tmpRunlevel in tmpRunlevels:
-                eventsList.insert(0, prefix + tmpRunlevel)
-            
-            tmpRunlevels = []
-      
-      # adds runlevel ranges, condensing if there's identical ranges
-      for i in range(len(runlevelRanges)):
-        if runlevelRanges[i]:
-          prefix, startLevel, endLevel = runlevelRanges[i]
-          
-          # check for matching ranges
-          matches = []
-          for j in range(i + 1, len(runlevelRanges)):
-            if runlevelRanges[j] and runlevelRanges[j][1] == startLevel and runlevelRanges[j][2] == endLevel:
-              matches.append(runlevelRanges[j])
-              runlevelRanges[j] = None
-          
-          if matches:
-            # strips underscores and replaces empty entries with "TOR"
-            prefixes = [entry[0] for entry in matches] + [prefix]
-            for k in range(len(prefixes)):
-              if prefixes[k] == "": prefixes[k] = "TOR"
-              else: prefixes[k] = prefixes[k].replace("_", "")
-            
-            eventsList.insert(0, "%s %s - %s" % ("/".join(prefixes), startLevel, endLevel))
-          else:
-            eventsList.insert(0, "%s%s - %s" % (prefix, startLevel, endLevel))
-      
-      # truncates to use an ellipsis if too long, for instance:
-      attrLabel = ", ".join(eventsList)
-      if currentPattern: attrLabel += " - filter: %s" % currentPattern
-      attrLabel = uiTools.cropStr(attrLabel, width - 10, 1)
-      if attrLabel: attrLabel = " (%s)" % attrLabel
-      panelLabel = "Events%s:" % attrLabel
-    
-    # cache results and return
-    self._titleCache = panelLabel
-    self._titleArgs = (list(self.loggedEvents), currentPattern, width)
-    self.valsLock.release()
-    return panelLabel
-  
-  def _trimEvents(self, eventListing):
-    """
-    Crops events that have either:
-    - grown beyond the cache limit
-    - outlived the configured log duration
-    
-    Argument:
-      eventListing - listing of log entries
-    """
-    
-    cacheSize = CONFIG["cache.logPanel.size"]
-    if len(eventListing) > cacheSize: del eventListing[cacheSize:]
-    
-    logTTL = CONFIG["features.log.entryDuration"]
-    if logTTL > 0:
-      currentDay = daysSince()
-      
-      breakpoint = None # index at which to crop from
-      for i in range(len(eventListing) - 1, -1, -1):
-        daysSinceEvent = currentDay - daysSince(eventListing[i].timestamp)
-        if daysSinceEvent > logTTL: breakpoint = i # older than the ttl
-        else: break
-      
-      # removes entries older than the ttl
-      if breakpoint != None: del eventListing[breakpoint:]
-
diff --git a/src/cli/menu/__init__.py b/src/cli/menu/__init__.py
deleted file mode 100644
index f6d43ec..0000000
--- a/src/cli/menu/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""
-Resources for displaying the menu.
-"""
-
-__all__ = ["actions", "item", "menu"]
-
diff --git a/src/cli/menu/actions.py b/src/cli/menu/actions.py
deleted file mode 100644
index 8cc265b..0000000
--- a/src/cli/menu/actions.py
+++ /dev/null
@@ -1,296 +0,0 @@
-"""
-Generates the menu for arm, binding options with their related actions.
-"""
-
-import functools
-
-import cli.popups
-import cli.controller
-import cli.menu.item
-import cli.graphing.graphPanel
-
-from util import connections, torTools, uiTools
-
-from stem.util import conf, str_tools
-
-CONFIG = conf.config_dict("arm", {
-  "features.log.showDuplicateEntries": False,
-})
-
-def makeMenu():
-  """
-  Constructs the base menu and all of its contents.
-  """
-  
-  baseMenu = cli.menu.item.Submenu("")
-  baseMenu.add(makeActionsMenu())
-  baseMenu.add(makeViewMenu())
-  
-  control = cli.controller.getController()
-  
-  for pagePanel in control.getDisplayPanels(includeSticky = False):
-    if pagePanel.getName() == "graph":
-      baseMenu.add(makeGraphMenu(pagePanel))
-    elif pagePanel.getName() == "log":
-      baseMenu.add(makeLogMenu(pagePanel))
-    elif pagePanel.getName() == "connections":
-      baseMenu.add(makeConnectionsMenu(pagePanel))
-    elif pagePanel.getName() == "configuration":
-      baseMenu.add(makeConfigurationMenu(pagePanel))
-    elif pagePanel.getName() == "torrc":
-      baseMenu.add(makeTorrcMenu(pagePanel))
-  
-  baseMenu.add(makeHelpMenu())
-  
-  return baseMenu
-
-def makeActionsMenu():
-  """
-  Submenu consisting of...
-    Close Menu
-    New Identity
-    Pause / Unpause
-    Reset Tor
-    Exit
-  """
-  
-  control = cli.controller.getController()
-  conn = torTools.getConn()
-  headerPanel = control.getPanel("header")
-  actionsMenu = cli.menu.item.Submenu("Actions")
-  actionsMenu.add(cli.menu.item.MenuItem("Close Menu", None))
-  actionsMenu.add(cli.menu.item.MenuItem("New Identity", headerPanel.sendNewnym))
-  
-  if conn.isAlive():
-    actionsMenu.add(cli.menu.item.MenuItem("Stop Tor", conn.shutdown))
-  
-  actionsMenu.add(cli.menu.item.MenuItem("Reset Tor", conn.reload))
-  
-  if control.isPaused(): label, arg = "Unpause", False
-  else: label, arg = "Pause", True
-  actionsMenu.add(cli.menu.item.MenuItem(label, functools.partial(control.setPaused, arg)))
-  
-  actionsMenu.add(cli.menu.item.MenuItem("Exit", control.quit))
-  return actionsMenu
-
-def makeViewMenu():
-  """
-  Submenu consisting of...
-    [X] <Page 1>
-    [ ] <Page 2>
-    [ ] etc...
-        Color (Submenu)
-  """
-  
-  viewMenu = cli.menu.item.Submenu("View")
-  control = cli.controller.getController()
-  
-  if control.getPageCount() > 0:
-    pageGroup = cli.menu.item.SelectionGroup(control.setPage, control.getPage())
-    
-    for i in range(control.getPageCount()):
-      pagePanels = control.getDisplayPanels(pageNumber = i, includeSticky = False)
-      label = " / ".join([str_tools._to_camel_case(panel.getName()) for panel in pagePanels])
-      
-      viewMenu.add(cli.menu.item.SelectionMenuItem(label, pageGroup, i))
-  
-  if uiTools.isColorSupported():
-    colorMenu = cli.menu.item.Submenu("Color")
-    colorGroup = cli.menu.item.SelectionGroup(uiTools.setColorOverride, uiTools.getColorOverride())
-    
-    colorMenu.add(cli.menu.item.SelectionMenuItem("All", colorGroup, None))
-    
-    for color in uiTools.COLOR_LIST:
-      colorMenu.add(cli.menu.item.SelectionMenuItem(str_tools._to_camel_case(color), colorGroup, color))
-    
-    viewMenu.add(colorMenu)
-  
-  return viewMenu
-
-def makeHelpMenu():
-  """
-  Submenu consisting of...
-    Hotkeys
-    About
-  """
-  
-  helpMenu = cli.menu.item.Submenu("Help")
-  helpMenu.add(cli.menu.item.MenuItem("Hotkeys", cli.popups.showHelpPopup))
-  helpMenu.add(cli.menu.item.MenuItem("About", cli.popups.showAboutPopup))
-  return helpMenu
-
-def makeGraphMenu(graphPanel):
-  """
-  Submenu for the graph panel, consisting of...
-    [X] <Stat 1>
-    [ ] <Stat 2>
-    [ ] <Stat 2>
-        Resize...
-        Interval (Submenu)
-        Bounds (Submenu)
-  
-  Arguments:
-    graphPanel - instance of the graph panel
-  """
-  
-  graphMenu = cli.menu.item.Submenu("Graph")
-  
-  # stats options
-  statGroup = cli.menu.item.SelectionGroup(graphPanel.setStats, graphPanel.getStats())
-  availableStats = graphPanel.stats.keys()
-  availableStats.sort()
-  
-  for statKey in ["None"] + availableStats:
-    label = str_tools._to_camel_case(statKey, divider = " ")
-    statKey = None if statKey == "None" else statKey
-    graphMenu.add(cli.menu.item.SelectionMenuItem(label, statGroup, statKey))
-  
-  # resizing option
-  graphMenu.add(cli.menu.item.MenuItem("Resize...", graphPanel.resizeGraph))
-  
-  # interval submenu
-  intervalMenu = cli.menu.item.Submenu("Interval")
-  intervalGroup = cli.menu.item.SelectionGroup(graphPanel.setUpdateInterval, graphPanel.getUpdateInterval())
-  
-  for i in range(len(cli.graphing.graphPanel.UPDATE_INTERVALS)):
-    label = cli.graphing.graphPanel.UPDATE_INTERVALS[i][0]
-    label = str_tools._to_camel_case(label, divider = " ")
-    intervalMenu.add(cli.menu.item.SelectionMenuItem(label, intervalGroup, i))
-  
-  graphMenu.add(intervalMenu)
-  
-  # bounds submenu
-  boundsMenu = cli.menu.item.Submenu("Bounds")
-  boundsGroup = cli.menu.item.SelectionGroup(graphPanel.setBoundsType, graphPanel.getBoundsType())
-  
-  for boundsType in cli.graphing.graphPanel.Bounds:
-    boundsMenu.add(cli.menu.item.SelectionMenuItem(boundsType, boundsGroup, boundsType))
-  
-  graphMenu.add(boundsMenu)
-  
-  return graphMenu
-
-def makeLogMenu(logPanel):
-  """
-  Submenu for the log panel, consisting of...
-    Events...
-    Snapshot...
-    Clear
-    Show / Hide Duplicates
-    Filter (Submenu)
-  
-  Arguments:
-    logPanel - instance of the log panel
-  """
-  
-  logMenu = cli.menu.item.Submenu("Log")
-  
-  logMenu.add(cli.menu.item.MenuItem("Events...", logPanel.showEventSelectionPrompt))
-  logMenu.add(cli.menu.item.MenuItem("Snapshot...", logPanel.showSnapshotPrompt))
-  logMenu.add(cli.menu.item.MenuItem("Clear", logPanel.clear))
-  
-  if CONFIG["features.log.showDuplicateEntries"]:
-    label, arg = "Hide", False
-  else: label, arg = "Show", True
-  logMenu.add(cli.menu.item.MenuItem("%s Duplicates" % label, functools.partial(logPanel.setDuplicateVisability, arg)))
-  
-  # filter submenu
-  filterMenu = cli.menu.item.Submenu("Filter")
-  filterGroup = cli.menu.item.SelectionGroup(logPanel.makeFilterSelection, logPanel.getFilter())
-  
-  filterMenu.add(cli.menu.item.SelectionMenuItem("None", filterGroup, None))
-  
-  for option in logPanel.filterOptions:
-    filterMenu.add(cli.menu.item.SelectionMenuItem(option, filterGroup, option))
-  
-  filterMenu.add(cli.menu.item.MenuItem("New...", logPanel.showFilterPrompt))
-  logMenu.add(filterMenu)
-  
-  return logMenu
-
-def makeConnectionsMenu(connPanel):
-  """
-  Submenu for the connections panel, consisting of...
-    [X] IP Address
-    [ ] Fingerprint
-    [ ] Nickname
-        Sorting...
-        Resolver (Submenu)
-  
-  Arguments:
-    connPanel - instance of the connections panel
-  """
-  
-  connectionsMenu = cli.menu.item.Submenu("Connections")
-  
-  # listing options
-  listingGroup = cli.menu.item.SelectionGroup(connPanel.setListingType, connPanel.getListingType())
-  
-  listingOptions = list(cli.connections.entries.ListingType)
-  listingOptions.remove(cli.connections.entries.ListingType.HOSTNAME)
-  
-  for option in listingOptions:
-    connectionsMenu.add(cli.menu.item.SelectionMenuItem(option, listingGroup, option))
-  
-  # sorting option
-  connectionsMenu.add(cli.menu.item.MenuItem("Sorting...", connPanel.showSortDialog))
-  
-  # resolver submenu
-  connResolver = connections.getResolver("tor")
-  resolverMenu = cli.menu.item.Submenu("Resolver")
-  resolverGroup = cli.menu.item.SelectionGroup(connResolver.setOverwriteResolver, connResolver.getOverwriteResolver())
-  
-  resolverMenu.add(cli.menu.item.SelectionMenuItem("auto", resolverGroup, None))
-  
-  for option in connections.Resolver:
-    resolverMenu.add(cli.menu.item.SelectionMenuItem(option, resolverGroup, option))
-  
-  connectionsMenu.add(resolverMenu)
-  
-  return connectionsMenu
-
-def makeConfigurationMenu(configPanel):
-  """
-  Submenu for the configuration panel, consisting of...
-    Save Config...
-    Sorting...
-    Filter / Unfilter Options
-  
-  Arguments:
-    configPanel - instance of the configuration panel
-  """
-  
-  configMenu = cli.menu.item.Submenu("Configuration")
-  configMenu.add(cli.menu.item.MenuItem("Save Config...", configPanel.showWriteDialog))
-  configMenu.add(cli.menu.item.MenuItem("Sorting...", configPanel.showSortDialog))
-  
-  if configPanel.showAll: label, arg = "Filter", True
-  else: label, arg = "Unfilter", False
-  configMenu.add(cli.menu.item.MenuItem("%s Options" % label, functools.partial(configPanel.setFiltering, arg)))
-  
-  return configMenu
-
-def makeTorrcMenu(torrcPanel):
-  """
-  Submenu for the torrc panel, consisting of...
-    Reload
-    Show / Hide Comments
-    Show / Hide Line Numbers
-  
-  Arguments:
-    torrcPanel - instance of the torrc panel
-  """
-  
-  torrcMenu = cli.menu.item.Submenu("Torrc")
-  torrcMenu.add(cli.menu.item.MenuItem("Reload", torrcPanel.reloadTorrc))
-  
-  if torrcPanel.stripComments: label, arg = "Show", True
-  else: label, arg = "Hide", False
-  torrcMenu.add(cli.menu.item.MenuItem("%s Comments" % label, functools.partial(torrcPanel.setCommentsVisible, arg)))
-  
-  if torrcPanel.showLineNum: label, arg = "Hide", False
-  else: label, arg = "Show", True
-  torrcMenu.add(cli.menu.item.MenuItem("%s Line Numbers" % label, functools.partial(torrcPanel.setLineNumberVisible, arg)))
-  
-  return torrcMenu
-
diff --git a/src/cli/menu/item.py b/src/cli/menu/item.py
deleted file mode 100644
index 1ed3f1f..0000000
--- a/src/cli/menu/item.py
+++ /dev/null
@@ -1,201 +0,0 @@
-"""
-Menu item, representing an option in the drop-down menu.
-"""
-
-import cli.controller
-
-class MenuItem():
-  """
-  Option in a drop-down menu.
-  """
-  
-  def __init__(self, label, callback):
-    self._label = label
-    self._callback = callback
-    self._parent = None
-  
-  def getLabel(self):
-    """
-    Provides a tuple of three strings representing the prefix, label, and
-    suffix for this item.
-    """
-    
-    return ("", self._label, "")
-  
-  def getParent(self):
-    """
-    Provides the Submenu we're contained within.
-    """
-    
-    return self._parent
-  
-  def getHierarchy(self):
-    """
-    Provides a list with all of our parents, up to the root.
-    """
-    
-    myHierarchy = [self]
-    while myHierarchy[-1].getParent():
-      myHierarchy.append(myHierarchy[-1].getParent())
-    
-    myHierarchy.reverse()
-    return myHierarchy
-  
-  def getRoot(self):
-    """
-    Provides the base submenu we belong to.
-    """
-    
-    if self._parent: return self._parent.getRoot()
-    else: return self
-  
-  def select(self):
-    """
-    Performs the callback for the menu item, returning true if we should close
-    the menu and false otherwise.
-    """
-    
-    if self._callback:
-      control = cli.controller.getController()
-      control.setMsg()
-      control.redraw()
-      self._callback()
-    return True
-  
-  def next(self):
-    """
-    Provides the next option for the submenu we're in, raising a ValueError
-    if we don't have a parent.
-    """
-    
-    return self._getSibling(1)
-  
-  def prev(self):
-    """
-    Provides the previous option for the submenu we're in, raising a ValueError
-    if we don't have a parent.
-    """
-    
-    return self._getSibling(-1)
-  
-  def _getSibling(self, offset):
-    """
-    Provides our sibling with a given index offset from us, raising a
-    ValueError if we don't have a parent.
-    
-    Arguments:
-      offset - index offset for the sibling to be returned
-    """
-    
-    if self._parent:
-      mySiblings = self._parent.getChildren()
-      
-      try:
-        myIndex = mySiblings.index(self)
-        return mySiblings[(myIndex + offset) % len(mySiblings)]
-      except ValueError:
-        # We expect a bidirectional references between submenus and their
-        # children. If we don't have this then our menu's screwed up.
-        
-        msg = "The '%s' submenu doesn't contain '%s' (children: '%s')" % (self, self._parent, "', '".join(mySiblings))
-        raise ValueError(msg)
-    else: raise ValueError("Menu option '%s' doesn't have a parent" % self)
-  
-  def __str__(self):
-    return self._label
-
-class Submenu(MenuItem):
-  """
-  Menu item that lists other menu options.
-  """
-  
-  def __init__(self, label):
-    MenuItem.__init__(self, label, None)
-    self._children = []
-  
-  def getLabel(self):
-    """
-    Provides our label with a ">" suffix to indicate that we have suboptions.
-    """
-    
-    myLabel = MenuItem.getLabel(self)[1]
-    return ("", myLabel, " >")
-  
-  def add(self, menuItem):
-    """
-    Adds the given menu item to our listing. This raises a ValueError if the
-    item already has a parent.
-    
-    Arguments:
-      menuItem - menu option to be added
-    """
-    
-    if menuItem.getParent():
-      raise ValueError("Menu option '%s' already has a parent" % menuItem)
-    else:
-      menuItem._parent = self
-      self._children.append(menuItem)
-  
-  def getChildren(self):
-    """
-    Provides the menu and submenus we contain.
-    """
-    
-    return list(self._children)
-  
-  def isEmpty(self):
-    """
-    True if we have no children, false otherwise.
-    """
-    
-    return not bool(self._children)
-  
-  def select(self):
-    return False
-
-class SelectionGroup():
-  """
-  Radio button groups that SelectionMenuItems can belong to.
-  """
-  
-  def __init__(self, action, selectedArg):
-    self.action = action
-    self.selectedArg = selectedArg
-
-class SelectionMenuItem(MenuItem):
-  """
-  Menu item with an associated group which determines the selection. This is
-  for the common single argument getter/setter pattern.
-  """
-  
-  def __init__(self, label, group, arg):
-    MenuItem.__init__(self, label, None)
-    self._group = group
-    self._arg = arg
-  
-  def isSelected(self):
-    """
-    True if we're the selected item, false otherwise.
-    """
-    
-    return self._arg == self._group.selectedArg
-  
-  def getLabel(self):
-    """
-    Provides our label with a "[X]" prefix if selected and "[ ]" if not.
-    """
-    
-    myLabel = MenuItem.getLabel(self)[1]
-    myPrefix = "[X] " if self.isSelected() else "[ ] "
-    return (myPrefix, myLabel, "")
-  
-  def select(self):
-    """
-    Performs the group's setter action with our argument.
-    """
-    
-    if not self.isSelected():
-      self._group.action(self._arg)
-    
-    return True
-
diff --git a/src/cli/menu/menu.py b/src/cli/menu/menu.py
deleted file mode 100644
index a93a1e0..0000000
--- a/src/cli/menu/menu.py
+++ /dev/null
@@ -1,164 +0,0 @@
-"""
-Display logic for presenting the menu.
-"""
-
-import curses
-
-import cli.popups
-import cli.controller
-import cli.menu.item
-import cli.menu.actions
-
-from util import uiTools
-
-class MenuCursor:
-  """
-  Tracks selection and key handling in the menu.
-  """
-  
-  def __init__(self, initialSelection):
-    self._selection = initialSelection
-    self._isDone = False
-  
-  def isDone(self):
-    """
-    Provides true if a selection has indicated that we should close the menu.
-    False otherwise.
-    """
-    
-    return self._isDone
-  
-  def getSelection(self):
-    """
-    Provides the currently selected menu item.
-    """
-    
-    return self._selection
-  
-  def handleKey(self, key):
-    isSelectionSubmenu = isinstance(self._selection, cli.menu.item.Submenu)
-    selectionHierarchy = self._selection.getHierarchy()
-    
-    if uiTools.isSelectionKey(key):
-      if isSelectionSubmenu:
-        if not self._selection.isEmpty():
-          self._selection = self._selection.getChildren()[0]
-      else: self._isDone = self._selection.select()
-    elif key == curses.KEY_UP:
-      self._selection = self._selection.prev()
-    elif key == curses.KEY_DOWN:
-      self._selection = self._selection.next()
-    elif key == curses.KEY_LEFT:
-      if len(selectionHierarchy) <= 3:
-        # shift to the previous main submenu
-        prevSubmenu = selectionHierarchy[1].prev()
-        self._selection = prevSubmenu.getChildren()[0]
-      else:
-        # go up a submenu level
-        self._selection = self._selection.getParent()
-    elif key == curses.KEY_RIGHT:
-      if isSelectionSubmenu:
-        # open submenu (same as making a selection)
-        if not self._selection.isEmpty():
-          self._selection = self._selection.getChildren()[0]
-      else:
-        # shift to the next main submenu
-        nextSubmenu = selectionHierarchy[1].next()
-        self._selection = nextSubmenu.getChildren()[0]
-    elif key in (27, ord('m'), ord('M')):
-      # close menu
-      self._isDone = True
-
-def showMenu():
-  popup, _, _ = cli.popups.init(1, belowStatic = False)
-  if not popup: return
-  control = cli.controller.getController()
-  
-  try:
-    # generates the menu and uses the initial selection of the first item in
-    # the file menu
-    menu = cli.menu.actions.makeMenu()
-    cursor = MenuCursor(menu.getChildren()[0].getChildren()[0])
-    
-    while not cursor.isDone():
-      # sets the background color
-      popup.win.clear()
-      popup.win.bkgd(' ', curses.A_STANDOUT | uiTools.getColor("red"))
-      selectionHierarchy = cursor.getSelection().getHierarchy()
-      
-      # provide a message saying how to close the menu
-      control.setMsg("Press m or esc to close the menu.", curses.A_BOLD, True)
-      
-      # renders the menu bar, noting where the open submenu is positioned
-      drawLeft, selectionLeft = 0, 0
-      
-      for topLevelItem in menu.getChildren():
-        drawFormat = curses.A_BOLD
-        if topLevelItem == selectionHierarchy[1]:
-          drawFormat |= curses.A_UNDERLINE
-          selectionLeft = drawLeft
-        
-        drawLabel = " %s " % topLevelItem.getLabel()[1]
-        popup.addstr(0, drawLeft, drawLabel, drawFormat)
-        popup.addch(0, drawLeft + len(drawLabel), curses.ACS_VLINE)
-        
-        drawLeft += len(drawLabel) + 1
-      
-      # recursively shows opened submenus
-      _drawSubmenu(cursor, 1, 1, selectionLeft)
-      
-      popup.win.refresh()
-      
-      curses.cbreak()
-      key = control.getScreen().getch()
-      cursor.handleKey(key)
-      
-      # redraws the rest of the interface if we're rendering on it again
-      if not cursor.isDone(): control.redraw()
-  finally:
-    control.setMsg()
-    cli.popups.finalize()
-
-def _drawSubmenu(cursor, level, top, left):
-  selectionHierarchy = cursor.getSelection().getHierarchy()
-  
-  # checks if there's nothing to display
-  if len(selectionHierarchy) < level + 2: return
-  
-  # fetches the submenu and selection we're displaying
-  submenu = selectionHierarchy[level]
-  selection = selectionHierarchy[level + 1]
-  
-  # gets the size of the prefix, middle, and suffix columns
-  allLabelSets = [entry.getLabel() for entry in submenu.getChildren()]
-  prefixColSize = max([len(entry[0]) for entry in allLabelSets])
-  middleColSize = max([len(entry[1]) for entry in allLabelSets])
-  suffixColSize = max([len(entry[2]) for entry in allLabelSets])
-  
-  # formatted string so we can display aligned menu entries
-  labelFormat = " %%-%is%%-%is%%-%is " % (prefixColSize, middleColSize, suffixColSize)
-  menuWidth = len(labelFormat % ("", "", ""))
-  
-  popup, _, _ = cli.popups.init(len(submenu.getChildren()), menuWidth, top, left, belowStatic = False)
-  if not popup: return
-  
-  try:
-    # sets the background color
-    popup.win.bkgd(' ', curses.A_STANDOUT | uiTools.getColor("red"))
-    
-    drawTop, selectionTop = 0, 0
-    for menuItem in submenu.getChildren():
-      if menuItem == selection:
-        drawFormat = curses.A_BOLD | uiTools.getColor("white")
-        selectionTop = drawTop
-      else: drawFormat = curses.A_NORMAL
-      
-      popup.addstr(drawTop, 0, labelFormat % menuItem.getLabel(), drawFormat)
-      drawTop += 1
-    
-    popup.win.refresh()
-    
-    # shows the next submenu
-    _drawSubmenu(cursor, level + 1, top + selectionTop, left + menuWidth)
-  finally: cli.popups.finalize()
-  
diff --git a/src/cli/popups.py b/src/cli/popups.py
deleted file mode 100644
index 8a41f73..0000000
--- a/src/cli/popups.py
+++ /dev/null
@@ -1,337 +0,0 @@
-"""
-Functions for displaying popups in the interface.
-"""
-
-import curses
-
-import version
-import cli.controller
-
-from util import panel, uiTools
-
-def init(height = -1, width = -1, top = 0, left = 0, belowStatic = True):
-  """
-  Preparation for displaying a popup. This creates a popup with a valid
-  subwindow instance. If that's successful then the curses lock is acquired
-  and this returns a tuple of the...
-  (popup, draw width, draw height)
-  Otherwise this leaves curses unlocked and returns None.
-  
-  Arguments:
-    height      - maximum height of the popup
-    width       - maximum width of the popup
-    top         - top position, relative to the sticky content
-    left        - left position from the screen
-    belowStatic - positions popup below static content if true
-  """
-  
-  control = cli.controller.getController()
-  if belowStatic:
-    stickyHeight = sum([stickyPanel.getHeight() for stickyPanel in control.getStickyPanels()])
-  else: stickyHeight = 0
-  
-  popup = panel.Panel(control.getScreen(), "popup", top + stickyHeight, left, height, width)
-  popup.setVisible(True)
-  
-  # Redraws the popup to prepare a subwindow instance. If none is spawned then
-  # the panel can't be drawn (for instance, due to not being visible).
-  popup.redraw(True)
-  if popup.win != None:
-    panel.CURSES_LOCK.acquire()
-    return (popup, popup.maxX - 1, popup.maxY)
-  else: return (None, 0, 0)
-
-def finalize():
-  """
-  Cleans up after displaying a popup, releasing the cureses lock and redrawing
-  the rest of the display.
-  """
-  
-  cli.controller.getController().requestRedraw()
-  panel.CURSES_LOCK.release()
-
-def inputPrompt(msg, initialValue = ""):
-  """
-  Prompts the user to enter a string on the control line (which usually
-  displays the page number and basic controls).
-  
-  Arguments:
-    msg          - message to prompt the user for input with
-    initialValue - initial value of the field
-  """
-  
-  panel.CURSES_LOCK.acquire()
-  control = cli.controller.getController()
-  msgPanel = control.getPanel("msg")
-  msgPanel.setMessage(msg)
-  msgPanel.redraw(True)
-  userInput = msgPanel.getstr(0, len(msg), initialValue)
-  control.setMsg()
-  panel.CURSES_LOCK.release()
-  return userInput
-
-def showMsg(msg, maxWait = -1, attr = curses.A_STANDOUT):
-  """
-  Displays a single line message on the control line for a set time. Pressing
-  any key will end the message. This returns the key pressed.
-  
-  Arguments:
-    msg     - message to be displayed to the user
-    maxWait - time to show the message, indefinite if -1
-    attr    - attributes with which to draw the message
-  """
-  
-  panel.CURSES_LOCK.acquire()
-  control = cli.controller.getController()
-  control.setMsg(msg, attr, True)
-  
-  if maxWait == -1: curses.cbreak()
-  else: curses.halfdelay(maxWait * 10)
-  keyPress = control.getScreen().getch()
-  control.setMsg()
-  panel.CURSES_LOCK.release()
-  
-  return keyPress
-
-def showHelpPopup():
-  """
-  Presents a popup with instructions for the current page's hotkeys. This
-  returns the user input used to close the popup. If the popup didn't close
-  properly, this is an arrow, enter, or scroll key then this returns None.
-  """
-  
-  popup, _, height = init(9, 80)
-  if not popup: return
-  
-  exitKey = None
-  try:
-    control = cli.controller.getController()
-    pagePanels = control.getDisplayPanels()
-    
-    # the first page is the only one with multiple panels, and it looks better
-    # with the log entries first, so reversing the order
-    pagePanels.reverse()
-    
-    helpOptions = []
-    for entry in pagePanels:
-      helpOptions += entry.getHelp()
-    
-    # test doing afterward in case of overwriting
-    popup.win.box()
-    popup.addstr(0, 0, "Page %i Commands:" % (control.getPage() + 1), curses.A_STANDOUT)
-    
-    for i in range(len(helpOptions)):
-      if i / 2 >= height - 2: break
-      
-      # draws entries in the form '<key>: <description>[ (<selection>)]', for
-      # instance...
-      # u: duplicate log entries (hidden)
-      key, description, selection = helpOptions[i]
-      if key: description = ": " + description
-      row = (i / 2) + 1
-      col = 2 if i % 2 == 0 else 41
-      
-      popup.addstr(row, col, key, curses.A_BOLD)
-      col += len(key)
-      popup.addstr(row, col, description)
-      col += len(description)
-      
-      if selection:
-        popup.addstr(row, col, " (")
-        popup.addstr(row, col + 2, selection, curses.A_BOLD)
-        popup.addstr(row, col + 2 + len(selection), ")")
-    
-    # tells user to press a key if the lower left is unoccupied
-    if len(helpOptions) < 13 and height == 9:
-      popup.addstr(7, 2, "Press any key...")
-    
-    popup.win.refresh()
-    curses.cbreak()
-    exitKey = control.getScreen().getch()
-  finally: finalize()
-  
-  if not uiTools.isSelectionKey(exitKey) and \
-    not uiTools.isScrollKey(exitKey) and \
-    not exitKey in (curses.KEY_LEFT, curses.KEY_RIGHT):
-    return exitKey
-  else: return None
-
-def showAboutPopup():
-  """
-  Presents a popup with author and version information.
-  """
-  
-  popup, _, height = init(9, 80)
-  if not popup: return
-  
-  try:
-    control = cli.controller.getController()
-    
-    popup.win.box()
-    popup.addstr(0, 0, "About:", curses.A_STANDOUT)
-    popup.addstr(1, 2, "arm, version %s (released %s)" % (version.VERSION, version.LAST_MODIFIED), curses.A_BOLD)
-    popup.addstr(2, 4, "Written by Damian Johnson (atagar at torproject.org)")
-    popup.addstr(3, 4, "Project page: www.atagar.com/arm")
-    popup.addstr(5, 2, "Released under the GPL v3 (http://www.gnu.org/licenses/gpl.html)")
-    popup.addstr(7, 2, "Press any key...")
-    popup.win.refresh()
-    
-    curses.cbreak()
-    control.getScreen().getch()
-  finally: finalize()
-
-def showSortDialog(title, options, oldSelection, optionColors):
-  """
-  Displays a sorting dialog of the form:
-  
-    Current Order: <previous selection>
-    New Order: <selections made>
-    
-    <option 1>    <option 2>    <option 3>   Cancel
-  
-  Options are colored when among the "Current Order" or "New Order", but not
-  when an option below them. If cancel is selected or the user presses escape
-  then this returns None. Otherwise, the new ordering is provided.
-  
-  Arguments:
-    title   - title displayed for the popup window
-    options      - ordered listing of option labels
-    oldSelection - current ordering
-    optionColors - mappings of options to their color
-  """
-  
-  popup, _, _ = init(9, 80)
-  if not popup: return
-  newSelections = []  # new ordering
-  
-  try:
-    cursorLoc = 0     # index of highlighted option
-    curses.cbreak()   # wait indefinitely for key presses (no timeout)
-    
-    selectionOptions = list(options)
-    selectionOptions.append("Cancel")
-    
-    while len(newSelections) < len(oldSelection):
-      popup.win.erase()
-      popup.win.box()
-      popup.addstr(0, 0, title, curses.A_STANDOUT)
-      
-      _drawSortSelection(popup, 1, 2, "Current Order: ", oldSelection, optionColors)
-      _drawSortSelection(popup, 2, 2, "New Order: ", newSelections, optionColors)
-      
-      # presents remaining options, each row having up to four options with
-      # spacing of nineteen cells
-      row, col = 4, 0
-      for i in range(len(selectionOptions)):
-        optionFormat = curses.A_STANDOUT if cursorLoc == i else curses.A_NORMAL
-        popup.addstr(row, col * 19 + 2, selectionOptions[i], optionFormat)
-        col += 1
-        if col == 4: row, col = row + 1, 0
-      
-      popup.win.refresh()
-      
-      key = cli.controller.getController().getScreen().getch()
-      if key == curses.KEY_LEFT:
-        cursorLoc = max(0, cursorLoc - 1)
-      elif key == curses.KEY_RIGHT:
-        cursorLoc = min(len(selectionOptions) - 1, cursorLoc + 1)
-      elif key == curses.KEY_UP:
-        cursorLoc = max(0, cursorLoc - 4)
-      elif key == curses.KEY_DOWN:
-        cursorLoc = min(len(selectionOptions) - 1, cursorLoc + 4)
-      elif uiTools.isSelectionKey(key):
-        selection = selectionOptions[cursorLoc]
-        
-        if selection == "Cancel": break
-        else:
-          newSelections.append(selection)
-          selectionOptions.remove(selection)
-          cursorLoc = min(cursorLoc, len(selectionOptions) - 1)
-      elif key == 27: break # esc - cancel
-  finally: finalize()
-  
-  if len(newSelections) == len(oldSelection):
-    return newSelections
-  else: return None
-
-def _drawSortSelection(popup, y, x, prefix, options, optionColors):
-  """
-  Draws a series of comma separated sort selections. The whole line is bold
-  and sort options also have their specified color. Example:
-  
-    Current Order: Man Page Entry, Option Name, Is Default
-  
-  Arguments:
-    popup        - panel in which to draw sort selection
-    y            - vertical location
-    x            - horizontal location
-    prefix       - initial string description
-    options      - sort options to be shown
-    optionColors - mappings of options to their color
-  """
-  
-  popup.addstr(y, x, prefix, curses.A_BOLD)
-  x += len(prefix)
-  
-  for i in range(len(options)):
-    sortType = options[i]
-    sortColor = uiTools.getColor(optionColors.get(sortType, "white"))
-    popup.addstr(y, x, sortType, sortColor | curses.A_BOLD)
-    x += len(sortType)
-    
-    # comma divider between options, if this isn't the last
-    if i < len(options) - 1:
-      popup.addstr(y, x, ", ", curses.A_BOLD)
-      x += 2
-
-def showMenu(title, options, oldSelection):
-  """
-  Provides menu with options laid out in a single column. User can cancel
-  selection with the escape key, in which case this proives -1. Otherwise this
-  returns the index of the selection.
-  
-  Arguments:
-    title        - title displayed for the popup window
-    options      - ordered listing of options to display
-    oldSelection - index of the initially selected option (uses the first
-                   selection without a carrot if -1)
-  """
-  
-  maxWidth = max(map(len, options)) + 9
-  popup, _, _ = init(len(options) + 2, maxWidth)
-  if not popup: return
-  key, selection = 0, oldSelection if oldSelection != -1 else 0
-  
-  try:
-    # hides the title of the first panel on the page
-    control = cli.controller.getController()
-    topPanel = control.getDisplayPanels(includeSticky = False)[0]
-    topPanel.setTitleVisible(False)
-    topPanel.redraw(True)
-    
-    curses.cbreak()   # wait indefinitely for key presses (no timeout)
-    
-    while not uiTools.isSelectionKey(key):
-      popup.win.erase()
-      popup.win.box()
-      popup.addstr(0, 0, title, curses.A_STANDOUT)
-      
-      for i in range(len(options)):
-        label = options[i]
-        format = curses.A_STANDOUT if i == selection else curses.A_NORMAL
-        tab = "> " if i == oldSelection else "  "
-        popup.addstr(i + 1, 2, tab)
-        popup.addstr(i + 1, 4, " %s " % label, format)
-      
-      popup.win.refresh()
-      
-      key = control.getScreen().getch()
-      if key == curses.KEY_UP: selection = max(0, selection - 1)
-      elif key == curses.KEY_DOWN: selection = min(len(options) - 1, selection + 1)
-      elif key == 27: selection, key = -1, curses.KEY_ENTER # esc - cancel
-  finally:
-    topPanel.setTitleVisible(True)
-    finalize()
-  
-  return selection
-
diff --git a/src/cli/torrcPanel.py b/src/cli/torrcPanel.py
deleted file mode 100644
index c9d83e6..0000000
--- a/src/cli/torrcPanel.py
+++ /dev/null
@@ -1,311 +0,0 @@
-"""
-Panel displaying the torrc or armrc with the validation done against it.
-"""
-
-import math
-import curses
-import threading
-
-import popups
-
-from util import panel, torConfig, torTools, uiTools
-
-from stem.control import State
-from stem.util import conf, enum
-
-def conf_handler(key, value):
-  if key == "features.config.file.maxLinesPerEntry":
-    return max(1, value)
-
-CONFIG = conf.config_dict("arm", {
-  "features.config.file.showScrollbars": True,
-  "features.config.file.maxLinesPerEntry": 8,
-}, conf_handler)
-
-# TODO: The armrc use case is incomplete. There should be equivilant reloading
-# and validation capabilities to the torrc.
-Config = enum.Enum("TORRC", "ARMRC") # configuration file types that can be displayed
-
-class TorrcPanel(panel.Panel):
-  """
-  Renders the current torrc or armrc with syntax highlighting in a scrollable
-  area.
-  """
-  
-  def __init__(self, stdscr, configType):
-    panel.Panel.__init__(self, stdscr, "torrc", 0)
-    
-    self.valsLock = threading.RLock()
-    self.configType = configType
-    self.scroll = 0
-    self.showLineNum = True     # shows left aligned line numbers
-    self.stripComments = False  # drops comments and extra whitespace
-    
-    # height of the content when last rendered (the cached value is invalid if
-    # _lastContentHeightArgs is None or differs from the current dimensions)
-    self._lastContentHeight = 1
-    self._lastContentHeightArgs = None
-    
-    # listens for tor reload (sighup) events
-    conn = torTools.getConn()
-    conn.addStatusListener(self.resetListener)
-    if conn.isAlive(): self.resetListener(None, State.INIT, None)
-  
-  def resetListener(self, controller, eventType, _):
-    """
-    Reloads and displays the torrc on tor reload (sighup) events.
-    """
-    
-    if eventType == State.INIT:
-      # loads the torrc and provides warnings in case of validation errors
-      try:
-        loadedTorrc = torConfig.getTorrc()
-        loadedTorrc.load(True)
-        loadedTorrc.logValidationIssues()
-        self.redraw(True)
-      except: pass
-    elif eventType == State.RESET:
-      try:
-        torConfig.getTorrc().load(True)
-        self.redraw(True)
-      except: pass
-  
-  def setCommentsVisible(self, isVisible):
-    """
-    Sets if comments and blank lines are shown or stripped.
-    
-    Arguments:
-      isVisible - displayed comments and blank lines if true, strips otherwise
-    """
-    
-    self.stripComments = not isVisible
-    self._lastContentHeightArgs = None
-    self.redraw(True)
-  
-  def setLineNumberVisible(self, isVisible):
-    """
-    Sets if line numbers are shown or hidden.
-    
-    Arguments:
-      isVisible - displays line numbers if true, hides otherwise
-    """
-    
-    self.showLineNum = isVisible
-    self._lastContentHeightArgs = None
-    self.redraw(True)
-  
-  def reloadTorrc(self):
-    """
-    Reloads the torrc, displaying an indicator of success or failure.
-    """
-    
-    try:
-      torConfig.getTorrc().load()
-      self._lastContentHeightArgs = None
-      self.redraw(True)
-      resultMsg = "torrc reloaded"
-    except IOError:
-      resultMsg = "failed to reload torrc"
-    
-    self._lastContentHeightArgs = None
-    self.redraw(True)
-    popups.showMsg(resultMsg, 1)
-  
-  def handleKey(self, key):
-    self.valsLock.acquire()
-    isKeystrokeConsumed = True
-    if uiTools.isScrollKey(key):
-      pageHeight = self.getPreferredSize()[0] - 1
-      newScroll = uiTools.getScrollPosition(key, self.scroll, pageHeight, self._lastContentHeight)
-      
-      if self.scroll != newScroll:
-        self.scroll = newScroll
-        self.redraw(True)
-    elif key == ord('n') or key == ord('N'):
-      self.setLineNumberVisible(not self.showLineNum)
-    elif key == ord('s') or key == ord('S'):
-      self.setCommentsVisible(self.stripComments)
-    elif key == ord('r') or key == ord('R'):
-      self.reloadTorrc()
-    else: isKeystrokeConsumed = False
-    
-    self.valsLock.release()
-    return isKeystrokeConsumed
-  
-  def setVisible(self, isVisible):
-    if not isVisible:
-      self._lastContentHeightArgs = None # redraws when next displayed
-    
-    panel.Panel.setVisible(self, isVisible)
-  
-  def getHelp(self):
-    options = []
-    options.append(("up arrow", "scroll up a line", None))
-    options.append(("down arrow", "scroll down a line", None))
-    options.append(("page up", "scroll up a page", None))
-    options.append(("page down", "scroll down a page", None))
-    options.append(("s", "comment stripping", "on" if self.stripComments else "off"))
-    options.append(("n", "line numbering", "on" if self.showLineNum else "off"))
-    options.append(("r", "reload torrc", None))
-    options.append(("x", "reset tor (issue sighup)", None))
-    return options
-  
-  def draw(self, width, height):
-    self.valsLock.acquire()
-    
-    # If true, we assume that the cached value in self._lastContentHeight is
-    # still accurate, and stop drawing when there's nothing more to display.
-    # Otherwise the self._lastContentHeight is suspect, and we'll process all
-    # the content to check if it's right (and redraw again with the corrected
-    # height if not).
-    trustLastContentHeight = self._lastContentHeightArgs == (width, height)
-    
-    # restricts scroll location to valid bounds
-    self.scroll = max(0, min(self.scroll, self._lastContentHeight - height + 1))
-    
-    renderedContents, corrections, confLocation = None, {}, None
-    if self.configType == Config.TORRC:
-      loadedTorrc = torConfig.getTorrc()
-      loadedTorrc.getLock().acquire()
-      confLocation = loadedTorrc.getConfigLocation()
-      
-      if not loadedTorrc.isLoaded():
-        renderedContents = ["### Unable to load the torrc ###"]
-      else:
-        renderedContents = loadedTorrc.getDisplayContents(self.stripComments)
-        
-        # constructs a mapping of line numbers to the issue on it
-        corrections = dict((lineNum, (issue, msg)) for lineNum, issue, msg in loadedTorrc.getCorrections())
-      
-      loadedTorrc.getLock().release()
-    else:
-      loadedArmrc = conf.get_config("arm")
-      confLocation = loadedArmrc._path
-      renderedContents = list(loadedArmrc._raw_contents)
-    
-    # offset to make room for the line numbers
-    lineNumOffset = 0
-    if self.showLineNum:
-      if len(renderedContents) == 0: lineNumOffset = 2
-      else: lineNumOffset = int(math.log10(len(renderedContents))) + 2
-    
-    # draws left-hand scroll bar if content's longer than the height
-    scrollOffset = 0
-    if CONFIG["features.config.file.showScrollbars"] and self._lastContentHeight > height - 1:
-      scrollOffset = 3
-      self.addScrollBar(self.scroll, self.scroll + height - 1, self._lastContentHeight, 1)
-    
-    displayLine = -self.scroll + 1 # line we're drawing on
-    
-    # draws the top label
-    if self.isTitleVisible():
-      sourceLabel = "Tor" if self.configType == Config.TORRC else "Arm"
-      locationLabel = " (%s)" % confLocation if confLocation else ""
-      self.addstr(0, 0, "%s Configuration File%s:" % (sourceLabel, locationLabel), curses.A_STANDOUT)
-    
-    isMultiline = False # true if we're in the middle of a multiline torrc entry
-    for lineNumber in range(0, len(renderedContents)):
-      lineText = renderedContents[lineNumber]
-      lineText = lineText.rstrip() # remove ending whitespace
-      
-      # blank lines are hidden when stripping comments
-      if self.stripComments and not lineText: continue
-      
-      # splits the line into its component (msg, format) tuples
-      lineComp = {"option": ["", curses.A_BOLD | uiTools.getColor("green")],
-                  "argument": ["", curses.A_BOLD | uiTools.getColor("cyan")],
-                  "correction": ["", curses.A_BOLD | uiTools.getColor("cyan")],
-                  "comment": ["", uiTools.getColor("white")]}
-      
-      # parses the comment
-      commentIndex = lineText.find("#")
-      if commentIndex != -1:
-        lineComp["comment"][0] = lineText[commentIndex:]
-        lineText = lineText[:commentIndex]
-      
-      # splits the option and argument, preserving any whitespace around them
-      strippedLine = lineText.strip()
-      optionIndex = strippedLine.find(" ")
-      if isMultiline:
-        # part of a multiline entry started on a previous line so everything
-        # is part of the argument
-        lineComp["argument"][0] = lineText
-      elif optionIndex == -1:
-        # no argument provided
-        lineComp["option"][0] = lineText
-      else:
-        optionText = strippedLine[:optionIndex]
-        optionEnd = lineText.find(optionText) + len(optionText)
-        lineComp["option"][0] = lineText[:optionEnd]
-        lineComp["argument"][0] = lineText[optionEnd:]
-      
-      # flags following lines as belonging to this multiline entry if it ends
-      # with a slash
-      if strippedLine: isMultiline = strippedLine.endswith("\\")
-      
-      # gets the correction
-      if lineNumber in corrections:
-        lineIssue, lineIssueMsg = corrections[lineNumber]
-        
-        if lineIssue in (torConfig.ValidationError.DUPLICATE, torConfig.ValidationError.IS_DEFAULT):
-          lineComp["option"][1] = curses.A_BOLD | uiTools.getColor("blue")
-          lineComp["argument"][1] = curses.A_BOLD | uiTools.getColor("blue")
-        elif lineIssue == torConfig.ValidationError.MISMATCH:
-          lineComp["argument"][1] = curses.A_BOLD | uiTools.getColor("red")
-          lineComp["correction"][0] = " (%s)" % lineIssueMsg
-        else:
-          # For some types of configs the correction field is simply used to
-          # provide extra data (for instance, the type for tor state fields).
-          lineComp["correction"][0] = " (%s)" % lineIssueMsg
-          lineComp["correction"][1] = curses.A_BOLD | uiTools.getColor("magenta")
-      
-      # draws the line number
-      if self.showLineNum and displayLine < height and displayLine >= 1:
-        lineNumStr = ("%%%ii" % (lineNumOffset - 1)) % (lineNumber + 1)
-        self.addstr(displayLine, scrollOffset, lineNumStr, curses.A_BOLD | uiTools.getColor("yellow"))
-      
-      # draws the rest of the components with line wrap
-      cursorLoc, lineOffset = lineNumOffset + scrollOffset, 0
-      maxLinesPerEntry = CONFIG["features.config.file.maxLinesPerEntry"]
-      displayQueue = [lineComp[entry] for entry in ("option", "argument", "correction", "comment")]
-      
-      while displayQueue:
-        msg, format = displayQueue.pop(0)
-        
-        maxMsgSize, includeBreak = width - cursorLoc, False
-        if len(msg) >= maxMsgSize:
-          # message is too long - break it up
-          if lineOffset == maxLinesPerEntry - 1:
-            msg = uiTools.cropStr(msg, maxMsgSize)
-          else:
-            includeBreak = True
-            msg, remainder = uiTools.cropStr(msg, maxMsgSize, 4, 4, uiTools.Ending.HYPHEN, True)
-            displayQueue.insert(0, (remainder.strip(), format))
-        
-        drawLine = displayLine + lineOffset
-        if msg and drawLine < height and drawLine >= 1:
-          self.addstr(drawLine, cursorLoc, msg, format)
-        
-        # If we're done, and have added content to this line, then start
-        # further content on the next line.
-        cursorLoc += len(msg)
-        includeBreak |= not displayQueue and cursorLoc != lineNumOffset + scrollOffset
-        
-        if includeBreak:
-          lineOffset += 1
-          cursorLoc = lineNumOffset + scrollOffset
-      
-      displayLine += max(lineOffset, 1)
-      
-      if trustLastContentHeight and displayLine >= height: break
-    
-    if not trustLastContentHeight:
-      self._lastContentHeightArgs = (width, height)
-      newContentHeight = displayLine + self.scroll - 1
-      
-      if self._lastContentHeight != newContentHeight:
-        self._lastContentHeight = newContentHeight
-        self.redraw(True)
-    
-    self.valsLock.release()
-
diff --git a/src/prereq.py b/src/prereq.py
deleted file mode 100644
index c01dfbd..0000000
--- a/src/prereq.py
+++ /dev/null
@@ -1,141 +0,0 @@
-"""
-Provides a warning and error code if python version isn't compatible.
-"""
-
-import os
-import sys
-import shutil
-import urllib
-import hashlib
-import tarfile
-import tempfile
-
-# Library dependencies can be fetched on request. By default this is via
-# the following mirrors with their sha256 signatures checked.
-#STEM_ARCHIVE = "http://www.atagar.com/arm/resources/deps/11-06-16/torctl.tar.gz"
-#STEM_SIG = "5460adb1394c368ba492cc33d6681618b3d3062b3f5f70b2a87520fc291701c3"
-
-# optionally we can do an unverified fetch from the library's sources
-STEM_REPO = "git://git.torproject.org/stem.git"
-
-def isStemAvailable():
-  """
-  True if stem is already available on the platform, false otherwise.
-  """
-  
-  try:
-    import stem
-    return True
-  except ImportError:
-    return False
-
-def promptStemInstall():
-  """
-  Asks the user to install stem. This returns True if it was installed and
-  False otherwise (if it was either declined or failed to be fetched).
-  """
-  
-  userInput = raw_input("Arm requires stem to run, but it's unavailable. Would you like to install it? (y/n): ")
-  
-  # if user says no then terminate
-  if not userInput.lower() in ("y", "yes"): return False
-  
-  # attempt to install stem, printing the issue if unsuccessful
-  try:
-    #fetchLibrary(STEM_ARCHIVE, STEM_SIG)
-    installStem()
-    
-    if not isStemAvailable():
-      raise IOError("Unable to install stem, sorry")
-    
-    print "Stem successfully installed"
-    return True
-  except IOError, exc:
-    print exc
-    return False
-
-def fetchLibrary(url, sig):
-  """
-  Downloads the given archive, verifies its signature, then installs the
-  library. This raises an IOError if any of these steps fail.
-  
-  Arguments:
-    url - url from which to fetch the gzipped tarball
-    sig - sha256 signature for the archive
-  """
-  
-  tmpDir = tempfile.mkdtemp()
-  destination = tmpDir + "/" + url.split("/")[-1]
-  urllib.urlretrieve(url, destination)
-  
-  # checks the signature, reading the archive in 256-byte chunks
-  m = hashlib.sha256()
-  fd = open(destination, "rb")
-  
-  while True:
-    data = fd.read(256)
-    if not data: break
-    m.update(data)
-  
-  fd.close()
-  actualSig = m.hexdigest()
-  
-  if sig != actualSig:
-    raise IOError("Signature of the library is incorrect (got '%s' rather than '%s')" % (actualSig, sig))
-  
-  # extracts the tarball
-  tarFd = tarfile.open(destination, 'r:gz')
-  tarFd.extractall("src/")
-  tarFd.close()
-  
-  # clean up the temporary contents (fails quietly if unsuccessful)
-  shutil.rmtree(destination, ignore_errors=True)
-
-def installStem():
-  """
-  Checks out the current git head release for stem and bundles it with arm.
-  This raises an IOError if unsuccessful.
-  """
-  
-  if isStemAvailable(): return
-  
-  # temporary destination for stem's git clone, guarenteed to be unoccupied
-  # (to avoid conflicting with files that are already there)
-  tmpFilename = tempfile.mktemp("/stem")
-  
-  # fetches stem
-  exitStatus = os.system("git clone --quiet %s %s > /dev/null" % (STEM_REPO, tmpFilename))
-  if exitStatus: raise IOError("Unable to get stem from %s. Is git installed?" % STEM_REPO)
-  
-  # the destination for stem will be our directory
-  ourDir = os.path.dirname(os.path.realpath(__file__))
-  
-  # exports stem to our location
-  exitStatus = os.system("(cd %s && git archive --format=tar master stem) | (cd %s && tar xf - 2> /dev/null)" % (tmpFilename, ourDir))
-  if exitStatus: raise IOError("Unable to install stem to %s" % ourDir)
-  
-  # Clean up the temporary contents. This isn't vital so quietly fails in case
-  # of errors.
-  shutil.rmtree(tmpFilename, ignore_errors=True)
-
-if __name__ == '__main__':
-  majorVersion = sys.version_info[0]
-  minorVersion = sys.version_info[1]
-  
-  if majorVersion > 2:
-    print("arm isn't compatible beyond the python 2.x series\n")
-    sys.exit(1)
-  elif majorVersion < 2 or minorVersion < 5:
-    print("arm requires python version 2.5 or greater\n")
-    sys.exit(1)
-  
-  if not isStemAvailable():
-    isInstalled = promptStemInstall()
-    if not isInstalled: sys.exit(1)
-  
-  try:
-    import curses
-  except ImportError:
-    print("arm requires curses - try installing the python-curses package\n")
-    sys.exit(1)
-
diff --git a/src/resources/arm.1 b/src/resources/arm.1
deleted file mode 100644
index 3b8cfd4..0000000
--- a/src/resources/arm.1
+++ /dev/null
@@ -1,74 +0,0 @@
-.TH arm 1 "27 August 2010"
-.SH NAME
-arm - Terminal Tor status monitor
-
-.SH SYNOPSIS
-arm [\fIOPTION\fR]
-
-.SH DESCRIPTION
-The anonymizing relay monitor (arm) is a terminal status monitor for Tor
-relays, intended for command-line aficionados, ssh connections, and anyone
-stuck with a tty terminal. This works much like top does for system usage,
-providing real time statistics for:
-  * bandwidth, cpu, and memory usage
-  * relay's current configuration
-  * logged events
-  * connection details (ip, hostname, fingerprint, and consensus data)
-  * etc
-
-Defaults and interface properties are configurable via a user provided
-configuration file (for an example see the provided \fBarmrc.sample\fR).
-Releases and information are available at \fIhttp://www.atagar.com/arm\fR.
-
-.SH OPTIONS
-.TP
-\fB\-i\fR, \fB\-\-interface [ADDRESS:]PORT\fR
-tor control port arm should attach to (default is \fB127.0.0.1:9051\fR)
-
-.TP
-\fB\-c\fR, \fB\-\-config CONFIG_PATH\fR
-user provided configuration file (default is \fB~/.arm/armrc\fR)
-
-.TP
-\fB\-d\fR, \fB\-\-debug\fR
-writes all arm logs to ~/.arm/log
-
-.TP
-\fB\-b\fR, \fB\-\-blind\fR
-disable connection lookups (netstat, lsof, and ss), dropping the parts of the
-interface that rely on this information
-
-.TP
-\fB\-e\fR, \fB\-\-event EVENT_FLAGS\fR
-flags for tor, arm, and torctl events to be logged (default is \fBN3\fR)
-
-  d DEBUG      a ADDRMAP           k DESCCHANGED   s STREAM
-  i INFO       f AUTHDIR_NEWDESCS  g GUARD         r STREAM_BW
-  n NOTICE     h BUILDTIMEOUT_SET  l NEWCONSENSUS  t STATUS_CLIENT
-  w WARN       b BW                m NEWDESC       u STATUS_GENERAL
-  e ERR        c CIRC              p NS            v STATUS_SERVER
-               j CLIENTS_SEEN      q ORCONN
-    DINWE tor runlevel+            A All Events
-    12345 arm runlevel+            X No Events
-    67890 torctl runlevel+         U Unknown Events
-
-.TP
-\fB\-v\fR, \fB\-\-version\fR
-provides version information
-
-.TP
-\fB\-h\fR, \fB\-\-help\fR
-provides usage information
-
-.SH FILES
-.TP
-\fB~/.arm/armrc\fR
-Your personal arm configuration file
-
-.TP
-\fB/usr/share/doc/arm/armrc.sample\fR
-Sample armrc configuration file that documents all options
-
-.SH AUTHOR
-Written by Damian Johnson (atagar at torproject.org)
-
diff --git a/src/resources/tor-arm.desktop b/src/resources/tor-arm.desktop
deleted file mode 100644
index da94017..0000000
--- a/src/resources/tor-arm.desktop
+++ /dev/null
@@ -1,12 +0,0 @@
-[Desktop Entry]
-Name=Tor monitor
-Name[es]=Monitor de Tor
-Comment=Status monitor for Tor routers
-Comment[es]=Monitor de estado para routers Tor
-GenericName=Monitor
-GenericName[es]=Monitor
-Exec=arm -g
-Icon=tor-arm
-Terminal=false
-Type=Application
-Categories=System;Monitor;GTK;
diff --git a/src/resources/tor-arm.svg b/src/resources/tor-arm.svg
deleted file mode 100644
index 8e710ab..0000000
--- a/src/resources/tor-arm.svg
+++ /dev/null
@@ -1,1074 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
-   xmlns:dc="http://purl.org/dc/elements/1.1/"
-   xmlns:cc="http://creativecommons.org/ns#"
-   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
-   xmlns:svg="http://www.w3.org/2000/svg"
-   xmlns="http://www.w3.org/2000/svg"
-   xmlns:xlink="http://www.w3.org/1999/xlink"
-   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
-   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-   version="1.0"
-   width="128"
-   height="128"
-   id="svg2"
-   inkscape:version="0.48.1 r9760"
-   sodipodi:docname="utilities-system-monitor.svg">
-  <metadata
-     id="metadata261">
-    <rdf:RDF>
-      <cc:Work
-         rdf:about="">
-        <dc:format>image/svg+xml</dc:format>
-        <dc:type
-           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
-        <dc:title></dc:title>
-      </cc:Work>
-    </rdf:RDF>
-  </metadata>
-  <sodipodi:namedview
-     pagecolor="#ffffff"
-     bordercolor="#666666"
-     borderopacity="1"
-     objecttolerance="10"
-     gridtolerance="10"
-     guidetolerance="10"
-     inkscape:pageopacity="0"
-     inkscape:pageshadow="2"
-     inkscape:window-width="1024"
-     inkscape:window-height="550"
-     id="namedview259"
-     showgrid="false"
-     inkscape:zoom="2.3828125"
-     inkscape:cx="64"
-     inkscape:cy="63.692344"
-     inkscape:window-x="0"
-     inkscape:window-y="25"
-     inkscape:window-maximized="1"
-     inkscape:current-layer="layer1" />
-  <defs
-     id="defs4">
-    <linearGradient
-       id="linearGradient4199">
-      <stop
-         style="stop-color:white;stop-opacity:1"
-         offset="0"
-         id="stop4201" />
-      <stop
-         style="stop-color:white;stop-opacity:0"
-         offset="1"
-         id="stop4203" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient4167">
-      <stop
-         style="stop-color:#171717;stop-opacity:1"
-         offset="0"
-         id="stop4169" />
-      <stop
-         style="stop-color:#777;stop-opacity:1"
-         offset="1"
-         id="stop4171" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient4159">
-      <stop
-         style="stop-color:white;stop-opacity:1"
-         offset="0"
-         id="stop4161" />
-      <stop
-         style="stop-color:white;stop-opacity:0"
-         offset="1"
-         id="stop4163" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient4142">
-      <stop
-         style="stop-color:#e5ff00;stop-opacity:1"
-         offset="0"
-         id="stop4144" />
-      <stop
-         style="stop-color:#e5ff00;stop-opacity:0"
-         offset="1"
-         id="stop4146" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3399">
-      <stop
-         style="stop-color:yellow;stop-opacity:1"
-         offset="0"
-         id="stop3401" />
-      <stop
-         style="stop-color:yellow;stop-opacity:0"
-         offset="1"
-         id="stop3403" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3391">
-      <stop
-         style="stop-color:#ffff1d;stop-opacity:1"
-         offset="0"
-         id="stop3393" />
-      <stop
-         style="stop-color:#ffff6f;stop-opacity:0"
-         offset="1"
-         id="stop3395" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3383">
-      <stop
-         style="stop-color:yellow;stop-opacity:1"
-         offset="0"
-         id="stop3385" />
-      <stop
-         style="stop-color:yellow;stop-opacity:0"
-         offset="1"
-         id="stop3387" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient4111">
-      <stop
-         style="stop-color:black;stop-opacity:1"
-         offset="0"
-         id="stop4113" />
-      <stop
-         style="stop-color:black;stop-opacity:0"
-         offset="1"
-         id="stop4115" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient4031">
-      <stop
-         style="stop-color:#292929;stop-opacity:1"
-         offset="0"
-         id="stop4033" />
-      <stop
-         style="stop-color:#e9e9e9;stop-opacity:1"
-         offset="1"
-         id="stop4035" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient4002">
-      <stop
-         style="stop-color:lime;stop-opacity:1"
-         offset="0"
-         id="stop4004" />
-      <stop
-         style="stop-color:#f0ff80;stop-opacity:0"
-         offset="1"
-         id="stop4006" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3785">
-      <stop
-         style="stop-color:black;stop-opacity:1"
-         offset="0"
-         id="stop3787" />
-      <stop
-         style="stop-color:black;stop-opacity:0"
-         offset="1"
-         id="stop3789" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3761">
-      <stop
-         style="stop-color:#f6f6f6;stop-opacity:1"
-         offset="0"
-         id="stop3763" />
-      <stop
-         style="stop-color:#5a5a5a;stop-opacity:1"
-         offset="1"
-         id="stop3765" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3749">
-      <stop
-         style="stop-color:#181818;stop-opacity:1"
-         offset="0"
-         id="stop3751" />
-      <stop
-         style="stop-color:#ababab;stop-opacity:1"
-         offset="1"
-         id="stop3753" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3737">
-      <stop
-         style="stop-color:gray;stop-opacity:1"
-         offset="0"
-         id="stop3739" />
-      <stop
-         style="stop-color:#232323;stop-opacity:1"
-         offset="1"
-         id="stop3741" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3729">
-      <stop
-         style="stop-color:#ededed;stop-opacity:1"
-         offset="0"
-         id="stop3731" />
-      <stop
-         style="stop-color:#bcbcbc;stop-opacity:1"
-         offset="1"
-         id="stop3733" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3570">
-      <stop
-         style="stop-color:black;stop-opacity:1"
-         offset="0"
-         id="stop3572" />
-      <stop
-         style="stop-color:black;stop-opacity:0"
-         offset="1"
-         id="stop3574" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3470">
-      <stop
-         style="stop-color:#ddd;stop-opacity:1"
-         offset="0"
-         id="stop3472" />
-      <stop
-         style="stop-color:#fbfbfb;stop-opacity:1"
-         offset="1"
-         id="stop3474" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3452">
-      <stop
-         style="stop-color:#979797;stop-opacity:1"
-         offset="0"
-         id="stop3454" />
-      <stop
-         style="stop-color:#454545;stop-opacity:1"
-         offset="1"
-         id="stop3456" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3440">
-      <stop
-         style="stop-color:black;stop-opacity:1"
-         offset="0"
-         id="stop3442" />
-      <stop
-         style="stop-color:black;stop-opacity:0"
-         offset="1"
-         id="stop3444" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3384">
-      <stop
-         style="stop-color:black;stop-opacity:1"
-         offset="0"
-         id="stop3386" />
-      <stop
-         style="stop-color:black;stop-opacity:0"
-         offset="1"
-         id="stop3388" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3292">
-      <stop
-         style="stop-color:#5e5e5e;stop-opacity:1"
-         offset="0"
-         id="stop3294" />
-      <stop
-         style="stop-color:#292929;stop-opacity:1"
-         offset="1"
-         id="stop3296" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3275">
-      <stop
-         style="stop-color:#323232;stop-opacity:1"
-         offset="0"
-         id="stop3277" />
-      <stop
-         style="stop-color:#1a1a1a;stop-opacity:1"
-         offset="1"
-         id="stop3279" />
-    </linearGradient>
-    <linearGradient
-       id="linearGradient3265">
-      <stop
-         style="stop-color:white;stop-opacity:1"
-         offset="0"
-         id="stop3267" />
-      <stop
-         style="stop-color:white;stop-opacity:0"
-         offset="1"
-         id="stop3269" />
-    </linearGradient>
-    <filter
-       id="filter3162">
-      <feGaussianBlur
-         id="feGaussianBlur3164"
-         stdDeviation="0.14753906"
-         inkscape:collect="always" />
-    </filter>
-    <filter
-       id="filter3193">
-      <feGaussianBlur
-         id="feGaussianBlur3195"
-         stdDeviation="0.12753906"
-         inkscape:collect="always" />
-    </filter>
-    <filter
-       id="filter3247"
-       height="1.60944"
-       y="-0.30472"
-       width="1.03826"
-       x="-0.019130022">
-      <feGaussianBlur
-         id="feGaussianBlur3249"
-         stdDeviation="0.89273437"
-         inkscape:collect="always" />
-    </filter>
-    <radialGradient
-       cx="64"
-       cy="7.1979251"
-       r="56"
-       fx="64"
-       fy="7.1979251"
-       id="radialGradient3271"
-       xlink:href="#linearGradient3265"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(1.236503,0,0,0.798045,-15.13621,10.25573)" />
-    <radialGradient
-       cx="56"
-       cy="65.961678"
-       r="44"
-       fx="56"
-       fy="64.752823"
-       id="radialGradient3281"
-       xlink:href="#linearGradient3292"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(2.206761,0,0,2.057714,-67.57862,-106.9325)" />
-    <radialGradient
-       cx="56"
-       cy="60"
-       r="44"
-       fx="56"
-       fy="99.821198"
-       id="radialGradient3287"
-       xlink:href="#linearGradient3275"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(1.497439,3.473066e-8,-3.238492e-8,1.3963,-27.85656,-45.05228)" />
-    <radialGradient
-       cx="56"
-       cy="60"
-       r="44"
-       fx="56"
-       fy="99.821198"
-       id="radialGradient3289"
-       xlink:href="#linearGradient3275"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(1.497439,3.473066e-8,-3.238492e-8,1.3963,-27.85656,-44.05228)" />
-    <clipPath
-       id="clipPath3361">
-      <rect
-         width="88"
-         height="72"
-         rx="5.0167508"
-         ry="5.0167508"
-         x="12"
-         y="24"
-         style="opacity:1;fill:url(#radialGradient3365);fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
-         id="rect3363" />
-    </clipPath>
-    <radialGradient
-       cx="56"
-       cy="65.961678"
-       r="44"
-       fx="56"
-       fy="64.752823"
-       id="radialGradient3365"
-       xlink:href="#linearGradient3292"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(2.206761,0,0,2.057714,-67.57862,-106.9325)" />
-    <linearGradient
-       x1="52.513512"
-       y1="97"
-       x2="52.513512"
-       y2="74.244766"
-       id="linearGradient3390"
-       xlink:href="#linearGradient3384"
-       gradientUnits="userSpaceOnUse" />
-    <clipPath
-       id="clipPath3402">
-      <rect
-         width="88"
-         height="72"
-         rx="5.0167508"
-         ry="5.0167508"
-         x="12"
-         y="24"
-         style="opacity:1;fill:url(#radialGradient3406);fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
-         id="rect3404" />
-    </clipPath>
-    <radialGradient
-       cx="56"
-       cy="65.961678"
-       r="44"
-       fx="56"
-       fy="64.752823"
-       id="radialGradient3406"
-       xlink:href="#linearGradient3292"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(2.206761,0,0,2.057714,-67.57862,-106.9325)" />
-    <filter
-       id="filter3424">
-      <feGaussianBlur
-         id="feGaussianBlur3426"
-         stdDeviation="0.23507812"
-         inkscape:collect="always" />
-    </filter>
-    <filter
-       id="filter3430">
-      <feGaussianBlur
-         id="feGaussianBlur3432"
-         stdDeviation="0.23507812"
-         inkscape:collect="always" />
-    </filter>
-    <linearGradient
-       x1="100"
-       y1="92.763115"
-       x2="100"
-       y2="60"
-       id="linearGradient3446"
-       xlink:href="#linearGradient3440"
-       gradientUnits="userSpaceOnUse" />
-    <linearGradient
-       x1="100"
-       y1="92.763115"
-       x2="100"
-       y2="60"
-       id="linearGradient3450"
-       xlink:href="#linearGradient3440"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="translate(0,-120)" />
-    <radialGradient
-       cx="108.33566"
-       cy="25.487402"
-       r="4.171701"
-       fx="108.33566"
-       fy="25.487402"
-       id="radialGradient3458"
-       xlink:href="#linearGradient3452"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(1.432375,0,0,1.432375,-46.84166,-11.02012)" />
-    <linearGradient
-       x1="110.75722"
-       y1="32.559616"
-       x2="106.72433"
-       y2="24.216215"
-       id="linearGradient3476"
-       xlink:href="#linearGradient3470"
-       gradientUnits="userSpaceOnUse" />
-    <filter
-       id="filter3549"
-       height="1.348368"
-       y="-0.17418399"
-       width="1.1806649"
-       x="-0.090332433">
-      <feGaussianBlur
-         id="feGaussianBlur3551"
-         stdDeviation="0.099971814"
-         inkscape:collect="always" />
-    </filter>
-    <filter
-       id="filter3553"
-       height="1.2047423"
-       y="-0.10237114"
-       width="1.2103517"
-       x="-0.10517583">
-      <feGaussianBlur
-         id="feGaussianBlur3555"
-         stdDeviation="0.099971814"
-         inkscape:collect="always" />
-    </filter>
-    <filter
-       id="filter3557"
-       height="1.348368"
-       y="-0.17418399"
-       width="1.1806649"
-       x="-0.090332433">
-      <feGaussianBlur
-         id="feGaussianBlur3559"
-         stdDeviation="0.099971814"
-         inkscape:collect="always" />
-    </filter>
-    <filter
-       id="filter3561"
-       height="1.2047423"
-       y="-0.10237114"
-       width="1.2103517"
-       x="-0.10517583">
-      <feGaussianBlur
-         id="feGaussianBlur3563"
-         stdDeviation="0.099971814"
-         inkscape:collect="always" />
-    </filter>
-    <linearGradient
-       x1="111.58585"
-       y1="31.213261"
-       x2="116.79939"
-       y2="35.079716"
-       id="linearGradient3576"
-       xlink:href="#linearGradient3570"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="translate(-0.559618,-0.203498)" />
-    <filter
-       id="filter3590">
-      <feGaussianBlur
-         id="feGaussianBlur3592"
-         stdDeviation="0.29695312"
-         inkscape:collect="always" />
-    </filter>
-    <linearGradient
-       x1="111.58585"
-       y1="31.213261"
-       x2="116.79939"
-       y2="35.079716"
-       id="linearGradient3671"
-       xlink:href="#linearGradient3570"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="translate(-0.559618,-0.203498)" />
-    <radialGradient
-       cx="108.33566"
-       cy="25.487402"
-       r="4.171701"
-       fx="108.33566"
-       fy="25.487402"
-       id="radialGradient3673"
-       xlink:href="#linearGradient3452"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(1.432375,0,0,1.432375,-46.84166,-11.02012)" />
-    <linearGradient
-       x1="110.75722"
-       y1="32.559616"
-       x2="106.72433"
-       y2="24.216215"
-       id="linearGradient3675"
-       xlink:href="#linearGradient3470"
-       gradientUnits="userSpaceOnUse" />
-    <linearGradient
-       x1="111.58585"
-       y1="31.213261"
-       x2="116.79939"
-       y2="35.079716"
-       id="linearGradient3711"
-       xlink:href="#linearGradient3570"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="translate(-0.559618,-0.203498)" />
-    <radialGradient
-       cx="108.33566"
-       cy="25.487402"
-       r="4.171701"
-       fx="108.33566"
-       fy="25.487402"
-       id="radialGradient3713"
-       xlink:href="#linearGradient3452"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(1.432375,0,0,1.432375,-46.84166,-11.02012)" />
-    <linearGradient
-       x1="110.75722"
-       y1="32.559616"
-       x2="106.72433"
-       y2="24.216215"
-       id="linearGradient3715"
-       xlink:href="#linearGradient3470"
-       gradientUnits="userSpaceOnUse" />
-    <linearGradient
-       x1="110"
-       y1="84"
-       x2="110"
-       y2="72.081078"
-       id="linearGradient3735"
-       xlink:href="#linearGradient3729"
-       gradientUnits="userSpaceOnUse" />
-    <linearGradient
-       x1="110"
-       y1="84"
-       x2="110"
-       y2="88"
-       id="linearGradient3743"
-       xlink:href="#linearGradient3737"
-       gradientUnits="userSpaceOnUse" />
-    <linearGradient
-       x1="110"
-       y1="84"
-       x2="110"
-       y2="72.081078"
-       id="linearGradient3747"
-       xlink:href="#linearGradient3729"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(1,0,0,0.2,0,-90.8)" />
-    <radialGradient
-       cx="110"
-       cy="87.735802"
-       r="4"
-       fx="110"
-       fy="87.735802"
-       id="radialGradient3755"
-       xlink:href="#linearGradient3749"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(5.343975,0,0,6.161922,-477.8373,-454.2492)" />
-    <linearGradient
-       x1="113.34818"
-       y1="79.669319"
-       x2="118.02862"
-       y2="79.669319"
-       id="linearGradient3791"
-       xlink:href="#linearGradient3785"
-       gradientUnits="userSpaceOnUse" />
-    <filter
-       id="filter3853"
-       height="1.1794737"
-       y="-0.089736843"
-       width="1.6153383"
-       x="-0.30766916">
-      <feGaussianBlur
-         id="feGaussianBlur3855"
-         stdDeviation="0.54783699"
-         inkscape:collect="always" />
-    </filter>
-    <linearGradient
-       x1="98.899841"
-       y1="40.170177"
-       x2="98.899841"
-       y2="104.503"
-       id="linearGradient4008"
-       xlink:href="#linearGradient4002"
-       gradientUnits="userSpaceOnUse" />
-    <clipPath
-       id="clipPath4019">
-      <rect
-         width="88"
-         height="72"
-         rx="5.0167508"
-         ry="5.0167508"
-         x="12"
-         y="24"
-         style="opacity:0.65263157;fill:url(#linearGradient4023);fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
-         id="rect4021" />
-    </clipPath>
-    <linearGradient
-       x1="100"
-       y1="92.763115"
-       x2="100"
-       y2="60"
-       id="linearGradient4023"
-       xlink:href="#linearGradient3440"
-       gradientUnits="userSpaceOnUse" />
-    <linearGradient
-       x1="100"
-       y1="92.763115"
-       x2="100"
-       y2="72.820351"
-       id="linearGradient4027"
-       xlink:href="#linearGradient3440"
-       gradientUnits="userSpaceOnUse" />
-    <linearGradient
-       x1="100"
-       y1="65.697929"
-       x2="95.716316"
-       y2="65.697929"
-       id="linearGradient4099"
-       xlink:href="#linearGradient3440"
-       gradientUnits="userSpaceOnUse" />
-    <linearGradient
-       x1="100"
-       y1="65.697929"
-       x2="95.909744"
-       y2="65.697929"
-       id="linearGradient4103"
-       xlink:href="#linearGradient3440"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="translate(-112,0)" />
-    <linea