commit adf453c2402b805582d448d17f62ac173f00ec61 Author: Damian Johnson atagar@torproject.org Date: Tue Apr 26 19:20:55 2011 -0700
Renaming interface directory to cli
Kamran's working on an arm gui for GSoC, so renaming the interface directory to cli (and he'll introduce a /gui). --- src/cli/__init__.py | 6 + src/cli/configPanel.py | 364 +++++++ src/cli/connections/__init__.py | 6 + src/cli/connections/circEntry.py | 216 ++++ src/cli/connections/connEntry.py | 850 ++++++++++++++++ src/cli/connections/connPanel.py | 398 ++++++++ src/cli/connections/entries.py | 164 +++ src/cli/controller.py | 1584 ++++++++++++++++++++++++++++++ src/cli/descriptorPopup.py | 181 ++++ src/cli/graphing/__init__.py | 6 + src/cli/graphing/bandwidthStats.py | 398 ++++++++ src/cli/graphing/connStats.py | 54 + src/cli/graphing/graphPanel.py | 407 ++++++++ src/cli/graphing/resourceStats.py | 47 + src/cli/headerPanel.py | 474 +++++++++ src/cli/logPanel.py | 1100 +++++++++++++++++++++ src/cli/torrcPanel.py | 221 +++++ src/interface/__init__.py | 6 - src/interface/configPanel.py | 364 ------- src/interface/connections/__init__.py | 6 - src/interface/connections/circEntry.py | 216 ---- src/interface/connections/connEntry.py | 850 ---------------- src/interface/connections/connPanel.py | 398 -------- src/interface/connections/entries.py | 164 --- src/interface/controller.py | 1584 ------------------------------ src/interface/descriptorPopup.py | 181 ---- src/interface/graphing/__init__.py | 6 - src/interface/graphing/bandwidthStats.py | 398 -------- src/interface/graphing/connStats.py | 54 - src/interface/graphing/graphPanel.py | 407 -------- src/interface/graphing/resourceStats.py | 47 - src/interface/headerPanel.py | 474 --------- src/interface/logPanel.py | 1100 --------------------- src/interface/torrcPanel.py | 221 ----- src/starter.py | 10 +- 35 files changed, 6481 insertions(+), 6481 deletions(-)
diff --git a/src/cli/__init__.py b/src/cli/__init__.py new file mode 100644 index 0000000..0f11fc1 --- /dev/null +++ b/src/cli/__init__.py @@ -0,0 +1,6 @@ +""" +Panels, popups, and handlers comprising the arm user interface. +""" + +__all__ = ["configPanel", "connPanel", "controller", "descriptorPopup", "headerPanel", "logPanel", "torrcPanel"] + diff --git a/src/cli/configPanel.py b/src/cli/configPanel.py new file mode 100644 index 0000000..fd6fb54 --- /dev/null +++ b/src/cli/configPanel.py @@ -0,0 +1,364 @@ +""" +Panel presenting the configuration state for tor or arm. Options can be edited +and the resulting configuration files saved. +""" + +import curses +import threading + +from util import conf, enum, panel, torTools, torConfig, uiTools + +DEFAULT_CONFIG = {"features.config.selectionDetails.height": 6, + "features.config.state.showPrivateOptions": False, + "features.config.state.showVirtualOptions": False, + "features.config.state.colWidth.option": 25, + "features.config.state.colWidth.value": 15} + +# 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") +DEFAULT_SORT_ORDER = (Field.MAN_ENTRY, Field.OPTION, Field.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")} + +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 _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 = uiTools.getSizeLabel(int(confValue)) + elif self.get(Field.TYPE) == "TimeInterval" and confValue.isdigit(): + confValue = uiTools.getTimeLabel(int(confValue), isLong = 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, config=None): + panel.Panel.__init__(self, stdscr, "configState", 0) + + self.sortOrdering = DEFAULT_SORT_ORDER + self._config = dict(DEFAULT_CONFIG) + if config: + config.update(self._config, { + "features.config.selectionDetails.height": 0, + "features.config.state.colWidth.option": 5, + "features.config.state.colWidth.value": 5}) + + sortFields = Field.values() + customOrdering = config.getIntCSV("features.config.order", None, 3, 0, len(sortFields)) + + if customOrdering: + self.sortOrdering = [sortFields[i] for i in customOrdering] + + self.configType = configType + self.confContents = [] + 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 + + if self.configType == State.TOR: + conn = torTools.getConn() + customOptions = torConfig.getCustomOptions() + configOptionLines = conn.getInfo("config/names", "").strip().split("\n") + + for line in configOptionLines: + # lines are of the form "<option> <type>", like: + # UseEntryGuards Boolean + confOption, confType = line.strip().split(" ", 1) + + # skips private and virtual entries if not configured to show them + if not self._config["features.config.state.showPrivateOptions"] and confOption.startswith("__"): + continue + elif not self._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.getConfig("arm") + for key in armConf.getKeys(): + 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 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: self.sortOrdering = ordering + self.confContents.sort(key=lambda i: (i.getAll(self.sortOrdering))) + self.confImportantContents.sort(key=lambda i: (i.getAll(self.sortOrdering))) + self.valsLock.release() + + def handleKey(self, key): + self.valsLock.acquire() + if uiTools.isScrollKey(key): + pageHeight = self.getPreferredSize()[0] - 1 + detailPanelHeight = self._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 key == ord('a') or key == ord('A'): + self.showAll = not self.showAll + self.redraw(True) + self.valsLock.release() + + def draw(self, width, height): + self.valsLock.acquire() + + # draws the top label + 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" + + # panel with details for the current selection + detailPanelHeight = self._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 + + self._drawSelectionPanel(cursorSelection, width, detailPanelHeight, isScrollbarVisible) + + 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 = self._config["features.config.state.colWidth.option"] + valueWidth = self._config["features.config.state.colWidth.value"] + descriptionWidth = max(0, width - scrollOffset - optionWidth - valueWidth - 2) + + 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 - 2, 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 - 2, 4, 4) + + self.addstr(3 + i, 2, msg, selectionFormat) + diff --git a/src/cli/connections/__init__.py b/src/cli/connections/__init__.py new file mode 100644 index 0000000..5babdde --- /dev/null +++ b/src/cli/connections/__init__.py @@ -0,0 +1,6 @@ +""" +Connection panel related resources. +""" + +__all__ = ["circEntry", "connEntry", "connPanel", "entries"] + diff --git a/src/cli/connections/circEntry.py b/src/cli/connections/circEntry.py new file mode 100644 index 0000000..b15b26a --- /dev/null +++ b/src/cli/connections/circEntry.py @@ -0,0 +1,216 @@ +""" +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 + +# cached fingerprint -> (IP Address, ORPort) results +RELAY_INFO = {} + +def getRelayInfo(fingerprint): + """ + Provides the (IP Address, ORPort) tuple for the given relay. If the lookup + fails then this returns ("192.168.0.1", "0"). + + Arguments: + fingerprint - relay to look up + """ + + if not fingerprint in RELAY_INFO: + conn = torTools.getConn() + failureResult = ("192.168.0.1", "0") + + nsEntry = conn.getConsensusEntry(fingerprint) + if not nsEntry: return failureResult + + nsLineComp = nsEntry.split("\n")[0].split(" ") + if len(nsLineComp) < 8: return failureResult + + RELAY_INFO[fingerprint] = (nsLineComp[6], nsLineComp[7]) + + return RELAY_INFO[fingerprint] + +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]] + + if status == "BUILT" and not self.lines[0].isBuilt: + exitIp, exitORPort = getRelayInfo(path[-1]) + self.lines[0].setExit(exitIp, exitORPort, path[-1]) + + for i in range(len(path)): + relayFingerprint = path[i] + relayIp, relayOrPort = getRelayInfo(relayFingerprint) + + 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 [uiTools.DrawEntry("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 getListingEntry(self, width, currentTime, listingType): + """ + Provides the DrawEntry 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: + # bracketing (3 characters) + # placementLabel (14 characters) + # gap between etc and placement label (5 characters) + + if self.isLast: bracket = (curses.ACS_LLCORNER, curses.ACS_HLINE, ord(' ')) + else: bracket = (curses.ACS_VLINE, ord(' '), ord(' ')) + baselineSpace = len(bracket) + 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) + 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() + + drawEntry = uiTools.DrawEntry("%-14s" % self.placementLabel, lineFormat) + drawEntry = uiTools.DrawEntry(" " * (width - baselineSpace - len(dst) - len(etc) + 5), lineFormat, drawEntry) + drawEntry = uiTools.DrawEntry(dst + etc, lineFormat, drawEntry) + drawEntry = uiTools.DrawEntry(bracket, curses.A_NORMAL, drawEntry, lockFormat = True) + return drawEntry + diff --git a/src/cli/connections/connEntry.py b/src/cli/connections/connEntry.py new file mode 100644 index 0000000..ac45656 --- /dev/null +++ b/src/cli/connections/connEntry.py @@ -0,0 +1,850 @@ +""" +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, enum, torTools, uiTools +from cli.connections import entries + +# 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 = {"features.connection.markInitialConnections": 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} + +def loadConfig(config): + config.update(CONFIG) + +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 ORPort when searching for matching + # fingerprints (otherwise the ORPort is assumed to be unknown) + self.isORPort = False + + # 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() + orPort = self.port if self.isORPort else None + myFingerprint = conn.getRelayFingerprint(self.ipAddr, orPort) + + 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.indexOf(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") + + # 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") + myDirPort = conn.getOption("DirPort") + mySocksPort = conn.getOption("SocksPort", "9050") + myCtlPort = conn.getOption("ControlPort") + myHiddenServicePorts = conn.getHiddenServicePorts() + + # the ORListenAddress can overwrite the ORPort + listenAddr = conn.getOption("ORListenAddress") + if listenAddr and ":" in listenAddr: + myOrPort = listenAddr[listenAddr.find(":") + 1:] + + if lPort in (myOrPort, myDirPort): + self.baseType = Category.INBOUND + self.local.isORPort = True + 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.isORPort = True + + 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 DrawEntry 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 = "" + + timeEntry = myListing.getNext() + timeEntry.text = timePrefix + "%5s" % uiTools.getTimeLabel(currentTime - self.startTime, 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: + # 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 = uiTools.DrawEntry(")" + " " * (9 - len(entryType)), lineFormat) + drawEntry = uiTools.DrawEntry(entryType.upper(), lineFormat | curses.A_BOLD, drawEntry) + drawEntry = uiTools.DrawEntry(" (", lineFormat, drawEntry) + drawEntry = uiTools.DrawEntry(" " * timeWidth, lineFormat, drawEntry) + drawEntry = uiTools.DrawEntry(self._getListingContent(width - (12 + timeWidth), listingType), lineFormat, drawEntry) + 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 [uiTools.DrawEntry(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. + """ + + # 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") == "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:] + + # The network status exit policy doesn't exist for older tor versions. + # If unavailable we'll need the full exit policy which is on the + # descriptor (if that's available). + + exitPolicy = "unknown" + if len(nsLines) >= 4 and nsLines[3].startswith("p "): + exitPolicy = nsLines[3][2:].replace(",", ", ") + elif descEntry: + # the descriptor has an individual line for each entry in the exit policy + exitPolicyEntries = [] + + for line in descEntry.split("\n"): + if line.startswith("accept") or line.startswith("reject"): + exitPolicyEntries.append(line.strip()) + + exitPolicy = ", ".join(exitPolicyEntries) + + dirPortLabel = "" if dirPort == "0" else "dirport: %s" % dirPort + lines[2] = "nickname: %-25s orport: %-10s %s" % (nickname, orPort, dirPortLabel) + lines[3] = "published: %s %s" % (pubDate, pubTime) + lines[4] = "flags: %s" % flags.replace(" ", ", ") + lines[5] = "exit policy: %s" % exitPolicy + + if descEntry: + torVersion, platform, contact = "", "", "" + + for descLine in descEntry.split("\n"): + if descLine.startswith("platform"): + # has the tor version and platform, ex: + # platform Tor 0.2.1.29 (r318f470bc5f2ad43) on Linux x86_64 + + torVersion = descLine[13:descLine.find(" ", 13)] + platform = descLine[descLine.rfind(" on ") + 4:] + elif descLine.startswith("contact"): + contact = descLine[8:] + + # clears up some highly common obscuring + for alias in (" at ", " AT "): contact = contact.replace(alias, "@") + for alias in (" dot ", " DOT "): contact = contact.replace(alias, ".") + + break # contact lines come after the platform + + lines[3] = "%-35s os: %-14s version: %s" % (lines[3], platform, torVersion) + + # contact information is an optional field + if contact: lines[6] = "contact: %s" % contact + else: + allMatches = conn.getRelayFingerprint(self.foreign.getIpAddr(), getAllMatches = True) + + if allMatches: + # multiple matches + lines[2] = "Multiple matches, possible fingerprints are:" + + for i in range(len(allMatches)): + isLastLine = i == 3 + + relayPort, relayFingerprint = allMatches[i] + lineText = "%i. or port: %-5s fingerprint: %s" % (i, relayPort, relayFingerprint) + + # if there's multiple lines remaining at the end then give a count + remainingRelays = len(allMatches) - i + if isLastLine and remainingRelays > 1: + lineText = "... %i more" % remainingRelays + + lines[3 + i] = lineText + + if isLastLine: break + else: + # no consensus entry for this ip address + lines[2] = "No consensus data found" + + # crops any lines that are too long + for i in range(len(lines)): + lines[i] = uiTools.cropStr(lines[i], width - 2) + + return lines + + def getDestinationLabel(self, maxLength, includeLocale=False, includeHostname=False): + """ + Provides a short description of the destination. This is made up of two + components, the base <ip addr>:<port> and an extra piece of information in + parentheses. The IP address is scrubbed from private connections. + + Extra information is... + - the port's purpose for exit connections + - the locale and/or hostname if set to do so, the address isn't private, + and isn't on the local network + - nothing otherwise + + Arguments: + maxLength - maximum length of the string returned + includeLocale - possibly includes the locale + includeHostname - possibly includes the hostname + """ + + # the port and port derived data can be hidden by config or without includePort + includePort = self.includePort and (CONFIG["features.connection.showExitPort"] or self.getType() != Category.EXIT) + + # destination of the connection + ipLabel = "<scrubbed>" if self.isPrivate() else self.foreign.getIpAddr() + portLabel = ":%s" % self.foreign.getPort() if includePort else "" + dstAddress = ipLabel + portLabel + + # Only append the extra info if there's at least a couple characters of + # space (this is what's needed for the country codes). + if len(dstAddress) + 5 <= maxLength: + spaceAvailable = maxLength - len(dstAddress) - 3 + + if self.getType() == Category.EXIT and includePort: + purpose = connections.getPortUsage(self.foreign.getPort()) + + if purpose: + # BitTorrent is a common protocol to truncate, so just use "Torrent" + # if there's not enough room. + if len(purpose) > spaceAvailable and purpose == "BitTorrent": + purpose = "Torrent" + + # crops with a hyphen if too long + purpose = uiTools.cropStr(purpose, spaceAvailable, endType = uiTools.Ending.HYPHEN) + + dstAddress += " (%s)" % purpose + elif not connections.isIpAddressPrivate(self.foreign.getIpAddr()): + extraInfo = [] + conn = torTools.getConn() + + if includeLocale and not conn.isGeoipUnavailable(): + foreignLocale = self.foreign.getLocale("??") + extraInfo.append(foreignLocale) + spaceAvailable -= len(foreignLocale) + 2 + + if includeHostname: + dstHostname = self.foreign.getHostname() + + if dstHostname: + # determines the full space available, taking into account the ", " + # dividers if there's multiple pieces of extra data + + maxHostnameSpace = spaceAvailable - 2 * len(extraInfo) + dstHostname = uiTools.cropStr(dstHostname, maxHostnameSpace) + extraInfo.append(dstHostname) + spaceAvailable -= len(dstHostname) + + if extraInfo: + dstAddress += " (%s)" % ", ".join(extraInfo) + + return dstAddress[:maxLength] + diff --git a/src/cli/connections/connPanel.py b/src/cli/connections/connPanel.py new file mode 100644 index 0000000..569f57c --- /dev/null +++ b/src/cli/connections/connPanel.py @@ -0,0 +1,398 @@ +""" +Listing of the currently established connections tor has made. +""" + +import time +import curses +import threading + +from cli.connections import entries, connEntry, circEntry +from util import connections, enum, panel, torTools, uiTools + +DEFAULT_CONFIG = {"features.connection.resolveApps": True, + "features.connection.listingType": 0, + "features.connection.refreshRate": 5} + +# 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") + +DEFAULT_SORT_ORDER = (entries.SortAttr.CATEGORY, entries.SortAttr.LISTING, entries.SortAttr.UPTIME) + +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, config=None): + panel.Panel.__init__(self, stdscr, "conn", 0) + threading.Thread.__init__(self) + self.setDaemon(True) + + self._sortOrdering = DEFAULT_SORT_ORDER + self._config = dict(DEFAULT_CONFIG) + + if config: + config.update(self._config, { + "features.connection.listingType": (0, len(Listing.values()) - 1), + "features.connection.refreshRate": 1}) + + sortFields = entries.SortAttr.values() + customOrdering = config.getIntCSV("features.connection.order", None, 3, 0, len(sortFields)) + + if customOrdering: + self._sortOrdering = [sortFields[i] for i in customOrdering] + + self._listingType = Listing.values()[self._config["features.connection.listingType"]] + 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._isPaused = True # prevents updates if true + self._pauseTime = None # time when the panel was paused + self._halt = False # terminates thread if true + self._cond = threading.Condition() # used for pausing the thread + self.valsLock = threading.RLock() + + # 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 + + self._update() # populates initial entries + self._resolveApps(False) # resolves initial applications + + # 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 + torTools.getConn().addStatusListener(self.torStateListener) + + def torStateListener(self, conn, eventType): + """ + Freezes the connection contents when Tor stops. + + Arguments: + conn - tor controller + eventType - type of event detected + """ + + self._isTorRunning = eventType == torTools.State.INIT + + if self._isPaused or not self._isTorRunning: + if not self._pauseTime: self._pauseTime = time.time() + else: self._pauseTime = None + + self.redraw(True) + + def setPaused(self, isPause): + """ + If true, prevents the panel from updating. + """ + + if not self._isPaused == isPause: + self._isPaused = isPause + + if isPause or not self._isTorRunning: + if not self._pauseTime: self._pauseTime = time.time() + else: self._pauseTime = None + + # redraws so the display reflects any changes between the last update + # and being paused + self.redraw(True) + + 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: self._sortOrdering = ordering + self._entries.sort(key=lambda i: (i.getSortValues(self._sortOrdering, self._listingType))) + + self._entryLines = [] + for entry in self._entries: + self._entryLines += entry.getLines() + self.valsLock.release() + + def setListingType(self, listingType): + """ + Sets the priority information presented by the panel. + + Arguments: + listingType - Listing instance for the primary information to be shown + """ + + self.valsLock.acquire() + self._listingType = listingType + + # if we're sorting by the listing then we need to resort + if entries.SortAttr.LISTING in self._sortOrdering: + self.setSortOrder() + + self.valsLock.release() + + def handleKey(self, key): + self.valsLock.acquire() + + 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) + + self.valsLock.release() + + def run(self): + """ + Keeps connections listing updated, checking for new entries at a set rate. + """ + + lastDraw = time.time() - 1 + while not self._halt: + currentTime = time.time() + + if self._isPaused or not self._isTorRunning or currentTime - lastDraw < self._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) / self._config["features.connection.refreshRate"] + lastDraw += self._config["features.connection.refreshRate"] * drawTicks + + def draw(self, width, height): + self.valsLock.acquire() + + # 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._scroller.getCursorSelection(self._entryLines) + + # draws the detail panel if currently displaying it + if self._showDetails: + # 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)): + drawEntries[i].render(self, 1 + i, 2) + + # title label with connection counts + title = "Connection Details:" if self._showDetails else self._title + self.addstr(0, 0, title, curses.A_STANDOUT) + + scrollOffset = 1 + if isScrollbarVisible: + scrollOffset = 3 + self.addScrollBar(scrollLoc, scrollLoc + height - detailPanelOffset - 1, len(self._entryLines), 1 + detailPanelOffset) + + currentTime = self._pauseTime if self._pauseTime else 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 + + drawEntry = entryLine.getListingEntry(width - scrollOffset, currentTime, self._listingType) + drawLine = lineNum + detailPanelOffset + 1 - scrollLoc + drawEntry.render(self, drawLine, scrollOffset, extraFormat) + 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. + """ + + connResolver = connections.getResolver("tor") + currentResolutionCount = connResolver.getResolutionCount() + self.appResolveSinceUpdate = False + + if self._lastResourceFetch != currentResolutionCount: + 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) + if newConnEntry.getLines()[0].getType() != connEntry.Category.CIRCUIT: + newEntries.append(newConnEntry) + + 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 = connEntry.Category.values() + 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 self._config["features.connection.resolveApps"]: return + unresolvedLines = [l for l in self._entryLines if isinstance(l, connEntry.ConnectionLine) and l.isUnresolvedApp()] + + # get the ports used for unresolved applications + appPorts = [] + + for line in unresolvedLines: + appConn = line.local if line.getType() == connEntry.Category.HIDDEN else line.foreign + appPorts.append(appConn.getPort()) + + # Queue up resolution for the unresolved ports (skips if it's still working + # on the last query). + if appPorts and not self._appResolver.isResolving: + self._appResolver.resolve(appPorts) + + # Fetches results. If the query finishes quickly then this is what we just + # asked for, otherwise these belong to an earlier resolution. + # + # The application resolver might have given up querying (for instance, if + # the lsof lookups aren't working on this platform or lacks permissions). + # The isAppResolving flag lets the unresolved entries indicate if there's + # a lookup in progress for them or not. + + appResults = self._appResolver.getResults(0.2) + + for line in unresolvedLines: + isLocal = line.getType() == connEntry.Category.HIDDEN + linePort = line.local.getPort() if isLocal else line.foreign.getPort() + + if linePort in appResults: + # sets application attributes if there's a result with this as the + # inbound port + for inboundPort, outboundPort, cmd, pid in appResults[linePort]: + appPort = outboundPort if isLocal else inboundPort + + if linePort == appPort: + line.appName = cmd + line.appPid = pid + line.isAppResolving = False + else: + line.isAppResolving = self._appResolver.isResolving + + if flagQuery: + self.appResolveSinceUpdate = True + diff --git a/src/cli/connections/entries.py b/src/cli/connections/entries.py new file mode 100644 index 0000000..6b24412 --- /dev/null +++ b/src/cli/connections/entries.py @@ -0,0 +1,164 @@ +""" +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 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 getListingEntry(self, width, currentTime, listingType): + """ + Provides a DrawEntry instance 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 DrawEntry instances with detailed information for this + connection. + + Arguments: + width - available space to display in + """ + + if self._detailsCacheArgs != width: + self._detailsCache = self._getDetails(width) + self._detailsCacheArgs = width + + return self._detailsCache + + def _getDetails(self, width): + # implementation of getDetails + return [] + + def resetDisplay(self): + """ + Flushes cached display results. + """ + + self._listingCacheArgs = (None, None) + self._detailsCacheArgs = None + diff --git a/src/cli/controller.py b/src/cli/controller.py new file mode 100644 index 0000000..2afbf6a --- /dev/null +++ b/src/cli/controller.py @@ -0,0 +1,1584 @@ +#!/usr/bin/env python +# controller.py -- arm interface (curses monitor for relay status) +# Released under the GPL v3 (http://www.gnu.org/licenses/gpl.html) + +""" +Curses (terminal) interface for the arm relay status monitor. +""" + +import os +import re +import math +import time +import curses +import curses.textpad +import socket +from TorCtl import TorCtl + +import headerPanel +import graphing.graphPanel +import logPanel +import configPanel +import torrcPanel +import descriptorPopup + +import cli.connections.connPanel +import cli.connections.connEntry +import cli.connections.entries +from util import conf, log, connections, hostnames, panel, sysTools, torConfig, torTools, uiTools +import graphing.bandwidthStats +import graphing.connStats +import graphing.resourceStats + +CONFIRM_QUIT = True +REFRESH_RATE = 5 # seconds between redrawing screen +MAX_REGEX_FILTERS = 5 # maximum number of previous regex filters that'll be remembered + +# enums for message in control label +CTL_HELP, CTL_PAUSED = range(2) + +# panel order per page +PAGE_S = ["header", "control", "popup"] # sticky (ie, always available) page +PAGES = [ + ["graph", "log"], + ["conn"], + ["config"], + ["torrc"]] + +PAUSEABLE = ["header", "graph", "log", "conn"] + +CONFIG = {"log.torrc.readFailed": log.WARN, + "features.graph.type": 1, + "features.config.prepopulateEditValues": True, + "queries.refreshRate.rate": 5, + "log.torEventTypeUnrecognized": log.NOTICE, + "features.graph.bw.prepopulate": True, + "log.startTime": log.INFO, + "log.refreshRate": log.DEBUG, + "log.highCpuUsage": log.WARN, + "log.configEntryUndefined": log.NOTICE, + "log.torrc.validation.torStateDiffers": log.WARN, + "log.torrc.validation.unnecessaryTorrcEntries": log.NOTICE} + +class ControlPanel(panel.Panel): + """ Draws single line label for interface controls. """ + + def __init__(self, stdscr, isBlindMode): + panel.Panel.__init__(self, stdscr, "control", 0, 1) + self.msgText = CTL_HELP # message text to be displyed + self.msgAttr = curses.A_NORMAL # formatting attributes + self.page = 1 # page number currently being displayed + self.resolvingCounter = -1 # count of resolver when starting (-1 if we aren't working on a batch) + self.isBlindMode = isBlindMode + + def setMsg(self, msgText, msgAttr=curses.A_NORMAL): + """ + Sets the message and display attributes. If msgType matches CTL_HELP or + CTL_PAUSED then uses the default message for those statuses. + """ + + self.msgText = msgText + self.msgAttr = msgAttr + + def draw(self, width, height): + msgText = self.msgText + msgAttr = self.msgAttr + barTab = 2 # space between msgText and progress bar + barWidthMax = 40 # max width to progress bar + barWidth = -1 # space between "[ ]" in progress bar (not visible if -1) + barProgress = 0 # cells to fill + + if msgText == CTL_HELP: + msgAttr = curses.A_NORMAL + + if self.resolvingCounter != -1: + if hostnames.isPaused() or not hostnames.isResolving(): + # done resolving dns batch + self.resolvingCounter = -1 + curses.halfdelay(REFRESH_RATE * 10) # revert to normal refresh rate + else: + batchSize = hostnames.getRequestCount() - self.resolvingCounter + entryCount = batchSize - hostnames.getPendingCount() + if batchSize > 0: progress = 100 * entryCount / batchSize + else: progress = 0 + + additive = "or l " if self.page == 2 else "" + batchSizeDigits = int(math.log10(batchSize)) + 1 + entryCountLabel = ("%%%ii" % batchSizeDigits) % entryCount + #msgText = "Resolving hostnames (%i / %i, %i%%) - press esc %sto cancel" % (entryCount, batchSize, progress, additive) + msgText = "Resolving hostnames (press esc %sto cancel) - %s / %i, %2i%%" % (additive, entryCountLabel, batchSize, progress) + + barWidth = min(barWidthMax, width - len(msgText) - 3 - barTab) + barProgress = barWidth * entryCount / batchSize + + if self.resolvingCounter == -1: + currentPage = self.page + pageCount = len(PAGES) + + if self.isBlindMode: + if currentPage >= 2: currentPage -= 1 + pageCount -= 1 + + msgText = "page %i / %i - q: quit, p: pause, h: page help" % (currentPage, pageCount) + elif msgText == CTL_PAUSED: + msgText = "Paused" + msgAttr = curses.A_STANDOUT + + self.addstr(0, 0, msgText, msgAttr) + if barWidth > -1: + xLoc = len(msgText) + barTab + self.addstr(0, xLoc, "[", curses.A_BOLD) + self.addstr(0, xLoc + 1, " " * barProgress, curses.A_STANDOUT | uiTools.getColor("red")) + self.addstr(0, xLoc + barWidth + 1, "]", curses.A_BOLD) + +class Popup(panel.Panel): + """ + Temporarily providing old panel methods until permanent workaround for popup + can be derrived (this passive drawing method is horrible - I'll need to + provide a version using the more active repaint design later in the + revision). + """ + + def __init__(self, stdscr, height): + panel.Panel.__init__(self, stdscr, "popup", 0, height) + + # The following methods are to emulate old panel functionality (this was the + # only implementations to use these methods and will require a complete + # rewrite when refactoring gets here) + def clear(self): + if self.win: + self.isDisplaced = self.top > self.win.getparyx()[0] + if not self.isDisplaced: self.win.erase() + + def refresh(self): + if self.win and not self.isDisplaced: self.win.refresh() + + def recreate(self, stdscr, newWidth=-1, newTop=None): + self.setParent(stdscr) + self.setWidth(newWidth) + if newTop != None: self.setTop(newTop) + + newHeight, newWidth = self.getPreferredSize() + if newHeight > 0: + self.win = self.parent.subwin(newHeight, newWidth, self.top, 0) + elif self.win == None: + # don't want to leave the window as none (in very edge cases could cause + # problems) - rather, create a displaced instance + self.win = self.parent.subwin(1, newWidth, 0, 0) + + self.maxY, self.maxX = self.win.getmaxyx() + +def addstr_wrap(panel, y, x, text, formatting, startX = 0, endX = -1, maxY = -1): + """ + Writes text with word wrapping, returning the ending y/x coordinate. + y: starting write line + x: column offset from startX + text / formatting: content to be written + startX / endX: column bounds in which text may be written + """ + + # moved out of panel (trying not to polute new code!) + # TODO: unpleaseantly complex usage - replace with something else when + # rewriting confPanel and descriptorPopup (the only places this is used) + if not text: return (y, x) # nothing to write + if endX == -1: endX = panel.maxX # defaults to writing to end of panel + if maxY == -1: maxY = panel.maxY + 1 # defaults to writing to bottom of panel + lineWidth = endX - startX # room for text + while True: + if len(text) > lineWidth - x - 1: + chunkSize = text.rfind(" ", 0, lineWidth - x) + writeText = text[:chunkSize] + text = text[chunkSize:].strip() + + panel.addstr(y, x + startX, writeText, formatting) + y, x = y + 1, 0 + if y >= maxY: return (y, x) + else: + panel.addstr(y, x + startX, text, formatting) + return (y, x + len(text)) + +class sighupListener(TorCtl.PostEventListener): + """ + Listens for reload signal (hup), which is produced by: + pkill -sighup tor + causing the torrc and internal state to be reset. + """ + + def __init__(self): + TorCtl.PostEventListener.__init__(self) + self.isReset = False + + def msg_event(self, event): + self.isReset |= event.level == "NOTICE" and event.msg.startswith("Received reload signal (hup)") + +def setPauseState(panels, monitorIsPaused, currentPage, overwrite=False): + """ + Resets the isPaused state of panels. If overwrite is True then this pauses + reguardless of the monitor is paused or not. + """ + + for key in PAUSEABLE: panels[key].setPaused(overwrite or monitorIsPaused or (key not in PAGES[currentPage] and key not in PAGE_S)) + +def showMenu(stdscr, popup, title, options, initialSelection): + """ + 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. If initialSelection is -1 then the first + option is used and the carrot indicating past selection is ommitted. + """ + + selection = initialSelection if initialSelection != -1 else 0 + + if popup.win: + if not panel.CURSES_LOCK.acquire(False): return -1 + try: + # TODO: should pause interface (to avoid event accumilation) + curses.cbreak() # wait indefinitely for key presses (no timeout) + + # uses smaller dimentions more fitting for small content + popup.height = len(options) + 2 + + newWidth = max([len(label) for label in options]) + 9 + popup.recreate(stdscr, newWidth) + + key = 0 + while not uiTools.isSelectionKey(key): + popup.clear() + 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 == initialSelection else " " + popup.addstr(i + 1, 2, tab) + popup.addstr(i + 1, 4, " %s " % label, format) + + popup.refresh() + key = stdscr.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 + + # reverts popup dimensions and conn panel label + popup.height = 9 + popup.recreate(stdscr, 80) + + curses.halfdelay(REFRESH_RATE * 10) # reset normal pausing behavior + finally: + panel.CURSES_LOCK.release() + + return selection + +def showSortDialog(stdscr, panels, isPaused, page, titleLabel, 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: + stdscr, panels, isPaused, page - boiler plate arguments of the controller + (should be refactored away when rewriting) + + titleLabel - title displayed for the popup window + options - ordered listing of option labels + oldSelection - current ordering + optionColors - mappings of options to their color + + """ + + panel.CURSES_LOCK.acquire() + newSelections = [] # new ordering + + try: + setPauseState(panels, isPaused, page, True) + curses.cbreak() # wait indefinitely for key presses (no timeout) + + popup = panels["popup"] + cursorLoc = 0 # index of highlighted option + + # label for the inital ordering + formattedPrevListing = [] + for sortType in oldSelection: + colorStr = optionColors.get(sortType, "white") + formattedPrevListing.append("<%s>%s</%s>" % (colorStr, sortType, colorStr)) + prevOrderingLabel = "<b>Current Order: %s</b>" % ", ".join(formattedPrevListing) + + selectionOptions = list(options) + selectionOptions.append("Cancel") + + while len(newSelections) < len(oldSelection): + popup.clear() + popup.win.box() + popup.addstr(0, 0, titleLabel, curses.A_STANDOUT) + popup.addfstr(1, 2, prevOrderingLabel) + + # provides new ordering + formattedNewListing = [] + for sortType in newSelections: + colorStr = optionColors.get(sortType, "white") + formattedNewListing.append("<%s>%s</%s>" % (colorStr, sortType, colorStr)) + newOrderingLabel = "<b>New Order: %s</b>" % ", ".join(formattedNewListing) + popup.addfstr(2, 2, newOrderingLabel) + + # 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)): + popup.addstr(row, col * 19 + 2, selectionOptions[i], curses.A_STANDOUT if cursorLoc == i else curses.A_NORMAL) + col += 1 + if col == 4: row, col = row + 1, 0 + + popup.refresh() + + key = stdscr.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): + # selected entry (the ord of '10' seems needed to pick up enter) + 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 + + setPauseState(panels, isPaused, page) + curses.halfdelay(REFRESH_RATE * 10) # reset normal pausing behavior + finally: + panel.CURSES_LOCK.release() + + if len(newSelections) == len(oldSelection): + return newSelections + else: return None + +def setEventListening(selectedEvents, isBlindMode): + # creates a local copy, note that a suspected python bug causes *very* + # puzzling results otherwise when trying to discard entries (silently + # returning out of this function!) + events = set(selectedEvents) + isLoggingUnknown = "UNKNOWN" in events + + # removes special types only used in arm (UNKNOWN, TORCTL, ARM_DEBUG, etc) + toDiscard = [] + for eventType in events: + if eventType not in logPanel.TOR_EVENT_TYPES.values(): toDiscard += [eventType] + + for eventType in list(toDiscard): events.discard(eventType) + + # adds events unrecognized by arm if we're listening to the 'UNKNOWN' type + if isLoggingUnknown: + events.update(set(logPanel.getMissingEventTypes())) + + setEvents = torTools.getConn().setControllerEvents(list(events)) + + # temporary hack for providing user selected events minus those that failed + # (wouldn't be a problem if I wasn't storing tor and non-tor events together...) + returnVal = list(selectedEvents.difference(torTools.FAILED_EVENTS)) + returnVal.sort() # alphabetizes + return returnVal + +def connResetListener(conn, eventType): + """ + Pauses connection resolution when tor's shut down, and resumes if started + again. + """ + + if connections.isResolverAlive("tor"): + resolver = connections.getResolver("tor") + resolver.setPaused(eventType == torTools.State.CLOSED) + +def selectiveRefresh(panels, page): + """ + This forces a redraw of content on the currently active page (should be done + after changing pages, popups, or anything else that overwrites panels). + """ + + for panelKey in PAGES[page]: + panels[panelKey].redraw(True) + +def drawTorMonitor(stdscr, startTime, loggedEvents, isBlindMode): + """ + Starts arm interface reflecting information on provided control port. + + stdscr - curses window + conn - active Tor control port connection + loggedEvents - types of events to be logged (plus an optional "UNKNOWN" for + otherwise unrecognized events) + """ + + # loads config for various interface components + config = conf.getConfig("arm") + config.update(CONFIG) + graphing.graphPanel.loadConfig(config) + cli.connections.connEntry.loadConfig(config) + + # 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 isBlindMode: + torTools.REQ_EVENTS["CIRC"] = "may cause issues in identifying client connections" + + # pauses/unpauses connection resolution according to if tor's connected or not + torTools.getConn().addStatusListener(connResetListener) + + # TODO: incrementally drop this requirement until everything's using the singleton + conn = torTools.getConn().getTorCtl() + + curses.halfdelay(REFRESH_RATE * 10) # uses getch call as timer for REFRESH_RATE seconds + try: curses.use_default_colors() # allows things like semi-transparent backgrounds (call can fail with ERR) + except curses.error: pass + + # attempts to make the cursor invisible (not supported in all terminals) + try: curses.curs_set(0) + except curses.error: pass + + # attempts to determine tor's current pid (left as None if unresolveable, logging an error later) + torPid = torTools.getConn().getMyPid() + + #try: + # confLocation = conn.get_info("config-file")["config-file"] + # if confLocation[0] != "/": + # # relative path - attempt to add process pwd + # try: + # results = sysTools.call("pwdx %s" % torPid) + # if len(results) == 1 and len(results[0].split()) == 2: confLocation = "%s/%s" % (results[0].split()[1], confLocation) + # except IOError: pass # pwdx call failed + #except (socket.error, TorCtl.ErrorReply, TorCtl.TorCtlClosed): + # confLocation = "" + + # loads the torrc and provides warnings in case of validation errors + loadedTorrc = torConfig.getTorrc() + loadedTorrc.getLock().acquire() + + try: + loadedTorrc.load() + except IOError, exc: + msg = "Unable to load torrc (%s)" % sysTools.getFileErrorMsg(exc) + log.log(CONFIG["log.torrc.readFailed"], msg) + + if loadedTorrc.isLoaded(): + corrections = loadedTorrc.getCorrections() + duplicateOptions, defaultOptions, mismatchLines, missingOptions = [], [], [], [] + + for lineNum, issue, msg in corrections: + if issue == torConfig.ValidationError.DUPLICATE: + duplicateOptions.append("%s (line %i)" % (msg, lineNum + 1)) + elif issue == torConfig.ValidationError.IS_DEFAULT: + defaultOptions.append("%s (line %i)" % (msg, lineNum + 1)) + elif issue == torConfig.ValidationError.MISMATCH: mismatchLines.append(lineNum + 1) + elif issue == torConfig.ValidationError.MISSING: missingOptions.append(msg) + + if duplicateOptions or defaultOptions: + msg = "Unneeded torrc entries found. They've been highlighted in blue on the torrc page." + + if duplicateOptions: + if len(duplicateOptions) > 1: + msg += "\n- entries ignored due to having duplicates: " + else: + msg += "\n- entry ignored due to having a duplicate: " + + duplicateOptions.sort() + msg += ", ".join(duplicateOptions) + + if defaultOptions: + if len(defaultOptions) > 1: + msg += "\n- entries match their default values: " + else: + msg += "\n- entry matches its default value: " + + defaultOptions.sort() + msg += ", ".join(defaultOptions) + + log.log(CONFIG["log.torrc.validation.unnecessaryTorrcEntries"], msg) + + if mismatchLines or missingOptions: + msg = "The torrc differ from what tor's using. You can issue a sighup to reload the torrc values by pressing x." + + if mismatchLines: + if len(mismatchLines) > 1: + msg += "\n- torrc values differ on lines: " + else: + msg += "\n- torrc value differs on line: " + + mismatchLines.sort() + msg += ", ".join([str(val + 1) for val in mismatchLines]) + + if missingOptions: + if len(missingOptions) > 1: + msg += "\n- configuration values are missing from the torrc: " + else: + msg += "\n- configuration value is missing from the torrc: " + + missingOptions.sort() + msg += ", ".join(missingOptions) + + log.log(CONFIG["log.torrc.validation.torStateDiffers"], msg) + + loadedTorrc.getLock().release() + + # minor refinements for connection resolver + if not isBlindMode: + if torPid: + # use the tor pid to help narrow connection results + torCmdName = sysTools.getProcessName(torPid, "tor") + resolver = connections.getResolver(torCmdName, torPid, "tor") + else: + resolver = connections.getResolver("tor") + + # hack to display a better (arm specific) notice if all resolvers fail + connections.RESOLVER_FINAL_FAILURE_MSG += " (connection related portions of the monitor won't function)" + + panels = { + "header": headerPanel.HeaderPanel(stdscr, startTime, config), + "popup": Popup(stdscr, 9), + "graph": graphing.graphPanel.GraphPanel(stdscr), + "log": logPanel.LogPanel(stdscr, loggedEvents, config)} + + # TODO: later it would be good to set the right 'top' values during initialization, + # but for now this is just necessary for the log panel (and a hack in the log...) + + # TODO: bug from not setting top is that the log panel might attempt to draw + # before being positioned - the following is a quick hack til rewritten + panels["log"].setPaused(True) + + panels["conn"] = cli.connections.connPanel.ConnectionPanel(stdscr, config) + + panels["control"] = ControlPanel(stdscr, isBlindMode) + panels["config"] = configPanel.ConfigPanel(stdscr, configPanel.State.TOR, config) + panels["torrc"] = torrcPanel.TorrcPanel(stdscr, torrcPanel.Config.TORRC, config) + + # provides error if pid coulnd't be determined (hopefully shouldn't happen...) + if not torPid: log.log(log.WARN, "Unable to resolve tor pid, abandoning connection listing") + + # statistical monitors for graph + panels["graph"].addStats("bandwidth", graphing.bandwidthStats.BandwidthStats(config)) + panels["graph"].addStats("system resources", graphing.resourceStats.ResourceStats()) + if not isBlindMode: panels["graph"].addStats("connections", graphing.connStats.ConnStats()) + + # sets graph based on config parameter + graphType = CONFIG["features.graph.type"] + if graphType == 0: panels["graph"].setStats(None) + elif graphType == 1: panels["graph"].setStats("bandwidth") + elif graphType == 2 and not isBlindMode: panels["graph"].setStats("connections") + elif graphType == 3: panels["graph"].setStats("system resources") + + # listeners that update bandwidth and log panels with Tor status + sighupTracker = sighupListener() + #conn.add_event_listener(panels["log"]) + conn.add_event_listener(panels["graph"].stats["bandwidth"]) + conn.add_event_listener(panels["graph"].stats["system resources"]) + if not isBlindMode: conn.add_event_listener(panels["graph"].stats["connections"]) + conn.add_event_listener(sighupTracker) + + # prepopulates bandwidth values from state file + if CONFIG["features.graph.bw.prepopulate"]: + isSuccessful = panels["graph"].stats["bandwidth"].prepopulateFromState() + if isSuccessful: panels["graph"].updateInterval = 4 + + # tells Tor to listen to the events we're interested + loggedEvents = setEventListening(loggedEvents, isBlindMode) + #panels["log"].loggedEvents = loggedEvents # strips any that couldn't be set + panels["log"].setLoggedEvents(loggedEvents) # strips any that couldn't be set + + # directs logged TorCtl events to log panel + #TorUtil.loglevel = "DEBUG" + #TorUtil.logfile = panels["log"] + #torTools.getConn().addTorCtlListener(panels["log"].tor_ctl_event) + + # provides a notice about any event types tor supports but arm doesn't + missingEventTypes = logPanel.getMissingEventTypes() + if missingEventTypes: + pluralLabel = "s" if len(missingEventTypes) > 1 else "" + log.log(CONFIG["log.torEventTypeUnrecognized"], "arm doesn't recognize the following event type%s: %s (log 'UNKNOWN' events to see them)" % (pluralLabel, ", ".join(missingEventTypes))) + + # tells revised panels to run as daemons + panels["header"].start() + panels["log"].start() + panels["conn"].start() + + # warns if tor isn't updating descriptors + #try: + # if conn.get_option("FetchUselessDescriptors")[0][1] == "0" and conn.get_option("DirPort")[0][1] == "0": + # warning = """Descriptors won't be updated (causing some connection information to be stale) unless: + #a. 'FetchUselessDescriptors 1' is set in your torrc + #b. the directory service is provided ('DirPort' defined) + #c. or tor is used as a client""" + # log.log(log.WARN, warning) + #except (socket.error, TorCtl.ErrorReply, TorCtl.TorCtlClosed): pass + + isUnresponsive = False # true if it's been over ten seconds since the last BW event (probably due to Tor closing) + isPaused = False # if true updates are frozen + overrideKey = None # immediately runs with this input rather than waiting for the user if set + page = 0 + regexFilters = [] # previously used log regex filters + panels["popup"].redraw(True) # hack to make sure popup has a window instance (not entirely sure why...) + + # provides notice about any unused config keys + for key in config.getUnusedKeys(): + log.log(CONFIG["log.configEntryUndefined"], "Unused configuration entry: %s" % key) + + lastPerformanceLog = 0 # ensures we don't do performance logging too frequently + redrawStartTime = time.time() + + # TODO: popups need to force the panels it covers to redraw (or better, have + # a global refresh function for after changing pages, popups, etc) + + initTime = time.time() - startTime + log.log(CONFIG["log.startTime"], "arm started (initialization took %0.3f seconds)" % initTime) + + # attributes to give a WARN level event if arm's resource usage is too high + isResourceWarningGiven = False + lastResourceCheck = startTime + + lastSize = None + + # sets initial visiblity for the pages + for i in range(len(PAGES)): + isVisible = i == page + for entry in PAGES[i]: panels[entry].setVisible(isVisible) + + # TODO: come up with a nice, clean method for other threads to immediately + # terminate the draw loop and provide a stacktrace + while True: + # tried only refreshing when the screen was resized but it caused a + # noticeable lag when resizing and didn't have an appreciable effect + # on system usage + + panel.CURSES_LOCK.acquire() + try: + redrawStartTime = time.time() + + # if sighup received then reload related information + if sighupTracker.isReset: + #panels["header"]._updateParams(True) + + # other panels that use torrc data + #if not isBlindMode: panels["graph"].stats["connections"].resetOptions(conn) + #panels["graph"].stats["bandwidth"].resetOptions() + + # if bandwidth graph is being shown then height might have changed + if panels["graph"].currentDisplay == "bandwidth": + panels["graph"].setHeight(panels["graph"].stats["bandwidth"].getContentHeight()) + + # TODO: should redraw the torrcPanel + #panels["torrc"].loadConfig() + + # reload the torrc if it's previously been loaded + if loadedTorrc.isLoaded(): + try: + loadedTorrc.load() + if page == 3: panels["torrc"].redraw(True) + except IOError, exc: + msg = "Unable to load torrc (%s)" % sysTools.getFileErrorMsg(exc) + log.log(CONFIG["log.torrc.readFailed"], msg) + + sighupTracker.isReset = False + + # gives panels a chance to take advantage of the maximum bounds + # originally this checked in the bounds changed but 'recreate' is a no-op + # if panel properties are unchanged and checking every redraw is more + # resilient in case of funky changes (such as resizing during popups) + + # hack to make sure header picks layout before using the dimensions below + #panels["header"].getPreferredSize() + + startY = 0 + for panelKey in PAGE_S[:2]: + #panels[panelKey].recreate(stdscr, -1, startY) + panels[panelKey].setParent(stdscr) + panels[panelKey].setWidth(-1) + panels[panelKey].setTop(startY) + startY += panels[panelKey].getHeight() + + panels["popup"].recreate(stdscr, 80, startY) + + for panelSet in PAGES: + tmpStartY = startY + + for panelKey in panelSet: + #panels[panelKey].recreate(stdscr, -1, tmpStartY) + panels[panelKey].setParent(stdscr) + panels[panelKey].setWidth(-1) + panels[panelKey].setTop(tmpStartY) + tmpStartY += panels[panelKey].getHeight() + + # provides a notice if there's been ten seconds since the last BW event + lastHeartbeat = torTools.getConn().getHeartbeat() + if torTools.getConn().isAlive() and "BW" in torTools.getConn().getControllerEvents() and lastHeartbeat != 0: + if not isUnresponsive and (time.time() - lastHeartbeat) >= 10: + isUnresponsive = True + log.log(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.log(log.NOTICE, "Relay resumed") + + # TODO: part two of hack to prevent premature drawing by log panel + if page == 0 and not isPaused: panels["log"].setPaused(False) + + # I haven't the foggiest why, but doesn't work if redrawn out of order... + for panelKey in (PAGE_S + PAGES[page]): + # redrawing popup can result in display flicker when it should be hidden + if panelKey != "popup": + newSize = stdscr.getmaxyx() + isResize = lastSize != newSize + lastSize = newSize + + if panelKey in ("header", "graph", "log", "config", "torrc", "conn2"): + # revised panel (manages its own content refreshing) + panels[panelKey].redraw(isResize) + else: + panels[panelKey].redraw(True) + + stdscr.refresh() + + currentTime = time.time() + if currentTime - lastPerformanceLog >= CONFIG["queries.refreshRate.rate"]: + cpuTotal = sum(os.times()[:3]) + pythonCpuAvg = cpuTotal / (currentTime - startTime) + sysCallCpuAvg = sysTools.getSysCpuUsage() + totalCpuAvg = pythonCpuAvg + sysCallCpuAvg + + if sysCallCpuAvg > 0.00001: + log.log(CONFIG["log.refreshRate"], "refresh rate: %0.3f seconds, average cpu usage: %0.3f%% (python), %0.3f%% (system calls), %0.3f%% (total)" % (currentTime - redrawStartTime, 100 * pythonCpuAvg, 100 * sysCallCpuAvg, 100 * totalCpuAvg)) + else: + # with the proc enhancements the sysCallCpuAvg is usually zero + log.log(CONFIG["log.refreshRate"], "refresh rate: %0.3f seconds, average cpu usage: %0.3f%%" % (currentTime - redrawStartTime, 100 * totalCpuAvg)) + + lastPerformanceLog = currentTime + + # once per minute check if the sustained cpu usage is above 5%, if so + # then give a warning (and if able, some advice for lowering it) + # TODO: disabling this for now (scrolling causes cpu spikes for quick + # redraws, ie this is usually triggered by user input) + if False and not isResourceWarningGiven and currentTime > (lastResourceCheck + 60): + if totalCpuAvg >= 0.05: + msg = "Arm's cpu usage is high (averaging %0.3f%%)." % (100 * totalCpuAvg) + + if not isBlindMode: + msg += " You could lower it by dropping the connection data (running as "arm -b")." + + log.log(CONFIG["log.highCpuUsage"], msg) + isResourceWarningGiven = True + + lastResourceCheck = currentTime + finally: + panel.CURSES_LOCK.release() + + # wait for user keyboard input until timeout (unless an override was set) + if overrideKey: + key = overrideKey + overrideKey = None + else: + key = stdscr.getch() + + if key == ord('q') or key == ord('Q'): + quitConfirmed = not CONFIRM_QUIT + + # provides prompt to confirm that arm should exit + if CONFIRM_QUIT: + panel.CURSES_LOCK.acquire() + try: + setPauseState(panels, isPaused, page, True) + + # provides prompt + panels["control"].setMsg("Are you sure (q again to confirm)?", curses.A_BOLD) + panels["control"].redraw(True) + + curses.cbreak() + confirmationKey = stdscr.getch() + quitConfirmed = confirmationKey in (ord('q'), ord('Q')) + curses.halfdelay(REFRESH_RATE * 10) + + panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) + setPauseState(panels, isPaused, page) + finally: + panel.CURSES_LOCK.release() + + if quitConfirmed: + # quits arm + # very occasionally stderr gets "close failed: [Errno 11] Resource temporarily unavailable" + # this appears to be a python bug: http://bugs.python.org/issue3014 + # (haven't seen this is quite some time... mysteriously resolved?) + + torTools.NO_SPAWN = True # prevents further worker threads from being spawned + + # stops panel daemons + panels["header"].stop() + panels["conn"].stop() + panels["log"].stop() + + panels["header"].join() + panels["conn"].join() + panels["log"].join() + + # joins on utility daemon threads - this might take a moment since + # the internal threadpools being joined might be sleeping + conn = torTools.getConn() + myPid = conn.getMyPid() + + resourceTracker = sysTools.getResourceTracker(myPid) if (myPid and sysTools.isTrackerAlive(myPid)) else None + resolver = connections.getResolver("tor") if connections.isResolverAlive("tor") else None + if resourceTracker: resourceTracker.stop() + if resolver: resolver.stop() # sets halt flag (returning immediately) + hostnames.stop() # halts and joins on hostname worker thread pool + if resourceTracker: resourceTracker.join() + if resolver: resolver.join() # joins on halted resolver + + conn.close() # joins on TorCtl event thread + break + elif key == curses.KEY_LEFT or key == curses.KEY_RIGHT: + # switch page + if key == curses.KEY_LEFT: page = (page - 1) % len(PAGES) + else: page = (page + 1) % len(PAGES) + + # skip connections listing if it's disabled + if page == 1 and isBlindMode: + if key == curses.KEY_LEFT: page = (page - 1) % len(PAGES) + else: page = (page + 1) % len(PAGES) + + # pauses panels that aren't visible to prevent events from accumilating + # (otherwise they'll wait on the curses lock which might get demanding) + setPauseState(panels, isPaused, page) + + # prevents panels on other pages from redrawing + for i in range(len(PAGES)): + isVisible = i == page + for entry in PAGES[i]: panels[entry].setVisible(isVisible) + + panels["control"].page = page + 1 + + # TODO: this redraw doesn't seem necessary (redraws anyway after this + # loop) - look into this when refactoring + panels["control"].redraw(True) + + selectiveRefresh(panels, page) + elif key == ord('p') or key == ord('P'): + # toggles update freezing + panel.CURSES_LOCK.acquire() + try: + isPaused = not isPaused + setPauseState(panels, isPaused, page) + panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) + finally: + panel.CURSES_LOCK.release() + + selectiveRefresh(panels, page) + elif key == ord('x') or key == ord('X'): + # provides prompt to confirm that arm should issue a sighup + panel.CURSES_LOCK.acquire() + try: + setPauseState(panels, isPaused, page, True) + + # provides prompt + panels["control"].setMsg("This will reset Tor's internal state. Are you sure (x again to confirm)?", curses.A_BOLD) + panels["control"].redraw(True) + + curses.cbreak() + confirmationKey = stdscr.getch() + if confirmationKey in (ord('x'), ord('X')): + try: + torTools.getConn().reload() + except IOError, exc: + log.log(log.ERR, "Error detected when reloading tor: %s" % sysTools.getFileErrorMsg(exc)) + + #errorMsg = " (%s)" % str(err) if str(err) else "" + #panels["control"].setMsg("Sighup failed%s" % errorMsg, curses.A_STANDOUT) + #panels["control"].redraw(True) + #time.sleep(2) + + # reverts display settings + curses.halfdelay(REFRESH_RATE * 10) + panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) + setPauseState(panels, isPaused, page) + finally: + panel.CURSES_LOCK.release() + elif key == ord('h') or key == ord('H'): + # displays popup for current page's controls + panel.CURSES_LOCK.acquire() + try: + setPauseState(panels, isPaused, page, True) + + # lists commands + popup = panels["popup"] + popup.clear() + popup.win.box() + popup.addstr(0, 0, "Page %i Commands:" % (page + 1), curses.A_STANDOUT) + + pageOverrideKeys = () + + if page == 0: + graphedStats = panels["graph"].currentDisplay + if not graphedStats: graphedStats = "none" + popup.addfstr(1, 2, "<b>up arrow</b>: scroll log up a line") + popup.addfstr(1, 41, "<b>down arrow</b>: scroll log down a line") + popup.addfstr(2, 2, "<b>m</b>: increase graph size") + popup.addfstr(2, 41, "<b>n</b>: decrease graph size") + popup.addfstr(3, 2, "<b>s</b>: graphed stats (<b>%s</b>)" % graphedStats) + popup.addfstr(3, 41, "<b>i</b>: graph update interval (<b>%s</b>)" % graphing.graphPanel.UPDATE_INTERVALS[panels["graph"].updateInterval][0]) + popup.addfstr(4, 2, "<b>b</b>: graph bounds (<b>%s</b>)" % panels["graph"].bounds.lower()) + popup.addfstr(4, 41, "<b>a</b>: save snapshot of the log") + popup.addfstr(5, 2, "<b>e</b>: change logged events") + + regexLabel = "enabled" if panels["log"].regexFilter else "disabled" + popup.addfstr(5, 41, "<b>f</b>: log regex filter (<b>%s</b>)" % regexLabel) + + hiddenEntryLabel = "visible" if panels["log"].showDuplicates else "hidden" + popup.addfstr(6, 2, "<b>u</b>: duplicate log entries (<b>%s</b>)" % hiddenEntryLabel) + popup.addfstr(6, 41, "<b>c</b>: clear event log") + + pageOverrideKeys = (ord('m'), ord('n'), ord('s'), ord('i'), ord('d'), ord('e'), ord('r'), ord('f'), ord('x')) + if page == 1: + popup.addfstr(1, 2, "<b>up arrow</b>: scroll up a line") + popup.addfstr(1, 41, "<b>down arrow</b>: scroll down a line") + popup.addfstr(2, 2, "<b>page up</b>: scroll up a page") + popup.addfstr(2, 41, "<b>page down</b>: scroll down a page") + + popup.addfstr(3, 2, "<b>enter</b>: edit configuration option") + popup.addfstr(3, 41, "<b>d</b>: raw consensus descriptor") + + listingType = panels["conn"]._listingType.lower() + popup.addfstr(4, 2, "<b>l</b>: listed identity (<b>%s</b>)" % listingType) + + popup.addfstr(4, 41, "<b>s</b>: sort ordering") + + resolverUtil = connections.getResolver("tor").overwriteResolver + if resolverUtil == None: resolverUtil = "auto" + popup.addfstr(5, 2, "<b>u</b>: resolving utility (<b>%s</b>)" % resolverUtil) + + pageOverrideKeys = (ord('d'), ord('l'), ord('s'), ord('u')) + elif page == 2: + popup.addfstr(1, 2, "<b>up arrow</b>: scroll up a line") + popup.addfstr(1, 41, "<b>down arrow</b>: scroll down a line") + popup.addfstr(2, 2, "<b>page up</b>: scroll up a page") + popup.addfstr(2, 41, "<b>page down</b>: scroll down a page") + + strippingLabel = "on" if panels["torrc"].stripComments else "off" + popup.addfstr(3, 2, "<b>s</b>: comment stripping (<b>%s</b>)" % strippingLabel) + + lineNumLabel = "on" if panels["torrc"].showLineNum else "off" + popup.addfstr(3, 41, "<b>n</b>: line numbering (<b>%s</b>)" % lineNumLabel) + + popup.addfstr(4, 2, "<b>r</b>: reload torrc") + popup.addfstr(4, 41, "<b>x</b>: reset tor (issue sighup)") + elif page == 3: + popup.addfstr(1, 2, "<b>up arrow</b>: scroll up a line") + popup.addfstr(1, 41, "<b>down arrow</b>: scroll down a line") + popup.addfstr(2, 2, "<b>page up</b>: scroll up a page") + popup.addfstr(2, 41, "<b>page down</b>: scroll down a page") + popup.addfstr(3, 2, "<b>enter</b>: connection details") + + popup.addstr(7, 2, "Press any key...") + popup.refresh() + + # waits for user to hit a key, if it belongs to a command then executes it + curses.cbreak() + helpExitKey = stdscr.getch() + if helpExitKey in pageOverrideKeys: overrideKey = helpExitKey + curses.halfdelay(REFRESH_RATE * 10) + + setPauseState(panels, isPaused, page) + selectiveRefresh(panels, page) + finally: + panel.CURSES_LOCK.release() + elif page == 0 and (key == ord('s') or key == ord('S')): + # provides menu to pick stats to be graphed + #options = ["None"] + [label for label in panels["graph"].stats.keys()] + options = ["None"] + + # appends stats labels with first letters of each word capitalized + initialSelection, i = -1, 1 + if not panels["graph"].currentDisplay: initialSelection = 0 + graphLabels = panels["graph"].stats.keys() + graphLabels.sort() + for label in graphLabels: + if label == panels["graph"].currentDisplay: initialSelection = i + words = label.split() + options.append(" ".join(word[0].upper() + word[1:] for word in words)) + i += 1 + + # hides top label of the graph panel and pauses panels + if panels["graph"].currentDisplay: + panels["graph"].showLabel = False + panels["graph"].redraw(True) + setPauseState(panels, isPaused, page, True) + + selection = showMenu(stdscr, panels["popup"], "Graphed Stats:", options, initialSelection) + + # reverts changes made for popup + panels["graph"].showLabel = True + setPauseState(panels, isPaused, page) + + # applies new setting + if selection != -1 and selection != initialSelection: + if selection == 0: panels["graph"].setStats(None) + else: panels["graph"].setStats(options[selection].lower()) + + selectiveRefresh(panels, page) + + # TODO: this shouldn't be necessary with the above refresh, but doesn't seem responsive otherwise... + panels["graph"].redraw(True) + elif page == 0 and (key == ord('i') or key == ord('I')): + # provides menu to pick graph panel update interval + options = [label for (label, intervalTime) in graphing.graphPanel.UPDATE_INTERVALS] + + initialSelection = panels["graph"].updateInterval + + #initialSelection = -1 + #for i in range(len(options)): + # if options[i] == panels["graph"].updateInterval: initialSelection = i + + # hides top label of the graph panel and pauses panels + if panels["graph"].currentDisplay: + panels["graph"].showLabel = False + panels["graph"].redraw(True) + setPauseState(panels, isPaused, page, True) + + selection = showMenu(stdscr, panels["popup"], "Update Interval:", options, initialSelection) + + # reverts changes made for popup + panels["graph"].showLabel = True + setPauseState(panels, isPaused, page) + + # applies new setting + if selection != -1: panels["graph"].updateInterval = selection + + selectiveRefresh(panels, page) + elif page == 0 and (key == ord('b') or key == ord('B')): + # uses the next boundary type for graph + panels["graph"].bounds = graphing.graphPanel.Bounds.next(panels["graph"].bounds) + + selectiveRefresh(panels, page) + elif page == 0 and (key == ord('a') or key == ord('A')): + # allow user to enter a path to take a snapshot - abandons if left blank + panel.CURSES_LOCK.acquire() + try: + setPauseState(panels, isPaused, page, True) + + # provides prompt + panels["control"].setMsg("Path to save log snapshot: ") + panels["control"].redraw(True) + + # gets user input (this blocks monitor updates) + pathInput = panels["control"].getstr(0, 27) + + if pathInput: + try: + panels["log"].saveSnapshot(pathInput) + panels["control"].setMsg("Saved: %s" % pathInput, curses.A_STANDOUT) + panels["control"].redraw(True) + time.sleep(2) + except IOError, exc: + panels["control"].setMsg("Unable to save snapshot: %s" % sysTools.getFileErrorMsg(exc), curses.A_STANDOUT) + panels["control"].redraw(True) + time.sleep(2) + + panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) + setPauseState(panels, isPaused, page) + finally: + panel.CURSES_LOCK.release() + + panels["graph"].redraw(True) + elif page == 0 and (key == ord('e') or key == ord('E')): + # allow user to enter new types of events to log - unchanged if left blank + panel.CURSES_LOCK.acquire() + try: + setPauseState(panels, isPaused, page, True) + + # provides prompt + panels["control"].setMsg("Events to log: ") + panels["control"].redraw(True) + + # lists event types + popup = panels["popup"] + popup.height = 11 + popup.recreate(stdscr, 80) + + popup.clear() + popup.win.box() + popup.addstr(0, 0, "Event Types:", curses.A_STANDOUT) + lineNum = 1 + for line in logPanel.EVENT_LISTING.split("\n"): + line = line[6:] + popup.addstr(lineNum, 1, line) + lineNum += 1 + popup.refresh() + + # gets user input (this blocks monitor updates) + eventsInput = panels["control"].getstr(0, 15) + if eventsInput: eventsInput = eventsInput.replace(' ', '') # strips spaces + + # it would be nice to quit on esc, but looks like this might not be possible... + if eventsInput: + try: + expandedEvents = logPanel.expandEvents(eventsInput) + loggedEvents = setEventListening(expandedEvents, isBlindMode) + panels["log"].setLoggedEvents(loggedEvents) + except ValueError, exc: + panels["control"].setMsg("Invalid flags: %s" % str(exc), curses.A_STANDOUT) + panels["control"].redraw(True) + time.sleep(2) + + # reverts popup dimensions + popup.height = 9 + popup.recreate(stdscr, 80) + + panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) + setPauseState(panels, isPaused, page) + finally: + panel.CURSES_LOCK.release() + + panels["graph"].redraw(True) + elif page == 0 and (key == ord('f') or key == ord('F')): + # provides menu to pick previous regular expression filters or to add a new one + # for syntax see: http://docs.python.org/library/re.html#regular-expression-syntax + options = ["None"] + regexFilters + ["New..."] + initialSelection = 0 if not panels["log"].regexFilter else 1 + + # hides top label of the graph panel and pauses panels + if panels["graph"].currentDisplay: + panels["graph"].showLabel = False + panels["graph"].redraw(True) + setPauseState(panels, isPaused, page, True) + + selection = showMenu(stdscr, panels["popup"], "Log Filter:", options, initialSelection) + + # applies new setting + if selection == 0: + panels["log"].setFilter(None) + elif selection == len(options) - 1: + # selected 'New...' option - prompt user to input regular expression + panel.CURSES_LOCK.acquire() + try: + # provides prompt + panels["control"].setMsg("Regular expression: ") + panels["control"].redraw(True) + + # gets user input (this blocks monitor updates) + regexInput = panels["control"].getstr(0, 20) + + if regexInput: + try: + panels["log"].setFilter(re.compile(regexInput)) + if regexInput in regexFilters: regexFilters.remove(regexInput) + regexFilters = [regexInput] + regexFilters + except re.error, exc: + panels["control"].setMsg("Unable to compile expression: %s" % str(exc), curses.A_STANDOUT) + panels["control"].redraw(True) + time.sleep(2) + panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) + finally: + panel.CURSES_LOCK.release() + elif selection != -1: + try: + panels["log"].setFilter(re.compile(regexFilters[selection - 1])) + + # move selection to top + regexFilters = [regexFilters[selection - 1]] + regexFilters + del regexFilters[selection] + except re.error, exc: + # shouldn't happen since we've already checked validity + log.log(log.WARN, "Invalid regular expression ('%s': %s) - removing from listing" % (regexFilters[selection - 1], str(exc))) + del regexFilters[selection - 1] + + if len(regexFilters) > MAX_REGEX_FILTERS: del regexFilters[MAX_REGEX_FILTERS:] + + # reverts changes made for popup + panels["graph"].showLabel = True + setPauseState(panels, isPaused, page) + panels["graph"].redraw(True) + elif page == 0 and key in (ord('n'), ord('N'), ord('m'), ord('M')): + # Unfortunately modifier keys don't work with the up/down arrows (sending + # multiple keycodes. The only exception to this is shift + left/right, + # but for now just gonna use standard characters. + + if key in (ord('n'), ord('N')): + panels["graph"].setGraphHeight(panels["graph"].graphHeight - 1) + else: + # don't grow the graph if it's already consuming the whole display + # (plus an extra line for the graph/log gap) + maxHeight = panels["graph"].parent.getmaxyx()[0] - panels["graph"].top + currentHeight = panels["graph"].getHeight() + + if currentHeight < maxHeight + 1: + panels["graph"].setGraphHeight(panels["graph"].graphHeight + 1) + elif page == 0 and (key == ord('c') or key == ord('C')): + # provides prompt to confirm that arm should clear the log + panel.CURSES_LOCK.acquire() + try: + setPauseState(panels, isPaused, page, True) + + # provides prompt + panels["control"].setMsg("This will clear the log. Are you sure (c again to confirm)?", curses.A_BOLD) + panels["control"].redraw(True) + + curses.cbreak() + confirmationKey = stdscr.getch() + if confirmationKey in (ord('c'), ord('C')): panels["log"].clear() + + # reverts display settings + curses.halfdelay(REFRESH_RATE * 10) + panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) + setPauseState(panels, isPaused, page) + finally: + panel.CURSES_LOCK.release() + elif page == 1 and (key == ord('u') or key == ord('U')): + # provides menu to pick identification resolving utility + options = ["auto"] + connections.Resolver.values() + + currentOverwrite = connections.getResolver("tor").overwriteResolver # enums correspond to indices + if currentOverwrite == None: initialSelection = 0 + else: initialSelection = options.index(currentOverwrite) + + # hides top label of conn panel and pauses panels + panelTitle = panels["conn"]._title + panels["conn"]._title = "" + panels["conn"].redraw(True) + setPauseState(panels, isPaused, page, True) + + selection = showMenu(stdscr, panels["popup"], "Resolver Util:", options, initialSelection) + selectedOption = options[selection] if selection != "auto" else None + + # reverts changes made for popup + panels["conn"]._title = panelTitle + setPauseState(panels, isPaused, page) + + # applies new setting + if selection != -1 and selectedOption != connections.getResolver("tor").overwriteResolver: + connections.getResolver("tor").overwriteResolver = selectedOption + elif page == 1 and key in (ord('d'), ord('D')): + # presents popup for raw consensus data + panel.CURSES_LOCK.acquire() + try: + setPauseState(panels, isPaused, page, True) + curses.cbreak() # wait indefinitely for key presses (no timeout) + panelTitle = panels["conn"]._title + panels["conn"]._title = "" + panels["conn"].redraw(True) + + descriptorPopup.showDescriptorPopup(panels["popup"], stdscr, panels["conn"]) + + panels["conn"]._title = panelTitle + setPauseState(panels, isPaused, page) + curses.halfdelay(REFRESH_RATE * 10) # reset normal pausing behavior + finally: + panel.CURSES_LOCK.release() + elif page == 1 and (key == ord('l') or key == ord('L')): + # provides a menu to pick the primary information we list connections by + options = cli.connections.entries.ListingType.values() + + # dropping the HOSTNAME listing type until we support displaying that content + options.remove(cli.connections.entries.ListingType.HOSTNAME) + + initialSelection = options.index(panels["conn"]._listingType) + + # hides top label of connection panel and pauses the display + panelTitle = panels["conn"]._title + panels["conn"]._title = "" + panels["conn"].redraw(True) + setPauseState(panels, isPaused, page, True) + + selection = showMenu(stdscr, panels["popup"], "List By:", options, initialSelection) + + # reverts changes made for popup + panels["conn"]._title = panelTitle + setPauseState(panels, isPaused, page) + + # applies new setting + if selection != -1 and options[selection] != panels["conn"]._listingType: + panels["conn"].setListingType(options[selection]) + panels["conn"].redraw(True) + elif page == 1 and (key == ord('s') or key == ord('S')): + # set ordering for connection options + titleLabel = "Connection Ordering:" + options = cli.connections.entries.SortAttr.values() + oldSelection = panels["conn"]._sortOrdering + optionColors = dict([(attr, cli.connections.entries.SORT_COLORS[attr]) for attr in options]) + results = showSortDialog(stdscr, panels, isPaused, page, titleLabel, options, oldSelection, optionColors) + + if results: + panels["conn"].setSortOrder(results) + + panels["conn"].redraw(True) + elif page == 2 and (key == ord('c') or key == ord('C')) and False: + # TODO: disabled for now (probably gonna be going with separate pages + # rather than popup menu) + # provides menu to pick config being displayed + #options = [confPanel.CONFIG_LABELS[confType] for confType in range(4)] + options = [] + initialSelection = panels["torrc"].configType + + # hides top label of the graph panel and pauses panels + panels["torrc"].showLabel = False + panels["torrc"].redraw(True) + setPauseState(panels, isPaused, page, True) + + selection = showMenu(stdscr, panels["popup"], "Configuration:", options, initialSelection) + + # reverts changes made for popup + panels["torrc"].showLabel = True + setPauseState(panels, isPaused, page) + + # applies new setting + if selection != -1: panels["torrc"].setConfigType(selection) + + selectiveRefresh(panels, page) + elif page == 2 and (key == ord('w') or key == ord('W')): + # display a popup for saving the current configuration + panel.CURSES_LOCK.acquire() + try: + configLines = torConfig.getCustomOptions(True) + + # lists event types + popup = panels["popup"] + popup.height = len(configLines) + 3 + popup.recreate(stdscr) + displayHeight, displayWidth = panels["popup"].getPreferredSize() + + # displayed options (truncating the labels if there's limited room) + if displayWidth >= 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 + lastIndex = min(displayHeight - 3, len(configLines) - 1) + isOptionLineSeparate = displayWidth < (30 + len(configLines[lastIndex])) + + # if we're showing all the content and have room to display selection + # options besides the text then shrink the popup by a row + if not isOptionLineSeparate and displayHeight == len(configLines) + 3: + popup.height -= 1 + popup.recreate(stdscr) + + 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 = panels["popup"].getPreferredSize() + if (displayHeight, displayWidth) != (newHeight, newWidth): + displayHeight, displayWidth = newHeight, newWidth + popup.recreate(stdscr) + + # if there isn't room to display the popup then cancel it + if displayHeight <= 2: + selection = 2 + break + + popup.clear() + popup.win.box() + popup.addstr(0, 0, "Configuration being saved:", curses.A_STANDOUT) + + visibleConfigLines = displayHeight - 3 if isOptionLineSeparate else displayHeight - 2 + for i in range(visibleConfigLines): + line = uiTools.cropStr(configLines[i], displayWidth - 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 'T' between the lower left and the covered panel's scroll bar + if displayWidth > 1: popup.win.addch(displayHeight - 1, 1, curses.ACS_TTEE) + + # draws selection options (drawn right to left) + drawX = displayWidth - 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(displayHeight - 2, drawX, "[") + popup.addstr(displayHeight - 2, drawX + 1, optionLabel, selectionFormat | curses.A_BOLD) + popup.addstr(displayHeight - 2, drawX + len(optionLabel) + 1, "]") + + drawX -= 1 # space gap between the options + + popup.refresh() + + key = stdscr.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 = torConfig.getTorrc() + try: configLocation = loadedTorrc.getConfigLocation() + except IOError: configLocation = "" + + if selection == 1: + # prompts user for a configuration location + promptMsg = "Save to (esc to cancel): " + panels["control"].setMsg(promptMsg) + panels["control"].redraw(True) + configLocation = panels["control"].getstr(0, len(promptMsg), configLocation) + if configLocation: configLocation = os.path.abspath(configLocation) + + if configLocation: + try: + # make dir if the path doesn't already exist + baseDir = os.path.dirname(configLocation) + if not os.path.exists(baseDir): os.makedirs(baseDir) + + # saves the configuration to the file + configFile = open(configLocation, "w") + configFile.write("\n".join(configLines)) + configFile.close() + + # reloads the cached torrc if overwriting it + if configLocation == loadedTorrc.getConfigLocation(): + try: + loadedTorrc.load() + panels["torrc"]._lastContentHeightArgs = None + except IOError: pass + + msg = "Saved configuration to %s" % configLocation + except (IOError, OSError), exc: + msg = "Unable to save configuration (%s)" % sysTools.getFileErrorMsg(exc) + + panels["control"].setMsg(msg, curses.A_STANDOUT) + panels["control"].redraw(True) + time.sleep(2) + + panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) + + # reverts popup dimensions + popup.height = 9 + popup.recreate(stdscr, 80) + finally: + panel.CURSES_LOCK.release() + + panels["config"].redraw(True) + elif page == 2 and (key == ord('s') or key == ord('S')): + # set ordering for config options + titleLabel = "Config Option Ordering:" + options = [configPanel.FIELD_ATTR[field][0] for field in configPanel.Field.values()] + oldSelection = [configPanel.FIELD_ATTR[field][0] for field in panels["config"].sortOrdering] + optionColors = dict([configPanel.FIELD_ATTR[field] for field in configPanel.Field.values()]) + results = showSortDialog(stdscr, panels, isPaused, page, titleLabel, options, oldSelection, optionColors) + + if results: + # converts labels back to enums + resultEnums = [] + + for label in results: + for entryEnum in configPanel.FIELD_ATTR: + if label == configPanel.FIELD_ATTR[entryEnum][0]: + resultEnums.append(entryEnum) + break + + panels["config"].setSortOrder(resultEnums) + + panels["config"].redraw(True) + elif page == 2 and uiTools.isSelectionKey(key): + # let the user edit the configuration value, unchanged if left blank + panel.CURSES_LOCK.acquire() + try: + setPauseState(panels, isPaused, page, True) + + # provides prompt + selection = panels["config"].getSelection() + configOption = selection.get(configPanel.Field.OPTION) + titleMsg = "%s Value (esc to cancel): " % configOption + panels["control"].setMsg(titleMsg) + panels["control"].redraw(True) + + displayWidth = panels["control"].getPreferredSize()[1] + initialValue = selection.get(configPanel.Field.VALUE) + + # initial input for the text field + initialText = "" + if CONFIG["features.config.prepopulateEditValues"] and initialValue != "<none>": + initialText = initialValue + + newConfigValue = panels["control"].getstr(0, len(titleMsg), initialText) + + # it would be nice to quit on esc, but looks like this might not be possible... + if newConfigValue != None and newConfigValue != initialValue: + conn = torTools.getConn() + + # if the value's a boolean then allow for 'true' and 'false' inputs + if selection.get(configPanel.Field.TYPE) == "Boolean": + if newConfigValue.lower() == "true": newConfigValue = "1" + elif newConfigValue.lower() == "false": newConfigValue = "0" + + try: + if selection.get(configPanel.Field.TYPE) == "LineList": + newConfigValue = newConfigValue.split(",") + + conn.setOption(configOption, newConfigValue) + + # resets the isDefault flag + customOptions = torConfig.getCustomOptions() + selection.fields[configPanel.Field.IS_DEFAULT] = not configOption in customOptions + + panels["config"].redraw(True) + except Exception, exc: + errorMsg = "%s (press any key)" % exc + panels["control"].setMsg(uiTools.cropStr(errorMsg, displayWidth), curses.A_STANDOUT) + panels["control"].redraw(True) + + curses.cbreak() # wait indefinitely for key presses (no timeout) + stdscr.getch() + curses.halfdelay(REFRESH_RATE * 10) + + panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) + setPauseState(panels, isPaused, page) + finally: + panel.CURSES_LOCK.release() + elif page == 3 and key == ord('r') or key == ord('R'): + # reloads torrc, providing a notice if successful or not + loadedTorrc = torConfig.getTorrc() + loadedTorrc.getLock().acquire() + + try: + loadedTorrc.load() + isSuccessful = True + except IOError: + isSuccessful = False + + loadedTorrc.getLock().release() + + #isSuccessful = panels["torrc"].loadConfig(logErrors = False) + #confTypeLabel = confPanel.CONFIG_LABELS[panels["torrc"].configType] + resetMsg = "torrc reloaded" if isSuccessful else "failed to reload torrc" + if isSuccessful: + panels["torrc"]._lastContentHeightArgs = None + panels["torrc"].redraw(True) + + panels["control"].setMsg(resetMsg, curses.A_STANDOUT) + panels["control"].redraw(True) + time.sleep(1) + + panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) + elif page == 0: + panels["log"].handleKey(key) + elif page == 1: + panels["conn"].handleKey(key) + elif page == 2: + panels["config"].handleKey(key) + elif page == 3: + panels["torrc"].handleKey(key) + +def startTorMonitor(startTime, loggedEvents, isBlindMode): + try: + curses.wrapper(drawTorMonitor, startTime, loggedEvents, isBlindMode) + except KeyboardInterrupt: + pass # skip printing stack trace in case of keyboard interrupt + diff --git a/src/cli/descriptorPopup.py b/src/cli/descriptorPopup.py new file mode 100644 index 0000000..cdc959d --- /dev/null +++ b/src/cli/descriptorPopup.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python +# descriptorPopup.py -- popup panel used to show raw consensus data +# Released under the GPL v3 (http://www.gnu.org/licenses/gpl.html) + +import math +import socket +import curses +from TorCtl import TorCtl + +import controller +import 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" + +class PopupProperties: + """ + State attributes of popup window for consensus descriptions. + """ + + def __init__(self): + self.fingerprint = "" + self.entryColor = "white" + self.text = [] + self.scroll = 0 + self.showLineNum = True + + def reset(self, fingerprint, entryColor): + self.fingerprint = fingerprint + self.entryColor = entryColor + self.text = [] + self.scroll = 0 + + if fingerprint == "UNKNOWN": + self.fingerprint = None + self.showLineNum = False + self.text.append(UNRESOLVED_MSG) + else: + conn = torTools.getConn() + + try: + self.showLineNum = True + self.text.append("ns/id/%s" % fingerprint) + self.text += conn.getConsensusEntry(fingerprint).split("\n") + except (socket.error, TorCtl.ErrorReply, TorCtl.TorCtlClosed): + self.text = self.text + [ERROR_MSG, ""] + + try: + descCommand = "desc/id/%s" % fingerprint + self.text.append("desc/id/%s" % fingerprint) + self.text += conn.getDescriptorEntry(fingerprint).split("\n") + except (socket.error, TorCtl.ErrorReply, TorCtl.TorCtlClosed): + self.text = self.text + [ERROR_MSG] + + def handleKey(self, key, height): + if key == curses.KEY_UP: self.scroll = max(self.scroll - 1, 0) + elif key == curses.KEY_DOWN: self.scroll = max(0, min(self.scroll + 1, len(self.text) - height)) + elif key == curses.KEY_PPAGE: self.scroll = max(self.scroll - height, 0) + elif key == curses.KEY_NPAGE: self.scroll = max(0, min(self.scroll + height, len(self.text) - height)) + +def showDescriptorPopup(popup, stdscr, connectionPanel): + """ + 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 + """ + + properties = PopupProperties() + isVisible = True + + if not panel.CURSES_LOCK.acquire(False): return + try: + while isVisible: + selection = connectionPanel._scroller.getCursorSelection(connectionPanel._entryLines) + if not selection: break + fingerprint = selection.foreign.getFingerprint() + entryColor = connections.connEntry.CATEGORY_COLOR[selection.getType()] + properties.reset(fingerprint, entryColor) + + # constrains popup size to match text + width, height = 0, 0 + for line in properties.text: + # width includes content, line number field, and border + lineWidth = len(line) + 5 + if properties.showLineNum: lineWidth += int(math.log10(len(properties.text))) + 1 + width = max(width, lineWidth) + + # tracks number of extra lines that will be taken due to text wrap + height += (lineWidth - 2) / connectionPanel.maxX + + popup.setHeight(min(len(properties.text) + height + 2, connectionPanel.maxY)) + popup.recreate(stdscr, width) + + while isVisible: + draw(popup, properties) + key = stdscr.getch() + + if uiTools.isSelectionKey(key) or key in (ord('d'), ord('D')): + # closes popup + isVisible = False + elif key in (curses.KEY_LEFT, curses.KEY_RIGHT): + # navigation - pass on to connPanel and recreate popup + connectionPanel.handleKey(curses.KEY_UP if key == curses.KEY_LEFT else curses.KEY_DOWN) + break + else: properties.handleKey(key, popup.height - 2) + + popup.setHeight(9) + popup.recreate(stdscr, 80) + finally: + panel.CURSES_LOCK.release() + +def draw(popup, properties): + popup.clear() + popup.win.box() + xOffset = 2 + + if properties.text: + if properties.fingerprint: popup.addstr(0, 0, "Consensus Descriptor (%s):" % properties.fingerprint, curses.A_STANDOUT) + else: popup.addstr(0, 0, "Consensus Descriptor:", curses.A_STANDOUT) + + isEncryption = False # true if line is part of an encryption block + + # checks if first line is in an encryption block + for i in range(0, properties.scroll): + lineText = properties.text[i].strip() + if lineText in SIG_START_KEYS: isEncryption = True + elif lineText in SIG_END_KEYS: isEncryption = False + + pageHeight = popup.maxY - 2 + numFieldWidth = int(math.log10(len(properties.text))) + 1 + lineNum = 1 + for i in range(properties.scroll, min(len(properties.text), properties.scroll + pageHeight)): + lineText = properties.text[i].strip() + + numOffset = 0 # offset for line numbering + if properties.showLineNum: + popup.addstr(lineNum, xOffset, ("%%%ii" % numFieldWidth) % (i + 1), curses.A_BOLD | uiTools.getColor(LINE_NUM_COLOR)) + numOffset = numFieldWidth + 1 + + if lineText: + keyword = lineText.split()[0] # first word of line + remainder = lineText[len(keyword):] + keywordFormat = curses.A_BOLD | uiTools.getColor(properties.entryColor) + remainderFormat = uiTools.getColor(properties.entryColor) + + if lineText.startswith(HEADER_PREFIX[0]) or lineText.startswith(HEADER_PREFIX[1]): + keyword, remainder = lineText, "" + keywordFormat = curses.A_BOLD | uiTools.getColor(HEADER_COLOR) + if lineText == UNRESOLVED_MSG or lineText == ERROR_MSG: + keyword, remainder = lineText, "" + if lineText in SIG_START_KEYS: + keyword, remainder = lineText, "" + isEncryption = True + keywordFormat = curses.A_BOLD | uiTools.getColor(SIG_COLOR) + elif lineText in SIG_END_KEYS: + keyword, remainder = lineText, "" + isEncryption = False + keywordFormat = curses.A_BOLD | uiTools.getColor(SIG_COLOR) + elif isEncryption: + keyword, remainder = lineText, "" + keywordFormat = uiTools.getColor(SIG_COLOR) + + lineNum, xLoc = controller.addstr_wrap(popup, lineNum, 0, keyword, keywordFormat, xOffset + numOffset, popup.maxX - 1, popup.maxY - 1) + lineNum, xLoc = controller.addstr_wrap(popup, lineNum, xLoc, remainder, remainderFormat, xOffset + numOffset, popup.maxX - 1, popup.maxY - 1) + + lineNum += 1 + if lineNum > pageHeight: break + + popup.refresh() + diff --git a/src/cli/graphing/__init__.py b/src/cli/graphing/__init__.py new file mode 100644 index 0000000..2dddaa3 --- /dev/null +++ b/src/cli/graphing/__init__.py @@ -0,0 +1,6 @@ +""" +Graphing panel resources. +""" + +__all__ = ["graphPanel", "bandwidthStats", "connStats", "resourceStats"] + diff --git a/src/cli/graphing/bandwidthStats.py b/src/cli/graphing/bandwidthStats.py new file mode 100644 index 0000000..2864dd8 --- /dev/null +++ b/src/cli/graphing/bandwidthStats.py @@ -0,0 +1,398 @@ +""" +Tracks bandwidth usage of the tor process, expanding to include accounting +stats if they're set. +""" + +import time + +from cli.graphing import graphPanel +from util import log, sysTools, torTools, uiTools + +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)" + +DEFAULT_CONFIG = {"features.graph.bw.transferInBytes": False, + "features.graph.bw.accounting.show": True, + "features.graph.bw.accounting.rate": 10, + "features.graph.bw.accounting.isTimeLong": False, + "log.graph.bw.prepopulateSuccess": log.NOTICE, + "log.graph.bw.prepopulateFailure": log.NOTICE} + +class BandwidthStats(graphPanel.GraphStats): + """ + Uses tor BW events to generate bandwidth usage graph. + """ + + def __init__(self, config=None): + graphPanel.GraphStats.__init__(self) + + self._config = dict(DEFAULT_CONFIG) + if config: + config.update(self._config, {"features.graph.bw.accounting.rate": 1}) + + # 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 + self.resetListener(conn, torTools.State.INIT) # 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") + if readTotal and readTotal.isdigit(): + self.initialPrimaryTotal = int(readTotal) / 1024 # Bytes -> KB + + writeTotal = conn.getInfo("traffic/written") + if writeTotal and writeTotal.isdigit(): + self.initialSecondaryTotal = int(writeTotal) / 1024 # Bytes -> KB + + def resetListener(self, conn, 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 == torTools.State.INIT and self._config["features.graph.bw.accounting.show"]: + self.isAccounting = conn.getInfo('accounting/enabled') == '1' + + 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") + if orPort == "0": return + + # gets the uptime (using the same parameters as the header panel to take + # advantage of caching + uptime = None + queryPid = conn.getMyPid() + if queryPid: + queryParam = ["%cpu", "rss", "%mem", "etime"] + queryCmd = "ps -p %s -o %s" % (queryPid, ",".join(queryParam)) + psCall = sysTools.call(queryCmd, 3600, True) + + 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.log(self._config["log.graph.bw.prepopulateFailure"], msg) + return False + + # get the user's data directory (usually '~/.tor') + dataDir = conn.getOption("DataDirectory") + if not dataDir: + msg = PREPOPULATE_FAILURE_MSG % "data directory not found" + log.log(self._config["log.graph.bw.prepopulateFailure"], 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.log(self._config["log.graph.bw.prepopulateFailure"], 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.log(self._config["log.graph.bw.prepopulateFailure"], 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)" % uiTools.getTimeLabel(missingSec, 0, True) + log.log(self._config["log.graph.bw.prepopulateSuccess"], msg) + + return True + + def bandwidth_event(self, event): + if self.isAccounting and self.isNextTickRedraw(): + if time.time() - self.accountingLastUpdated >= self._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.addfstr(labelingLine + 2, 0, "<b>Accounting (<%s>%s</%s>)</b>" % (hibernateColor, status, hibernateColor)) + + 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.addfstr(labelingLine + 2, 0, "<b>Accounting:</b> 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" % uiTools.getSizeLabel((self.lastPrimary if isPrimary else self.lastSecondary) * 1024, 1, False, self._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") + 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 = self._config["features.graph.bw.transferInBytes"] + + if bwRate and bwBurst: + bwRateLabel = uiTools.getSizeLabel(bwRate, 1, False, labelInBytes) + bwBurstLabel = uiTools.getSizeLabel(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" % uiTools.getSizeLabel(bwObserved, 1, False, labelInBytes)) + elif bwMeasured: + stats.append("measured: %s/s" % uiTools.getSizeLabel(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" % uiTools.getSizeLabel((total / max(1, self.tick + self.prepopulateTicks)) * 1024, 1, False, self._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" % uiTools.getSizeLabel(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") + + # provides a nicely formatted reset time + endInterval = conn.getInfo("accounting/interval-end") + 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 self._config["features.graph.bw.accounting.isTimeLong"]: + queried["resetTime"] = ", ".join(uiTools.getTimeLabels(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") + left = conn.getInfo("accounting/bytes-left") + + 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"] = uiTools.getSizeLabel(read) + queried["written"] = uiTools.getSizeLabel(written) + queried["readLimit"] = uiTools.getSizeLabel(read + readLeft) + queried["writtenLimit"] = uiTools.getSizeLabel(written + writtenLeft) + + self.accountingInfo = queried + self.accountingLastUpdated = time.time() + diff --git a/src/cli/graphing/connStats.py b/src/cli/graphing/connStats.py new file mode 100644 index 0000000..51227b7 --- /dev/null +++ b/src/cli/graphing/connStats.py @@ -0,0 +1,54 @@ +""" +Tracks stats concerning tor's current connections. +""" + +from cli.graphing import graphPanel +from util import connections, torTools + +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, torTools.State.INIT) # initialize port values + conn.addStatusListener(self.resetListener) + + def resetListener(self, conn, eventType): + if eventType == torTools.State.INIT: + self.orPort = conn.getOption("ORPort", "0") + self.dirPort = conn.getOption("DirPort", "0") + self.controlPort = conn.getOption("ControlPort", "0") + + def eventTick(self): + """ + Fetches connection stats from cached information. + """ + + inboundCount, outboundCount = 0, 0 + + for entry in connections.getResolver("tor").getConnections(): + localPort = entry[1] + if localPort in (self.orPort, self.dirPort): inboundCount += 1 + elif localPort == self.controlPort: pass # control connection + else: outboundCount += 1 + + self._processEvent(inboundCount, outboundCount) + + def getTitle(self, width): + return "Connection Count:" + + def getHeaderLabel(self, width, isPrimary): + avg = (self.primaryTotal if isPrimary else self.secondaryTotal) / max(1, self.tick) + if isPrimary: return "Inbound (%s, avg: %s):" % (self.lastPrimary, avg) + else: return "Outbound (%s, avg: %s):" % (self.lastSecondary, avg) + + def getRefreshRate(self): + return 5 + diff --git a/src/cli/graphing/graphPanel.py b/src/cli/graphing/graphPanel.py new file mode 100644 index 0000000..e4b493d --- /dev/null +++ b/src/cli/graphing/graphPanel.py @@ -0,0 +1,407 @@ +""" +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 +from TorCtl import TorCtl + +from util import enum, panel, uiTools + +# 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 + +# used for setting defaults when initializing GraphStats and GraphPanel instances +CONFIG = {"features.graph.height": 7, + "features.graph.interval": 0, + "features.graph.bound": 1, + "features.graph.maxWidth": 150, + "features.graph.showIntermediateBounds": True} + +def loadConfig(config): + config.update(CONFIG, { + "features.graph.height": MIN_GRAPH_HEIGHT, + "features.graph.maxWidth": 1, + "features.graph.interval": (0, len(UPDATE_INTERVALS) - 1), + "features.graph.bound": (0, 2)}) + +class GraphStats(TorCtl.PostEventListener): + """ + 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, isPauseBuffer=False): + """ + Initializes parameters needed to present a graph. + """ + + TorCtl.PostEventListener.__init__(self) + + # panel to be redrawn when updated (set when added to GraphPanel) + self._graphPanel = None + + # mirror instance used to track updates when paused + self.isPaused, self.isPauseBuffer = False, isPauseBuffer + if isPauseBuffer: self._pauseBuffer = None + else: self._pauseBuffer = GraphStats(True) + + # 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] + + 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 not self.isPauseBuffer and not self.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 setPaused(self, isPause): + """ + If true, prevents bandwidth updates from being presented. This is a no-op + if a pause buffer. + """ + + if isPause == self.isPaused or self.isPauseBuffer: return + self.isPaused = isPause + + if self.isPaused: active, inactive = self._pauseBuffer, self + else: active, inactive = self, self._pauseBuffer + self._parameterSwap(active, inactive) + + def bandwidth_event(self, event): + self.eventTick() + + def _parameterSwap(self, active, inactive): + """ + Either overwrites parameters of pauseBuffer or with the current values or + vice versa. This is a helper method for setPaused and should be overwritten + to append with additional parameters that need to be preserved when paused. + """ + + # The pause buffer is constructed as a GraphStats instance which will + # become problematic if this is overridden by any implementations (which + # currently isn't the case). If this happens then the pause buffer will + # need to be of the requester's type (not quite sure how to do this + # gracefully...). + + active.tick = inactive.tick + active.lastPrimary = inactive.lastPrimary + active.lastSecondary = inactive.lastSecondary + active.primaryTotal = inactive.primaryTotal + active.secondaryTotal = inactive.secondaryTotal + active.maxPrimary = dict(inactive.maxPrimary) + active.maxSecondary = dict(inactive.maxSecondary) + active.primaryCounts = copy.deepcopy(inactive.primaryCounts) + active.secondaryCounts = copy.deepcopy(inactive.secondaryCounts) + + def _processEvent(self, primary, secondary): + """ + Includes new stats in graphs and notifies associated GraphPanel of changes. + """ + + if self.isPaused: self._pauseBuffer._processEvent(primary, secondary) + else: + 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: 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 = Bounds.values()[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.showLabel = True # shows top label if true, hides otherwise + self.isPaused = False + + 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 draw(self, width, height): + """ Redraws graph panel """ + + if self.currentDisplay: + param = self.stats[self.currentDisplay] + graphCol = min((width - 10) / 2, param.maxCol) + + primaryColor = uiTools.getColor(param.getColor(True)) + secondaryColor = uiTools.getColor(param.getColor(False)) + + if self.showLabel: 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 - 1) * (self.graphHeight - row - 1) + if not primaryVal in (primaryMinBound, primaryMaxBound): self.addstr(row + 2, 0, "%4i" % primaryVal, primaryColor) + + if secondaryMinBound != secondaryMaxBound: + secondaryVal = (secondaryMaxBound - secondaryMinBound) / (self.graphHeight - 1) * (self.graphHeight - row - 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 = uiTools.getTimeLabel(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 + stats.isPaused = True + self.stats[label] = stats + + 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].setPaused(True) + + if not label: + self.currentDisplay = None + elif label in self.stats.keys(): + self.currentDisplay = label + self.stats[label].setPaused(self.isPaused) + else: raise ValueError("Unrecognized stats label: %s" % label) + + def setPaused(self, isPause): + """ + If true, prevents bandwidth updates from being presented. + """ + + if isPause == self.isPaused: return + self.isPaused = isPause + if self.currentDisplay: self.stats[self.currentDisplay].setPaused(self.isPaused) + diff --git a/src/cli/graphing/resourceStats.py b/src/cli/graphing/resourceStats.py new file mode 100644 index 0000000..f26d5c1 --- /dev/null +++ b/src/cli/graphing/resourceStats.py @@ -0,0 +1,47 @@ +""" +Tracks the system resource usage (cpu and memory) of the tor process. +""" + +from cli.graphing import graphPanel +from util import sysTools, torTools, uiTools + +class ResourceStats(graphPanel.GraphStats): + """ + System resource usage tracker. + """ + + def __init__(self): + graphPanel.GraphStats.__init__(self) + self.queryPid = torTools.getConn().getMyPid() + + 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 = uiTools.getSizeLabel(lastAmount * 1048576, 1) + avgLabel = uiTools.getSizeLabel(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) + + if not resourceTracker.lastQueryFailed(): + primary, _, secondary, _ = resourceTracker.getResourceUsage() + primary *= 100 # decimal percentage to whole numbers + secondary /= 1048576 # translate size to MB so axis labels are short + + self._processEvent(primary, secondary) + diff --git a/src/cli/headerPanel.py b/src/cli/headerPanel.py new file mode 100644 index 0000000..f653299 --- /dev/null +++ b/src/cli/headerPanel.py @@ -0,0 +1,474 @@ +""" +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 + +from util import log, panel, sysTools, torTools, uiTools + +# 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"} + +DEFAULT_CONFIG = {"features.showFdUsage": False, + "log.fdUsageSixtyPercent": log.NOTICE, + "log.fdUsageNinetyPercent": log.WARN} + +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, + 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, config = None): + panel.Panel.__init__(self, stdscr, "header", 0) + threading.Thread.__init__(self) + self.setDaemon(True) + + self._config = dict(DEFAULT_CONFIG) + if config: config.update(self._config) + + self._isTorConnected = True + self._lastUpdate = -1 # time the content was last revised + self._isPaused = False # prevents updates if true + 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 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: + versionColor = VERSION_STATUS_COLORS[self.vals["tor/versionStatus"]] if \ + self.vals["tor/versionStatus"] in VERSION_STATUS_COLORS else "white" + versionStatusMsg = "<%s>%s</%s>" % (versionColor, self.vals["tor/versionStatus"], versionColor) + self.addfstr(0, 43, "Tor %s (%s)" % (self.vals["tor/version"], versionStatusMsg)) + 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) + 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 + entry = "" + 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 len(entry) + len(label) <= leftWidth: entry += label + else: break + else: + # non-relay (client only) + # TODO: not sure what sort of stats to provide... + entry = "<red><b>Relaying Disabled</b></red>" + + if self.vals["tor/isAuthPassword"]: authType = "password" + elif self.vals["tor/isAuthCookie"]: authType = "cookie" + else: authType = "open" + + if len(entry) + 19 + len(self.vals["tor/controlPort"]) + len(authType) <= leftWidth: + authColor = "red" if authType == "open" else "green" + authLabel = "<%s>%s</%s>" % (authColor, authType, authColor) + self.addfstr(1, 0, "%s, Control Port (%s): %s" % (entry, authLabel, self.vals["tor/controlPort"])) + elif len(entry) + 16 + len(self.vals["tor/controlPort"]) <= leftWidth: + self.addstr(1, 0, "%s, Control Port: %s" % (entry, self.vals["tor/controlPort"])) + else: self.addstr(1, 0, entry) + + # Line 3 / Line 1 Right (system usage info) + y, x = (0, leftWidth) if isWide else (2, 0) + if self.vals["stat/rss"] != "0": memoryLabel = uiTools.getSizeLabel(int(self.vals["stat/rss"])) + else: memoryLabel = "0" + + uptimeLabel = "" + if self.vals["tor/startTime"]: + if self._haltTime: + # freeze the uptime when paused or the tor process is stopped + uptimeLabel = uiTools.getShortTimeLabel(self._haltTime - self.vals["tor/startTime"]) + else: + uptimeLabel = uiTools.getShortTimeLabel(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 self._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: + flagLine = "flags: " + for flag in self.vals["tor/flags"]: + flagColor = FLAG_COLORS[flag] if flag in FLAG_COLORS.keys() else "white" + flagLine += "<b><%s>%s</%s></b>, " % (flagColor, flag, flagColor) + + if len(self.vals["tor/flags"]) > 0: flagLine = flagLine[:-2] + else: flagLine += "<b><cyan>none</cyan></b>" + + self.addfstr(2 if isWide else 4, 0, flagLine) + else: + statusTime = torTools.getConn().getStatus()[1] + statusTimeLabel = time.strftime("%H:%M %m/%d/%Y", time.localtime(statusTime)) + self.addfstr(2 if isWide else 4, 0, "<b><red>Tor Disconnected</red></b> (%s)" % 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>" + + # 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() + displayedPolicy = policy.replace("accept", "").replace("reject", "").strip() if isSimple else policy + if policy.startswith("accept"): policy = "<green><b>%s</b></green>" % displayedPolicy + elif policy.startswith("reject"): policy = "<red><b>%s</b></red>" % displayedPolicy + elif policy.startswith("<default>"): policy = "<cyan><b>%s</b></cyan>" % displayedPolicy + policies[i] = policy + + self.addfstr(2, leftWidth, "exit policy: %s" % ", ".join(policies)) + else: + # Client only + # TODO: not sure what information to provide here... + pass + + self.valsLock.release() + + def setPaused(self, isPause): + """ + If true, prevents updates from being presented. + """ + + if not self._isPaused == isPause: + self._isPaused = isPause + if self._isTorConnected: + if isPause: self._haltTime = time.time() + else: self._haltTime = None + + # Redraw now so we'll be displaying the state right when paused + # (otherwise the uptime might be off by a second, and change when + # the panel's redrawn for other reasons). + self.redraw(True) + + 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, conn, eventType): + """ + Updates static parameters on tor reload (sighup) events. + + Arguments: + conn - tor controller + eventType - type of event detected + """ + + if eventType == torTools.State.INIT: + self._isTorConnected = True + if self._isPaused: self._haltTime = time.time() + else: self._haltTime = None + + self._update(True) + self.redraw(True) + elif eventType == torTools.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", "") + self.vals["tor/isAuthPassword"] = conn.getOption("HashedControlPassword") != None + self.vals["tor/isAuthCookie"] = conn.getOption("CookieAuthentication") == "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") + 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] + + pid = conn.getMyPid() + self.vals["tor/pid"] = pid if pid else "" + + 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.log(self._config["log.fdUsageNinetyPercent"], msg) + elif fdPercent >= 60 and not self._isFdSixtyPercentWarned: + self._isFdSixtyPercentWarned = True + log.log(self._config["log.fdUsageSixtyPercent"], msg) + + # ps or proc derived resource usage stats + if self.vals["tor/pid"]: + resourceTracker = sysTools.getResourceTracker(self.vals["tor/pid"]) + + if resourceTracker.lastQueryFailed(): + self.vals["stat/%torCpu"] = "0" + self.vals["stat/rss"] = "0" + self.vals["stat/%mem"] = "0" + else: + cpuUsage, _, memUsage, memUsagePercent = resourceTracker.getResourceUsage() + self._lastResourceFetch = resourceTracker.getRunCount() + self.vals["stat/%torCpu"] = "%0.1f" % (100 * cpuUsage) + self.vals["stat/rss"] = str(memUsage) + self.vals["stat/%mem"] = "%0.1f" % (100 * memUsagePercent) + + # determines the cpu time for the arm process (including user and system + # time of both the primary and child processes) + + totalArmCpuTime, currentTime = sum(os.times()[:3]), time.time() + armCpuDelta = totalArmCpuTime - self._armCpuSampling[0] + armTimeDelta = currentTime - self._armCpuSampling[1] + pythonCpuTime = armCpuDelta / armTimeDelta + sysCallCpuTime = sysTools.getSysCpuUsage() + self.vals["stat/%armCpu"] = "%0.1f" % (100 * (pythonCpuTime + sysCallCpuTime)) + self._armCpuSampling = (totalArmCpuTime, currentTime) + + self._lastUpdate = currentTime + self.valsLock.release() + diff --git a/src/cli/logPanel.py b/src/cli/logPanel.py new file mode 100644 index 0000000..86e680f --- /dev/null +++ b/src/cli/logPanel.py @@ -0,0 +1,1100 @@ +""" +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 time +import os +import curses +import threading + +from TorCtl import TorCtl + +from version import VERSION +from util import conf, log, 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 + 67890 torctl runlevel+ 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 +DEFAULT_CONFIG = {"features.logFile": "", + "features.log.showDateDividers": True, + "features.log.showDuplicateEntries": False, + "features.log.entryDuration": 7, + "features.log.maxLinesPerEntry": 4, + "features.log.prepopulate": True, + "features.log.prepopulateReadLimit": 5000, + "features.log.maxRefreshRate": 300, + "cache.logPanel.size": 1000, + "log.logPanel.prepopulateSuccess": log.INFO, + "log.logPanel.prepopulateFailed": log.WARN, + "log.logPanel.logFileOpened": log.NOTICE, + "log.logPanel.logFileWriteFailed": log.ERR, + "log.logPanel.forceDoubleRedraw": log.DEBUG} + +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 + +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 runlevel and higher (ARM_DEBUG - ARM_ERR) + 67890 - torctl runlevel and higher (TORCTL_DEBUG - TORCTL_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.values()] + torctlRunlevels = ["TORCTL_" + runlevel for runlevel in log.Runlevel.values()] + expandedEvents = set(TOR_EVENT_TYPES.values() + armRunlevels + torctlRunlevels + ["UNKNOWN"]) + break + elif flag == "X": + expandedEvents = set() + break + elif flag in "DINWE1234567890": + # all events for a runlevel and higher + if flag in "DINWE": typePrefix = "" + elif flag in "12345": typePrefix = "ARM_" + elif flag in "67890": typePrefix = "TORCTL_" + + if flag in "D16": runlevelIndex = 0 + elif flag in "I27": runlevelIndex = 1 + elif flag in "N38": runlevelIndex = 2 + elif flag in "W49": runlevelIndex = 3 + elif flag in "E50": runlevelIndex = 4 + + runlevelSet = [typePrefix + runlevel for runlevel in log.Runlevel.values()[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 torctl 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") + + 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.getConfig("arm") + + COMMON_LOG_MESSAGES = {} + for confKey in armConf.getKeys(): + if confKey.startswith("msg."): + eventType = confKey[4:].upper() + messages = armConf.get(confKey, []) + COMMON_LOG_MESSAGES[eventType] = messages + +def getLogFileEntries(runlevels, readLimit = None, addLimit = None, config = 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) + config - configuration parameters related to this panel, uses defaults + if left as None + """ + + startTime = time.time() + if not runlevels: return [] + + if not config: config = DEFAULT_CONFIG + + # 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 = log.Runlevel.values() + 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 = sysTools.call("tail -n %i %s" % (readLimit, loggingLocation)) + if not lines: raise IOError() + else: + logFile = open(loggingLocation, "r") + lines = logFile.readlines() + logFile.close() + except IOError: + msg = "Unable to read tor's log file: %s" % loggingLocation + log.log(config["log.logPanel.prepopulateFailed"], msg) + + 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() + 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(".")] + + # overwrites missing time parameters with the local time (ignoring wday + # and yday since they aren't used) + eventTimeComp = list(time.strptime(timestamp, "%b %d %H:%M:%S")) + eventTimeComp[0] = currentLocalTime.tm_year + 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] + msg = "Read %i entries from tor's log file: %s (read limit: %i, runtime: %0.3f)" % (len(loggedEvents), loggingLocation, readLimit, time.time() - startTime) + log.log(config["log.logPanel.prepopulateSuccess"], msg) + 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 TorEventObserver(TorCtl.PostEventListener): + """ + Listens for all types of events provided by TorCtl, providing an LogEntry + instance to the given callback function. + """ + + def __init__(self, callback): + """ + Tor event listener with the purpose of translating events to nicely + formatted calls of a callback function. + + Arguments: + callback - function accepting a LogEntry, called when an event of these + types occur + """ + + TorCtl.PostEventListener.__init__(self) + self.callback = callback + + def circ_status_event(self, event): + msg = "ID: %-3s STATUS: %-10s PATH: %s" % (event.circ_id, event.status, ", ".join(event.path)) + if event.purpose: msg += " PURPOSE: %s" % event.purpose + if event.reason: msg += " REASON: %s" % event.reason + if event.remote_reason: msg += " REMOTE_REASON: %s" % event.remote_reason + self._notify(event, msg, "yellow") + + def buildtimeout_set_event(self, event): + self._notify(event, "SET_TYPE: %s, TOTAL_TIMES: %s, TIMEOUT_MS: %s, XM: %s, ALPHA: %s, CUTOFF_QUANTILE: %s" % (event.set_type, event.total_times, event.timeout_ms, event.xm, event.alpha, event.cutoff_quantile)) + + def stream_status_event(self, event): + self._notify(event, "ID: %s STATUS: %s CIRC_ID: %s TARGET: %s:%s REASON: %s REMOTE_REASON: %s SOURCE: %s SOURCE_ADDR: %s PURPOSE: %s" % (event.strm_id, event.status, event.circ_id, event.target_host, event.target_port, event.reason, event.remote_reason, event.source, event.source_addr, event.purpose)) + + def or_conn_status_event(self, event): + msg = "STATUS: %-10s ENDPOINT: %-20s" % (event.status, event.endpoint) + if event.age: msg += " AGE: %-3s" % event.age + if event.read_bytes: msg += " READ: %-4i" % event.read_bytes + if event.wrote_bytes: msg += " WRITTEN: %-4i" % event.wrote_bytes + if event.reason: msg += " REASON: %-6s" % event.reason + if event.ncircs: msg += " NCIRCS: %i" % event.ncircs + self._notify(event, msg) + + def stream_bw_event(self, event): + self._notify(event, "ID: %s READ: %s WRITTEN: %s" % (event.strm_id, event.bytes_read, event.bytes_written)) + + def bandwidth_event(self, event): + self._notify(event, "READ: %i, WRITTEN: %i" % (event.read, event.written), "cyan") + + def msg_event(self, event): + self._notify(event, event.msg, RUNLEVEL_EVENT_COLOR[event.level]) + + def new_desc_event(self, event): + idlistStr = [str(item) for item in event.idlist] + self._notify(event, ", ".join(idlistStr)) + + def address_mapped_event(self, event): + self._notify(event, "%s, %s -> %s" % (event.when, event.from_addr, event.to_addr)) + + def ns_event(self, event): + # NetworkStatus params: nickname, idhash, orhash, ip, orport (int), + # dirport (int), flags, idhex, bandwidth, updated (datetime) + msg = ", ".join(["%s (%s)" % (ns.idhex, ns.nickname) for ns in event.nslist]) + self._notify(event, "Listed (%i): %s" % (len(event.nslist), msg), "blue") + + def new_consensus_event(self, event): + msg = ", ".join(["%s (%s)" % (ns.idhex, ns.nickname) for ns in event.nslist]) + self._notify(event, "Listed (%i): %s" % (len(event.nslist), msg), "magenta") + + def unknown_event(self, event): + msg = "(%s) %s" % (event.event_name, event.event_string) + self.callback(LogEntry(event.arrived_at, "UNKNOWN", msg, "red")) + + def _notify(self, event, msg, color="white"): + self.callback(LogEntry(event.arrived_at, event.event_name, msg, color)) + +class LogPanel(panel.Panel, threading.Thread): + """ + Listens for and displays tor, arm, and torctl events. This can prepopulate + from tor's log file if it exists. + """ + + def __init__(self, stdscr, loggedEvents, config=None): + panel.Panel.__init__(self, stdscr, "log", 0) + threading.Thread.__init__(self) + self.setDaemon(True) + + self._config = dict(DEFAULT_CONFIG) + + if config: + config.update(self._config, { + "features.log.maxLinesPerEntry": 1, + "features.log.prepopulateReadLimit": 0, + "features.log.maxRefreshRate": 10, + "cache.logPanel.size": 1000}) + + # collapses duplicate log entries if false, showing only the most recent + self.showDuplicates = self._config["features.log.showDuplicateEntries"] + + self.msgLog = [] # log entries, sorted by the timestamp + self.loggedEvents = loggedEvents # events we're listening to + 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._isPaused = False + self._pauseBuffer = [] # location where messages are buffered if paused + + 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, _pauseBuffer + 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) + + # fetches past tor events from log file, if available + torEventBacklog = [] + if self._config["features.log.prepopulate"]: + setRunlevels = list(set.intersection(set(self.loggedEvents), set(log.Runlevel.values()))) + readLimit = self._config["features.log.prepopulateReadLimit"] + addLimit = self._config["cache.logPanel.size"] + torEventBacklog = getLogFileEntries(setRunlevels, readLimit, addLimit, self._config) + + # adds arm listener and fetches past events + log.LOG_LOCK.acquire() + try: + armRunlevels = [log.DEBUG, log.INFO, log.NOTICE, log.WARN, log.ERR] + log.addListeners(armRunlevels, self._registerArmEvent) + + # gets the set of arm events we're logging + setRunlevels = [] + for i in range(len(armRunlevels)): + if "ARM_" + log.Runlevel.values()[i] in self.loggedEvents: + setRunlevels.append(armRunlevels[i]) + + armEventBacklog = [] + for level, msg, eventTime in log._getEntries(setRunlevels): + armEventEntry = LogEntry(eventTime, "ARM_" + level, msg, RUNLEVEL_EVENT_COLOR[level]) + armEventBacklog.insert(0, armEventEntry) + + # joins armEventBacklog and torEventBacklog chronologically into msgLog + while armEventBacklog or torEventBacklog: + if not armEventBacklog: + self.msgLog.append(torEventBacklog.pop(0)) + elif not torEventBacklog: + self.msgLog.append(armEventBacklog.pop(0)) + elif armEventBacklog[0].timestamp < torEventBacklog[0].timestamp: + self.msgLog.append(torEventBacklog.pop(0)) + else: + self.msgLog.append(armEventBacklog.pop(0)) + finally: + log.LOG_LOCK.release() + + # crops events that are either too old, or more numerous than the caching size + self._trimEvents(self.msgLog) + + # leaving lastContentHeight as being too low causes initialization problems + self.lastContentHeight = len(self.msgLog) + + # adds listeners for tor and torctl events + conn = torTools.getConn() + conn.addEventListener(TorEventObserver(self.registerEvent)) + conn.addTorCtlListener(self._registerTorCtlEvent) + + # opens log file if we'll be saving entries + if self._config["features.logFile"]: + logPath = self._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.log(self._config["log.logPanel.logFileOpened"], "arm %s opening log file (%s)" % (VERSION, logPath)) + except (IOError, OSError), exc: + log.log(self._config["log.logPanel.logFileWriteFailed"], "Unable to write to log file: %s" % sysTools.getFileErrorMsg(exc)) + self.logFile = None + + 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.log(self._config["log.logPanel.logFileWriteFailed"], "Unable to write to log file: %s" % sysTools.getFileErrorMsg(exc)) + self.logFile = None + + if self._isPaused: + self.valsLock.acquire() + self._pauseBuffer.insert(0, event) + self._trimEvents(self._pauseBuffer) + self.valsLock.release() + else: + 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 _registerArmEvent(self, level, msg, eventTime): + eventColor = RUNLEVEL_EVENT_COLOR[level] + self.registerEvent(LogEntry(eventTime, "ARM_%s" % level, msg, eventColor)) + + def _registerTorCtlEvent(self, level, msg): + eventColor = RUNLEVEL_EVENT_COLOR[level] + self.registerEvent(LogEntry(time.time(), "TORCTL_%s" % level, msg, eventColor)) + + 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() + self.loggedEvents = eventTypes + self.redraw(True) + self.valsLock.release() + + 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 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 + """ + + # make dir if the path doesn't already exist + baseDir = os.path.dirname(path) + if not os.path.exists(baseDir): os.makedirs(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): + 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.showDuplicates = not self.showDuplicates + self.redraw(True) + self.valsLock.release() + + def setPaused(self, isPause): + """ + If true, prevents message log from being updated with new events. + """ + + if isPause == self._isPaused: return + + self._isPaused = isPause + if self._isPaused: self._pauseBuffer = [] + else: + self.valsLock.acquire() + self.msgLog = (self._pauseBuffer + self.msgLog)[:self._config["cache.logPanel.size"]] + self.redraw(True) + self.valsLock.release() + + 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. + """ + + self.valsLock.acquire() + self._lastLoggedEvents, self._lastUpdate = list(self.msgLog), time.time() + + # draws the top label + 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 self._config["features.log.showDateDividers"] + eventLog = getDaybreaks(self.msgLog, self._isPaused) if isDatesShown else list(self.msgLog) + if not self.showDuplicates: + deduplicatedLog = getDuplicates(eventLog) + + if deduplicatedLog == None: + msg = "Deduplication took too long. Its current implementation has difficulty handling large logs so disabling it to keep the interface responsive." + log.log(log.WARN, msg) + self.showDuplicates = 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 - 1, dividerAttr) + self.addch(lineCount, width, 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) - 2 + 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 = self._config["features.log.maxLinesPerEntry"] + while displayQueue: + msg, format, includeBreak = displayQueue.pop(0) + drawLine = lineCount + lineOffset + if lineOffset == maxEntriesPerLine: break + + maxMsgSize = width - cursorLoc + 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, 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 - 1, dividerAttr) + self.addch(lineCount, width, 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: + forceRedrawReason = "redrawing the log panel with the corrected content height (%s)" % forceRedrawReason + log.log(self._config["log.logPanel.forceDoubleRedraw"], 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 = self._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) + + def stop(self): + """ + Halts further resolutions and terminates the thread. + """ + + self._cond.acquire() + self._halt = True + self._cond.notifyAll() + self._cond.release() + + 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 torctl): + # - 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 = log.Runlevel.values() + reversedRunlevels.reverse() + for prefix in ("TORCTL_", "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 = self._config["cache.logPanel.size"] + if len(eventListing) > cacheSize: del eventListing[cacheSize:] + + logTTL = self._config["features.log.entryDuration"] + if logTTL > 0: + currentDay = daysSince() + + breakpoint = None # index at which to crop from + for i in range(len(eventListing) - 1, -1, -1): + daysSinceEvent = currentDay - daysSince(eventListing[i].timestamp) + if daysSinceEvent > logTTL: breakpoint = i # older than the ttl + else: break + + # removes entries older than the ttl + if breakpoint != None: del eventListing[breakpoint:] + diff --git a/src/cli/torrcPanel.py b/src/cli/torrcPanel.py new file mode 100644 index 0000000..b7cad86 --- /dev/null +++ b/src/cli/torrcPanel.py @@ -0,0 +1,221 @@ +""" +Panel displaying the torrc or armrc with the validation done against it. +""" + +import math +import curses +import threading + +from util import conf, enum, panel, torConfig, uiTools + +DEFAULT_CONFIG = {"features.config.file.showScrollbars": True, + "features.config.file.maxLinesPerEntry": 8} + +# 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, config=None): + panel.Panel.__init__(self, stdscr, "configFile", 0) + + self._config = dict(DEFAULT_CONFIG) + if config: + config.update(self._config, {"features.config.file.maxLinesPerEntry": 1}) + + self.valsLock = threading.RLock() + self.configType = configType + self.scroll = 0 + self.showLabel = True # shows top label (hides otherwise) + 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 + + def handleKey(self, key): + self.valsLock.acquire() + 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.showLineNum = not self.showLineNum + self._lastContentHeightArgs = None + self.redraw(True) + elif key == ord('s') or key == ord('S'): + self.stripComments = not self.stripComments + self._lastContentHeightArgs = None + self.redraw(True) + + self.valsLock.release() + + 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.getConfig("arm") + confLocation = loadedArmrc.path + renderedContents = list(loadedArmrc.rawContents) + + # 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 self._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.showLabel: + 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 = self._config["features.config.file.maxLinesPerEntry"] + displayQueue = [lineComp[entry] for entry in ("option", "argument", "correction", "comment")] + + while displayQueue: + msg, format = displayQueue.pop(0) + + maxMsgSize, includeBreak = width - cursorLoc, False + if len(msg) >= maxMsgSize: + # message is too long - break it up + if lineOffset == maxLinesPerEntry - 1: + msg = uiTools.cropStr(msg, maxMsgSize) + else: + includeBreak = True + msg, remainder = uiTools.cropStr(msg, maxMsgSize, 4, 4, uiTools.Ending.HYPHEN, True) + displayQueue.insert(0, (remainder.strip(), format)) + + drawLine = displayLine + lineOffset + if msg and drawLine < height and drawLine >= 1: + self.addstr(drawLine, cursorLoc, msg, format) + + # If we're done, and have added content to this line, then start + # further content on the next line. + cursorLoc += len(msg) + includeBreak |= not displayQueue and cursorLoc != lineNumOffset + scrollOffset + + if includeBreak: + lineOffset += 1 + cursorLoc = lineNumOffset + scrollOffset + + displayLine += max(lineOffset, 1) + + if trustLastContentHeight and displayLine >= height: break + + if not trustLastContentHeight: + self._lastContentHeightArgs = (width, height) + newContentHeight = displayLine + self.scroll - 1 + + if self._lastContentHeight != newContentHeight: + self._lastContentHeight = newContentHeight + self.redraw(True) + + self.valsLock.release() + diff --git a/src/interface/__init__.py b/src/interface/__init__.py deleted file mode 100644 index 0f11fc1..0000000 --- a/src/interface/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Panels, popups, and handlers comprising the arm user interface. -""" - -__all__ = ["configPanel", "connPanel", "controller", "descriptorPopup", "headerPanel", "logPanel", "torrcPanel"] - diff --git a/src/interface/configPanel.py b/src/interface/configPanel.py deleted file mode 100644 index fd6fb54..0000000 --- a/src/interface/configPanel.py +++ /dev/null @@ -1,364 +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 - -from util import conf, enum, panel, torTools, torConfig, uiTools - -DEFAULT_CONFIG = {"features.config.selectionDetails.height": 6, - "features.config.state.showPrivateOptions": False, - "features.config.state.showVirtualOptions": False, - "features.config.state.colWidth.option": 25, - "features.config.state.colWidth.value": 15} - -# 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") -DEFAULT_SORT_ORDER = (Field.MAN_ENTRY, Field.OPTION, Field.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")} - -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 _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 = uiTools.getSizeLabel(int(confValue)) - elif self.get(Field.TYPE) == "TimeInterval" and confValue.isdigit(): - confValue = uiTools.getTimeLabel(int(confValue), isLong = 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, config=None): - panel.Panel.__init__(self, stdscr, "configState", 0) - - self.sortOrdering = DEFAULT_SORT_ORDER - self._config = dict(DEFAULT_CONFIG) - if config: - config.update(self._config, { - "features.config.selectionDetails.height": 0, - "features.config.state.colWidth.option": 5, - "features.config.state.colWidth.value": 5}) - - sortFields = Field.values() - customOrdering = config.getIntCSV("features.config.order", None, 3, 0, len(sortFields)) - - if customOrdering: - self.sortOrdering = [sortFields[i] for i in customOrdering] - - self.configType = configType - self.confContents = [] - 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 - - if self.configType == State.TOR: - conn = torTools.getConn() - customOptions = torConfig.getCustomOptions() - configOptionLines = conn.getInfo("config/names", "").strip().split("\n") - - for line in configOptionLines: - # lines are of the form "<option> <type>", like: - # UseEntryGuards Boolean - confOption, confType = line.strip().split(" ", 1) - - # skips private and virtual entries if not configured to show them - if not self._config["features.config.state.showPrivateOptions"] and confOption.startswith("__"): - continue - elif not self._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.getConfig("arm") - for key in armConf.getKeys(): - 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 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: self.sortOrdering = ordering - self.confContents.sort(key=lambda i: (i.getAll(self.sortOrdering))) - self.confImportantContents.sort(key=lambda i: (i.getAll(self.sortOrdering))) - self.valsLock.release() - - def handleKey(self, key): - self.valsLock.acquire() - if uiTools.isScrollKey(key): - pageHeight = self.getPreferredSize()[0] - 1 - detailPanelHeight = self._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 key == ord('a') or key == ord('A'): - self.showAll = not self.showAll - self.redraw(True) - self.valsLock.release() - - def draw(self, width, height): - self.valsLock.acquire() - - # draws the top label - 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" - - # panel with details for the current selection - detailPanelHeight = self._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 - - self._drawSelectionPanel(cursorSelection, width, detailPanelHeight, isScrollbarVisible) - - 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 = self._config["features.config.state.colWidth.option"] - valueWidth = self._config["features.config.state.colWidth.value"] - descriptionWidth = max(0, width - scrollOffset - optionWidth - valueWidth - 2) - - 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 - 2, 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 - 2, 4, 4) - - self.addstr(3 + i, 2, msg, selectionFormat) - diff --git a/src/interface/connections/__init__.py b/src/interface/connections/__init__.py deleted file mode 100644 index 7a32d77..0000000 --- a/src/interface/connections/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Panels, popups, and handlers comprising the arm user interface. -""" - -__all__ = ["circEntry", "connEntry", "connPanel", "entries"] - diff --git a/src/interface/connections/circEntry.py b/src/interface/connections/circEntry.py deleted file mode 100644 index 80c3f81..0000000 --- a/src/interface/connections/circEntry.py +++ /dev/null @@ -1,216 +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 interface.connections import entries, connEntry -from util import torTools, uiTools - -# cached fingerprint -> (IP Address, ORPort) results -RELAY_INFO = {} - -def getRelayInfo(fingerprint): - """ - Provides the (IP Address, ORPort) tuple for the given relay. If the lookup - fails then this returns ("192.168.0.1", "0"). - - Arguments: - fingerprint - relay to look up - """ - - if not fingerprint in RELAY_INFO: - conn = torTools.getConn() - failureResult = ("192.168.0.1", "0") - - nsEntry = conn.getConsensusEntry(fingerprint) - if not nsEntry: return failureResult - - nsLineComp = nsEntry.split("\n")[0].split(" ") - if len(nsLineComp) < 8: return failureResult - - RELAY_INFO[fingerprint] = (nsLineComp[6], nsLineComp[7]) - - return RELAY_INFO[fingerprint] - -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]] - - if status == "BUILT" and not self.lines[0].isBuilt: - exitIp, exitORPort = getRelayInfo(path[-1]) - self.lines[0].setExit(exitIp, exitORPort, path[-1]) - - for i in range(len(path)): - relayFingerprint = path[i] - relayIp, relayOrPort = getRelayInfo(relayFingerprint) - - 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 [uiTools.DrawEntry("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 getListingEntry(self, width, currentTime, listingType): - """ - Provides the DrawEntry 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: - # bracketing (3 characters) - # placementLabel (14 characters) - # gap between etc and placement label (5 characters) - - if self.isLast: bracket = (curses.ACS_LLCORNER, curses.ACS_HLINE, ord(' ')) - else: bracket = (curses.ACS_VLINE, ord(' '), ord(' ')) - baselineSpace = len(bracket) + 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) - 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() - - drawEntry = uiTools.DrawEntry("%-14s" % self.placementLabel, lineFormat) - drawEntry = uiTools.DrawEntry(" " * (width - baselineSpace - len(dst) - len(etc) + 5), lineFormat, drawEntry) - drawEntry = uiTools.DrawEntry(dst + etc, lineFormat, drawEntry) - drawEntry = uiTools.DrawEntry(bracket, curses.A_NORMAL, drawEntry, lockFormat = True) - return drawEntry - diff --git a/src/interface/connections/connEntry.py b/src/interface/connections/connEntry.py deleted file mode 100644 index e6c0d92..0000000 --- a/src/interface/connections/connEntry.py +++ /dev/null @@ -1,850 +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, enum, torTools, uiTools -from interface.connections import entries - -# 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 = {"features.connection.markInitialConnections": 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} - -def loadConfig(config): - config.update(CONFIG) - -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 ORPort when searching for matching - # fingerprints (otherwise the ORPort is assumed to be unknown) - self.isORPort = False - - # 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() - orPort = self.port if self.isORPort else None - myFingerprint = conn.getRelayFingerprint(self.ipAddr, orPort) - - 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.indexOf(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") - - # 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") - myDirPort = conn.getOption("DirPort") - mySocksPort = conn.getOption("SocksPort", "9050") - myCtlPort = conn.getOption("ControlPort") - myHiddenServicePorts = conn.getHiddenServicePorts() - - # the ORListenAddress can overwrite the ORPort - listenAddr = conn.getOption("ORListenAddress") - if listenAddr and ":" in listenAddr: - myOrPort = listenAddr[listenAddr.find(":") + 1:] - - if lPort in (myOrPort, myDirPort): - self.baseType = Category.INBOUND - self.local.isORPort = True - 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.isORPort = True - - 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 DrawEntry 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 = "" - - timeEntry = myListing.getNext() - timeEntry.text = timePrefix + "%5s" % uiTools.getTimeLabel(currentTime - self.startTime, 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: - # 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 = uiTools.DrawEntry(")" + " " * (9 - len(entryType)), lineFormat) - drawEntry = uiTools.DrawEntry(entryType.upper(), lineFormat | curses.A_BOLD, drawEntry) - drawEntry = uiTools.DrawEntry(" (", lineFormat, drawEntry) - drawEntry = uiTools.DrawEntry(" " * timeWidth, lineFormat, drawEntry) - drawEntry = uiTools.DrawEntry(self._getListingContent(width - (12 + timeWidth), listingType), lineFormat, drawEntry) - 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 [uiTools.DrawEntry(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. - """ - - # 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") == "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:] - - # The network status exit policy doesn't exist for older tor versions. - # If unavailable we'll need the full exit policy which is on the - # descriptor (if that's available). - - exitPolicy = "unknown" - if len(nsLines) >= 4 and nsLines[3].startswith("p "): - exitPolicy = nsLines[3][2:].replace(",", ", ") - elif descEntry: - # the descriptor has an individual line for each entry in the exit policy - exitPolicyEntries = [] - - for line in descEntry.split("\n"): - if line.startswith("accept") or line.startswith("reject"): - exitPolicyEntries.append(line.strip()) - - exitPolicy = ", ".join(exitPolicyEntries) - - dirPortLabel = "" if dirPort == "0" else "dirport: %s" % dirPort - lines[2] = "nickname: %-25s orport: %-10s %s" % (nickname, orPort, dirPortLabel) - lines[3] = "published: %s %s" % (pubDate, pubTime) - lines[4] = "flags: %s" % flags.replace(" ", ", ") - lines[5] = "exit policy: %s" % exitPolicy - - if descEntry: - torVersion, platform, contact = "", "", "" - - for descLine in descEntry.split("\n"): - if descLine.startswith("platform"): - # has the tor version and platform, ex: - # platform Tor 0.2.1.29 (r318f470bc5f2ad43) on Linux x86_64 - - torVersion = descLine[13:descLine.find(" ", 13)] - platform = descLine[descLine.rfind(" on ") + 4:] - elif descLine.startswith("contact"): - contact = descLine[8:] - - # clears up some highly common obscuring - for alias in (" at ", " AT "): contact = contact.replace(alias, "@") - for alias in (" dot ", " DOT "): contact = contact.replace(alias, ".") - - break # contact lines come after the platform - - lines[3] = "%-35s os: %-14s version: %s" % (lines[3], platform, torVersion) - - # contact information is an optional field - if contact: lines[6] = "contact: %s" % contact - else: - allMatches = conn.getRelayFingerprint(self.foreign.getIpAddr(), getAllMatches = True) - - if allMatches: - # multiple matches - lines[2] = "Multiple matches, possible fingerprints are:" - - for i in range(len(allMatches)): - isLastLine = i == 3 - - relayPort, relayFingerprint = allMatches[i] - lineText = "%i. or port: %-5s fingerprint: %s" % (i, relayPort, relayFingerprint) - - # if there's multiple lines remaining at the end then give a count - remainingRelays = len(allMatches) - i - if isLastLine and remainingRelays > 1: - lineText = "... %i more" % remainingRelays - - lines[3 + i] = lineText - - if isLastLine: break - else: - # no consensus entry for this ip address - lines[2] = "No consensus data found" - - # crops any lines that are too long - for i in range(len(lines)): - lines[i] = uiTools.cropStr(lines[i], width - 2) - - return lines - - def getDestinationLabel(self, maxLength, includeLocale=False, includeHostname=False): - """ - Provides a short description of the destination. This is made up of two - components, the base <ip addr>:<port> and an extra piece of information in - parentheses. The IP address is scrubbed from private connections. - - Extra information is... - - the port's purpose for exit connections - - the locale and/or hostname if set to do so, the address isn't private, - and isn't on the local network - - nothing otherwise - - Arguments: - maxLength - maximum length of the string returned - includeLocale - possibly includes the locale - includeHostname - possibly includes the hostname - """ - - # the port and port derived data can be hidden by config or without includePort - includePort = self.includePort and (CONFIG["features.connection.showExitPort"] or self.getType() != Category.EXIT) - - # destination of the connection - ipLabel = "<scrubbed>" if self.isPrivate() else self.foreign.getIpAddr() - portLabel = ":%s" % self.foreign.getPort() if includePort else "" - dstAddress = ipLabel + portLabel - - # Only append the extra info if there's at least a couple characters of - # space (this is what's needed for the country codes). - if len(dstAddress) + 5 <= maxLength: - spaceAvailable = maxLength - len(dstAddress) - 3 - - if self.getType() == Category.EXIT and includePort: - purpose = connections.getPortUsage(self.foreign.getPort()) - - if purpose: - # BitTorrent is a common protocol to truncate, so just use "Torrent" - # if there's not enough room. - if len(purpose) > spaceAvailable and purpose == "BitTorrent": - purpose = "Torrent" - - # crops with a hyphen if too long - purpose = uiTools.cropStr(purpose, spaceAvailable, endType = uiTools.Ending.HYPHEN) - - dstAddress += " (%s)" % purpose - elif not connections.isIpAddressPrivate(self.foreign.getIpAddr()): - extraInfo = [] - conn = torTools.getConn() - - if includeLocale and not conn.isGeoipUnavailable(): - foreignLocale = self.foreign.getLocale("??") - extraInfo.append(foreignLocale) - spaceAvailable -= len(foreignLocale) + 2 - - if includeHostname: - dstHostname = self.foreign.getHostname() - - if dstHostname: - # determines the full space available, taking into account the ", " - # dividers if there's multiple pieces of extra data - - maxHostnameSpace = spaceAvailable - 2 * len(extraInfo) - dstHostname = uiTools.cropStr(dstHostname, maxHostnameSpace) - extraInfo.append(dstHostname) - spaceAvailable -= len(dstHostname) - - if extraInfo: - dstAddress += " (%s)" % ", ".join(extraInfo) - - return dstAddress[:maxLength] - diff --git a/src/interface/connections/connPanel.py b/src/interface/connections/connPanel.py deleted file mode 100644 index 79fe9df..0000000 --- a/src/interface/connections/connPanel.py +++ /dev/null @@ -1,398 +0,0 @@ -""" -Listing of the currently established connections tor has made. -""" - -import time -import curses -import threading - -from interface.connections import entries, connEntry, circEntry -from util import connections, enum, panel, torTools, uiTools - -DEFAULT_CONFIG = {"features.connection.resolveApps": True, - "features.connection.listingType": 0, - "features.connection.refreshRate": 5} - -# 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") - -DEFAULT_SORT_ORDER = (entries.SortAttr.CATEGORY, entries.SortAttr.LISTING, entries.SortAttr.UPTIME) - -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, config=None): - panel.Panel.__init__(self, stdscr, "conn", 0) - threading.Thread.__init__(self) - self.setDaemon(True) - - self._sortOrdering = DEFAULT_SORT_ORDER - self._config = dict(DEFAULT_CONFIG) - - if config: - config.update(self._config, { - "features.connection.listingType": (0, len(Listing.values()) - 1), - "features.connection.refreshRate": 1}) - - sortFields = entries.SortAttr.values() - customOrdering = config.getIntCSV("features.connection.order", None, 3, 0, len(sortFields)) - - if customOrdering: - self._sortOrdering = [sortFields[i] for i in customOrdering] - - self._listingType = Listing.values()[self._config["features.connection.listingType"]] - 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._isPaused = True # prevents updates if true - self._pauseTime = None # time when the panel was paused - self._halt = False # terminates thread if true - self._cond = threading.Condition() # used for pausing the thread - self.valsLock = threading.RLock() - - # 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 - - self._update() # populates initial entries - self._resolveApps(False) # resolves initial applications - - # 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 - torTools.getConn().addStatusListener(self.torStateListener) - - def torStateListener(self, conn, eventType): - """ - Freezes the connection contents when Tor stops. - - Arguments: - conn - tor controller - eventType - type of event detected - """ - - self._isTorRunning = eventType == torTools.State.INIT - - if self._isPaused or not self._isTorRunning: - if not self._pauseTime: self._pauseTime = time.time() - else: self._pauseTime = None - - self.redraw(True) - - def setPaused(self, isPause): - """ - If true, prevents the panel from updating. - """ - - if not self._isPaused == isPause: - self._isPaused = isPause - - if isPause or not self._isTorRunning: - if not self._pauseTime: self._pauseTime = time.time() - else: self._pauseTime = None - - # redraws so the display reflects any changes between the last update - # and being paused - self.redraw(True) - - 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: self._sortOrdering = ordering - self._entries.sort(key=lambda i: (i.getSortValues(self._sortOrdering, self._listingType))) - - self._entryLines = [] - for entry in self._entries: - self._entryLines += entry.getLines() - self.valsLock.release() - - def setListingType(self, listingType): - """ - Sets the priority information presented by the panel. - - Arguments: - listingType - Listing instance for the primary information to be shown - """ - - self.valsLock.acquire() - self._listingType = listingType - - # if we're sorting by the listing then we need to resort - if entries.SortAttr.LISTING in self._sortOrdering: - self.setSortOrder() - - self.valsLock.release() - - def handleKey(self, key): - self.valsLock.acquire() - - 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) - - self.valsLock.release() - - def run(self): - """ - Keeps connections listing updated, checking for new entries at a set rate. - """ - - lastDraw = time.time() - 1 - while not self._halt: - currentTime = time.time() - - if self._isPaused or not self._isTorRunning or currentTime - lastDraw < self._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) / self._config["features.connection.refreshRate"] - lastDraw += self._config["features.connection.refreshRate"] * drawTicks - - def draw(self, width, height): - self.valsLock.acquire() - - # 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._scroller.getCursorSelection(self._entryLines) - - # draws the detail panel if currently displaying it - if self._showDetails: - # 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)): - drawEntries[i].render(self, 1 + i, 2) - - # title label with connection counts - title = "Connection Details:" if self._showDetails else self._title - self.addstr(0, 0, title, curses.A_STANDOUT) - - scrollOffset = 1 - if isScrollbarVisible: - scrollOffset = 3 - self.addScrollBar(scrollLoc, scrollLoc + height - detailPanelOffset - 1, len(self._entryLines), 1 + detailPanelOffset) - - currentTime = self._pauseTime if self._pauseTime else 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 - - drawEntry = entryLine.getListingEntry(width - scrollOffset, currentTime, self._listingType) - drawLine = lineNum + detailPanelOffset + 1 - scrollLoc - drawEntry.render(self, drawLine, scrollOffset, extraFormat) - 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. - """ - - connResolver = connections.getResolver("tor") - currentResolutionCount = connResolver.getResolutionCount() - self.appResolveSinceUpdate = False - - if self._lastResourceFetch != currentResolutionCount: - 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) - if newConnEntry.getLines()[0].getType() != connEntry.Category.CIRCUIT: - newEntries.append(newConnEntry) - - 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 = connEntry.Category.values() - 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 self._config["features.connection.resolveApps"]: return - unresolvedLines = [l for l in self._entryLines if isinstance(l, connEntry.ConnectionLine) and l.isUnresolvedApp()] - - # get the ports used for unresolved applications - appPorts = [] - - for line in unresolvedLines: - appConn = line.local if line.getType() == connEntry.Category.HIDDEN else line.foreign - appPorts.append(appConn.getPort()) - - # Queue up resolution for the unresolved ports (skips if it's still working - # on the last query). - if appPorts and not self._appResolver.isResolving: - self._appResolver.resolve(appPorts) - - # Fetches results. If the query finishes quickly then this is what we just - # asked for, otherwise these belong to an earlier resolution. - # - # The application resolver might have given up querying (for instance, if - # the lsof lookups aren't working on this platform or lacks permissions). - # The isAppResolving flag lets the unresolved entries indicate if there's - # a lookup in progress for them or not. - - appResults = self._appResolver.getResults(0.2) - - for line in unresolvedLines: - isLocal = line.getType() == connEntry.Category.HIDDEN - linePort = line.local.getPort() if isLocal else line.foreign.getPort() - - if linePort in appResults: - # sets application attributes if there's a result with this as the - # inbound port - for inboundPort, outboundPort, cmd, pid in appResults[linePort]: - appPort = outboundPort if isLocal else inboundPort - - if linePort == appPort: - line.appName = cmd - line.appPid = pid - line.isAppResolving = False - else: - line.isAppResolving = self._appResolver.isResolving - - if flagQuery: - self.appResolveSinceUpdate = True - diff --git a/src/interface/connections/entries.py b/src/interface/connections/entries.py deleted file mode 100644 index 6b24412..0000000 --- a/src/interface/connections/entries.py +++ /dev/null @@ -1,164 +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 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 getListingEntry(self, width, currentTime, listingType): - """ - Provides a DrawEntry instance 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 DrawEntry instances with detailed information for this - connection. - - Arguments: - width - available space to display in - """ - - if self._detailsCacheArgs != width: - self._detailsCache = self._getDetails(width) - self._detailsCacheArgs = width - - return self._detailsCache - - def _getDetails(self, width): - # implementation of getDetails - return [] - - def resetDisplay(self): - """ - Flushes cached display results. - """ - - self._listingCacheArgs = (None, None) - self._detailsCacheArgs = None - diff --git a/src/interface/controller.py b/src/interface/controller.py deleted file mode 100644 index 5060188..0000000 --- a/src/interface/controller.py +++ /dev/null @@ -1,1584 +0,0 @@ -#!/usr/bin/env python -# controller.py -- arm interface (curses monitor for relay status) -# Released under the GPL v3 (http://www.gnu.org/licenses/gpl.html) - -""" -Curses (terminal) interface for the arm relay status monitor. -""" - -import os -import re -import math -import time -import curses -import curses.textpad -import socket -from TorCtl import TorCtl - -import headerPanel -import graphing.graphPanel -import logPanel -import configPanel -import torrcPanel -import descriptorPopup - -import interface.connections.connPanel -import interface.connections.connEntry -import interface.connections.entries -from util import conf, log, connections, hostnames, panel, sysTools, torConfig, torTools, uiTools -import graphing.bandwidthStats -import graphing.connStats -import graphing.resourceStats - -CONFIRM_QUIT = True -REFRESH_RATE = 5 # seconds between redrawing screen -MAX_REGEX_FILTERS = 5 # maximum number of previous regex filters that'll be remembered - -# enums for message in control label -CTL_HELP, CTL_PAUSED = range(2) - -# panel order per page -PAGE_S = ["header", "control", "popup"] # sticky (ie, always available) page -PAGES = [ - ["graph", "log"], - ["conn"], - ["config"], - ["torrc"]] - -PAUSEABLE = ["header", "graph", "log", "conn"] - -CONFIG = {"log.torrc.readFailed": log.WARN, - "features.graph.type": 1, - "features.config.prepopulateEditValues": True, - "queries.refreshRate.rate": 5, - "log.torEventTypeUnrecognized": log.NOTICE, - "features.graph.bw.prepopulate": True, - "log.startTime": log.INFO, - "log.refreshRate": log.DEBUG, - "log.highCpuUsage": log.WARN, - "log.configEntryUndefined": log.NOTICE, - "log.torrc.validation.torStateDiffers": log.WARN, - "log.torrc.validation.unnecessaryTorrcEntries": log.NOTICE} - -class ControlPanel(panel.Panel): - """ Draws single line label for interface controls. """ - - def __init__(self, stdscr, isBlindMode): - panel.Panel.__init__(self, stdscr, "control", 0, 1) - self.msgText = CTL_HELP # message text to be displyed - self.msgAttr = curses.A_NORMAL # formatting attributes - self.page = 1 # page number currently being displayed - self.resolvingCounter = -1 # count of resolver when starting (-1 if we aren't working on a batch) - self.isBlindMode = isBlindMode - - def setMsg(self, msgText, msgAttr=curses.A_NORMAL): - """ - Sets the message and display attributes. If msgType matches CTL_HELP or - CTL_PAUSED then uses the default message for those statuses. - """ - - self.msgText = msgText - self.msgAttr = msgAttr - - def draw(self, width, height): - msgText = self.msgText - msgAttr = self.msgAttr - barTab = 2 # space between msgText and progress bar - barWidthMax = 40 # max width to progress bar - barWidth = -1 # space between "[ ]" in progress bar (not visible if -1) - barProgress = 0 # cells to fill - - if msgText == CTL_HELP: - msgAttr = curses.A_NORMAL - - if self.resolvingCounter != -1: - if hostnames.isPaused() or not hostnames.isResolving(): - # done resolving dns batch - self.resolvingCounter = -1 - curses.halfdelay(REFRESH_RATE * 10) # revert to normal refresh rate - else: - batchSize = hostnames.getRequestCount() - self.resolvingCounter - entryCount = batchSize - hostnames.getPendingCount() - if batchSize > 0: progress = 100 * entryCount / batchSize - else: progress = 0 - - additive = "or l " if self.page == 2 else "" - batchSizeDigits = int(math.log10(batchSize)) + 1 - entryCountLabel = ("%%%ii" % batchSizeDigits) % entryCount - #msgText = "Resolving hostnames (%i / %i, %i%%) - press esc %sto cancel" % (entryCount, batchSize, progress, additive) - msgText = "Resolving hostnames (press esc %sto cancel) - %s / %i, %2i%%" % (additive, entryCountLabel, batchSize, progress) - - barWidth = min(barWidthMax, width - len(msgText) - 3 - barTab) - barProgress = barWidth * entryCount / batchSize - - if self.resolvingCounter == -1: - currentPage = self.page - pageCount = len(PAGES) - - if self.isBlindMode: - if currentPage >= 2: currentPage -= 1 - pageCount -= 1 - - msgText = "page %i / %i - q: quit, p: pause, h: page help" % (currentPage, pageCount) - elif msgText == CTL_PAUSED: - msgText = "Paused" - msgAttr = curses.A_STANDOUT - - self.addstr(0, 0, msgText, msgAttr) - if barWidth > -1: - xLoc = len(msgText) + barTab - self.addstr(0, xLoc, "[", curses.A_BOLD) - self.addstr(0, xLoc + 1, " " * barProgress, curses.A_STANDOUT | uiTools.getColor("red")) - self.addstr(0, xLoc + barWidth + 1, "]", curses.A_BOLD) - -class Popup(panel.Panel): - """ - Temporarily providing old panel methods until permanent workaround for popup - can be derrived (this passive drawing method is horrible - I'll need to - provide a version using the more active repaint design later in the - revision). - """ - - def __init__(self, stdscr, height): - panel.Panel.__init__(self, stdscr, "popup", 0, height) - - # The following methods are to emulate old panel functionality (this was the - # only implementations to use these methods and will require a complete - # rewrite when refactoring gets here) - def clear(self): - if self.win: - self.isDisplaced = self.top > self.win.getparyx()[0] - if not self.isDisplaced: self.win.erase() - - def refresh(self): - if self.win and not self.isDisplaced: self.win.refresh() - - def recreate(self, stdscr, newWidth=-1, newTop=None): - self.setParent(stdscr) - self.setWidth(newWidth) - if newTop != None: self.setTop(newTop) - - newHeight, newWidth = self.getPreferredSize() - if newHeight > 0: - self.win = self.parent.subwin(newHeight, newWidth, self.top, 0) - elif self.win == None: - # don't want to leave the window as none (in very edge cases could cause - # problems) - rather, create a displaced instance - self.win = self.parent.subwin(1, newWidth, 0, 0) - - self.maxY, self.maxX = self.win.getmaxyx() - -def addstr_wrap(panel, y, x, text, formatting, startX = 0, endX = -1, maxY = -1): - """ - Writes text with word wrapping, returning the ending y/x coordinate. - y: starting write line - x: column offset from startX - text / formatting: content to be written - startX / endX: column bounds in which text may be written - """ - - # moved out of panel (trying not to polute new code!) - # TODO: unpleaseantly complex usage - replace with something else when - # rewriting confPanel and descriptorPopup (the only places this is used) - if not text: return (y, x) # nothing to write - if endX == -1: endX = panel.maxX # defaults to writing to end of panel - if maxY == -1: maxY = panel.maxY + 1 # defaults to writing to bottom of panel - lineWidth = endX - startX # room for text - while True: - if len(text) > lineWidth - x - 1: - chunkSize = text.rfind(" ", 0, lineWidth - x) - writeText = text[:chunkSize] - text = text[chunkSize:].strip() - - panel.addstr(y, x + startX, writeText, formatting) - y, x = y + 1, 0 - if y >= maxY: return (y, x) - else: - panel.addstr(y, x + startX, text, formatting) - return (y, x + len(text)) - -class sighupListener(TorCtl.PostEventListener): - """ - Listens for reload signal (hup), which is produced by: - pkill -sighup tor - causing the torrc and internal state to be reset. - """ - - def __init__(self): - TorCtl.PostEventListener.__init__(self) - self.isReset = False - - def msg_event(self, event): - self.isReset |= event.level == "NOTICE" and event.msg.startswith("Received reload signal (hup)") - -def setPauseState(panels, monitorIsPaused, currentPage, overwrite=False): - """ - Resets the isPaused state of panels. If overwrite is True then this pauses - reguardless of the monitor is paused or not. - """ - - for key in PAUSEABLE: panels[key].setPaused(overwrite or monitorIsPaused or (key not in PAGES[currentPage] and key not in PAGE_S)) - -def showMenu(stdscr, popup, title, options, initialSelection): - """ - 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. If initialSelection is -1 then the first - option is used and the carrot indicating past selection is ommitted. - """ - - selection = initialSelection if initialSelection != -1 else 0 - - if popup.win: - if not panel.CURSES_LOCK.acquire(False): return -1 - try: - # TODO: should pause interface (to avoid event accumilation) - curses.cbreak() # wait indefinitely for key presses (no timeout) - - # uses smaller dimentions more fitting for small content - popup.height = len(options) + 2 - - newWidth = max([len(label) for label in options]) + 9 - popup.recreate(stdscr, newWidth) - - key = 0 - while not uiTools.isSelectionKey(key): - popup.clear() - 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 == initialSelection else " " - popup.addstr(i + 1, 2, tab) - popup.addstr(i + 1, 4, " %s " % label, format) - - popup.refresh() - key = stdscr.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 - - # reverts popup dimensions and conn panel label - popup.height = 9 - popup.recreate(stdscr, 80) - - curses.halfdelay(REFRESH_RATE * 10) # reset normal pausing behavior - finally: - panel.CURSES_LOCK.release() - - return selection - -def showSortDialog(stdscr, panels, isPaused, page, titleLabel, 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: - stdscr, panels, isPaused, page - boiler plate arguments of the controller - (should be refactored away when rewriting) - - titleLabel - title displayed for the popup window - options - ordered listing of option labels - oldSelection - current ordering - optionColors - mappings of options to their color - - """ - - panel.CURSES_LOCK.acquire() - newSelections = [] # new ordering - - try: - setPauseState(panels, isPaused, page, True) - curses.cbreak() # wait indefinitely for key presses (no timeout) - - popup = panels["popup"] - cursorLoc = 0 # index of highlighted option - - # label for the inital ordering - formattedPrevListing = [] - for sortType in oldSelection: - colorStr = optionColors.get(sortType, "white") - formattedPrevListing.append("<%s>%s</%s>" % (colorStr, sortType, colorStr)) - prevOrderingLabel = "<b>Current Order: %s</b>" % ", ".join(formattedPrevListing) - - selectionOptions = list(options) - selectionOptions.append("Cancel") - - while len(newSelections) < len(oldSelection): - popup.clear() - popup.win.box() - popup.addstr(0, 0, titleLabel, curses.A_STANDOUT) - popup.addfstr(1, 2, prevOrderingLabel) - - # provides new ordering - formattedNewListing = [] - for sortType in newSelections: - colorStr = optionColors.get(sortType, "white") - formattedNewListing.append("<%s>%s</%s>" % (colorStr, sortType, colorStr)) - newOrderingLabel = "<b>New Order: %s</b>" % ", ".join(formattedNewListing) - popup.addfstr(2, 2, newOrderingLabel) - - # 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)): - popup.addstr(row, col * 19 + 2, selectionOptions[i], curses.A_STANDOUT if cursorLoc == i else curses.A_NORMAL) - col += 1 - if col == 4: row, col = row + 1, 0 - - popup.refresh() - - key = stdscr.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): - # selected entry (the ord of '10' seems needed to pick up enter) - 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 - - setPauseState(panels, isPaused, page) - curses.halfdelay(REFRESH_RATE * 10) # reset normal pausing behavior - finally: - panel.CURSES_LOCK.release() - - if len(newSelections) == len(oldSelection): - return newSelections - else: return None - -def setEventListening(selectedEvents, isBlindMode): - # creates a local copy, note that a suspected python bug causes *very* - # puzzling results otherwise when trying to discard entries (silently - # returning out of this function!) - events = set(selectedEvents) - isLoggingUnknown = "UNKNOWN" in events - - # removes special types only used in arm (UNKNOWN, TORCTL, ARM_DEBUG, etc) - toDiscard = [] - for eventType in events: - if eventType not in logPanel.TOR_EVENT_TYPES.values(): toDiscard += [eventType] - - for eventType in list(toDiscard): events.discard(eventType) - - # adds events unrecognized by arm if we're listening to the 'UNKNOWN' type - if isLoggingUnknown: - events.update(set(logPanel.getMissingEventTypes())) - - setEvents = torTools.getConn().setControllerEvents(list(events)) - - # temporary hack for providing user selected events minus those that failed - # (wouldn't be a problem if I wasn't storing tor and non-tor events together...) - returnVal = list(selectedEvents.difference(torTools.FAILED_EVENTS)) - returnVal.sort() # alphabetizes - return returnVal - -def connResetListener(conn, eventType): - """ - Pauses connection resolution when tor's shut down, and resumes if started - again. - """ - - if connections.isResolverAlive("tor"): - resolver = connections.getResolver("tor") - resolver.setPaused(eventType == torTools.State.CLOSED) - -def selectiveRefresh(panels, page): - """ - This forces a redraw of content on the currently active page (should be done - after changing pages, popups, or anything else that overwrites panels). - """ - - for panelKey in PAGES[page]: - panels[panelKey].redraw(True) - -def drawTorMonitor(stdscr, startTime, loggedEvents, isBlindMode): - """ - Starts arm interface reflecting information on provided control port. - - stdscr - curses window - conn - active Tor control port connection - loggedEvents - types of events to be logged (plus an optional "UNKNOWN" for - otherwise unrecognized events) - """ - - # loads config for various interface components - config = conf.getConfig("arm") - config.update(CONFIG) - graphing.graphPanel.loadConfig(config) - interface.connections.connEntry.loadConfig(config) - - # 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 isBlindMode: - torTools.REQ_EVENTS["CIRC"] = "may cause issues in identifying client connections" - - # pauses/unpauses connection resolution according to if tor's connected or not - torTools.getConn().addStatusListener(connResetListener) - - # TODO: incrementally drop this requirement until everything's using the singleton - conn = torTools.getConn().getTorCtl() - - curses.halfdelay(REFRESH_RATE * 10) # uses getch call as timer for REFRESH_RATE seconds - try: curses.use_default_colors() # allows things like semi-transparent backgrounds (call can fail with ERR) - except curses.error: pass - - # attempts to make the cursor invisible (not supported in all terminals) - try: curses.curs_set(0) - except curses.error: pass - - # attempts to determine tor's current pid (left as None if unresolveable, logging an error later) - torPid = torTools.getConn().getMyPid() - - #try: - # confLocation = conn.get_info("config-file")["config-file"] - # if confLocation[0] != "/": - # # relative path - attempt to add process pwd - # try: - # results = sysTools.call("pwdx %s" % torPid) - # if len(results) == 1 and len(results[0].split()) == 2: confLocation = "%s/%s" % (results[0].split()[1], confLocation) - # except IOError: pass # pwdx call failed - #except (socket.error, TorCtl.ErrorReply, TorCtl.TorCtlClosed): - # confLocation = "" - - # loads the torrc and provides warnings in case of validation errors - loadedTorrc = torConfig.getTorrc() - loadedTorrc.getLock().acquire() - - try: - loadedTorrc.load() - except IOError, exc: - msg = "Unable to load torrc (%s)" % sysTools.getFileErrorMsg(exc) - log.log(CONFIG["log.torrc.readFailed"], msg) - - if loadedTorrc.isLoaded(): - corrections = loadedTorrc.getCorrections() - duplicateOptions, defaultOptions, mismatchLines, missingOptions = [], [], [], [] - - for lineNum, issue, msg in corrections: - if issue == torConfig.ValidationError.DUPLICATE: - duplicateOptions.append("%s (line %i)" % (msg, lineNum + 1)) - elif issue == torConfig.ValidationError.IS_DEFAULT: - defaultOptions.append("%s (line %i)" % (msg, lineNum + 1)) - elif issue == torConfig.ValidationError.MISMATCH: mismatchLines.append(lineNum + 1) - elif issue == torConfig.ValidationError.MISSING: missingOptions.append(msg) - - if duplicateOptions or defaultOptions: - msg = "Unneeded torrc entries found. They've been highlighted in blue on the torrc page." - - if duplicateOptions: - if len(duplicateOptions) > 1: - msg += "\n- entries ignored due to having duplicates: " - else: - msg += "\n- entry ignored due to having a duplicate: " - - duplicateOptions.sort() - msg += ", ".join(duplicateOptions) - - if defaultOptions: - if len(defaultOptions) > 1: - msg += "\n- entries match their default values: " - else: - msg += "\n- entry matches its default value: " - - defaultOptions.sort() - msg += ", ".join(defaultOptions) - - log.log(CONFIG["log.torrc.validation.unnecessaryTorrcEntries"], msg) - - if mismatchLines or missingOptions: - msg = "The torrc differ from what tor's using. You can issue a sighup to reload the torrc values by pressing x." - - if mismatchLines: - if len(mismatchLines) > 1: - msg += "\n- torrc values differ on lines: " - else: - msg += "\n- torrc value differs on line: " - - mismatchLines.sort() - msg += ", ".join([str(val + 1) for val in mismatchLines]) - - if missingOptions: - if len(missingOptions) > 1: - msg += "\n- configuration values are missing from the torrc: " - else: - msg += "\n- configuration value is missing from the torrc: " - - missingOptions.sort() - msg += ", ".join(missingOptions) - - log.log(CONFIG["log.torrc.validation.torStateDiffers"], msg) - - loadedTorrc.getLock().release() - - # minor refinements for connection resolver - if not isBlindMode: - if torPid: - # use the tor pid to help narrow connection results - torCmdName = sysTools.getProcessName(torPid, "tor") - resolver = connections.getResolver(torCmdName, torPid, "tor") - else: - resolver = connections.getResolver("tor") - - # hack to display a better (arm specific) notice if all resolvers fail - connections.RESOLVER_FINAL_FAILURE_MSG += " (connection related portions of the monitor won't function)" - - panels = { - "header": headerPanel.HeaderPanel(stdscr, startTime, config), - "popup": Popup(stdscr, 9), - "graph": graphing.graphPanel.GraphPanel(stdscr), - "log": logPanel.LogPanel(stdscr, loggedEvents, config)} - - # TODO: later it would be good to set the right 'top' values during initialization, - # but for now this is just necessary for the log panel (and a hack in the log...) - - # TODO: bug from not setting top is that the log panel might attempt to draw - # before being positioned - the following is a quick hack til rewritten - panels["log"].setPaused(True) - - panels["conn"] = interface.connections.connPanel.ConnectionPanel(stdscr, config) - - panels["control"] = ControlPanel(stdscr, isBlindMode) - panels["config"] = configPanel.ConfigPanel(stdscr, configPanel.State.TOR, config) - panels["torrc"] = torrcPanel.TorrcPanel(stdscr, torrcPanel.Config.TORRC, config) - - # provides error if pid coulnd't be determined (hopefully shouldn't happen...) - if not torPid: log.log(log.WARN, "Unable to resolve tor pid, abandoning connection listing") - - # statistical monitors for graph - panels["graph"].addStats("bandwidth", graphing.bandwidthStats.BandwidthStats(config)) - panels["graph"].addStats("system resources", graphing.resourceStats.ResourceStats()) - if not isBlindMode: panels["graph"].addStats("connections", graphing.connStats.ConnStats()) - - # sets graph based on config parameter - graphType = CONFIG["features.graph.type"] - if graphType == 0: panels["graph"].setStats(None) - elif graphType == 1: panels["graph"].setStats("bandwidth") - elif graphType == 2 and not isBlindMode: panels["graph"].setStats("connections") - elif graphType == 3: panels["graph"].setStats("system resources") - - # listeners that update bandwidth and log panels with Tor status - sighupTracker = sighupListener() - #conn.add_event_listener(panels["log"]) - conn.add_event_listener(panels["graph"].stats["bandwidth"]) - conn.add_event_listener(panels["graph"].stats["system resources"]) - if not isBlindMode: conn.add_event_listener(panels["graph"].stats["connections"]) - conn.add_event_listener(sighupTracker) - - # prepopulates bandwidth values from state file - if CONFIG["features.graph.bw.prepopulate"]: - isSuccessful = panels["graph"].stats["bandwidth"].prepopulateFromState() - if isSuccessful: panels["graph"].updateInterval = 4 - - # tells Tor to listen to the events we're interested - loggedEvents = setEventListening(loggedEvents, isBlindMode) - #panels["log"].loggedEvents = loggedEvents # strips any that couldn't be set - panels["log"].setLoggedEvents(loggedEvents) # strips any that couldn't be set - - # directs logged TorCtl events to log panel - #TorUtil.loglevel = "DEBUG" - #TorUtil.logfile = panels["log"] - #torTools.getConn().addTorCtlListener(panels["log"].tor_ctl_event) - - # provides a notice about any event types tor supports but arm doesn't - missingEventTypes = logPanel.getMissingEventTypes() - if missingEventTypes: - pluralLabel = "s" if len(missingEventTypes) > 1 else "" - log.log(CONFIG["log.torEventTypeUnrecognized"], "arm doesn't recognize the following event type%s: %s (log 'UNKNOWN' events to see them)" % (pluralLabel, ", ".join(missingEventTypes))) - - # tells revised panels to run as daemons - panels["header"].start() - panels["log"].start() - panels["conn"].start() - - # warns if tor isn't updating descriptors - #try: - # if conn.get_option("FetchUselessDescriptors")[0][1] == "0" and conn.get_option("DirPort")[0][1] == "0": - # warning = """Descriptors won't be updated (causing some connection information to be stale) unless: - #a. 'FetchUselessDescriptors 1' is set in your torrc - #b. the directory service is provided ('DirPort' defined) - #c. or tor is used as a client""" - # log.log(log.WARN, warning) - #except (socket.error, TorCtl.ErrorReply, TorCtl.TorCtlClosed): pass - - isUnresponsive = False # true if it's been over ten seconds since the last BW event (probably due to Tor closing) - isPaused = False # if true updates are frozen - overrideKey = None # immediately runs with this input rather than waiting for the user if set - page = 0 - regexFilters = [] # previously used log regex filters - panels["popup"].redraw(True) # hack to make sure popup has a window instance (not entirely sure why...) - - # provides notice about any unused config keys - for key in config.getUnusedKeys(): - log.log(CONFIG["log.configEntryUndefined"], "Unused configuration entry: %s" % key) - - lastPerformanceLog = 0 # ensures we don't do performance logging too frequently - redrawStartTime = time.time() - - # TODO: popups need to force the panels it covers to redraw (or better, have - # a global refresh function for after changing pages, popups, etc) - - initTime = time.time() - startTime - log.log(CONFIG["log.startTime"], "arm started (initialization took %0.3f seconds)" % initTime) - - # attributes to give a WARN level event if arm's resource usage is too high - isResourceWarningGiven = False - lastResourceCheck = startTime - - lastSize = None - - # sets initial visiblity for the pages - for i in range(len(PAGES)): - isVisible = i == page - for entry in PAGES[i]: panels[entry].setVisible(isVisible) - - # TODO: come up with a nice, clean method for other threads to immediately - # terminate the draw loop and provide a stacktrace - while True: - # tried only refreshing when the screen was resized but it caused a - # noticeable lag when resizing and didn't have an appreciable effect - # on system usage - - panel.CURSES_LOCK.acquire() - try: - redrawStartTime = time.time() - - # if sighup received then reload related information - if sighupTracker.isReset: - #panels["header"]._updateParams(True) - - # other panels that use torrc data - #if not isBlindMode: panels["graph"].stats["connections"].resetOptions(conn) - #panels["graph"].stats["bandwidth"].resetOptions() - - # if bandwidth graph is being shown then height might have changed - if panels["graph"].currentDisplay == "bandwidth": - panels["graph"].setHeight(panels["graph"].stats["bandwidth"].getContentHeight()) - - # TODO: should redraw the torrcPanel - #panels["torrc"].loadConfig() - - # reload the torrc if it's previously been loaded - if loadedTorrc.isLoaded(): - try: - loadedTorrc.load() - if page == 3: panels["torrc"].redraw(True) - except IOError, exc: - msg = "Unable to load torrc (%s)" % sysTools.getFileErrorMsg(exc) - log.log(CONFIG["log.torrc.readFailed"], msg) - - sighupTracker.isReset = False - - # gives panels a chance to take advantage of the maximum bounds - # originally this checked in the bounds changed but 'recreate' is a no-op - # if panel properties are unchanged and checking every redraw is more - # resilient in case of funky changes (such as resizing during popups) - - # hack to make sure header picks layout before using the dimensions below - #panels["header"].getPreferredSize() - - startY = 0 - for panelKey in PAGE_S[:2]: - #panels[panelKey].recreate(stdscr, -1, startY) - panels[panelKey].setParent(stdscr) - panels[panelKey].setWidth(-1) - panels[panelKey].setTop(startY) - startY += panels[panelKey].getHeight() - - panels["popup"].recreate(stdscr, 80, startY) - - for panelSet in PAGES: - tmpStartY = startY - - for panelKey in panelSet: - #panels[panelKey].recreate(stdscr, -1, tmpStartY) - panels[panelKey].setParent(stdscr) - panels[panelKey].setWidth(-1) - panels[panelKey].setTop(tmpStartY) - tmpStartY += panels[panelKey].getHeight() - - # provides a notice if there's been ten seconds since the last BW event - lastHeartbeat = torTools.getConn().getHeartbeat() - if torTools.getConn().isAlive() and "BW" in torTools.getConn().getControllerEvents() and lastHeartbeat != 0: - if not isUnresponsive and (time.time() - lastHeartbeat) >= 10: - isUnresponsive = True - log.log(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.log(log.NOTICE, "Relay resumed") - - # TODO: part two of hack to prevent premature drawing by log panel - if page == 0 and not isPaused: panels["log"].setPaused(False) - - # I haven't the foggiest why, but doesn't work if redrawn out of order... - for panelKey in (PAGE_S + PAGES[page]): - # redrawing popup can result in display flicker when it should be hidden - if panelKey != "popup": - newSize = stdscr.getmaxyx() - isResize = lastSize != newSize - lastSize = newSize - - if panelKey in ("header", "graph", "log", "config", "torrc", "conn2"): - # revised panel (manages its own content refreshing) - panels[panelKey].redraw(isResize) - else: - panels[panelKey].redraw(True) - - stdscr.refresh() - - currentTime = time.time() - if currentTime - lastPerformanceLog >= CONFIG["queries.refreshRate.rate"]: - cpuTotal = sum(os.times()[:3]) - pythonCpuAvg = cpuTotal / (currentTime - startTime) - sysCallCpuAvg = sysTools.getSysCpuUsage() - totalCpuAvg = pythonCpuAvg + sysCallCpuAvg - - if sysCallCpuAvg > 0.00001: - log.log(CONFIG["log.refreshRate"], "refresh rate: %0.3f seconds, average cpu usage: %0.3f%% (python), %0.3f%% (system calls), %0.3f%% (total)" % (currentTime - redrawStartTime, 100 * pythonCpuAvg, 100 * sysCallCpuAvg, 100 * totalCpuAvg)) - else: - # with the proc enhancements the sysCallCpuAvg is usually zero - log.log(CONFIG["log.refreshRate"], "refresh rate: %0.3f seconds, average cpu usage: %0.3f%%" % (currentTime - redrawStartTime, 100 * totalCpuAvg)) - - lastPerformanceLog = currentTime - - # once per minute check if the sustained cpu usage is above 5%, if so - # then give a warning (and if able, some advice for lowering it) - # TODO: disabling this for now (scrolling causes cpu spikes for quick - # redraws, ie this is usually triggered by user input) - if False and not isResourceWarningGiven and currentTime > (lastResourceCheck + 60): - if totalCpuAvg >= 0.05: - msg = "Arm's cpu usage is high (averaging %0.3f%%)." % (100 * totalCpuAvg) - - if not isBlindMode: - msg += " You could lower it by dropping the connection data (running as "arm -b")." - - log.log(CONFIG["log.highCpuUsage"], msg) - isResourceWarningGiven = True - - lastResourceCheck = currentTime - finally: - panel.CURSES_LOCK.release() - - # wait for user keyboard input until timeout (unless an override was set) - if overrideKey: - key = overrideKey - overrideKey = None - else: - key = stdscr.getch() - - if key == ord('q') or key == ord('Q'): - quitConfirmed = not CONFIRM_QUIT - - # provides prompt to confirm that arm should exit - if CONFIRM_QUIT: - panel.CURSES_LOCK.acquire() - try: - setPauseState(panels, isPaused, page, True) - - # provides prompt - panels["control"].setMsg("Are you sure (q again to confirm)?", curses.A_BOLD) - panels["control"].redraw(True) - - curses.cbreak() - confirmationKey = stdscr.getch() - quitConfirmed = confirmationKey in (ord('q'), ord('Q')) - curses.halfdelay(REFRESH_RATE * 10) - - panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) - setPauseState(panels, isPaused, page) - finally: - panel.CURSES_LOCK.release() - - if quitConfirmed: - # quits arm - # very occasionally stderr gets "close failed: [Errno 11] Resource temporarily unavailable" - # this appears to be a python bug: http://bugs.python.org/issue3014 - # (haven't seen this is quite some time... mysteriously resolved?) - - torTools.NO_SPAWN = True # prevents further worker threads from being spawned - - # stops panel daemons - panels["header"].stop() - panels["conn"].stop() - panels["log"].stop() - - panels["header"].join() - panels["conn"].join() - panels["log"].join() - - # joins on utility daemon threads - this might take a moment since - # the internal threadpools being joined might be sleeping - conn = torTools.getConn() - myPid = conn.getMyPid() - - resourceTracker = sysTools.getResourceTracker(myPid) if (myPid and sysTools.isTrackerAlive(myPid)) else None - resolver = connections.getResolver("tor") if connections.isResolverAlive("tor") else None - if resourceTracker: resourceTracker.stop() - if resolver: resolver.stop() # sets halt flag (returning immediately) - hostnames.stop() # halts and joins on hostname worker thread pool - if resourceTracker: resourceTracker.join() - if resolver: resolver.join() # joins on halted resolver - - conn.close() # joins on TorCtl event thread - break - elif key == curses.KEY_LEFT or key == curses.KEY_RIGHT: - # switch page - if key == curses.KEY_LEFT: page = (page - 1) % len(PAGES) - else: page = (page + 1) % len(PAGES) - - # skip connections listing if it's disabled - if page == 1 and isBlindMode: - if key == curses.KEY_LEFT: page = (page - 1) % len(PAGES) - else: page = (page + 1) % len(PAGES) - - # pauses panels that aren't visible to prevent events from accumilating - # (otherwise they'll wait on the curses lock which might get demanding) - setPauseState(panels, isPaused, page) - - # prevents panels on other pages from redrawing - for i in range(len(PAGES)): - isVisible = i == page - for entry in PAGES[i]: panels[entry].setVisible(isVisible) - - panels["control"].page = page + 1 - - # TODO: this redraw doesn't seem necessary (redraws anyway after this - # loop) - look into this when refactoring - panels["control"].redraw(True) - - selectiveRefresh(panels, page) - elif key == ord('p') or key == ord('P'): - # toggles update freezing - panel.CURSES_LOCK.acquire() - try: - isPaused = not isPaused - setPauseState(panels, isPaused, page) - panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) - finally: - panel.CURSES_LOCK.release() - - selectiveRefresh(panels, page) - elif key == ord('x') or key == ord('X'): - # provides prompt to confirm that arm should issue a sighup - panel.CURSES_LOCK.acquire() - try: - setPauseState(panels, isPaused, page, True) - - # provides prompt - panels["control"].setMsg("This will reset Tor's internal state. Are you sure (x again to confirm)?", curses.A_BOLD) - panels["control"].redraw(True) - - curses.cbreak() - confirmationKey = stdscr.getch() - if confirmationKey in (ord('x'), ord('X')): - try: - torTools.getConn().reload() - except IOError, exc: - log.log(log.ERR, "Error detected when reloading tor: %s" % sysTools.getFileErrorMsg(exc)) - - #errorMsg = " (%s)" % str(err) if str(err) else "" - #panels["control"].setMsg("Sighup failed%s" % errorMsg, curses.A_STANDOUT) - #panels["control"].redraw(True) - #time.sleep(2) - - # reverts display settings - curses.halfdelay(REFRESH_RATE * 10) - panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) - setPauseState(panels, isPaused, page) - finally: - panel.CURSES_LOCK.release() - elif key == ord('h') or key == ord('H'): - # displays popup for current page's controls - panel.CURSES_LOCK.acquire() - try: - setPauseState(panels, isPaused, page, True) - - # lists commands - popup = panels["popup"] - popup.clear() - popup.win.box() - popup.addstr(0, 0, "Page %i Commands:" % (page + 1), curses.A_STANDOUT) - - pageOverrideKeys = () - - if page == 0: - graphedStats = panels["graph"].currentDisplay - if not graphedStats: graphedStats = "none" - popup.addfstr(1, 2, "<b>up arrow</b>: scroll log up a line") - popup.addfstr(1, 41, "<b>down arrow</b>: scroll log down a line") - popup.addfstr(2, 2, "<b>m</b>: increase graph size") - popup.addfstr(2, 41, "<b>n</b>: decrease graph size") - popup.addfstr(3, 2, "<b>s</b>: graphed stats (<b>%s</b>)" % graphedStats) - popup.addfstr(3, 41, "<b>i</b>: graph update interval (<b>%s</b>)" % graphing.graphPanel.UPDATE_INTERVALS[panels["graph"].updateInterval][0]) - popup.addfstr(4, 2, "<b>b</b>: graph bounds (<b>%s</b>)" % panels["graph"].bounds.lower()) - popup.addfstr(4, 41, "<b>a</b>: save snapshot of the log") - popup.addfstr(5, 2, "<b>e</b>: change logged events") - - regexLabel = "enabled" if panels["log"].regexFilter else "disabled" - popup.addfstr(5, 41, "<b>f</b>: log regex filter (<b>%s</b>)" % regexLabel) - - hiddenEntryLabel = "visible" if panels["log"].showDuplicates else "hidden" - popup.addfstr(6, 2, "<b>u</b>: duplicate log entries (<b>%s</b>)" % hiddenEntryLabel) - popup.addfstr(6, 41, "<b>c</b>: clear event log") - - pageOverrideKeys = (ord('m'), ord('n'), ord('s'), ord('i'), ord('d'), ord('e'), ord('r'), ord('f'), ord('x')) - if page == 1: - popup.addfstr(1, 2, "<b>up arrow</b>: scroll up a line") - popup.addfstr(1, 41, "<b>down arrow</b>: scroll down a line") - popup.addfstr(2, 2, "<b>page up</b>: scroll up a page") - popup.addfstr(2, 41, "<b>page down</b>: scroll down a page") - - popup.addfstr(3, 2, "<b>enter</b>: edit configuration option") - popup.addfstr(3, 41, "<b>d</b>: raw consensus descriptor") - - listingType = panels["conn"]._listingType.lower() - popup.addfstr(4, 2, "<b>l</b>: listed identity (<b>%s</b>)" % listingType) - - popup.addfstr(4, 41, "<b>s</b>: sort ordering") - - resolverUtil = connections.getResolver("tor").overwriteResolver - if resolverUtil == None: resolverUtil = "auto" - popup.addfstr(5, 2, "<b>u</b>: resolving utility (<b>%s</b>)" % resolverUtil) - - pageOverrideKeys = (ord('d'), ord('l'), ord('s'), ord('u')) - elif page == 2: - popup.addfstr(1, 2, "<b>up arrow</b>: scroll up a line") - popup.addfstr(1, 41, "<b>down arrow</b>: scroll down a line") - popup.addfstr(2, 2, "<b>page up</b>: scroll up a page") - popup.addfstr(2, 41, "<b>page down</b>: scroll down a page") - - strippingLabel = "on" if panels["torrc"].stripComments else "off" - popup.addfstr(3, 2, "<b>s</b>: comment stripping (<b>%s</b>)" % strippingLabel) - - lineNumLabel = "on" if panels["torrc"].showLineNum else "off" - popup.addfstr(3, 41, "<b>n</b>: line numbering (<b>%s</b>)" % lineNumLabel) - - popup.addfstr(4, 2, "<b>r</b>: reload torrc") - popup.addfstr(4, 41, "<b>x</b>: reset tor (issue sighup)") - elif page == 3: - popup.addfstr(1, 2, "<b>up arrow</b>: scroll up a line") - popup.addfstr(1, 41, "<b>down arrow</b>: scroll down a line") - popup.addfstr(2, 2, "<b>page up</b>: scroll up a page") - popup.addfstr(2, 41, "<b>page down</b>: scroll down a page") - popup.addfstr(3, 2, "<b>enter</b>: connection details") - - popup.addstr(7, 2, "Press any key...") - popup.refresh() - - # waits for user to hit a key, if it belongs to a command then executes it - curses.cbreak() - helpExitKey = stdscr.getch() - if helpExitKey in pageOverrideKeys: overrideKey = helpExitKey - curses.halfdelay(REFRESH_RATE * 10) - - setPauseState(panels, isPaused, page) - selectiveRefresh(panels, page) - finally: - panel.CURSES_LOCK.release() - elif page == 0 and (key == ord('s') or key == ord('S')): - # provides menu to pick stats to be graphed - #options = ["None"] + [label for label in panels["graph"].stats.keys()] - options = ["None"] - - # appends stats labels with first letters of each word capitalized - initialSelection, i = -1, 1 - if not panels["graph"].currentDisplay: initialSelection = 0 - graphLabels = panels["graph"].stats.keys() - graphLabels.sort() - for label in graphLabels: - if label == panels["graph"].currentDisplay: initialSelection = i - words = label.split() - options.append(" ".join(word[0].upper() + word[1:] for word in words)) - i += 1 - - # hides top label of the graph panel and pauses panels - if panels["graph"].currentDisplay: - panels["graph"].showLabel = False - panels["graph"].redraw(True) - setPauseState(panels, isPaused, page, True) - - selection = showMenu(stdscr, panels["popup"], "Graphed Stats:", options, initialSelection) - - # reverts changes made for popup - panels["graph"].showLabel = True - setPauseState(panels, isPaused, page) - - # applies new setting - if selection != -1 and selection != initialSelection: - if selection == 0: panels["graph"].setStats(None) - else: panels["graph"].setStats(options[selection].lower()) - - selectiveRefresh(panels, page) - - # TODO: this shouldn't be necessary with the above refresh, but doesn't seem responsive otherwise... - panels["graph"].redraw(True) - elif page == 0 and (key == ord('i') or key == ord('I')): - # provides menu to pick graph panel update interval - options = [label for (label, intervalTime) in graphing.graphPanel.UPDATE_INTERVALS] - - initialSelection = panels["graph"].updateInterval - - #initialSelection = -1 - #for i in range(len(options)): - # if options[i] == panels["graph"].updateInterval: initialSelection = i - - # hides top label of the graph panel and pauses panels - if panels["graph"].currentDisplay: - panels["graph"].showLabel = False - panels["graph"].redraw(True) - setPauseState(panels, isPaused, page, True) - - selection = showMenu(stdscr, panels["popup"], "Update Interval:", options, initialSelection) - - # reverts changes made for popup - panels["graph"].showLabel = True - setPauseState(panels, isPaused, page) - - # applies new setting - if selection != -1: panels["graph"].updateInterval = selection - - selectiveRefresh(panels, page) - elif page == 0 and (key == ord('b') or key == ord('B')): - # uses the next boundary type for graph - panels["graph"].bounds = graphing.graphPanel.Bounds.next(panels["graph"].bounds) - - selectiveRefresh(panels, page) - elif page == 0 and (key == ord('a') or key == ord('A')): - # allow user to enter a path to take a snapshot - abandons if left blank - panel.CURSES_LOCK.acquire() - try: - setPauseState(panels, isPaused, page, True) - - # provides prompt - panels["control"].setMsg("Path to save log snapshot: ") - panels["control"].redraw(True) - - # gets user input (this blocks monitor updates) - pathInput = panels["control"].getstr(0, 27) - - if pathInput: - try: - panels["log"].saveSnapshot(pathInput) - panels["control"].setMsg("Saved: %s" % pathInput, curses.A_STANDOUT) - panels["control"].redraw(True) - time.sleep(2) - except IOError, exc: - panels["control"].setMsg("Unable to save snapshot: %s" % sysTools.getFileErrorMsg(exc), curses.A_STANDOUT) - panels["control"].redraw(True) - time.sleep(2) - - panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) - setPauseState(panels, isPaused, page) - finally: - panel.CURSES_LOCK.release() - - panels["graph"].redraw(True) - elif page == 0 and (key == ord('e') or key == ord('E')): - # allow user to enter new types of events to log - unchanged if left blank - panel.CURSES_LOCK.acquire() - try: - setPauseState(panels, isPaused, page, True) - - # provides prompt - panels["control"].setMsg("Events to log: ") - panels["control"].redraw(True) - - # lists event types - popup = panels["popup"] - popup.height = 11 - popup.recreate(stdscr, 80) - - popup.clear() - popup.win.box() - popup.addstr(0, 0, "Event Types:", curses.A_STANDOUT) - lineNum = 1 - for line in logPanel.EVENT_LISTING.split("\n"): - line = line[6:] - popup.addstr(lineNum, 1, line) - lineNum += 1 - popup.refresh() - - # gets user input (this blocks monitor updates) - eventsInput = panels["control"].getstr(0, 15) - if eventsInput: eventsInput = eventsInput.replace(' ', '') # strips spaces - - # it would be nice to quit on esc, but looks like this might not be possible... - if eventsInput: - try: - expandedEvents = logPanel.expandEvents(eventsInput) - loggedEvents = setEventListening(expandedEvents, isBlindMode) - panels["log"].setLoggedEvents(loggedEvents) - except ValueError, exc: - panels["control"].setMsg("Invalid flags: %s" % str(exc), curses.A_STANDOUT) - panels["control"].redraw(True) - time.sleep(2) - - # reverts popup dimensions - popup.height = 9 - popup.recreate(stdscr, 80) - - panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) - setPauseState(panels, isPaused, page) - finally: - panel.CURSES_LOCK.release() - - panels["graph"].redraw(True) - elif page == 0 and (key == ord('f') or key == ord('F')): - # provides menu to pick previous regular expression filters or to add a new one - # for syntax see: http://docs.python.org/library/re.html#regular-expression-syntax - options = ["None"] + regexFilters + ["New..."] - initialSelection = 0 if not panels["log"].regexFilter else 1 - - # hides top label of the graph panel and pauses panels - if panels["graph"].currentDisplay: - panels["graph"].showLabel = False - panels["graph"].redraw(True) - setPauseState(panels, isPaused, page, True) - - selection = showMenu(stdscr, panels["popup"], "Log Filter:", options, initialSelection) - - # applies new setting - if selection == 0: - panels["log"].setFilter(None) - elif selection == len(options) - 1: - # selected 'New...' option - prompt user to input regular expression - panel.CURSES_LOCK.acquire() - try: - # provides prompt - panels["control"].setMsg("Regular expression: ") - panels["control"].redraw(True) - - # gets user input (this blocks monitor updates) - regexInput = panels["control"].getstr(0, 20) - - if regexInput: - try: - panels["log"].setFilter(re.compile(regexInput)) - if regexInput in regexFilters: regexFilters.remove(regexInput) - regexFilters = [regexInput] + regexFilters - except re.error, exc: - panels["control"].setMsg("Unable to compile expression: %s" % str(exc), curses.A_STANDOUT) - panels["control"].redraw(True) - time.sleep(2) - panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) - finally: - panel.CURSES_LOCK.release() - elif selection != -1: - try: - panels["log"].setFilter(re.compile(regexFilters[selection - 1])) - - # move selection to top - regexFilters = [regexFilters[selection - 1]] + regexFilters - del regexFilters[selection] - except re.error, exc: - # shouldn't happen since we've already checked validity - log.log(log.WARN, "Invalid regular expression ('%s': %s) - removing from listing" % (regexFilters[selection - 1], str(exc))) - del regexFilters[selection - 1] - - if len(regexFilters) > MAX_REGEX_FILTERS: del regexFilters[MAX_REGEX_FILTERS:] - - # reverts changes made for popup - panels["graph"].showLabel = True - setPauseState(panels, isPaused, page) - panels["graph"].redraw(True) - elif page == 0 and key in (ord('n'), ord('N'), ord('m'), ord('M')): - # Unfortunately modifier keys don't work with the up/down arrows (sending - # multiple keycodes. The only exception to this is shift + left/right, - # but for now just gonna use standard characters. - - if key in (ord('n'), ord('N')): - panels["graph"].setGraphHeight(panels["graph"].graphHeight - 1) - else: - # don't grow the graph if it's already consuming the whole display - # (plus an extra line for the graph/log gap) - maxHeight = panels["graph"].parent.getmaxyx()[0] - panels["graph"].top - currentHeight = panels["graph"].getHeight() - - if currentHeight < maxHeight + 1: - panels["graph"].setGraphHeight(panels["graph"].graphHeight + 1) - elif page == 0 and (key == ord('c') or key == ord('C')): - # provides prompt to confirm that arm should clear the log - panel.CURSES_LOCK.acquire() - try: - setPauseState(panels, isPaused, page, True) - - # provides prompt - panels["control"].setMsg("This will clear the log. Are you sure (c again to confirm)?", curses.A_BOLD) - panels["control"].redraw(True) - - curses.cbreak() - confirmationKey = stdscr.getch() - if confirmationKey in (ord('c'), ord('C')): panels["log"].clear() - - # reverts display settings - curses.halfdelay(REFRESH_RATE * 10) - panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) - setPauseState(panels, isPaused, page) - finally: - panel.CURSES_LOCK.release() - elif page == 1 and (key == ord('u') or key == ord('U')): - # provides menu to pick identification resolving utility - options = ["auto"] + connections.Resolver.values() - - currentOverwrite = connections.getResolver("tor").overwriteResolver # enums correspond to indices - if currentOverwrite == None: initialSelection = 0 - else: initialSelection = options.index(currentOverwrite) - - # hides top label of conn panel and pauses panels - panelTitle = panels["conn"]._title - panels["conn"]._title = "" - panels["conn"].redraw(True) - setPauseState(panels, isPaused, page, True) - - selection = showMenu(stdscr, panels["popup"], "Resolver Util:", options, initialSelection) - selectedOption = options[selection] if selection != "auto" else None - - # reverts changes made for popup - panels["conn"]._title = panelTitle - setPauseState(panels, isPaused, page) - - # applies new setting - if selection != -1 and selectedOption != connections.getResolver("tor").overwriteResolver: - connections.getResolver("tor").overwriteResolver = selectedOption - elif page == 1 and key in (ord('d'), ord('D')): - # presents popup for raw consensus data - panel.CURSES_LOCK.acquire() - try: - setPauseState(panels, isPaused, page, True) - curses.cbreak() # wait indefinitely for key presses (no timeout) - panelTitle = panels["conn"]._title - panels["conn"]._title = "" - panels["conn"].redraw(True) - - descriptorPopup.showDescriptorPopup(panels["popup"], stdscr, panels["conn"]) - - panels["conn"]._title = panelTitle - setPauseState(panels, isPaused, page) - curses.halfdelay(REFRESH_RATE * 10) # reset normal pausing behavior - finally: - panel.CURSES_LOCK.release() - elif page == 1 and (key == ord('l') or key == ord('L')): - # provides a menu to pick the primary information we list connections by - options = interface.connections.entries.ListingType.values() - - # dropping the HOSTNAME listing type until we support displaying that content - options.remove(interface.connections.entries.ListingType.HOSTNAME) - - initialSelection = options.index(panels["conn"]._listingType) - - # hides top label of connection panel and pauses the display - panelTitle = panels["conn"]._title - panels["conn"]._title = "" - panels["conn"].redraw(True) - setPauseState(panels, isPaused, page, True) - - selection = showMenu(stdscr, panels["popup"], "List By:", options, initialSelection) - - # reverts changes made for popup - panels["conn"]._title = panelTitle - setPauseState(panels, isPaused, page) - - # applies new setting - if selection != -1 and options[selection] != panels["conn"]._listingType: - panels["conn"].setListingType(options[selection]) - panels["conn"].redraw(True) - elif page == 1 and (key == ord('s') or key == ord('S')): - # set ordering for connection options - titleLabel = "Connection Ordering:" - options = interface.connections.entries.SortAttr.values() - oldSelection = panels["conn"]._sortOrdering - optionColors = dict([(attr, interface.connections.entries.SORT_COLORS[attr]) for attr in options]) - results = showSortDialog(stdscr, panels, isPaused, page, titleLabel, options, oldSelection, optionColors) - - if results: - panels["conn"].setSortOrder(results) - - panels["conn"].redraw(True) - elif page == 2 and (key == ord('c') or key == ord('C')) and False: - # TODO: disabled for now (probably gonna be going with separate pages - # rather than popup menu) - # provides menu to pick config being displayed - #options = [confPanel.CONFIG_LABELS[confType] for confType in range(4)] - options = [] - initialSelection = panels["torrc"].configType - - # hides top label of the graph panel and pauses panels - panels["torrc"].showLabel = False - panels["torrc"].redraw(True) - setPauseState(panels, isPaused, page, True) - - selection = showMenu(stdscr, panels["popup"], "Configuration:", options, initialSelection) - - # reverts changes made for popup - panels["torrc"].showLabel = True - setPauseState(panels, isPaused, page) - - # applies new setting - if selection != -1: panels["torrc"].setConfigType(selection) - - selectiveRefresh(panels, page) - elif page == 2 and (key == ord('w') or key == ord('W')): - # display a popup for saving the current configuration - panel.CURSES_LOCK.acquire() - try: - configLines = torConfig.getCustomOptions(True) - - # lists event types - popup = panels["popup"] - popup.height = len(configLines) + 3 - popup.recreate(stdscr) - displayHeight, displayWidth = panels["popup"].getPreferredSize() - - # displayed options (truncating the labels if there's limited room) - if displayWidth >= 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 - lastIndex = min(displayHeight - 3, len(configLines) - 1) - isOptionLineSeparate = displayWidth < (30 + len(configLines[lastIndex])) - - # if we're showing all the content and have room to display selection - # options besides the text then shrink the popup by a row - if not isOptionLineSeparate and displayHeight == len(configLines) + 3: - popup.height -= 1 - popup.recreate(stdscr) - - 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 = panels["popup"].getPreferredSize() - if (displayHeight, displayWidth) != (newHeight, newWidth): - displayHeight, displayWidth = newHeight, newWidth - popup.recreate(stdscr) - - # if there isn't room to display the popup then cancel it - if displayHeight <= 2: - selection = 2 - break - - popup.clear() - popup.win.box() - popup.addstr(0, 0, "Configuration being saved:", curses.A_STANDOUT) - - visibleConfigLines = displayHeight - 3 if isOptionLineSeparate else displayHeight - 2 - for i in range(visibleConfigLines): - line = uiTools.cropStr(configLines[i], displayWidth - 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 'T' between the lower left and the covered panel's scroll bar - if displayWidth > 1: popup.win.addch(displayHeight - 1, 1, curses.ACS_TTEE) - - # draws selection options (drawn right to left) - drawX = displayWidth - 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(displayHeight - 2, drawX, "[") - popup.addstr(displayHeight - 2, drawX + 1, optionLabel, selectionFormat | curses.A_BOLD) - popup.addstr(displayHeight - 2, drawX + len(optionLabel) + 1, "]") - - drawX -= 1 # space gap between the options - - popup.refresh() - - key = stdscr.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 = torConfig.getTorrc() - try: configLocation = loadedTorrc.getConfigLocation() - except IOError: configLocation = "" - - if selection == 1: - # prompts user for a configuration location - promptMsg = "Save to (esc to cancel): " - panels["control"].setMsg(promptMsg) - panels["control"].redraw(True) - configLocation = panels["control"].getstr(0, len(promptMsg), configLocation) - if configLocation: configLocation = os.path.abspath(configLocation) - - if configLocation: - try: - # make dir if the path doesn't already exist - baseDir = os.path.dirname(configLocation) - if not os.path.exists(baseDir): os.makedirs(baseDir) - - # saves the configuration to the file - configFile = open(configLocation, "w") - configFile.write("\n".join(configLines)) - configFile.close() - - # reloads the cached torrc if overwriting it - if configLocation == loadedTorrc.getConfigLocation(): - try: - loadedTorrc.load() - panels["torrc"]._lastContentHeightArgs = None - except IOError: pass - - msg = "Saved configuration to %s" % configLocation - except (IOError, OSError), exc: - msg = "Unable to save configuration (%s)" % sysTools.getFileErrorMsg(exc) - - panels["control"].setMsg(msg, curses.A_STANDOUT) - panels["control"].redraw(True) - time.sleep(2) - - panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) - - # reverts popup dimensions - popup.height = 9 - popup.recreate(stdscr, 80) - finally: - panel.CURSES_LOCK.release() - - panels["config"].redraw(True) - elif page == 2 and (key == ord('s') or key == ord('S')): - # set ordering for config options - titleLabel = "Config Option Ordering:" - options = [configPanel.FIELD_ATTR[field][0] for field in configPanel.Field.values()] - oldSelection = [configPanel.FIELD_ATTR[field][0] for field in panels["config"].sortOrdering] - optionColors = dict([configPanel.FIELD_ATTR[field] for field in configPanel.Field.values()]) - results = showSortDialog(stdscr, panels, isPaused, page, titleLabel, options, oldSelection, optionColors) - - if results: - # converts labels back to enums - resultEnums = [] - - for label in results: - for entryEnum in configPanel.FIELD_ATTR: - if label == configPanel.FIELD_ATTR[entryEnum][0]: - resultEnums.append(entryEnum) - break - - panels["config"].setSortOrder(resultEnums) - - panels["config"].redraw(True) - elif page == 2 and uiTools.isSelectionKey(key): - # let the user edit the configuration value, unchanged if left blank - panel.CURSES_LOCK.acquire() - try: - setPauseState(panels, isPaused, page, True) - - # provides prompt - selection = panels["config"].getSelection() - configOption = selection.get(configPanel.Field.OPTION) - titleMsg = "%s Value (esc to cancel): " % configOption - panels["control"].setMsg(titleMsg) - panels["control"].redraw(True) - - displayWidth = panels["control"].getPreferredSize()[1] - initialValue = selection.get(configPanel.Field.VALUE) - - # initial input for the text field - initialText = "" - if CONFIG["features.config.prepopulateEditValues"] and initialValue != "<none>": - initialText = initialValue - - newConfigValue = panels["control"].getstr(0, len(titleMsg), initialText) - - # it would be nice to quit on esc, but looks like this might not be possible... - if newConfigValue != None and newConfigValue != initialValue: - conn = torTools.getConn() - - # if the value's a boolean then allow for 'true' and 'false' inputs - if selection.get(configPanel.Field.TYPE) == "Boolean": - if newConfigValue.lower() == "true": newConfigValue = "1" - elif newConfigValue.lower() == "false": newConfigValue = "0" - - try: - if selection.get(configPanel.Field.TYPE) == "LineList": - newConfigValue = newConfigValue.split(",") - - conn.setOption(configOption, newConfigValue) - - # resets the isDefault flag - customOptions = torConfig.getCustomOptions() - selection.fields[configPanel.Field.IS_DEFAULT] = not configOption in customOptions - - panels["config"].redraw(True) - except Exception, exc: - errorMsg = "%s (press any key)" % exc - panels["control"].setMsg(uiTools.cropStr(errorMsg, displayWidth), curses.A_STANDOUT) - panels["control"].redraw(True) - - curses.cbreak() # wait indefinitely for key presses (no timeout) - stdscr.getch() - curses.halfdelay(REFRESH_RATE * 10) - - panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) - setPauseState(panels, isPaused, page) - finally: - panel.CURSES_LOCK.release() - elif page == 3 and key == ord('r') or key == ord('R'): - # reloads torrc, providing a notice if successful or not - loadedTorrc = torConfig.getTorrc() - loadedTorrc.getLock().acquire() - - try: - loadedTorrc.load() - isSuccessful = True - except IOError: - isSuccessful = False - - loadedTorrc.getLock().release() - - #isSuccessful = panels["torrc"].loadConfig(logErrors = False) - #confTypeLabel = confPanel.CONFIG_LABELS[panels["torrc"].configType] - resetMsg = "torrc reloaded" if isSuccessful else "failed to reload torrc" - if isSuccessful: - panels["torrc"]._lastContentHeightArgs = None - panels["torrc"].redraw(True) - - panels["control"].setMsg(resetMsg, curses.A_STANDOUT) - panels["control"].redraw(True) - time.sleep(1) - - panels["control"].setMsg(CTL_PAUSED if isPaused else CTL_HELP) - elif page == 0: - panels["log"].handleKey(key) - elif page == 1: - panels["conn"].handleKey(key) - elif page == 2: - panels["config"].handleKey(key) - elif page == 3: - panels["torrc"].handleKey(key) - -def startTorMonitor(startTime, loggedEvents, isBlindMode): - try: - curses.wrapper(drawTorMonitor, startTime, loggedEvents, isBlindMode) - except KeyboardInterrupt: - pass # skip printing stack trace in case of keyboard interrupt - diff --git a/src/interface/descriptorPopup.py b/src/interface/descriptorPopup.py deleted file mode 100644 index cdc959d..0000000 --- a/src/interface/descriptorPopup.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python -# descriptorPopup.py -- popup panel used to show raw consensus data -# Released under the GPL v3 (http://www.gnu.org/licenses/gpl.html) - -import math -import socket -import curses -from TorCtl import TorCtl - -import controller -import 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" - -class PopupProperties: - """ - State attributes of popup window for consensus descriptions. - """ - - def __init__(self): - self.fingerprint = "" - self.entryColor = "white" - self.text = [] - self.scroll = 0 - self.showLineNum = True - - def reset(self, fingerprint, entryColor): - self.fingerprint = fingerprint - self.entryColor = entryColor - self.text = [] - self.scroll = 0 - - if fingerprint == "UNKNOWN": - self.fingerprint = None - self.showLineNum = False - self.text.append(UNRESOLVED_MSG) - else: - conn = torTools.getConn() - - try: - self.showLineNum = True - self.text.append("ns/id/%s" % fingerprint) - self.text += conn.getConsensusEntry(fingerprint).split("\n") - except (socket.error, TorCtl.ErrorReply, TorCtl.TorCtlClosed): - self.text = self.text + [ERROR_MSG, ""] - - try: - descCommand = "desc/id/%s" % fingerprint - self.text.append("desc/id/%s" % fingerprint) - self.text += conn.getDescriptorEntry(fingerprint).split("\n") - except (socket.error, TorCtl.ErrorReply, TorCtl.TorCtlClosed): - self.text = self.text + [ERROR_MSG] - - def handleKey(self, key, height): - if key == curses.KEY_UP: self.scroll = max(self.scroll - 1, 0) - elif key == curses.KEY_DOWN: self.scroll = max(0, min(self.scroll + 1, len(self.text) - height)) - elif key == curses.KEY_PPAGE: self.scroll = max(self.scroll - height, 0) - elif key == curses.KEY_NPAGE: self.scroll = max(0, min(self.scroll + height, len(self.text) - height)) - -def showDescriptorPopup(popup, stdscr, connectionPanel): - """ - 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 - """ - - properties = PopupProperties() - isVisible = True - - if not panel.CURSES_LOCK.acquire(False): return - try: - while isVisible: - selection = connectionPanel._scroller.getCursorSelection(connectionPanel._entryLines) - if not selection: break - fingerprint = selection.foreign.getFingerprint() - entryColor = connections.connEntry.CATEGORY_COLOR[selection.getType()] - properties.reset(fingerprint, entryColor) - - # constrains popup size to match text - width, height = 0, 0 - for line in properties.text: - # width includes content, line number field, and border - lineWidth = len(line) + 5 - if properties.showLineNum: lineWidth += int(math.log10(len(properties.text))) + 1 - width = max(width, lineWidth) - - # tracks number of extra lines that will be taken due to text wrap - height += (lineWidth - 2) / connectionPanel.maxX - - popup.setHeight(min(len(properties.text) + height + 2, connectionPanel.maxY)) - popup.recreate(stdscr, width) - - while isVisible: - draw(popup, properties) - key = stdscr.getch() - - if uiTools.isSelectionKey(key) or key in (ord('d'), ord('D')): - # closes popup - isVisible = False - elif key in (curses.KEY_LEFT, curses.KEY_RIGHT): - # navigation - pass on to connPanel and recreate popup - connectionPanel.handleKey(curses.KEY_UP if key == curses.KEY_LEFT else curses.KEY_DOWN) - break - else: properties.handleKey(key, popup.height - 2) - - popup.setHeight(9) - popup.recreate(stdscr, 80) - finally: - panel.CURSES_LOCK.release() - -def draw(popup, properties): - popup.clear() - popup.win.box() - xOffset = 2 - - if properties.text: - if properties.fingerprint: popup.addstr(0, 0, "Consensus Descriptor (%s):" % properties.fingerprint, curses.A_STANDOUT) - else: popup.addstr(0, 0, "Consensus Descriptor:", curses.A_STANDOUT) - - isEncryption = False # true if line is part of an encryption block - - # checks if first line is in an encryption block - for i in range(0, properties.scroll): - lineText = properties.text[i].strip() - if lineText in SIG_START_KEYS: isEncryption = True - elif lineText in SIG_END_KEYS: isEncryption = False - - pageHeight = popup.maxY - 2 - numFieldWidth = int(math.log10(len(properties.text))) + 1 - lineNum = 1 - for i in range(properties.scroll, min(len(properties.text), properties.scroll + pageHeight)): - lineText = properties.text[i].strip() - - numOffset = 0 # offset for line numbering - if properties.showLineNum: - popup.addstr(lineNum, xOffset, ("%%%ii" % numFieldWidth) % (i + 1), curses.A_BOLD | uiTools.getColor(LINE_NUM_COLOR)) - numOffset = numFieldWidth + 1 - - if lineText: - keyword = lineText.split()[0] # first word of line - remainder = lineText[len(keyword):] - keywordFormat = curses.A_BOLD | uiTools.getColor(properties.entryColor) - remainderFormat = uiTools.getColor(properties.entryColor) - - if lineText.startswith(HEADER_PREFIX[0]) or lineText.startswith(HEADER_PREFIX[1]): - keyword, remainder = lineText, "" - keywordFormat = curses.A_BOLD | uiTools.getColor(HEADER_COLOR) - if lineText == UNRESOLVED_MSG or lineText == ERROR_MSG: - keyword, remainder = lineText, "" - if lineText in SIG_START_KEYS: - keyword, remainder = lineText, "" - isEncryption = True - keywordFormat = curses.A_BOLD | uiTools.getColor(SIG_COLOR) - elif lineText in SIG_END_KEYS: - keyword, remainder = lineText, "" - isEncryption = False - keywordFormat = curses.A_BOLD | uiTools.getColor(SIG_COLOR) - elif isEncryption: - keyword, remainder = lineText, "" - keywordFormat = uiTools.getColor(SIG_COLOR) - - lineNum, xLoc = controller.addstr_wrap(popup, lineNum, 0, keyword, keywordFormat, xOffset + numOffset, popup.maxX - 1, popup.maxY - 1) - lineNum, xLoc = controller.addstr_wrap(popup, lineNum, xLoc, remainder, remainderFormat, xOffset + numOffset, popup.maxX - 1, popup.maxY - 1) - - lineNum += 1 - if lineNum > pageHeight: break - - popup.refresh() - diff --git a/src/interface/graphing/__init__.py b/src/interface/graphing/__init__.py deleted file mode 100644 index 9a81dbd..0000000 --- a/src/interface/graphing/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Panels, popups, and handlers comprising the arm user interface. -""" - -__all__ = ["graphPanel", "bandwidthStats", "connStats", "resourceStats"] - diff --git a/src/interface/graphing/bandwidthStats.py b/src/interface/graphing/bandwidthStats.py deleted file mode 100644 index f8e3020..0000000 --- a/src/interface/graphing/bandwidthStats.py +++ /dev/null @@ -1,398 +0,0 @@ -""" -Tracks bandwidth usage of the tor process, expanding to include accounting -stats if they're set. -""" - -import time - -from interface.graphing import graphPanel -from util import log, sysTools, torTools, uiTools - -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)" - -DEFAULT_CONFIG = {"features.graph.bw.transferInBytes": False, - "features.graph.bw.accounting.show": True, - "features.graph.bw.accounting.rate": 10, - "features.graph.bw.accounting.isTimeLong": False, - "log.graph.bw.prepopulateSuccess": log.NOTICE, - "log.graph.bw.prepopulateFailure": log.NOTICE} - -class BandwidthStats(graphPanel.GraphStats): - """ - Uses tor BW events to generate bandwidth usage graph. - """ - - def __init__(self, config=None): - graphPanel.GraphStats.__init__(self) - - self._config = dict(DEFAULT_CONFIG) - if config: - config.update(self._config, {"features.graph.bw.accounting.rate": 1}) - - # 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 - self.resetListener(conn, torTools.State.INIT) # 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") - if readTotal and readTotal.isdigit(): - self.initialPrimaryTotal = int(readTotal) / 1024 # Bytes -> KB - - writeTotal = conn.getInfo("traffic/written") - if writeTotal and writeTotal.isdigit(): - self.initialSecondaryTotal = int(writeTotal) / 1024 # Bytes -> KB - - def resetListener(self, conn, 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 == torTools.State.INIT and self._config["features.graph.bw.accounting.show"]: - self.isAccounting = conn.getInfo('accounting/enabled') == '1' - - 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") - if orPort == "0": return - - # gets the uptime (using the same parameters as the header panel to take - # advantage of caching - uptime = None - queryPid = conn.getMyPid() - if queryPid: - queryParam = ["%cpu", "rss", "%mem", "etime"] - queryCmd = "ps -p %s -o %s" % (queryPid, ",".join(queryParam)) - psCall = sysTools.call(queryCmd, 3600, True) - - 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.log(self._config["log.graph.bw.prepopulateFailure"], msg) - return False - - # get the user's data directory (usually '~/.tor') - dataDir = conn.getOption("DataDirectory") - if not dataDir: - msg = PREPOPULATE_FAILURE_MSG % "data directory not found" - log.log(self._config["log.graph.bw.prepopulateFailure"], 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.log(self._config["log.graph.bw.prepopulateFailure"], 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.log(self._config["log.graph.bw.prepopulateFailure"], 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)" % uiTools.getTimeLabel(missingSec, 0, True) - log.log(self._config["log.graph.bw.prepopulateSuccess"], msg) - - return True - - def bandwidth_event(self, event): - if self.isAccounting and self.isNextTickRedraw(): - if time.time() - self.accountingLastUpdated >= self._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.addfstr(labelingLine + 2, 0, "<b>Accounting (<%s>%s</%s>)</b>" % (hibernateColor, status, hibernateColor)) - - 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.addfstr(labelingLine + 2, 0, "<b>Accounting:</b> 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" % uiTools.getSizeLabel((self.lastPrimary if isPrimary else self.lastSecondary) * 1024, 1, False, self._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") - 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 = self._config["features.graph.bw.transferInBytes"] - - if bwRate and bwBurst: - bwRateLabel = uiTools.getSizeLabel(bwRate, 1, False, labelInBytes) - bwBurstLabel = uiTools.getSizeLabel(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" % uiTools.getSizeLabel(bwObserved, 1, False, labelInBytes)) - elif bwMeasured: - stats.append("measured: %s/s" % uiTools.getSizeLabel(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" % uiTools.getSizeLabel((total / max(1, self.tick + self.prepopulateTicks)) * 1024, 1, False, self._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" % uiTools.getSizeLabel(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") - - # provides a nicely formatted reset time - endInterval = conn.getInfo("accounting/interval-end") - 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 self._config["features.graph.bw.accounting.isTimeLong"]: - queried["resetTime"] = ", ".join(uiTools.getTimeLabels(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") - left = conn.getInfo("accounting/bytes-left") - - 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"] = uiTools.getSizeLabel(read) - queried["written"] = uiTools.getSizeLabel(written) - queried["readLimit"] = uiTools.getSizeLabel(read + readLeft) - queried["writtenLimit"] = uiTools.getSizeLabel(written + writtenLeft) - - self.accountingInfo = queried - self.accountingLastUpdated = time.time() - diff --git a/src/interface/graphing/connStats.py b/src/interface/graphing/connStats.py deleted file mode 100644 index 511490c..0000000 --- a/src/interface/graphing/connStats.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Tracks stats concerning tor's current connections. -""" - -from interface.graphing import graphPanel -from util import connections, torTools - -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, torTools.State.INIT) # initialize port values - conn.addStatusListener(self.resetListener) - - def resetListener(self, conn, eventType): - if eventType == torTools.State.INIT: - self.orPort = conn.getOption("ORPort", "0") - self.dirPort = conn.getOption("DirPort", "0") - self.controlPort = conn.getOption("ControlPort", "0") - - def eventTick(self): - """ - Fetches connection stats from cached information. - """ - - inboundCount, outboundCount = 0, 0 - - for entry in connections.getResolver("tor").getConnections(): - localPort = entry[1] - if localPort in (self.orPort, self.dirPort): inboundCount += 1 - elif localPort == self.controlPort: pass # control connection - else: outboundCount += 1 - - self._processEvent(inboundCount, outboundCount) - - def getTitle(self, width): - return "Connection Count:" - - def getHeaderLabel(self, width, isPrimary): - avg = (self.primaryTotal if isPrimary else self.secondaryTotal) / max(1, self.tick) - if isPrimary: return "Inbound (%s, avg: %s):" % (self.lastPrimary, avg) - else: return "Outbound (%s, avg: %s):" % (self.lastSecondary, avg) - - def getRefreshRate(self): - return 5 - diff --git a/src/interface/graphing/graphPanel.py b/src/interface/graphing/graphPanel.py deleted file mode 100644 index e4b493d..0000000 --- a/src/interface/graphing/graphPanel.py +++ /dev/null @@ -1,407 +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 -from TorCtl import TorCtl - -from util import enum, panel, uiTools - -# 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 - -# used for setting defaults when initializing GraphStats and GraphPanel instances -CONFIG = {"features.graph.height": 7, - "features.graph.interval": 0, - "features.graph.bound": 1, - "features.graph.maxWidth": 150, - "features.graph.showIntermediateBounds": True} - -def loadConfig(config): - config.update(CONFIG, { - "features.graph.height": MIN_GRAPH_HEIGHT, - "features.graph.maxWidth": 1, - "features.graph.interval": (0, len(UPDATE_INTERVALS) - 1), - "features.graph.bound": (0, 2)}) - -class GraphStats(TorCtl.PostEventListener): - """ - 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, isPauseBuffer=False): - """ - Initializes parameters needed to present a graph. - """ - - TorCtl.PostEventListener.__init__(self) - - # panel to be redrawn when updated (set when added to GraphPanel) - self._graphPanel = None - - # mirror instance used to track updates when paused - self.isPaused, self.isPauseBuffer = False, isPauseBuffer - if isPauseBuffer: self._pauseBuffer = None - else: self._pauseBuffer = GraphStats(True) - - # 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] - - 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 not self.isPauseBuffer and not self.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 setPaused(self, isPause): - """ - If true, prevents bandwidth updates from being presented. This is a no-op - if a pause buffer. - """ - - if isPause == self.isPaused or self.isPauseBuffer: return - self.isPaused = isPause - - if self.isPaused: active, inactive = self._pauseBuffer, self - else: active, inactive = self, self._pauseBuffer - self._parameterSwap(active, inactive) - - def bandwidth_event(self, event): - self.eventTick() - - def _parameterSwap(self, active, inactive): - """ - Either overwrites parameters of pauseBuffer or with the current values or - vice versa. This is a helper method for setPaused and should be overwritten - to append with additional parameters that need to be preserved when paused. - """ - - # The pause buffer is constructed as a GraphStats instance which will - # become problematic if this is overridden by any implementations (which - # currently isn't the case). If this happens then the pause buffer will - # need to be of the requester's type (not quite sure how to do this - # gracefully...). - - active.tick = inactive.tick - active.lastPrimary = inactive.lastPrimary - active.lastSecondary = inactive.lastSecondary - active.primaryTotal = inactive.primaryTotal - active.secondaryTotal = inactive.secondaryTotal - active.maxPrimary = dict(inactive.maxPrimary) - active.maxSecondary = dict(inactive.maxSecondary) - active.primaryCounts = copy.deepcopy(inactive.primaryCounts) - active.secondaryCounts = copy.deepcopy(inactive.secondaryCounts) - - def _processEvent(self, primary, secondary): - """ - Includes new stats in graphs and notifies associated GraphPanel of changes. - """ - - if self.isPaused: self._pauseBuffer._processEvent(primary, secondary) - else: - 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: 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 = Bounds.values()[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.showLabel = True # shows top label if true, hides otherwise - self.isPaused = False - - 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 draw(self, width, height): - """ Redraws graph panel """ - - if self.currentDisplay: - param = self.stats[self.currentDisplay] - graphCol = min((width - 10) / 2, param.maxCol) - - primaryColor = uiTools.getColor(param.getColor(True)) - secondaryColor = uiTools.getColor(param.getColor(False)) - - if self.showLabel: 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 - 1) * (self.graphHeight - row - 1) - if not primaryVal in (primaryMinBound, primaryMaxBound): self.addstr(row + 2, 0, "%4i" % primaryVal, primaryColor) - - if secondaryMinBound != secondaryMaxBound: - secondaryVal = (secondaryMaxBound - secondaryMinBound) / (self.graphHeight - 1) * (self.graphHeight - row - 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 = uiTools.getTimeLabel(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 - stats.isPaused = True - self.stats[label] = stats - - 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].setPaused(True) - - if not label: - self.currentDisplay = None - elif label in self.stats.keys(): - self.currentDisplay = label - self.stats[label].setPaused(self.isPaused) - else: raise ValueError("Unrecognized stats label: %s" % label) - - def setPaused(self, isPause): - """ - If true, prevents bandwidth updates from being presented. - """ - - if isPause == self.isPaused: return - self.isPaused = isPause - if self.currentDisplay: self.stats[self.currentDisplay].setPaused(self.isPaused) - diff --git a/src/interface/graphing/resourceStats.py b/src/interface/graphing/resourceStats.py deleted file mode 100644 index 864957e..0000000 --- a/src/interface/graphing/resourceStats.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Tracks the system resource usage (cpu and memory) of the tor process. -""" - -from interface.graphing import graphPanel -from util import sysTools, torTools, uiTools - -class ResourceStats(graphPanel.GraphStats): - """ - System resource usage tracker. - """ - - def __init__(self): - graphPanel.GraphStats.__init__(self) - self.queryPid = torTools.getConn().getMyPid() - - 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 = uiTools.getSizeLabel(lastAmount * 1048576, 1) - avgLabel = uiTools.getSizeLabel(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) - - if not resourceTracker.lastQueryFailed(): - primary, _, secondary, _ = resourceTracker.getResourceUsage() - primary *= 100 # decimal percentage to whole numbers - secondary /= 1048576 # translate size to MB so axis labels are short - - self._processEvent(primary, secondary) - diff --git a/src/interface/headerPanel.py b/src/interface/headerPanel.py deleted file mode 100644 index f653299..0000000 --- a/src/interface/headerPanel.py +++ /dev/null @@ -1,474 +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 - -from util import log, panel, sysTools, torTools, uiTools - -# 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"} - -DEFAULT_CONFIG = {"features.showFdUsage": False, - "log.fdUsageSixtyPercent": log.NOTICE, - "log.fdUsageNinetyPercent": log.WARN} - -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, - 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, config = None): - panel.Panel.__init__(self, stdscr, "header", 0) - threading.Thread.__init__(self) - self.setDaemon(True) - - self._config = dict(DEFAULT_CONFIG) - if config: config.update(self._config) - - self._isTorConnected = True - self._lastUpdate = -1 # time the content was last revised - self._isPaused = False # prevents updates if true - 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 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: - versionColor = VERSION_STATUS_COLORS[self.vals["tor/versionStatus"]] if \ - self.vals["tor/versionStatus"] in VERSION_STATUS_COLORS else "white" - versionStatusMsg = "<%s>%s</%s>" % (versionColor, self.vals["tor/versionStatus"], versionColor) - self.addfstr(0, 43, "Tor %s (%s)" % (self.vals["tor/version"], versionStatusMsg)) - 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) - 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 - entry = "" - 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 len(entry) + len(label) <= leftWidth: entry += label - else: break - else: - # non-relay (client only) - # TODO: not sure what sort of stats to provide... - entry = "<red><b>Relaying Disabled</b></red>" - - if self.vals["tor/isAuthPassword"]: authType = "password" - elif self.vals["tor/isAuthCookie"]: authType = "cookie" - else: authType = "open" - - if len(entry) + 19 + len(self.vals["tor/controlPort"]) + len(authType) <= leftWidth: - authColor = "red" if authType == "open" else "green" - authLabel = "<%s>%s</%s>" % (authColor, authType, authColor) - self.addfstr(1, 0, "%s, Control Port (%s): %s" % (entry, authLabel, self.vals["tor/controlPort"])) - elif len(entry) + 16 + len(self.vals["tor/controlPort"]) <= leftWidth: - self.addstr(1, 0, "%s, Control Port: %s" % (entry, self.vals["tor/controlPort"])) - else: self.addstr(1, 0, entry) - - # Line 3 / Line 1 Right (system usage info) - y, x = (0, leftWidth) if isWide else (2, 0) - if self.vals["stat/rss"] != "0": memoryLabel = uiTools.getSizeLabel(int(self.vals["stat/rss"])) - else: memoryLabel = "0" - - uptimeLabel = "" - if self.vals["tor/startTime"]: - if self._haltTime: - # freeze the uptime when paused or the tor process is stopped - uptimeLabel = uiTools.getShortTimeLabel(self._haltTime - self.vals["tor/startTime"]) - else: - uptimeLabel = uiTools.getShortTimeLabel(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 self._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: - flagLine = "flags: " - for flag in self.vals["tor/flags"]: - flagColor = FLAG_COLORS[flag] if flag in FLAG_COLORS.keys() else "white" - flagLine += "<b><%s>%s</%s></b>, " % (flagColor, flag, flagColor) - - if len(self.vals["tor/flags"]) > 0: flagLine = flagLine[:-2] - else: flagLine += "<b><cyan>none</cyan></b>" - - self.addfstr(2 if isWide else 4, 0, flagLine) - else: - statusTime = torTools.getConn().getStatus()[1] - statusTimeLabel = time.strftime("%H:%M %m/%d/%Y", time.localtime(statusTime)) - self.addfstr(2 if isWide else 4, 0, "<b><red>Tor Disconnected</red></b> (%s)" % 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>" - - # 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() - displayedPolicy = policy.replace("accept", "").replace("reject", "").strip() if isSimple else policy - if policy.startswith("accept"): policy = "<green><b>%s</b></green>" % displayedPolicy - elif policy.startswith("reject"): policy = "<red><b>%s</b></red>" % displayedPolicy - elif policy.startswith("<default>"): policy = "<cyan><b>%s</b></cyan>" % displayedPolicy - policies[i] = policy - - self.addfstr(2, leftWidth, "exit policy: %s" % ", ".join(policies)) - else: - # Client only - # TODO: not sure what information to provide here... - pass - - self.valsLock.release() - - def setPaused(self, isPause): - """ - If true, prevents updates from being presented. - """ - - if not self._isPaused == isPause: - self._isPaused = isPause - if self._isTorConnected: - if isPause: self._haltTime = time.time() - else: self._haltTime = None - - # Redraw now so we'll be displaying the state right when paused - # (otherwise the uptime might be off by a second, and change when - # the panel's redrawn for other reasons). - self.redraw(True) - - 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, conn, eventType): - """ - Updates static parameters on tor reload (sighup) events. - - Arguments: - conn - tor controller - eventType - type of event detected - """ - - if eventType == torTools.State.INIT: - self._isTorConnected = True - if self._isPaused: self._haltTime = time.time() - else: self._haltTime = None - - self._update(True) - self.redraw(True) - elif eventType == torTools.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", "") - self.vals["tor/isAuthPassword"] = conn.getOption("HashedControlPassword") != None - self.vals["tor/isAuthCookie"] = conn.getOption("CookieAuthentication") == "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") - 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] - - pid = conn.getMyPid() - self.vals["tor/pid"] = pid if pid else "" - - 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.log(self._config["log.fdUsageNinetyPercent"], msg) - elif fdPercent >= 60 and not self._isFdSixtyPercentWarned: - self._isFdSixtyPercentWarned = True - log.log(self._config["log.fdUsageSixtyPercent"], msg) - - # ps or proc derived resource usage stats - if self.vals["tor/pid"]: - resourceTracker = sysTools.getResourceTracker(self.vals["tor/pid"]) - - if resourceTracker.lastQueryFailed(): - self.vals["stat/%torCpu"] = "0" - self.vals["stat/rss"] = "0" - self.vals["stat/%mem"] = "0" - else: - cpuUsage, _, memUsage, memUsagePercent = resourceTracker.getResourceUsage() - self._lastResourceFetch = resourceTracker.getRunCount() - self.vals["stat/%torCpu"] = "%0.1f" % (100 * cpuUsage) - self.vals["stat/rss"] = str(memUsage) - self.vals["stat/%mem"] = "%0.1f" % (100 * memUsagePercent) - - # determines the cpu time for the arm process (including user and system - # time of both the primary and child processes) - - totalArmCpuTime, currentTime = sum(os.times()[:3]), time.time() - armCpuDelta = totalArmCpuTime - self._armCpuSampling[0] - armTimeDelta = currentTime - self._armCpuSampling[1] - pythonCpuTime = armCpuDelta / armTimeDelta - sysCallCpuTime = sysTools.getSysCpuUsage() - self.vals["stat/%armCpu"] = "%0.1f" % (100 * (pythonCpuTime + sysCallCpuTime)) - self._armCpuSampling = (totalArmCpuTime, currentTime) - - self._lastUpdate = currentTime - self.valsLock.release() - diff --git a/src/interface/logPanel.py b/src/interface/logPanel.py deleted file mode 100644 index 86e680f..0000000 --- a/src/interface/logPanel.py +++ /dev/null @@ -1,1100 +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 time -import os -import curses -import threading - -from TorCtl import TorCtl - -from version import VERSION -from util import conf, log, 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 - 67890 torctl runlevel+ 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 -DEFAULT_CONFIG = {"features.logFile": "", - "features.log.showDateDividers": True, - "features.log.showDuplicateEntries": False, - "features.log.entryDuration": 7, - "features.log.maxLinesPerEntry": 4, - "features.log.prepopulate": True, - "features.log.prepopulateReadLimit": 5000, - "features.log.maxRefreshRate": 300, - "cache.logPanel.size": 1000, - "log.logPanel.prepopulateSuccess": log.INFO, - "log.logPanel.prepopulateFailed": log.WARN, - "log.logPanel.logFileOpened": log.NOTICE, - "log.logPanel.logFileWriteFailed": log.ERR, - "log.logPanel.forceDoubleRedraw": log.DEBUG} - -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 - -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 runlevel and higher (ARM_DEBUG - ARM_ERR) - 67890 - torctl runlevel and higher (TORCTL_DEBUG - TORCTL_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.values()] - torctlRunlevels = ["TORCTL_" + runlevel for runlevel in log.Runlevel.values()] - expandedEvents = set(TOR_EVENT_TYPES.values() + armRunlevels + torctlRunlevels + ["UNKNOWN"]) - break - elif flag == "X": - expandedEvents = set() - break - elif flag in "DINWE1234567890": - # all events for a runlevel and higher - if flag in "DINWE": typePrefix = "" - elif flag in "12345": typePrefix = "ARM_" - elif flag in "67890": typePrefix = "TORCTL_" - - if flag in "D16": runlevelIndex = 0 - elif flag in "I27": runlevelIndex = 1 - elif flag in "N38": runlevelIndex = 2 - elif flag in "W49": runlevelIndex = 3 - elif flag in "E50": runlevelIndex = 4 - - runlevelSet = [typePrefix + runlevel for runlevel in log.Runlevel.values()[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 torctl 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") - - 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.getConfig("arm") - - COMMON_LOG_MESSAGES = {} - for confKey in armConf.getKeys(): - if confKey.startswith("msg."): - eventType = confKey[4:].upper() - messages = armConf.get(confKey, []) - COMMON_LOG_MESSAGES[eventType] = messages - -def getLogFileEntries(runlevels, readLimit = None, addLimit = None, config = 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) - config - configuration parameters related to this panel, uses defaults - if left as None - """ - - startTime = time.time() - if not runlevels: return [] - - if not config: config = DEFAULT_CONFIG - - # 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 = log.Runlevel.values() - 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 = sysTools.call("tail -n %i %s" % (readLimit, loggingLocation)) - if not lines: raise IOError() - else: - logFile = open(loggingLocation, "r") - lines = logFile.readlines() - logFile.close() - except IOError: - msg = "Unable to read tor's log file: %s" % loggingLocation - log.log(config["log.logPanel.prepopulateFailed"], msg) - - 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() - 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(".")] - - # overwrites missing time parameters with the local time (ignoring wday - # and yday since they aren't used) - eventTimeComp = list(time.strptime(timestamp, "%b %d %H:%M:%S")) - eventTimeComp[0] = currentLocalTime.tm_year - 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] - msg = "Read %i entries from tor's log file: %s (read limit: %i, runtime: %0.3f)" % (len(loggedEvents), loggingLocation, readLimit, time.time() - startTime) - log.log(config["log.logPanel.prepopulateSuccess"], msg) - 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 TorEventObserver(TorCtl.PostEventListener): - """ - Listens for all types of events provided by TorCtl, providing an LogEntry - instance to the given callback function. - """ - - def __init__(self, callback): - """ - Tor event listener with the purpose of translating events to nicely - formatted calls of a callback function. - - Arguments: - callback - function accepting a LogEntry, called when an event of these - types occur - """ - - TorCtl.PostEventListener.__init__(self) - self.callback = callback - - def circ_status_event(self, event): - msg = "ID: %-3s STATUS: %-10s PATH: %s" % (event.circ_id, event.status, ", ".join(event.path)) - if event.purpose: msg += " PURPOSE: %s" % event.purpose - if event.reason: msg += " REASON: %s" % event.reason - if event.remote_reason: msg += " REMOTE_REASON: %s" % event.remote_reason - self._notify(event, msg, "yellow") - - def buildtimeout_set_event(self, event): - self._notify(event, "SET_TYPE: %s, TOTAL_TIMES: %s, TIMEOUT_MS: %s, XM: %s, ALPHA: %s, CUTOFF_QUANTILE: %s" % (event.set_type, event.total_times, event.timeout_ms, event.xm, event.alpha, event.cutoff_quantile)) - - def stream_status_event(self, event): - self._notify(event, "ID: %s STATUS: %s CIRC_ID: %s TARGET: %s:%s REASON: %s REMOTE_REASON: %s SOURCE: %s SOURCE_ADDR: %s PURPOSE: %s" % (event.strm_id, event.status, event.circ_id, event.target_host, event.target_port, event.reason, event.remote_reason, event.source, event.source_addr, event.purpose)) - - def or_conn_status_event(self, event): - msg = "STATUS: %-10s ENDPOINT: %-20s" % (event.status, event.endpoint) - if event.age: msg += " AGE: %-3s" % event.age - if event.read_bytes: msg += " READ: %-4i" % event.read_bytes - if event.wrote_bytes: msg += " WRITTEN: %-4i" % event.wrote_bytes - if event.reason: msg += " REASON: %-6s" % event.reason - if event.ncircs: msg += " NCIRCS: %i" % event.ncircs - self._notify(event, msg) - - def stream_bw_event(self, event): - self._notify(event, "ID: %s READ: %s WRITTEN: %s" % (event.strm_id, event.bytes_read, event.bytes_written)) - - def bandwidth_event(self, event): - self._notify(event, "READ: %i, WRITTEN: %i" % (event.read, event.written), "cyan") - - def msg_event(self, event): - self._notify(event, event.msg, RUNLEVEL_EVENT_COLOR[event.level]) - - def new_desc_event(self, event): - idlistStr = [str(item) for item in event.idlist] - self._notify(event, ", ".join(idlistStr)) - - def address_mapped_event(self, event): - self._notify(event, "%s, %s -> %s" % (event.when, event.from_addr, event.to_addr)) - - def ns_event(self, event): - # NetworkStatus params: nickname, idhash, orhash, ip, orport (int), - # dirport (int), flags, idhex, bandwidth, updated (datetime) - msg = ", ".join(["%s (%s)" % (ns.idhex, ns.nickname) for ns in event.nslist]) - self._notify(event, "Listed (%i): %s" % (len(event.nslist), msg), "blue") - - def new_consensus_event(self, event): - msg = ", ".join(["%s (%s)" % (ns.idhex, ns.nickname) for ns in event.nslist]) - self._notify(event, "Listed (%i): %s" % (len(event.nslist), msg), "magenta") - - def unknown_event(self, event): - msg = "(%s) %s" % (event.event_name, event.event_string) - self.callback(LogEntry(event.arrived_at, "UNKNOWN", msg, "red")) - - def _notify(self, event, msg, color="white"): - self.callback(LogEntry(event.arrived_at, event.event_name, msg, color)) - -class LogPanel(panel.Panel, threading.Thread): - """ - Listens for and displays tor, arm, and torctl events. This can prepopulate - from tor's log file if it exists. - """ - - def __init__(self, stdscr, loggedEvents, config=None): - panel.Panel.__init__(self, stdscr, "log", 0) - threading.Thread.__init__(self) - self.setDaemon(True) - - self._config = dict(DEFAULT_CONFIG) - - if config: - config.update(self._config, { - "features.log.maxLinesPerEntry": 1, - "features.log.prepopulateReadLimit": 0, - "features.log.maxRefreshRate": 10, - "cache.logPanel.size": 1000}) - - # collapses duplicate log entries if false, showing only the most recent - self.showDuplicates = self._config["features.log.showDuplicateEntries"] - - self.msgLog = [] # log entries, sorted by the timestamp - self.loggedEvents = loggedEvents # events we're listening to - 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._isPaused = False - self._pauseBuffer = [] # location where messages are buffered if paused - - 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, _pauseBuffer - 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) - - # fetches past tor events from log file, if available - torEventBacklog = [] - if self._config["features.log.prepopulate"]: - setRunlevels = list(set.intersection(set(self.loggedEvents), set(log.Runlevel.values()))) - readLimit = self._config["features.log.prepopulateReadLimit"] - addLimit = self._config["cache.logPanel.size"] - torEventBacklog = getLogFileEntries(setRunlevels, readLimit, addLimit, self._config) - - # adds arm listener and fetches past events - log.LOG_LOCK.acquire() - try: - armRunlevels = [log.DEBUG, log.INFO, log.NOTICE, log.WARN, log.ERR] - log.addListeners(armRunlevels, self._registerArmEvent) - - # gets the set of arm events we're logging - setRunlevels = [] - for i in range(len(armRunlevels)): - if "ARM_" + log.Runlevel.values()[i] in self.loggedEvents: - setRunlevels.append(armRunlevels[i]) - - armEventBacklog = [] - for level, msg, eventTime in log._getEntries(setRunlevels): - armEventEntry = LogEntry(eventTime, "ARM_" + level, msg, RUNLEVEL_EVENT_COLOR[level]) - armEventBacklog.insert(0, armEventEntry) - - # joins armEventBacklog and torEventBacklog chronologically into msgLog - while armEventBacklog or torEventBacklog: - if not armEventBacklog: - self.msgLog.append(torEventBacklog.pop(0)) - elif not torEventBacklog: - self.msgLog.append(armEventBacklog.pop(0)) - elif armEventBacklog[0].timestamp < torEventBacklog[0].timestamp: - self.msgLog.append(torEventBacklog.pop(0)) - else: - self.msgLog.append(armEventBacklog.pop(0)) - finally: - log.LOG_LOCK.release() - - # crops events that are either too old, or more numerous than the caching size - self._trimEvents(self.msgLog) - - # leaving lastContentHeight as being too low causes initialization problems - self.lastContentHeight = len(self.msgLog) - - # adds listeners for tor and torctl events - conn = torTools.getConn() - conn.addEventListener(TorEventObserver(self.registerEvent)) - conn.addTorCtlListener(self._registerTorCtlEvent) - - # opens log file if we'll be saving entries - if self._config["features.logFile"]: - logPath = self._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.log(self._config["log.logPanel.logFileOpened"], "arm %s opening log file (%s)" % (VERSION, logPath)) - except (IOError, OSError), exc: - log.log(self._config["log.logPanel.logFileWriteFailed"], "Unable to write to log file: %s" % sysTools.getFileErrorMsg(exc)) - self.logFile = None - - 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.log(self._config["log.logPanel.logFileWriteFailed"], "Unable to write to log file: %s" % sysTools.getFileErrorMsg(exc)) - self.logFile = None - - if self._isPaused: - self.valsLock.acquire() - self._pauseBuffer.insert(0, event) - self._trimEvents(self._pauseBuffer) - self.valsLock.release() - else: - 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 _registerArmEvent(self, level, msg, eventTime): - eventColor = RUNLEVEL_EVENT_COLOR[level] - self.registerEvent(LogEntry(eventTime, "ARM_%s" % level, msg, eventColor)) - - def _registerTorCtlEvent(self, level, msg): - eventColor = RUNLEVEL_EVENT_COLOR[level] - self.registerEvent(LogEntry(time.time(), "TORCTL_%s" % level, msg, eventColor)) - - 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() - self.loggedEvents = eventTypes - self.redraw(True) - self.valsLock.release() - - 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 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 - """ - - # make dir if the path doesn't already exist - baseDir = os.path.dirname(path) - if not os.path.exists(baseDir): os.makedirs(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): - 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.showDuplicates = not self.showDuplicates - self.redraw(True) - self.valsLock.release() - - def setPaused(self, isPause): - """ - If true, prevents message log from being updated with new events. - """ - - if isPause == self._isPaused: return - - self._isPaused = isPause - if self._isPaused: self._pauseBuffer = [] - else: - self.valsLock.acquire() - self.msgLog = (self._pauseBuffer + self.msgLog)[:self._config["cache.logPanel.size"]] - self.redraw(True) - self.valsLock.release() - - 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. - """ - - self.valsLock.acquire() - self._lastLoggedEvents, self._lastUpdate = list(self.msgLog), time.time() - - # draws the top label - 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 self._config["features.log.showDateDividers"] - eventLog = getDaybreaks(self.msgLog, self._isPaused) if isDatesShown else list(self.msgLog) - if not self.showDuplicates: - deduplicatedLog = getDuplicates(eventLog) - - if deduplicatedLog == None: - msg = "Deduplication took too long. Its current implementation has difficulty handling large logs so disabling it to keep the interface responsive." - log.log(log.WARN, msg) - self.showDuplicates = 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 - 1, dividerAttr) - self.addch(lineCount, width, 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) - 2 - 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 = self._config["features.log.maxLinesPerEntry"] - while displayQueue: - msg, format, includeBreak = displayQueue.pop(0) - drawLine = lineCount + lineOffset - if lineOffset == maxEntriesPerLine: break - - maxMsgSize = width - cursorLoc - 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, 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 - 1, dividerAttr) - self.addch(lineCount, width, 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: - forceRedrawReason = "redrawing the log panel with the corrected content height (%s)" % forceRedrawReason - log.log(self._config["log.logPanel.forceDoubleRedraw"], 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 = self._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) - - def stop(self): - """ - Halts further resolutions and terminates the thread. - """ - - self._cond.acquire() - self._halt = True - self._cond.notifyAll() - self._cond.release() - - 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 torctl): - # - 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 = log.Runlevel.values() - reversedRunlevels.reverse() - for prefix in ("TORCTL_", "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 = self._config["cache.logPanel.size"] - if len(eventListing) > cacheSize: del eventListing[cacheSize:] - - logTTL = self._config["features.log.entryDuration"] - if logTTL > 0: - currentDay = daysSince() - - breakpoint = None # index at which to crop from - for i in range(len(eventListing) - 1, -1, -1): - daysSinceEvent = currentDay - daysSince(eventListing[i].timestamp) - if daysSinceEvent > logTTL: breakpoint = i # older than the ttl - else: break - - # removes entries older than the ttl - if breakpoint != None: del eventListing[breakpoint:] - diff --git a/src/interface/torrcPanel.py b/src/interface/torrcPanel.py deleted file mode 100644 index b7cad86..0000000 --- a/src/interface/torrcPanel.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -Panel displaying the torrc or armrc with the validation done against it. -""" - -import math -import curses -import threading - -from util import conf, enum, panel, torConfig, uiTools - -DEFAULT_CONFIG = {"features.config.file.showScrollbars": True, - "features.config.file.maxLinesPerEntry": 8} - -# 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, config=None): - panel.Panel.__init__(self, stdscr, "configFile", 0) - - self._config = dict(DEFAULT_CONFIG) - if config: - config.update(self._config, {"features.config.file.maxLinesPerEntry": 1}) - - self.valsLock = threading.RLock() - self.configType = configType - self.scroll = 0 - self.showLabel = True # shows top label (hides otherwise) - 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 - - def handleKey(self, key): - self.valsLock.acquire() - 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.showLineNum = not self.showLineNum - self._lastContentHeightArgs = None - self.redraw(True) - elif key == ord('s') or key == ord('S'): - self.stripComments = not self.stripComments - self._lastContentHeightArgs = None - self.redraw(True) - - self.valsLock.release() - - 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.getConfig("arm") - confLocation = loadedArmrc.path - renderedContents = list(loadedArmrc.rawContents) - - # 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 self._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.showLabel: - 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 = self._config["features.config.file.maxLinesPerEntry"] - displayQueue = [lineComp[entry] for entry in ("option", "argument", "correction", "comment")] - - while displayQueue: - msg, format = displayQueue.pop(0) - - maxMsgSize, includeBreak = width - cursorLoc, False - if len(msg) >= maxMsgSize: - # message is too long - break it up - if lineOffset == maxLinesPerEntry - 1: - msg = uiTools.cropStr(msg, maxMsgSize) - else: - includeBreak = True - msg, remainder = uiTools.cropStr(msg, maxMsgSize, 4, 4, uiTools.Ending.HYPHEN, True) - displayQueue.insert(0, (remainder.strip(), format)) - - drawLine = displayLine + lineOffset - if msg and drawLine < height and drawLine >= 1: - self.addstr(drawLine, cursorLoc, msg, format) - - # If we're done, and have added content to this line, then start - # further content on the next line. - cursorLoc += len(msg) - includeBreak |= not displayQueue and cursorLoc != lineNumOffset + scrollOffset - - if includeBreak: - lineOffset += 1 - cursorLoc = lineNumOffset + scrollOffset - - displayLine += max(lineOffset, 1) - - if trustLastContentHeight and displayLine >= height: break - - if not trustLastContentHeight: - self._lastContentHeightArgs = (width, height) - newContentHeight = displayLine + self.scroll - 1 - - if self._lastContentHeight != newContentHeight: - self._lastContentHeight = newContentHeight - self.redraw(True) - - self.valsLock.release() - diff --git a/src/starter.py b/src/starter.py index 09fc37a..c0a0270 100644 --- a/src/starter.py +++ b/src/starter.py @@ -14,8 +14,8 @@ import socket import platform
import version -import interface.controller -import interface.logPanel +import cli.controller +import cli.logPanel import util.conf import util.connections import util.hostnames @@ -67,7 +67,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"], DEFAULT_CONFIG, LOG_DUMP_PATH, CONFIG["startup.events"], interface.logPanel.EVENT_LISTING) +""" % (CONFIG["startup.interface.ipAddress"], CONFIG["startup.interface.port"], DEFAULT_CONFIG, LOG_DUMP_PATH, CONFIG["startup.events"], cli.logPanel.EVENT_LISTING)
# filename used for cached tor config descriptions CONFIG_DESC_FILENAME = "torConfigDesc.txt" @@ -312,7 +312,7 @@ if __name__ == '__main__':
# validates and expands log event flags try: - expandedEvents = interface.logPanel.expandEvents(param["startup.events"]) + expandedEvents = cli.logPanel.expandEvents(param["startup.events"]) except ValueError, exc: for flag in str(exc): print "Unrecognized event flag: %s" % flag @@ -387,5 +387,5 @@ if __name__ == '__main__': util.log.log(CONFIG["log.savingDebugLog"], "Saving a debug log to '%s' (please check it for sensitive information before sharing)" % LOG_DUMP_PATH) _dumpConfig()
- interface.controller.startTorMonitor(time.time() - initTime, expandedEvents, param["startup.blindModeEnabled"]) + cli.controller.startTorMonitor(time.time() - initTime, expandedEvents, param["startup.blindModeEnabled"])
tor-commits@lists.torproject.org