commit c34f612a7fc8adc21a3d61d862fe924f53fc5f9a Author: Damian Johnson atagar@torproject.org Date: Mon Sep 2 11:29:18 2013 -0700
Shifting to a proper package layout
Making a couple important changes so we have a more proper layout for an arm package...
* eliminating the 'cli' directory (legacy from when we had a gui) * proper absolute imports (we were doing all imports relative of our base)
This no doubt breaks our installation process a thousand different ways, and that will need an overhaul later. That said, ever since arm's start we've had a very un-pythonic hacked up layout due to the above issues (and our 'src' directory). This gets us a lot closer to having a proper python package! --- arm/__init__.py | 2 +- 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/configPanel.py | 614 +++++++++++++++ arm/connections/__init__.py | 6 + arm/connections/circEntry.py | 196 +++++ arm/connections/connEntry.py | 849 +++++++++++++++++++++ arm/connections/connPanel.py | 587 +++++++++++++++ arm/connections/countPopup.py | 102 +++ arm/connections/descriptorPopup.py | 229 ++++++ arm/connections/entries.py | 171 +++++ arm/controller.py | 676 +++++++++++++++++ arm/graphing/__init__.py | 6 + arm/graphing/bandwidthStats.py | 430 +++++++++++ arm/graphing/connStats.py | 60 ++ arm/graphing/graphPanel.py | 518 +++++++++++++ arm/graphing/resourceStats.py | 53 ++ arm/headerPanel.py | 590 +++++++++++++++ arm/logPanel.py | 1270 ++++++++++++++++++++++++++++++++ arm/menu/__init__.py | 6 + arm/menu/actions.py | 296 ++++++++ arm/menu/item.py | 201 +++++ arm/menu/menu.py | 164 +++++ arm/popups.py | 337 +++++++++ arm/starter.py | 59 +- arm/torrcPanel.py | 311 ++++++++ arm/util/panel.py | 2 +- arm/util/torConfig.py | 2 +- arm/util/torTools.py | 2 +- run_arm | 2 +- runner.py | 3 + 52 files changed, 7711 insertions(+), 7711 deletions(-)
diff --git a/arm/__init__.py b/arm/__init__.py index 461b0ce..0fe42a0 100644 --- a/arm/__init__.py +++ b/arm/__init__.py @@ -2,5 +2,5 @@ Scripts involved in validating user input, system state, and initializing arm. """
-__all__ = ["starter", "prereq", "version"] +__all__ = ["starter", "prereq", "version", "configPanel", "controller", "headerPanel", "logPanel", "popups", "torrcPanel"]
diff --git a/arm/cli/__init__.py b/arm/cli/__init__.py deleted file mode 100644 index 052e06c..0000000 --- a/arm/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/arm/cli/configPanel.py b/arm/cli/configPanel.py deleted file mode 100644 index f37c1d1..0000000 --- a/arm/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/arm/cli/connections/__init__.py b/arm/cli/connections/__init__.py deleted file mode 100644 index abd3410..0000000 --- a/arm/cli/connections/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -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 deleted file mode 100644 index 25966df..0000000 --- a/arm/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/arm/cli/connections/connEntry.py b/arm/cli/connections/connEntry.py deleted file mode 100644 index 5b2eda5..0000000 --- a/arm/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/arm/cli/connections/connPanel.py b/arm/cli/connections/connPanel.py deleted file mode 100644 index ec11944..0000000 --- a/arm/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/arm/cli/connections/countPopup.py b/arm/cli/connections/countPopup.py deleted file mode 100644 index d2818ed..0000000 --- a/arm/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/arm/cli/connections/descriptorPopup.py b/arm/cli/connections/descriptorPopup.py deleted file mode 100644 index eed213f..0000000 --- a/arm/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/arm/cli/connections/entries.py b/arm/cli/connections/entries.py deleted file mode 100644 index d5085aa..0000000 --- a/arm/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/arm/cli/controller.py b/arm/cli/controller.py deleted file mode 100644 index 0ef07d8..0000000 --- a/arm/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/arm/cli/graphing/__init__.py b/arm/cli/graphing/__init__.py deleted file mode 100644 index 2dddaa3..0000000 --- a/arm/cli/graphing/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Graphing panel resources. -""" - -__all__ = ["graphPanel", "bandwidthStats", "connStats", "resourceStats"] - diff --git a/arm/cli/graphing/bandwidthStats.py b/arm/cli/graphing/bandwidthStats.py deleted file mode 100644 index 935e23d..0000000 --- a/arm/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/arm/cli/graphing/connStats.py b/arm/cli/graphing/connStats.py deleted file mode 100644 index 88ed44a..0000000 --- a/arm/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/arm/cli/graphing/graphPanel.py b/arm/cli/graphing/graphPanel.py deleted file mode 100644 index a0a348a..0000000 --- a/arm/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/arm/cli/graphing/resourceStats.py b/arm/cli/graphing/resourceStats.py deleted file mode 100644 index c0f18c9..0000000 --- a/arm/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/arm/cli/headerPanel.py b/arm/cli/headerPanel.py deleted file mode 100644 index f1704dc..0000000 --- a/arm/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/arm/cli/logPanel.py b/arm/cli/logPanel.py deleted file mode 100644 index d85144c..0000000 --- a/arm/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/arm/cli/menu/__init__.py b/arm/cli/menu/__init__.py deleted file mode 100644 index f6d43ec..0000000 --- a/arm/cli/menu/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Resources for displaying the menu. -""" - -__all__ = ["actions", "item", "menu"] - diff --git a/arm/cli/menu/actions.py b/arm/cli/menu/actions.py deleted file mode 100644 index 8cc265b..0000000 --- a/arm/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/arm/cli/menu/item.py b/arm/cli/menu/item.py deleted file mode 100644 index 1ed3f1f..0000000 --- a/arm/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/arm/cli/menu/menu.py b/arm/cli/menu/menu.py deleted file mode 100644 index a93a1e0..0000000 --- a/arm/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/arm/cli/popups.py b/arm/cli/popups.py deleted file mode 100644 index 8a41f73..0000000 --- a/arm/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@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 deleted file mode 100644 index c9d83e6..0000000 --- a/arm/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/arm/configPanel.py b/arm/configPanel.py new file mode 100644 index 0000000..8a11be4 --- /dev/null +++ b/arm/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 arm.controller +import popups + +from arm.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 = arm.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/connections/__init__.py b/arm/connections/__init__.py new file mode 100644 index 0000000..abd3410 --- /dev/null +++ b/arm/connections/__init__.py @@ -0,0 +1,6 @@ +""" +Connection panel related resources. +""" + +__all__ = ["circEntry", "connEntry", "connPanel", "countPopup", "descriptorPopup", "entries"] + diff --git a/arm/connections/circEntry.py b/arm/connections/circEntry.py new file mode 100644 index 0000000..6c809b1 --- /dev/null +++ b/arm/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 arm.connections import entries, connEntry +from arm.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/connections/connEntry.py b/arm/connections/connEntry.py new file mode 100644 index 0000000..1760d19 --- /dev/null +++ b/arm/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 arm.util import connections, torTools, uiTools +from arm.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/connections/connPanel.py b/arm/connections/connPanel.py new file mode 100644 index 0000000..6dec45a --- /dev/null +++ b/arm/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 arm.popups + +from arm.connections import countPopup, descriptorPopup, entries, connEntry, circEntry +from arm.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 = arm.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 = arm.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(arm.connections.entries.ListingType.HOSTNAME) + + oldSelection = options.index(self.getListingType()) + selection = arm.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/connections/countPopup.py b/arm/connections/countPopup.py new file mode 100644 index 0000000..34a779e --- /dev/null +++ b/arm/connections/countPopup.py @@ -0,0 +1,102 @@ +""" +Provides a dialog with client locale or exiting port counts. +""" + +import curses +import operator + +import arm.controller +import arm.popups + +from arm.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 = arm.popups.init(3, len(noStatsMsg) + 4) + else: + popup, width, height = arm.popups.init(4 + max(1, len(counts)), 80) + if not popup: return + + try: + control = arm.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: arm.popups.finalize() + diff --git a/arm/connections/descriptorPopup.py b/arm/connections/descriptorPopup.py new file mode 100644 index 0000000..a156280 --- /dev/null +++ b/arm/connections/descriptorPopup.py @@ -0,0 +1,229 @@ +""" +Popup providing the raw descriptor and consensus information for a relay. +""" + +import math +import curses + +import arm.popups +import arm.connections.connEntry + +from arm.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 = arm.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 = arm.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 = arm.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: arm.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/connections/entries.py b/arm/connections/entries.py new file mode 100644 index 0000000..d5085aa --- /dev/null +++ b/arm/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/controller.py b/arm/controller.py new file mode 100644 index 0000000..71d6adc --- /dev/null +++ b/arm/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 arm.menu.menu +import arm.popups +import arm.headerPanel +import arm.logPanel +import arm.configPanel +import arm.torrcPanel +import arm.graphing.graphPanel +import arm.graphing.bandwidthStats +import arm.graphing.connStats +import arm.graphing.resourceStats +import arm.connections.connPanel + +from stem.control import State, Controller + +from arm.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 = [arm.headerPanel.HeaderPanel(stdscr, startTime), + LabelPanel(stdscr)] + pagePanels, firstPagePanels = [], [] + + # first page: graph and log + if CONFIG["features.panels.show.graph"]: + firstPagePanels.append(arm.graphing.graphPanel.GraphPanel(stdscr)) + + if CONFIG["features.panels.show.log"]: + expandedEvents = arm.logPanel.expandEvents(CONFIG["startup.events"]) + firstPagePanels.append(arm.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([arm.connections.connPanel.ConnectionPanel(stdscr)]) + + # third page: config + if CONFIG["features.panels.show.config"]: + pagePanels.append([arm.configPanel.ConfigPanel(stdscr, arm.configPanel.State.TOR)]) + + # fourth page: torrc + if CONFIG["features.panels.show.torrc"]: + pagePanels.append([arm.torrcPanel.TorrcPanel(stdscr, arm.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 = arm.graphing.bandwidthStats.BandwidthStats() + graphPanel.addStats(GraphStat.BANDWIDTH, bwStats) + graphPanel.addStats(GraphStat.SYSTEM_RESOURCES, arm.graphing.resourceStats.ResourceStats()) + if not CONFIG["startup.blindModeEnabled"]: + graphPanel.addStats(GraphStat.CONNECTIONS, arm.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: arm.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 = arm.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'): + arm.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 = arm.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 = arm.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 = arm.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/graphing/__init__.py b/arm/graphing/__init__.py new file mode 100644 index 0000000..2dddaa3 --- /dev/null +++ b/arm/graphing/__init__.py @@ -0,0 +1,6 @@ +""" +Graphing panel resources. +""" + +__all__ = ["graphPanel", "bandwidthStats", "connStats", "resourceStats"] + diff --git a/arm/graphing/bandwidthStats.py b/arm/graphing/bandwidthStats.py new file mode 100644 index 0000000..40117dc --- /dev/null +++ b/arm/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 arm.controller + +from arm.graphing import graphPanel +from arm.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 + arm.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/graphing/connStats.py b/arm/graphing/connStats.py new file mode 100644 index 0000000..69d3489 --- /dev/null +++ b/arm/graphing/connStats.py @@ -0,0 +1,60 @@ +""" +Tracks stats concerning tor's current connections. +""" + +from arm.graphing import graphPanel +from arm.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/graphing/graphPanel.py b/arm/graphing/graphPanel.py new file mode 100644 index 0000000..c95adda --- /dev/null +++ b/arm/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 arm.popups +import arm.controller + +import stem.control + +from arm.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 = arm.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 = arm.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 = arm.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/graphing/resourceStats.py b/arm/graphing/resourceStats.py new file mode 100644 index 0000000..80d23bc --- /dev/null +++ b/arm/graphing/resourceStats.py @@ -0,0 +1,53 @@ +""" +Tracks the system resource usage (cpu and memory) of the tor process. +""" + +from arm.graphing import graphPanel +from arm.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/headerPanel.py b/arm/headerPanel.py new file mode 100644 index 0000000..6e33048 --- /dev/null +++ b/arm/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 arm.starter +import arm.popups +import arm.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: arm.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: + arm.popups.showMsg("Unable to reconnect (%s)" % exc, 3) + elif not allowPortConnection: + arm.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") + arm.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. + arm.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/logPanel.py b/arm/logPanel.py new file mode 100644 index 0000000..ad067b9 --- /dev/null +++ b/arm/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 arm.popups +from arm.version import VERSION +from arm.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/menu/__init__.py b/arm/menu/__init__.py new file mode 100644 index 0000000..f6d43ec --- /dev/null +++ b/arm/menu/__init__.py @@ -0,0 +1,6 @@ +""" +Resources for displaying the menu. +""" + +__all__ = ["actions", "item", "menu"] + diff --git a/arm/menu/actions.py b/arm/menu/actions.py new file mode 100644 index 0000000..ce58608 --- /dev/null +++ b/arm/menu/actions.py @@ -0,0 +1,296 @@ +""" +Generates the menu for arm, binding options with their related actions. +""" + +import functools + +import arm.popups +import arm.controller +import arm.menu.item +import arm.graphing.graphPanel + +from arm.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 = arm.menu.item.Submenu("") + baseMenu.add(makeActionsMenu()) + baseMenu.add(makeViewMenu()) + + control = arm.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 = arm.controller.getController() + conn = torTools.getConn() + headerPanel = control.getPanel("header") + actionsMenu = arm.menu.item.Submenu("Actions") + actionsMenu.add(arm.menu.item.MenuItem("Close Menu", None)) + actionsMenu.add(arm.menu.item.MenuItem("New Identity", headerPanel.sendNewnym)) + + if conn.isAlive(): + actionsMenu.add(arm.menu.item.MenuItem("Stop Tor", conn.shutdown)) + + actionsMenu.add(arm.menu.item.MenuItem("Reset Tor", conn.reload)) + + if control.isPaused(): label, arg = "Unpause", False + else: label, arg = "Pause", True + actionsMenu.add(arm.menu.item.MenuItem(label, functools.partial(control.setPaused, arg))) + + actionsMenu.add(arm.menu.item.MenuItem("Exit", control.quit)) + return actionsMenu + +def makeViewMenu(): + """ + Submenu consisting of... + [X] <Page 1> + [ ] <Page 2> + [ ] etc... + Color (Submenu) + """ + + viewMenu = arm.menu.item.Submenu("View") + control = arm.controller.getController() + + if control.getPageCount() > 0: + pageGroup = arm.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(arm.menu.item.SelectionMenuItem(label, pageGroup, i)) + + if uiTools.isColorSupported(): + colorMenu = arm.menu.item.Submenu("Color") + colorGroup = arm.menu.item.SelectionGroup(uiTools.setColorOverride, uiTools.getColorOverride()) + + colorMenu.add(arm.menu.item.SelectionMenuItem("All", colorGroup, None)) + + for color in uiTools.COLOR_LIST: + colorMenu.add(arm.menu.item.SelectionMenuItem(str_tools._to_camel_case(color), colorGroup, color)) + + viewMenu.add(colorMenu) + + return viewMenu + +def makeHelpMenu(): + """ + Submenu consisting of... + Hotkeys + About + """ + + helpMenu = arm.menu.item.Submenu("Help") + helpMenu.add(arm.menu.item.MenuItem("Hotkeys", arm.popups.showHelpPopup)) + helpMenu.add(arm.menu.item.MenuItem("About", arm.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 = arm.menu.item.Submenu("Graph") + + # stats options + statGroup = arm.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(arm.menu.item.SelectionMenuItem(label, statGroup, statKey)) + + # resizing option + graphMenu.add(arm.menu.item.MenuItem("Resize...", graphPanel.resizeGraph)) + + # interval submenu + intervalMenu = arm.menu.item.Submenu("Interval") + intervalGroup = arm.menu.item.SelectionGroup(graphPanel.setUpdateInterval, graphPanel.getUpdateInterval()) + + for i in range(len(arm.graphing.graphPanel.UPDATE_INTERVALS)): + label = arm.graphing.graphPanel.UPDATE_INTERVALS[i][0] + label = str_tools._to_camel_case(label, divider = " ") + intervalMenu.add(arm.menu.item.SelectionMenuItem(label, intervalGroup, i)) + + graphMenu.add(intervalMenu) + + # bounds submenu + boundsMenu = arm.menu.item.Submenu("Bounds") + boundsGroup = arm.menu.item.SelectionGroup(graphPanel.setBoundsType, graphPanel.getBoundsType()) + + for boundsType in arm.graphing.graphPanel.Bounds: + boundsMenu.add(arm.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 = arm.menu.item.Submenu("Log") + + logMenu.add(arm.menu.item.MenuItem("Events...", logPanel.showEventSelectionPrompt)) + logMenu.add(arm.menu.item.MenuItem("Snapshot...", logPanel.showSnapshotPrompt)) + logMenu.add(arm.menu.item.MenuItem("Clear", logPanel.clear)) + + if CONFIG["features.log.showDuplicateEntries"]: + label, arg = "Hide", False + else: label, arg = "Show", True + logMenu.add(arm.menu.item.MenuItem("%s Duplicates" % label, functools.partial(logPanel.setDuplicateVisability, arg))) + + # filter submenu + filterMenu = arm.menu.item.Submenu("Filter") + filterGroup = arm.menu.item.SelectionGroup(logPanel.makeFilterSelection, logPanel.getFilter()) + + filterMenu.add(arm.menu.item.SelectionMenuItem("None", filterGroup, None)) + + for option in logPanel.filterOptions: + filterMenu.add(arm.menu.item.SelectionMenuItem(option, filterGroup, option)) + + filterMenu.add(arm.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 = arm.menu.item.Submenu("Connections") + + # listing options + listingGroup = arm.menu.item.SelectionGroup(connPanel.setListingType, connPanel.getListingType()) + + listingOptions = list(arm.connections.entries.ListingType) + listingOptions.remove(arm.connections.entries.ListingType.HOSTNAME) + + for option in listingOptions: + connectionsMenu.add(arm.menu.item.SelectionMenuItem(option, listingGroup, option)) + + # sorting option + connectionsMenu.add(arm.menu.item.MenuItem("Sorting...", connPanel.showSortDialog)) + + # resolver submenu + connResolver = connections.getResolver("tor") + resolverMenu = arm.menu.item.Submenu("Resolver") + resolverGroup = arm.menu.item.SelectionGroup(connResolver.setOverwriteResolver, connResolver.getOverwriteResolver()) + + resolverMenu.add(arm.menu.item.SelectionMenuItem("auto", resolverGroup, None)) + + for option in connections.Resolver: + resolverMenu.add(arm.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 = arm.menu.item.Submenu("Configuration") + configMenu.add(arm.menu.item.MenuItem("Save Config...", configPanel.showWriteDialog)) + configMenu.add(arm.menu.item.MenuItem("Sorting...", configPanel.showSortDialog)) + + if configPanel.showAll: label, arg = "Filter", True + else: label, arg = "Unfilter", False + configMenu.add(arm.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 = arm.menu.item.Submenu("Torrc") + torrcMenu.add(arm.menu.item.MenuItem("Reload", torrcPanel.reloadTorrc)) + + if torrcPanel.stripComments: label, arg = "Show", True + else: label, arg = "Hide", False + torrcMenu.add(arm.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(arm.menu.item.MenuItem("%s Line Numbers" % label, functools.partial(torrcPanel.setLineNumberVisible, arg))) + + return torrcMenu + diff --git a/arm/menu/item.py b/arm/menu/item.py new file mode 100644 index 0000000..4e66b2b --- /dev/null +++ b/arm/menu/item.py @@ -0,0 +1,201 @@ +""" +Menu item, representing an option in the drop-down menu. +""" + +import arm.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 = arm.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/menu/menu.py b/arm/menu/menu.py new file mode 100644 index 0000000..d8cb514 --- /dev/null +++ b/arm/menu/menu.py @@ -0,0 +1,164 @@ +""" +Display logic for presenting the menu. +""" + +import curses + +import arm.popups +import arm.controller +import arm.menu.item +import arm.menu.actions + +from arm.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, arm.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, _, _ = arm.popups.init(1, belowStatic = False) + if not popup: return + control = arm.controller.getController() + + try: + # generates the menu and uses the initial selection of the first item in + # the file menu + menu = arm.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() + arm.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, _, _ = arm.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: arm.popups.finalize() + diff --git a/arm/popups.py b/arm/popups.py new file mode 100644 index 0000000..ce31218 --- /dev/null +++ b/arm/popups.py @@ -0,0 +1,337 @@ +""" +Functions for displaying popups in the interface. +""" + +import curses + +import version +import arm.controller + +from arm.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 = arm.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. + """ + + arm.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 = arm.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 = arm.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 = arm.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 = arm.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@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 = arm.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 = arm.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/starter.py b/arm/starter.py index 29e628f..b99ac83 100644 --- a/arm/starter.py +++ b/arm/starter.py @@ -15,14 +15,14 @@ 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 +import arm.version +import arm.controller +import arm.logPanel +import arm.util.connections +import arm.util.sysTools +import arm.util.torConfig +import arm.util.torTools +import arm.util.uiTools
from stem.control import Controller
@@ -68,7 +68,7 @@ Terminal status monitor for Tor relays. 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) +""" % (CONFIG["startup.interface.ipAddress"], CONFIG["startup.interface.port"], CONFIG["startup.interface.socket"], DEFAULT_CONFIG, LOG_DUMP_PATH, CONFIG["startup.events"], arm.logPanel.EVENT_LISTING)
# filename used for cached tor config descriptions CONFIG_DESC_FILENAME = "torConfigDesc.txt" @@ -142,43 +142,43 @@ def _loadConfigurationDescriptions(pathPrefix): if descriptorPath: try: loadStartTime = time.time() - util.torConfig.loadOptionDescriptions(descriptorPath) + arm.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)) + stem.util.log.info(DESC_LOAD_FAILED_MSG % arm.util.sysTools.getFileErrorMsg(exc))
# fetches configuration options from the man page if not isConfigDescriptionsLoaded: try: loadStartTime = time.time() - util.torConfig.loadOptionDescriptions() + arm.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)) + stem.util.log.notice(DESC_READ_MAN_FAILED_MSG % arm.util.sysTools.getFileErrorMsg(exc))
# persists configuration descriptions if isConfigDescriptionsLoaded and descriptorPath: try: loadStartTime = time.time() - util.torConfig.saveOptionDescriptions(descriptorPath) + arm.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)) + stem.util.log.notice(DESC_SAVE_FAILED_MSG % arm.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) + loadedVersion = arm.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)) + stem.util.log.error(DESC_INTERNAL_LOAD_FAILED_MSG % arm.util.sysTools.getFileErrorMsg(exc))
def _getController(controlAddr="127.0.0.1", controlPort=9051, passphrase=None, incorrectPasswordMsg=""): """ @@ -187,7 +187,7 @@ def _getController(controlAddr="127.0.0.1", controlPort=9051, passphrase=None, i
controller = None try: - chroot = util.torTools.getConn().getPathPrefix() + chroot = arm.util.torTools.getConn().getPathPrefix() controller = Controller.from_port(controlAddr, controlPort)
try: @@ -221,7 +221,7 @@ def _dumpConfig(): """
config = stem.util.conf.get_config("arm") - conn = util.torTools.getConn() + conn = arm.util.torTools.getConn()
# dumps arm's configuration armConfigEntry = "" @@ -255,7 +255,7 @@ def _dumpConfig(): stem.util.log.debug(armConfigEntry.strip()) stem.util.log.debug(torConfigEntry.strip())
-if __name__ == '__main__': +def main(): startTime = time.time() param = dict([(key, None) for key in CONFIG.keys()]) isDebugMode = False @@ -322,7 +322,7 @@ if __name__ == '__main__':
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) + print "Unable to write to debug log file: %s" % arm.util.sysTools.getFileErrorMsg(exc)
config = stem.util.conf.get_config("arm")
@@ -334,14 +334,14 @@ if __name__ == '__main__': try: config.load("%ssettings.cfg" % pathPrefix) except IOError, exc: - stem.util.log.warn(NO_INTERNAL_CFG_MSG % util.sysTools.getFileErrorMsg(exc)) + stem.util.log.warn(NO_INTERNAL_CFG_MSG % arm.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)) + stem.util.log.warn(STANDARD_CFG_LOAD_FAILED_MSG % arm.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) @@ -356,7 +356,7 @@ if __name__ == '__main__': controlAddr = param["startup.interface.ipAddress"] controlPort = param["startup.interface.port"]
- if not util.connections.isValidIpAddress(controlAddr): + if not arm.util.connections.isValidIpAddress(controlAddr): print "'%s' isn't a valid IP address" % controlAddr sys.exit() elif controlPort < 0 or controlPort > 65535: @@ -365,7 +365,7 @@ if __name__ == '__main__':
# validates and expands log event flags try: - cli.logPanel.expandEvents(param["startup.events"]) + arm.logPanel.expandEvents(param["startup.events"]) except ValueError, exc: for flag in str(exc): print "Unrecognized event flag: %s" % flag @@ -420,7 +420,7 @@ if __name__ == '__main__': # 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() + controllerWrapper = arm.util.torTools.getConn()
torUser = None if controller: @@ -458,8 +458,11 @@ if __name__ == '__main__': # 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(): + if arm.util.uiTools.isUnicodeAvailable(): locale.setlocale(locale.LC_ALL, "")
- cli.controller.startTorMonitor(time.time() - initTime) + arm.controller.startTorMonitor(time.time() - initTime) + +if __name__ == '__main__': + main()
diff --git a/arm/torrcPanel.py b/arm/torrcPanel.py new file mode 100644 index 0000000..7cf34d1 --- /dev/null +++ b/arm/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 arm.popups + +from arm.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/util/panel.py b/arm/util/panel.py index f9b3933..17b99b7 100644 --- a/arm/util/panel.py +++ b/arm/util/panel.py @@ -9,7 +9,7 @@ import curses.ascii import curses.textpad from threading import RLock
-from util import textInput, uiTools +from arm.util import textInput, uiTools
from stem.util import log
diff --git a/arm/util/torConfig.py b/arm/util/torConfig.py index 5f4e525..b9992ae 100644 --- a/arm/util/torConfig.py +++ b/arm/util/torConfig.py @@ -9,7 +9,7 @@ import threading
import stem.version
-from util import sysTools, torTools, uiTools +from arm.util import sysTools, torTools, uiTools
from stem.util import conf, enum, log, str_tools, system
diff --git a/arm/util/torTools.py b/arm/util/torTools.py index 21f94b4..dc95fd8 100644 --- a/arm/util/torTools.py +++ b/arm/util/torTools.py @@ -13,7 +13,7 @@ import stem.control import stem.descriptor import stem.util.system
-from util import connections +from arm.util import connections
from stem.util import conf, enum, log, proc, str_tools, system
diff --git a/run_arm b/run_arm index 0603abb..f82e895 100755 --- a/run_arm +++ b/run_arm @@ -12,6 +12,6 @@ fi python "${arm_base}prereq.py" $*
if [ $? = 0 ]; then - exec python -W ignore::DeprecationWarning "${arm_base}starter.py" $* + exec python -W ignore::DeprecationWarning "runner.py" $* fi
diff --git a/runner.py b/runner.py new file mode 100644 index 0000000..78ba482 --- /dev/null +++ b/runner.py @@ -0,0 +1,3 @@ +import arm.starter + +arm.starter.main()
tor-commits@lists.torproject.org