[or-cvs] r19549: {} Initial version of arm (terminal relay status monitor). (arm/trunk)

atagar at seul.org atagar at seul.org
Mon May 25 04:56:24 UTC 2009


Author: atagar
Date: 2009-05-25 00:56:23 -0400 (Mon, 25 May 2009)
New Revision: 19549

Added:
   arm/trunk/arm.py
   arm/trunk/armInterface.py
   arm/trunk/readme.txt
Log:
Initial version of arm (terminal relay status monitor).



Added: arm/trunk/arm.py
===================================================================
--- arm/trunk/arm.py	                        (rev 0)
+++ arm/trunk/arm.py	2009-05-25 04:56:23 UTC (rev 19549)
@@ -0,0 +1,232 @@
+#!/usr/bin/env python
+# arm.py -- Terminal status monitor for Tor relays.
+# Released under the GPL v3 (http://www.gnu.org/licenses/gpl.html)
+
+"""
+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 sys
+import os
+import socket
+import getpass
+import binascii
+import armInterface
+from TorCtl import TorCtl
+
+DEFAULT_CONTROL_ADDR = "127.0.0.1"
+DEFAULT_CONTROL_PORT = 9051
+DEFAULT_AUTH_COOKIE = os.path.expanduser("~/.tor/control_auth_cookie") # TODO: Check if this is valid for macs
+DEFAULT_LOGGED_EVENTS = "wefz" # WARN, ERR, DESCCHANGED, STATUS_SERVER
+
+NO_AUTH, COOKIE_AUTH, PASSWORD_AUTH = range(3) # enums for authentication type
+EVENT_TYPES = {
+  "d": "DEBUG",   "a": "ADDRMAP",     "l": "NEWDESC",   "u": "AUTHDIR_NEWDESCS",
+  "i": "INFO",    "b": "BW",          "m": "NS",        "v": "CLIENTS_SEEN",
+  "n": "NOTICE",  "c": "CIRC",        "o": "ORCONN",    "x": "STATUS_GENERAL",
+  "w": "WARN",    "f": "DESCCHANGED", "s": "STREAM",    "y": "STATUS_CLIENT",
+  "e": "ERR",     "g": "GUARD",       "t": "STREAM_BW", "z": "STATUS_SERVER"}
+
+HELP_TEXT = """Usage arm.py [OPTION]
+Terminal Tor relay status monitor.
+
+  -i, --interface [ADDRESS:]PORT  change control interface from %s:%i
+  -c, --cookie[=PATH]             authenticates using cookie, PATH defaults to
+                                    '%s'
+  -p, --password[=PASSWORD]       authenticates using password, prompting
+                                    without terminal echo if not provided
+  -e, --event=[EVENT FLAGS]       event types in message log  (default: %s)
+        d DEBUG     a ADDRMAP       l NEWDESC       u AUTHDIR_NEWDESCS
+        i INFO      b BW            m NS            v CLIENTS_SEEN
+        n NOTICE    c CIRC          o ORCONN        x STATUS_GENERAL
+        w WARN      f DESCCHANGED   s STREAM        y STATUS_CLIENT
+        e ERR       g GUARD         t STREAM_BW     z STATUS_SERVER
+        Aliases:    A All Events    N No Events     R Runlevels (dinwe)
+  -h, --help                      presents this help
+
+Example:
+arm.py -c                 authenticate using the default cookie
+arm.py -i 1643 -p         prompt for password using control port 1643
+arm.py -e=we -p=nemesis   use password 'nemesis' with 'WARN'/'ERR' events
+""" % (DEFAULT_CONTROL_ADDR, DEFAULT_CONTROL_PORT, DEFAULT_AUTH_COOKIE, DEFAULT_LOGGED_EVENTS)
+
+class Input:
+  "Collection of the user's command line input"
+  
+  def __init__(self, args):
+    self.controlAddr = DEFAULT_CONTROL_ADDR     # controller interface IP address
+    self.controlPort = DEFAULT_CONTROL_PORT     # controller interface port
+    self.authType = NO_AUTH                     # type of authentication used
+    self.authCookieLoc = DEFAULT_AUTH_COOKIE    # location of authentication cookie
+    self.authPassword = ""                      # authentication password
+    self.loggedEvents = DEFAULT_LOGGED_EVENTS   # flags for event types in message log
+    self.isValid = True                         # determines if the program should run
+    self.printHelp = False                      # prints help then quits
+    self._parseArgs(args)
+  
+  def _parseArgs(self, args):
+    """
+    Recursively parses arguments, populating parameters and checking input 
+    validity. This does not check if options are defined multiple times.
+    """
+    
+    if len(args) == 0: return
+    elif args[0] == "-i" or args[0] == "--interface":
+      # defines control interface address/port
+      if len(args) >= 2:
+        interfaceArg = args[1]
+        
+        try:
+          divIndex = interfaceArg.find(":")
+          
+          if divIndex == -1:
+            self.controlAddr = DEFAULT_CONTROL_ADDR
+            self.controlPort = int(interfaceArg)
+          else:
+            self.controlAddr = interfaceArg[0:divIndex]
+            if not isValidIpAddr(self.controlAddr): raise AssertionError()
+            self.controlPort = int(interfaceArg[divIndex + 1:])
+          self._parseArgs(args[2:])
+        except ValueError:
+          print "'%s' isn't a valid interface" % interfaceArg
+          self.isValid = False
+        except AssertionError:
+          print "'%s' isn't a valid IP address" % self.controlAddr
+          self.isValid = False
+      else:
+        print "%s argument provided without defining an interface" % args[0]
+        self.isValid = False
+        
+    elif args[0] == "-c" or args[0].startswith("-c=") or args[0] == "--cookie" or args[0].startswith("--cookie="):
+      # set to use cookie authentication (and possibly define location)
+      self.authType = COOKIE_AUTH
+      
+      # sets authentication path if provided
+      if args[0].startswith("-c="):
+        self.authCookieLoc = args[0][3:]
+      elif args[0].startswith("--cookie="):
+        self.authCookieLoc = args[0][9:]
+      
+      self._parseArgs(args[1:])
+    elif args[0] == "-p" or args[0].startswith("-p=") or args[0] == "--password" or args[0].startswith("--password="):
+      # set to use password authentication
+      self.authType = PASSWORD_AUTH
+      
+      # sets authentication password if provided
+      if args[0].startswith("-p="):
+        self.authPassword = args[0][3:]
+      elif args[0].startswith("--password="):
+        self.authPassword = args[0][11:]
+      
+      self._parseArgs(args[1:])
+    elif args[0].startswith("-e=") or args[0].startswith("--event="):
+      # set event flags
+      if args[0].startswith("-e="): self.loggedEvents = args[0][3:]
+      else: self.loggedEvents = args[0][8:]
+      self._parseArgs(args[1:])
+    elif args[0] == "-h" or args[0] == "--help":
+      self.printHelp = True
+      self._parseArgs(args[1:])
+    else:
+      print "Unrecognized command: " + args[0]
+      self.isValid = False
+
+def isValidIpAddr(ipStr):
+  """
+  Returns true if input is a valid IPv4 address, false otherwise.
+  """
+  
+  for i in range(4):
+    if i < 3:
+      divIndex = ipStr.find(".")
+      if divIndex == -1: return False # expected a period to be valid
+      octetStr = ipStr[:divIndex]
+      ipStr = ipStr[divIndex + 1:]
+    else:
+      octetStr = ipStr
+    
+    try:
+      octet = int(octetStr)
+      if not octet >= 0 or not octet <= 255: return False
+    except ValueError:
+      # address value isn't an integer
+      return False
+  
+  return True
+
+if __name__ == '__main__':
+  # parses user input, quitting if there's a problem
+  input = Input(sys.argv[1:])
+  if not input.isValid: sys.exit()
+  
+  # if help flag's set then prints help and quits
+  if input.printHelp:
+    print HELP_TEXT
+    sys.exit()
+  
+  # validates that cookie authentication path exists
+  if input.authType == COOKIE_AUTH and not os.path.exists(input.authCookieLoc):
+    print "Authentication cookie doesn't exist: %s" % input.authCookieLoc
+    sys.exit()
+  
+  # promts for password if not provided
+  if input.authType == PASSWORD_AUTH and input.authPassword == "":
+    input.authPassword = getpass.getpass()
+  
+  # validates and expands logged event flags
+  expandedEvents = set()
+  isValid = True
+  for flag in input.loggedEvents:
+    if flag == "A":
+      expandedEvents = set(EVENT_TYPES.values())
+      break
+    elif flag == "N":
+      expandedEvents = set()
+      break
+    elif flag == "R":
+      expandedEvents = expandedEvents.union(set(["DEBUG", "INFO", "NOTICE", "WARN", "ERR"]))
+    elif flag in EVENT_TYPES:
+      expandedEvents.add(EVENT_TYPES[flag])
+    else:
+      print "Unrecognized event flag: %s" % flag
+      isValid = False
+  if not isValid: sys.exit()
+  
+  # attempts to open a socket to the tor server
+  s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+  try:
+    s.connect((input.controlAddr, input.controlPort))
+    conn = TorCtl.Connection(s)
+    
+    # provides authentication credentials to the control port
+    if input.authType == NO_AUTH:
+      conn.authenticate("")
+    elif input.authType == COOKIE_AUTH:
+      # BUG: about a quarter of the time authentication fails with "Wrong 
+      # length on authentication cookie." or "Invalid quoted string.  You 
+      # need to put the password in double quotes." - this is possibly a TorCtl
+      # issue, but after sinking dozens of hours into this intermittent problem 
+      # I'm throwing in the towl for now...
+      
+      authCookie = open(input.authCookieLoc)
+      #conn.authenticate(authCookie.read(-1))
+      
+      # experimenting with an alternative to see if it works better - so far so good...
+      conn.sendAndRecv("AUTHENTICATE %s\r\n" % binascii.b2a_hex(authCookie.read()))
+      
+      authCookie.close()
+    else:
+      assert input.authType == PASSWORD_AUTH, "Invalid value in input.authType enum: " + str(input.authType)
+      conn.authenticate(input.authPassword)
+  except socket.error, exc:
+    print "Is the ControlPort enabled? Connection failed: %s" % exc
+    sys.exit()
+  except TorCtl.ErrorReply, exc:
+    print "Connection failed: %s" % exc
+    sys.exit()
+  
+  armInterface.startTorMonitor(conn, expandedEvents)
+  conn.close()
+

Added: arm/trunk/armInterface.py
===================================================================
--- arm/trunk/armInterface.py	                        (rev 0)
+++ arm/trunk/armInterface.py	2009-05-25 04:56:23 UTC (rev 19549)
@@ -0,0 +1,523 @@
+# armInterface.py -- arm interface (curses monitor for relay status).
+# Released under the GPL v3 (http://www.gnu.org/licenses/gpl.html)
+
+"""
+Curses (terminal) interface for the arm relay status monitor.
+"""
+
+import os
+import sys
+import time
+import curses
+from threading import Lock
+from TorCtl import TorCtl
+
+REFRESH_RATE = 5                    # seconds between redrawing screen
+BANDWIDTH_GRAPH_SAMPLES = 5         # seconds of data used for bar in graph
+BANDWIDTH_GRAPH_COL = 30            # columns of data in graph
+BANDWIDTH_GRAPH_COLOR_DL = "green"  # download section color
+BANDWIDTH_GRAPH_COLOR_UL = "cyan"   # upload section color
+MAX_LOG_ENTRIES = 80                # size of log buffer (max number of entries)
+
+# default formatting constants
+LABEL_ATTR = curses.A_STANDOUT
+SUMMARY_ATTR = curses.A_NORMAL
+LOG_ATTR = curses.A_NORMAL
+
+# 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))
+
+# foreground color mappings (starts uninitialized - all colors associated with default white fg / black bg)
+COLOR_ATTR_INITIALIZED = False
+COLOR_ATTR = dict([(color[0], 0) for color in COLOR_LIST])
+
+# color coding for runlevel events
+RUNLEVEL_EVENT_COLOR = {"DEBUG": "magenta", "INFO": "blue", "NOTICE": "green", "WARN": "yellow", "ERR": "red"}
+
+class LogMonitor(TorCtl.PostEventListener):
+  """
+  Tor event listener, noting messages, the time, and their type in a curses
+  subwindow.
+  """
+  
+  def __init__(self, logScreen, includeBW):
+    TorCtl.PostEventListener.__init__(self)
+    self.msgLog = []                # tuples of (isMsgFirstLine, logText, color)
+    self.logScreen = logScreen      # curses window where log's displayed
+    self.isPaused = False
+    self.pauseBuffer = []           # location where messages are buffered if paused
+    self.msgLogLock = Lock()        # haven't noticed any concurrency errors but better safe...
+    self.includeBW = includeBW      # true if we're supposed to listen for BW events
+  
+  # Listens for all event types and redirects to registerEvent
+  # TODO: not sure how to stimulate all event types - should be tried before
+  # implemented to see what's the best formatting, what information is
+  # important, and to make sure of variable's types so we don't get exceptions.
+  
+  def circ_status_event(self, event):
+    self.registerEvent("CIRC", "<STUB>", "white") # TODO: implement - variables: event.circ_id, event.status, event.path, event.purpose, event.reason, event.remote_reason
+  
+  def stream_status_event(self, event):
+    self.registerEvent("STREAM", "<STUB>", "white") # TODO: implement - variables: event.strm_id, event.status, event.circ_id, event.target_host, event.target_port, event.reason, event.remote_reason, event.source, event.source_addr, event.purpose
+  
+  def or_conn_status_event(self, event):
+    self.registerEvent("ORCONN", "<STUB>", "white") # TODO: implement - variables: event.status, event.endpoint, event.age, event.read_bytes, event.wrote_bytes, event.reason, event.ncircs
+  
+  def stream_bw_event(self, event):
+    self.registerEvent("STREAM_BW", "<STUB>", "white") # TODO: implement - variables: event.strm_id, event.bytes_read, event.bytes_written
+  
+  def bandwidth_event(self, event):
+    if self.includeBW: self.registerEvent("BW", "READ: %i, WRITTEN: %i" % (event.read, event.written), "cyan")
+  
+  def msg_event(self, event):
+    self.registerEvent(event.level, event.msg, RUNLEVEL_EVENT_COLOR[event.level])
+  
+  def new_desc_event(self, event):
+    self.registerEvent("NEWDESC", "<STUB>", "white") # TODO: implement - variables: event.idlist
+  
+  def address_mapped_event(self, event):
+    self.registerEvent("ADDRMAP", "<STUB>", "white") # TODO: implement - variables: event.from_addr, event.to_addr, event.when
+  
+  def ns_event(self, event):
+    self.registerEvent("NS", "<STUB>", "white") # TODO: implement - variables: event.nslist
+  
+  def new_consensus_event(self, event):
+    self.registerEvent("NEWCONSENSUS", "<STUB>", "white") # TODO: implement - variables: event.nslist
+  
+  def unknown_event(self, event):
+    self.registerEvent("UNKNOWN", event.event_string, "red")
+  
+  def registerEvent(self, type, msg, color):
+    """
+    Notes event and redraws log. If paused it's held in a temporary buffer.
+    """
+    
+    eventTime = time.localtime()
+    msgLine = "%02i:%02i:%02i [%s] %s" % (eventTime[3], eventTime[4], eventTime[5], type, msg)
+    
+    if self.isPaused:
+      self.pauseBuffer.insert(0, (msgLine, color))
+      if len(self.pauseBuffer) > MAX_LOG_ENTRIES: del self.pauseBuffer[MAX_LOG_ENTRIES:]
+    else:
+      self.msgLogLock.acquire()
+      self.msgLog.insert(0, (msgLine, color))
+      if len(self.msgLog) > MAX_LOG_ENTRIES: del self.msgLog[MAX_LOG_ENTRIES:]
+      self.refreshDisplay()
+      self.msgLogLock.release()
+  
+  def refreshDisplay(self):
+    """
+    Redraws message log. Entries stretch to use available space and may
+    contain up to two lines. Starts with newest entries.
+    """
+    
+    self.logScreen.erase()
+    y, x = self.logScreen.getmaxyx()
+    lineCount = 0
+    
+    for (line, color) in self.msgLog:
+      # splits over too lines if too long
+      if len(line) < x:
+        self.logScreen.addstr(lineCount, 0, line[:x - 1], LOG_ATTR | COLOR_ATTR[color])
+        lineCount += 1
+      else:
+        if lineCount >= y - 1: break
+        (line1, line2) = self._splitLine(line, x)
+        self.logScreen.addstr(lineCount, 0, line1, LOG_ATTR | COLOR_ATTR[color])
+        self.logScreen.addstr(lineCount + 1, 0, line2[:x - 1], LOG_ATTR | COLOR_ATTR[color])
+        lineCount += 2
+      
+      if lineCount >= y: break
+    self.logScreen.refresh()
+  
+  def setPaused(self, isPause):
+    """
+    If true, prevents message log from being updated with new events.
+    """
+    
+    if isPause == self.isPaused: return
+    
+    self.isPaused = isPause
+    if self.isPaused: self.pauseBuffer = []
+    else:
+      self.msgLog = self.pauseBuffer + self.msgLog
+      self.msgLogLock.acquire()
+      self.refreshDisplay()
+      self.msgLogLock.release()
+    
+  # divides long message to cover two lines
+  def _splitLine(self, message, x):
+    # divides message into two lines, attempting to do it on a wordbreak
+    lastWordbreak = message[:x].rfind(" ")
+    if x - lastWordbreak < 10:
+      line1 = message[:lastWordbreak]
+      line2 = "  %s" % message[lastWordbreak:].strip()
+    else:
+      # over ten characters until the last word - dividing
+      line1 = "%s-" % message[:x - 2]
+      line2 = "  %s" % message[x - 2:].strip()
+    
+    # ends line with ellipsis if too long
+    if len(line2) > x:
+      lastWordbreak = line2[:x - 4].rfind(" ")
+      
+      # doesn't use wordbreak if it's a long word or the whole line is one 
+      # word (picking up on two space indent to have index 1)
+      if x - lastWordbreak > 10 or lastWordbreak == 1: lastWordbreak = x - 4
+      line2 = "%s..." % line2[:lastWordbreak]
+    
+    return (line1, line2)
+
+class BandwidthMonitor(TorCtl.PostEventListener):
+  """
+  Tor event listener, taking bandwidth sampling and drawing bar graph. This is
+  updated every second by the BW events and graph samples are spaced at
+  BANDWIDTH_GRAPH_SAMPLES second intervals.
+  """
+  
+  def __init__(self, bandwidthScreen):
+    TorCtl.PostEventListener.__init__(self)
+    self.tick = 0                           # number of updates performed
+    self.bandwidthScreen = bandwidthScreen  # curses window where bandwidth's displayed
+    self.lastDownloadRate = 0               # most recently sampled rates
+    self.lastUploadRate = 0
+    self.maxDownloadRate = 1                # max rates seen, used to determine graph bounds
+    self.maxUploadRate = 1
+    self.isPaused = False
+    self.pauseBuffer = None                 # mirror instance used to track updates when paused
+    
+    # graphed download (read) and upload (write) rates - first index accumulator
+    self.downloadRates = [0] * (BANDWIDTH_GRAPH_COL + 1)
+    self.uploadRates = [0] * (BANDWIDTH_GRAPH_COL + 1)
+    
+  def bandwidth_event(self, event):
+    if self.isPaused:
+      self.pauseBuffer.bandwidth_event(event)
+    else:
+      self.lastDownloadRate = event.read
+      self.lastUploadRate = event.written
+      
+      self.downloadRates[0] += event.read
+      self.uploadRates[0] += event.written
+      
+      self.tick += 1
+      if self.tick % BANDWIDTH_GRAPH_SAMPLES == 0:
+        self.maxDownloadRate = max(self.maxDownloadRate, self.downloadRates[0])
+        self.downloadRates.insert(0, 0)
+        del self.downloadRates[BANDWIDTH_GRAPH_COL + 1:]
+        
+        self.maxUploadRate = max(self.maxUploadRate, self.uploadRates[0])
+        self.uploadRates.insert(0, 0)
+        del self.uploadRates[BANDWIDTH_GRAPH_COL + 1:]
+      
+      self.refreshDisplay()
+  
+  def refreshDisplay(self):
+    """ Redraws bandwidth panel. """
+    
+    # doesn't draw if headless (indicating that the instance is for a pause buffer)
+    if self.bandwidthScreen:
+      self.bandwidthScreen.erase()
+      y, x = self.bandwidthScreen.getmaxyx()
+      dlColor = COLOR_ATTR[BANDWIDTH_GRAPH_COLOR_DL]
+      ulColor = COLOR_ATTR[BANDWIDTH_GRAPH_COLOR_UL]
+      
+      # current numeric measures
+      self.bandwidthScreen.addstr(0, 0, ("Downloaded (%s/sec):" % getSizeLabel(self.lastDownloadRate))[:x - 1], curses.A_BOLD | dlColor)
+      if x > 35: self.bandwidthScreen.addstr(0, 35, ("Uploaded (%s/sec):" % getSizeLabel(self.lastUploadRate))[:x - 36], curses.A_BOLD | ulColor)
+      
+      # graph bounds in KB (uses highest recorded value as max)
+      self.bandwidthScreen.addstr(1, 0, ("%4s" % str(self.maxDownloadRate / 1024 / BANDWIDTH_GRAPH_SAMPLES))[:x - 1], dlColor)
+      self.bandwidthScreen.addstr(6, 0, "   0"[:x - 1], dlColor)
+      
+      if x > 35:
+        self.bandwidthScreen.addstr(1, 35, ("%4s" % str(self.maxUploadRate / 1024 / BANDWIDTH_GRAPH_SAMPLES))[:x - 36], ulColor)
+        self.bandwidthScreen.addstr(6, 35, "   0"[:x - 36], ulColor)
+      
+      # creates bar graph of bandwidth usage over time
+      for col in range(BANDWIDTH_GRAPH_COL):
+        if col > x - 8: break
+        bytesDownloaded = self.downloadRates[col + 1]
+        colHeight = min(5, 5 * bytesDownloaded / self.maxDownloadRate)
+        for row in range(colHeight): self.bandwidthScreen.addstr(6 - row, col + 5, " ", curses.A_STANDOUT | dlColor)
+      
+      for col in range(BANDWIDTH_GRAPH_COL):
+        if col > x - 42: break
+        bytesUploaded = self.uploadRates[col + 1]
+        colHeight = min(5, 5 * bytesUploaded / self.maxUploadRate)
+        for row in range(colHeight): self.bandwidthScreen.addstr(6 - row, col + 40, " ", curses.A_STANDOUT | ulColor)
+        
+      self.bandwidthScreen.refresh()
+  
+  def setPaused(self, isPause):
+    """
+    If true, prevents bandwidth updates from being presented.
+    """
+    
+    if isPause == self.isPaused: return
+    
+    self.isPaused = isPause
+    if self.isPaused:
+      if self.pauseBuffer == None:
+        self.pauseBuffer = BandwidthMonitor(None, None, None)
+      
+      self.pauseBuffer.tick = self.tick
+      self.pauseBuffer.lastDownloadRate = self.lastDownloadRate
+      self.pauseBuffer.lastuploadRate = self.lastUploadRate
+      self.pauseBuffer.downloadRates = self.downloadRates
+      self.pauseBuffer.uploadRates = self.uploadRates
+    else:
+      self.tick = self.pauseBuffer.tick
+      self.lastDownloadRate = self.pauseBuffer.lastDownloadRate
+      self.lastUploadRate = self.pauseBuffer.lastuploadRate
+      self.downloadRates = self.pauseBuffer.downloadRates
+      self.uploadRates = self.pauseBuffer.uploadRates
+  
+def getSizeLabel(bytes):
+  """
+  Converts byte count into label in its most significant units, for instance
+  7500 bytes would return "7 KB".
+  """
+  
+  if bytes >= 1073741824: return "%i GB" % (bytes / 1073741824)
+  elif bytes >= 1048576: return "%i MB" % (bytes / 1048576)
+  elif bytes >= 1024: return "%i KB" % (bytes / 1024)
+  else: return "%i bytes" % bytes
+
+def getStaticInfo(conn):
+  """
+  Provides mapping of static Tor settings and system information to their
+  corresponding string values. Keys include:
+  info - version, config-file, address, fingerprint
+  sys - sys-name, sys-os, sys-version
+  config - Nickname, ORPort, DirPort, ControlPort, ExitPolicy, BandwidthRate, BandwidthBurst
+  config booleans - IsPasswordAuthSet, IsCookieAuthSet
+  """
+  
+  vals = conn.get_info(["version", "config-file"])
+  
+  # gets parameters that throw errors if unavailable
+  for param in ["address", "fingerprint"]:
+    try:
+      vals.update(conn.get_info(param))
+    except TorCtl.ErrorReply:
+      vals[param] = "Unknown"
+  
+  # populates with some basic system information
+  unameVals = os.uname()
+  vals["sys-name"] = unameVals[1]
+  vals["sys-os"] = unameVals[0]
+  vals["sys-version"] = unameVals[2]
+  
+  # parameters from the user's torrc
+  configFields = ["Nickname", "ORPort", "DirPort", "ControlPort", "ExitPolicy", "BandwidthRate", "BandwidthBurst"]
+  vals.update(dict([(key, conn.get_option(key)[0][1]) for key in configFields]))
+  
+  # simply keeps booleans for if authentication info is set
+  vals["IsPasswordAuthSet"] = not conn.get_option("HashedControlPassword")[0][1] == None
+  vals["IsCookieAuthSet"] = conn.get_option("CookieAuthentication")[0][1] == "1"
+  
+  return vals
+
+def drawSummary(screen, vals, maxX, maxY):
+  """
+  Draws top area containing static information.
+  
+  arm - <System Name> (<OS> <Version>)     Tor <Tor Version>
+  <Relay Nickname> - <IP Addr>:<ORPort>, [Dir Port: <DirPort>, ]Control Port (<open, password, cookie>): <ControlPort>
+  Fingerprint: <Fingerprint>
+  Config: <Config>
+  Exit Policy: <ExitPolicy>
+  
+  Example:
+  arm - odin (Linux 2.6.24-24-generic)     Tor 0.2.0.34 (r18423)
+  odin - 76.104.132.98:9001, Dir Port: 9030, Control Port (cookie): 9051
+  Fingerprint: BDAD31F6F318E0413833E8EBDA956F76E4D66788
+  Config: /home/atagar/.vidalia/torrc
+  Exit Policy: reject *:*
+  """
+  
+  screen.erase()
+  
+  # Line 1
+  if maxY >= 1:
+    screen.addstr(0, 0, ("arm - %s (%s %s)" % (vals["sys-name"], vals["sys-os"], vals["sys-version"]))[:maxX - 1], SUMMARY_ATTR)
+    if 45 < maxX: screen.addstr(0, 45, ("Tor %s" % vals["version"])[:maxX - 46], SUMMARY_ATTR)
+  
+  # Line 2 (authentication label red if open, green if credentials required)
+  if maxY >= 2:
+    dirPortLabel = "Dir Port: %s, " % vals["DirPort"] if not vals["DirPort"] == None else ""
+    
+    # TODO: if both cookie and password are set then which takes priority?
+    if vals["IsPasswordAuthSet"]: controlPortAuthLabel = "password"
+    elif vals["IsCookieAuthSet"]: controlPortAuthLabel = "cookie"
+    else: controlPortAuthLabel = "open"
+    controlPortAuthColor = "red" if controlPortAuthLabel == "open" else "green"
+    
+    labelStart = "%s - %s:%s, %sControl Port (" % (vals["Nickname"], vals["address"], vals["ORPort"], dirPortLabel)
+    screen.addstr(1, 0, labelStart[:maxX - 1], SUMMARY_ATTR)
+    xLoc = len(labelStart)
+    if xLoc < maxX: screen.addstr(1, xLoc, controlPortAuthLabel[:maxX - xLoc - 1], COLOR_ATTR[controlPortAuthColor] | SUMMARY_ATTR)
+    xLoc += len(controlPortAuthLabel)
+    if xLoc < maxX: screen.addstr(1, xLoc, ("): %s" % vals["ControlPort"])[:maxX - xLoc - 1], SUMMARY_ATTR)
+    
+  # Lines 3-5
+  if maxY >= 3: screen.addstr(2, 0, ("Fingerprint: %s" % vals["fingerprint"])[:maxX - 1], SUMMARY_ATTR)
+  if maxY >= 4: screen.addstr(3, 0, ("Config: %s" % vals["config-file"])[:maxX - 1], SUMMARY_ATTR)
+  
+  # adds note when default exit policy is appended
+  if maxY >= 5:
+    exitPolicy = vals["ExitPolicy"]
+    if not exitPolicy.endswith("accept *:*") and not exitPolicy.endswith("reject *:*"):
+      exitPolicy += ", <default>"
+    screen.addstr(4, 0, ("Exit Policy: %s" % exitPolicy)[:maxX - 1], SUMMARY_ATTR)
+  
+  screen.refresh()
+
+def drawPauseLabel(screen, isPaused, maxX):
+  """ Draws single line label for interface controls. """
+  # TODO: possibly include 'h: help' if the project grows much
+  screen.erase()
+  if isPaused: screen.addstr(0, 0, "Paused"[:maxX - 1], LABEL_ATTR)
+  else: screen.addstr(0, 0, "q: quit, p: pause"[:maxX - 1])
+  screen.refresh()
+
+def drawTorMonitor(stdscr, conn, loggedEvents):
+  global COLOR_ATTR_INITIALIZED
+  
+  # use terminal defaults to allow things like semi-transparent backgrounds
+  curses.use_default_colors()
+  
+  # initializes color mappings if able
+  if curses.has_colors() and not COLOR_ATTR_INITIALIZED:
+    COLOR_ATTR_INITIALIZED = True
+    colorpair = 0
+    
+    for name, fgColor in COLOR_LIST:
+      colorpair += 1
+      curses.init_pair(colorpair, fgColor, -1) # -1 allows for default (possibly transparent) background
+      COLOR_ATTR[name] = curses.color_pair(colorpair)
+  
+  curses.halfdelay(REFRESH_RATE * 10)   # uses getch call as timer for REFRESH_RATE seconds
+  curses.curs_set(0)                    # makes cursor invisible
+  staticInfo = getStaticInfo(conn)
+  y, x = stdscr.getmaxyx()
+  oldX, oldY = -1, -1
+  
+  # note: subwindows need a character buffer (either in the x or y direction)
+  # from actual content to prevent crash when shrank
+  summaryScreen = stdscr.subwin(6, x, 0, 0)     # top static content
+  pauseLabel = stdscr.subwin(1, x, 6, 0)        # line concerned with user interface
+  bandwidthLabel = stdscr.subwin(1, x, 7, 0)    # bandwidth section label
+  bandwidthScreen = stdscr.subwin(8, x, 8, 0)   # bandwidth measurements / graph
+  logLabel = stdscr.subwin(1, x, 16, 0)         # message log label
+  logScreen = stdscr.subwin(y - 17, x, 17, 0)   # uses all remaining space for message log
+  
+  # listeners that update bandwidthScreen and logScreen with Tor statuses
+  logListener = LogMonitor(logScreen, "BW" in loggedEvents)
+  conn.add_event_listener(logListener)
+  
+  bandwidthListener = BandwidthMonitor(bandwidthScreen)
+  conn.add_event_listener(bandwidthListener)
+  
+  # Tries to set events being listened for, displaying error for any event
+  # types that aren't supported (possibly due to version issues)
+  eventsSet = False
+  
+  while not eventsSet:
+    try:
+      # adds BW events if not already included (so bandwidth monitor will work)
+      conn.set_events(loggedEvents.union(set(["BW"])))
+      eventsSet = True
+    except TorCtl.ErrorReply, exc:
+      msg = str(exc)
+      if "Unrecognized event" in msg:
+        # figure out type of event we failed to listen for
+        start = msg.find("event \"") + 7
+        end = msg.rfind("\"")
+        eventType = msg[start:end]
+        if eventType == "BW": raise exc # bandwidth monitoring won't work - best to crash
+        
+        # removes and notes problem
+        loggedEvents.remove(eventType)
+        logListener.registerEvent("ARM-ERR", "Unsupported event type: %s" % eventType, "red")
+      else:
+        raise exc
+  eventsListing = ", ".join(loggedEvents)
+  
+  bandwidthScreen.refresh()
+  logScreen.refresh()
+  isPaused = False
+  
+  while True:
+    y, x = stdscr.getmaxyx()
+    
+    if x != oldX or y != oldY:
+      # Screen size changed - redraw content to conform to the new dimensions.
+      # Labels attempt to shrink gracefully.
+      
+      drawSummary(summaryScreen, staticInfo, x, y)
+      drawPauseLabel(pauseLabel, isPaused, x)
+      
+      # Bandwidth label (drops stats if not enough room)
+      rateLabel = getSizeLabel(int(staticInfo["BandwidthRate"]))
+      burstLabel = getSizeLabel(int(staticInfo["BandwidthBurst"]))
+      labelContents = "Bandwidth (cap: %s, burst: %s):" % (rateLabel, burstLabel)
+      if x < len(labelContents):
+        labelContents = "%s):" % labelContents[:labelContents.find(",")] # removes burst measure
+        if x < len(labelContents): labelContents = "Bandwidth:"
+      
+      bandwidthLabel.erase()
+      bandwidthLabel.addstr(0, 0, labelContents[:x - 1], LABEL_ATTR)
+      bandwidthLabel.refresh()
+      
+      # gives bandwidth display a chance to redraw with new size
+      bandwidthListener.refreshDisplay()
+      
+      # Event log label - uses ellipsis if too long, for instance:
+      # Events (DEBUG, INFO, NOTICE, WARN...):
+      eventsLabel = "Events"
+      
+      firstLabelLen = eventsListing.find(", ")
+      if firstLabelLen == -1: firstLabelLen = len(eventsListing)
+      else: firstLabelLen += 3
+      
+      if x > 10 + firstLabelLen:
+        eventsLabel += " ("
+        if len(eventsListing) > x - 11:
+          labelBreak = eventsListing[:x - 12].rfind(", ")
+          eventsLabel += "%s..." % eventsListing[:labelBreak]
+        else: eventsLabel += eventsListing
+        eventsLabel += ")"
+      eventsLabel += ":"
+      
+      # gives message log a chance to redraw with new size
+      logLabel.erase()
+      logLabel.addstr(0, 0, eventsLabel[:x - 1], LABEL_ATTR)
+      logLabel.refresh()
+      
+      logListener.msgLogLock.acquire()
+      logListener.refreshDisplay()
+      logListener.msgLogLock.release()
+      oldX, oldY = x, y
+    
+    stdscr.refresh()
+    key = stdscr.getch()
+    if key == ord('q') or key == ord('Q'): break # quits
+    elif key == ord('p') or key == ord('P'):
+      # toggles update freezing
+      isPaused = not isPaused
+      logListener.setPaused(isPaused)
+      bandwidthListener.setPaused(isPaused)
+      drawPauseLabel(pauseLabel, isPaused, x)
+
+def startTorMonitor(conn, loggedEvents):
+  curses.wrapper(drawTorMonitor, conn, loggedEvents)
+

Added: arm/trunk/readme.txt
===================================================================
--- arm/trunk/readme.txt	                        (rev 0)
+++ arm/trunk/readme.txt	2009-05-25 04:56:23 UTC (rev 19549)
@@ -0,0 +1,30 @@
+ARM (ARM Relay Monitor) - Terminal status monitor for Tor relays.
+Developed by Damian Johnson (www.atagar.com - atagar1 at gmail.com)
+All code under the GPL v3 (http://www.gnu.org/licenses/gpl.html)
+
+Description:
+Command line application for monitoring Tor relays, providing real time status information such as the current configuration, bandwidth usage, message log, etc. This uses a curses interface much like 'top' does for system usage.
+
+Requirements:
+Python 2.5
+TorCtl - This needs to be in your Python path. In Linux this can be done via:
+  svn co https://tor-svn.freehaven.net/svn/torctl
+  export PYTHONPATH=$PWD/trunk/python/
+Tor is running with an available control port. This means either...
+  ... starting Tor with '--controlport <PORT>'
+  ... or including 'ControlPort <PORT>' in your torrc
+
+This is started via arm.py (use the '--help' argument for usage).
+
+Current Issues:
+- The monitor's resilient to having it's width changed (down to five cells or so), but not its height. The problem is that curses moves and resizes vertically displaced subwindows so if the terminal's shrank, it won't grow back when restored. The Python curses bindings lack support for moving, resizing, or deleting subwindows so I'm at a bit of a loss for how to fix this. Shot an email to the Python users list but no bites so far...
+
+- Currently TorCtl seems to like to provide log messages to the terminal, for instance when authentication fails it says:
+atagar at odin:~/Desktop/tormoni$ python tormoni.py
+NOTICE [ Wed May 13 13:10:13 2009 ]: Tor closed control connection. Exiting event thread.
+Connection failed: 515 Authentication failed: Wrong length on authentication cookie.
+
+The first message is from TorCtl and the second is mine. Tried remapping stderr but no luck. It's occasionally noisy with a TypeError when shutting down and messages seem capable of disrupting curses, overwriting displays. Planning on checking with Mike about this one.
+
+- Cookie authentication fails roughly a quarter of the time. Matt had a suggestion about an alternative method of authentication that seems to be working so far, but since it's an intermittent problem I'll hold my breath a little while before calling this one solved.
+



More information about the tor-commits mailing list