[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]
[tor-commits] [nyx/master] Move panels into their own module
commit fa3a3f0eb11eec7d5af69148f1fc1deff0f6d9bd
Author: Damian Johnson <atagar@xxxxxxxxxxxxxx>
Date: Tue Mar 8 09:40:12 2016 -0800
Move panels into their own module
Now that we're done overhauling the panels lets move them into their own
module. They've all had the same naming convention ('foo_panel.py') so this
just makes the grouping more explicit.
---
nyx/__init__.py | 5 -
nyx/config_panel.py | 357 -----------------------
nyx/connection_panel.py | 706 ---------------------------------------------
nyx/controller.py | 25 +-
nyx/graph_panel.py | 753 ------------------------------------------------
nyx/header_panel.py | 496 -------------------------------
nyx/log_panel.py | 454 -----------------------------
nyx/menu/actions.py | 8 +-
nyx/panel/__init__.py | 11 +
nyx/panel/config.py | 357 +++++++++++++++++++++++
nyx/panel/connection.py | 706 +++++++++++++++++++++++++++++++++++++++++++++
nyx/panel/graph.py | 753 ++++++++++++++++++++++++++++++++++++++++++++++++
nyx/panel/header.py | 496 +++++++++++++++++++++++++++++++
nyx/panel/log.py | 454 +++++++++++++++++++++++++++++
nyx/panel/torrc.py | 171 +++++++++++
nyx/torrc_panel.py | 171 -----------
nyx/util/log.py | 2 +-
setup.py | 2 +-
18 files changed, 2967 insertions(+), 2960 deletions(-)
diff --git a/nyx/__init__.py b/nyx/__init__.py
index 4d58435..25da9dd 100644
--- a/nyx/__init__.py
+++ b/nyx/__init__.py
@@ -14,14 +14,9 @@ __license__ = 'GPLv3'
__all__ = [
'arguments',
- 'config_panel',
- 'connection_panel',
'controller',
- 'header_panel',
- 'log_panel',
'popups',
'starter',
- 'torrc_panel',
]
diff --git a/nyx/config_panel.py b/nyx/config_panel.py
deleted file mode 100644
index 2352fe7..0000000
--- a/nyx/config_panel.py
+++ /dev/null
@@ -1,357 +0,0 @@
-"""
-Panel presenting the configuration state for tor or nyx. Options can be edited
-and the resulting configuration files saved.
-"""
-
-import curses
-import os
-
-import nyx.controller
-import nyx.popups
-
-import stem.control
-import stem.manual
-
-from nyx.util import DATA_DIR, panel, tor_controller, ui_tools
-
-from stem.util import conf, enum, log, str_tools
-
-SortAttr = enum.Enum('NAME', 'VALUE', 'VALUE_TYPE', 'CATEGORY', 'USAGE', 'SUMMARY', 'DESCRIPTION', 'MAN_PAGE_ENTRY', 'IS_SET')
-
-DETAILS_HEIGHT = 8
-NAME_WIDTH = 25
-VALUE_WIDTH = 15
-
-
-def conf_handler(key, value):
- if key == 'features.config.order':
- return conf.parse_enum_csv(key, value[0], SortAttr, 3)
-
-
-CONFIG = conf.config_dict('nyx', {
- 'attr.config.category_color': {},
- 'attr.config.sort_color': {},
- 'features.config.order': [SortAttr.MAN_PAGE_ENTRY, SortAttr.NAME, SortAttr.IS_SET],
- 'features.config.state.showPrivateOptions': False,
- 'features.config.state.showVirtualOptions': False,
-}, conf_handler)
-
-
-class ConfigEntry(object):
- """
- Configuration option presented in the panel.
-
- :var str name: name of the configuration option
- :var str value_type: type of value
- :var stem.manual.ConfigOption manual: manual information about the option
- """
-
- def __init__(self, name, value_type, manual):
- self.name = name
- self.value_type = value_type
- self.manual = manual.config_options.get(name, stem.manual.ConfigOption(name))
- self._index = manual.config_options.keys().index(name) if name in manual.config_options else 99999
-
- def value(self):
- """
- Provides the value of this configuration option.
-
- :returns: **str** representation of the current config value
- """
-
- values = tor_controller().get_conf(self.name, [], True)
-
- if not values:
- return '<none>'
- elif self.value_type == 'Boolean' and values[0] in ('0', '1'):
- return 'False' if values[0] == '0' else 'True'
- elif self.value_type == 'DataSize' and values[0].isdigit():
- return str_tools.size_label(int(values[0]))
- elif self.value_type == 'TimeInterval' and values[0].isdigit():
- return str_tools.time_label(int(values[0]), is_long = True)
- else:
- return ', '.join(values)
-
- def is_set(self):
- """
- Checks if the configuration option has a custom value.
-
- :returns: **True** if the option has a custom value, **False** otherwise
- """
-
- return tor_controller().is_set(self.name, False)
-
- def sort_value(self, attr):
- """
- Provides a heuristic for sorting by a given value.
-
- :param SortAttr attr: sort attribute to provide a heuristic for
-
- :returns: comparable value for sorting
- """
-
- if attr == SortAttr.CATEGORY:
- return self.manual.category
- elif attr == SortAttr.NAME:
- return self.name
- elif attr == SortAttr.VALUE:
- return self.value()
- elif attr == SortAttr.VALUE_TYPE:
- return self.value_type
- elif attr == SortAttr.USAGE:
- return self.manual.usage
- elif attr == SortAttr.SUMMARY:
- return self.manual.summary
- elif attr == SortAttr.DESCRIPTION:
- return self.manual.description
- elif attr == SortAttr.MAN_PAGE_ENTRY:
- return self._index
- elif attr == SortAttr.IS_SET:
- return not self.is_set()
-
-
-class ConfigPanel(panel.Panel):
- """
- Editor for tor's configuration.
- """
-
- def __init__(self, stdscr):
- panel.Panel.__init__(self, stdscr, 'configuration', 0)
-
- self._contents = []
- self._scroller = ui_tools.Scroller(True)
- self._sort_order = CONFIG['features.config.order']
- self._show_all = False # show all options, or just the important ones
-
- cached_manual_path = os.path.join(DATA_DIR, 'manual')
-
- if os.path.exists(cached_manual_path):
- manual = stem.manual.Manual.from_cache(cached_manual_path)
- else:
- try:
- manual = stem.manual.Manual.from_man()
-
- try:
- manual.save(cached_manual_path)
- except IOError as exc:
- log.debug("Unable to cache manual information to '%s'. This is fine, but means starting Nyx takes a little longer than usual: " % (cached_manual_path, exc))
- except IOError as exc:
- log.debug("Unable to use 'man tor' to get information about config options (%s), using bundled information instead" % exc)
- manual = stem.manual.Manual.from_cache()
-
- try:
- for line in tor_controller().get_info('config/names').splitlines():
- # Lines of the form "<option> <type>[ <documentation>]". Documentation
- # was apparently only in old tor versions like 0.2.1.25.
-
- if ' ' not in line:
- continue
-
- line_comp = line.split()
- name, value_type = line_comp[0], line_comp[1]
-
- # skips private and virtual entries if not configured to show them
-
- if name.startswith('__') and not CONFIG['features.config.state.showPrivateOptions']:
- continue
- elif value_type == 'Virtual' and not CONFIG['features.config.state.showVirtualOptions']:
- continue
-
- self._contents.append(ConfigEntry(name, value_type, manual))
-
- self._contents = sorted(self._contents, key = lambda entry: [entry.sort_value(field) for field in self._sort_order])
- except stem.ControllerError as exc:
- log.warn('Unable to determine the configuration options tor supports: %s' % exc)
-
- def show_sort_dialog(self):
- """
- Provides the dialog for sorting our configuration options.
- """
-
- sort_colors = dict([(attr, CONFIG['attr.config.sort_color'].get(attr, 'white')) for attr in SortAttr])
- results = nyx.popups.show_sort_dialog('Config Option Ordering:', SortAttr, self._sort_order, sort_colors)
-
- if results:
- self._sort_order = results
- self._contents = sorted(self._contents, key = lambda entry: [entry.sort_value(field) for field in self._sort_order])
-
- def show_write_dialog(self):
- """
- Confirmation dialog for saving tor's configuration.
- """
-
- selection, controller = 1, tor_controller()
- config_text = controller.get_info('config-text', None)
- config_lines = config_text.splitlines() if config_text else []
-
- with nyx.popups.popup_window(len(config_lines) + 2) as (popup, width, height):
- if not popup or height <= 2:
- return
-
- while True:
- height, width = popup.get_preferred_size() # allow us to be resized
- popup.win.erase()
-
- for i, full_line in enumerate(config_lines):
- line = str_tools.crop(full_line, width - 2)
- option, arg = line.split(' ', 1) if ' ' in line else (line, '')
-
- popup.addstr(i + 1, 1, option, curses.A_BOLD, 'green')
- popup.addstr(i + 1, len(option) + 2, arg, curses.A_BOLD, 'cyan')
-
- x = width - 16
-
- for i, option in enumerate(['Save', 'Cancel']):
- x = popup.addstr(height - 2, x, '[')
- x = popup.addstr(height - 2, x, option, curses.A_BOLD, curses.A_STANDOUT if i == selection else curses.A_NORMAL)
- x = popup.addstr(height - 2, x, '] ')
-
- popup.win.box()
- popup.addstr(0, 0, 'Torrc to save:', curses.A_STANDOUT)
- popup.win.refresh()
-
- key = nyx.controller.get_controller().key_input()
-
- if key.match('left'):
- selection = max(0, selection - 1)
- elif key.match('right'):
- selection = min(1, selection + 1)
- elif key.is_selection():
- if selection == 0:
- try:
- controller.save_conf()
- nyx.popups.show_msg('Saved configuration to %s' % controller.get_info('config-file', '<unknown>'), 2)
- except IOError as exc:
- nyx.popups.show_msg('Unable to save configuration (%s)' % exc.strerror, 2)
-
- break
- elif key.match('esc'):
- break # esc - cancel
-
- def handle_key(self, key):
- if key.is_scroll():
- page_height = self.get_preferred_size()[0] - DETAILS_HEIGHT
- is_changed = self._scroller.handle_key(key, self._get_config_options(), page_height)
-
- if is_changed:
- self.redraw(True)
- elif key.is_selection():
- selection = self._scroller.get_cursor_selection(self._get_config_options())
- initial_value = selection.value() if selection.is_set() else ''
- new_value = nyx.popups.input_prompt('%s Value (esc to cancel): ' % selection.name, initial_value)
-
- if new_value != initial_value:
- try:
- if selection.value_type == 'Boolean':
- # if the value's a boolean then allow for 'true' and 'false' inputs
-
- if new_value.lower() == 'true':
- new_value = '1'
- elif new_value.lower() == 'false':
- new_value = '0'
- elif selection.value_type == 'LineList':
- new_value = new_value.split(',') # set_conf accepts list inputs
-
- tor_controller().set_conf(selection.name, new_value)
- self.redraw(True)
- except Exception as exc:
- nyx.popups.show_msg('%s (press any key)' % exc)
- elif key.match('a'):
- self._show_all = not self._show_all
- self.redraw(True)
- elif key.match('s'):
- self.show_sort_dialog()
- elif key.match('w'):
- self.show_write_dialog()
- else:
- return False
-
- return True
-
- def get_help(self):
- return [
- ('up arrow', 'scroll up a line', None),
- ('down arrow', 'scroll down a line', None),
- ('page up', 'scroll up a page', None),
- ('page down', 'scroll down a page', None),
- ('enter', 'edit configuration option', None),
- ('w', 'write torrc', None),
- ('a', 'toggle filtering', None),
- ('s', 'sort ordering', None),
- ]
-
- def draw(self, width, height):
- contents = self._get_config_options()
- selection = self._scroller.get_cursor_selection(contents)
- scroll_location = self._scroller.get_scroll_location(contents, height - DETAILS_HEIGHT)
- is_scrollbar_visible = len(contents) > height - DETAILS_HEIGHT
-
- if selection is not None:
- self._draw_selection_details(selection, width)
-
- if self.is_title_visible():
- hidden_msg = "press 'a' to hide most options" if self._show_all else "press 'a' to show all options"
- self.addstr(0, 0, 'Tor Configuration (%s):' % hidden_msg, curses.A_STANDOUT)
-
- scroll_offset = 1
-
- if is_scrollbar_visible:
- scroll_offset = 3
- self.add_scroll_bar(scroll_location, scroll_location + height - DETAILS_HEIGHT, len(contents), DETAILS_HEIGHT)
-
- if selection is not None:
- self.addch(DETAILS_HEIGHT - 1, 1, curses.ACS_TTEE)
-
- # Description column can grow up to eighty characters. After that any extra
- # space goes to the value.
-
- description_width = max(0, width - scroll_offset - NAME_WIDTH - VALUE_WIDTH - 2)
-
- if description_width > 80:
- value_width = VALUE_WIDTH + (description_width - 80)
- description_width = 80
- else:
- value_width = VALUE_WIDTH
-
- for i, entry in enumerate(contents[scroll_location:]):
- attr = nyx.util.ui_tools.get_color(CONFIG['attr.config.category_color'].get(entry.manual.category, 'white'))
- attr |= curses.A_BOLD if entry.is_set() else curses.A_NORMAL
- attr |= curses.A_STANDOUT if entry == selection else curses.A_NORMAL
-
- option_label = str_tools.crop(entry.name, NAME_WIDTH).ljust(NAME_WIDTH + 1)
- value_label = str_tools.crop(entry.value(), value_width).ljust(value_width + 1)
- summary_label = str_tools.crop(entry.manual.summary, description_width).ljust(description_width)
-
- self.addstr(DETAILS_HEIGHT + i, scroll_offset, option_label + value_label + summary_label, attr)
-
- if DETAILS_HEIGHT + i >= height:
- break
-
- def _get_config_options(self):
- return self._contents if self._show_all else filter(lambda entry: stem.manual.is_important(entry.name) or entry.is_set(), self._contents)
-
- def _draw_selection_details(self, selection, width):
- """
- Shows details of the currently selected option.
- """
-
- description = 'Description: %s' % (selection.manual.description)
- attr = ', '.join(('custom' if selection.is_set() else 'default', selection.value_type, 'usage: %s' % selection.manual.usage))
- selected_color = CONFIG['attr.config.category_color'].get(selection.manual.category, 'white')
- ui_tools.draw_box(self, 0, 0, width, DETAILS_HEIGHT)
-
- self.addstr(1, 2, '%s (%s Option)' % (selection.name, selection.manual.category), curses.A_BOLD, selected_color)
- self.addstr(2, 2, 'Value: %s (%s)' % (selection.value(), str_tools.crop(attr, width - len(selection.value()) - 13)), curses.A_BOLD, selected_color)
-
- for i in range(DETAILS_HEIGHT - 4):
- if not description:
- break # done writing description
-
- line, description = description.split('\n', 1) if '\n' in description else (description, '')
-
- if i < DETAILS_HEIGHT - 5:
- line, remainder = str_tools.crop(line, width - 3, 4, 4, str_tools.Ending.HYPHEN, True)
- description = ' ' + remainder.strip() + description
- self.addstr(3 + i, 2, line, curses.A_BOLD, selected_color)
- else:
- self.addstr(3 + i, 2, str_tools.crop(line, width - 3, 4, 4), curses.A_BOLD, selected_color)
diff --git a/nyx/connection_panel.py b/nyx/connection_panel.py
deleted file mode 100644
index e28f531..0000000
--- a/nyx/connection_panel.py
+++ /dev/null
@@ -1,706 +0,0 @@
-"""
-Listing of the currently established connections tor has made.
-"""
-
-import re
-import time
-import collections
-import curses
-import itertools
-import threading
-
-import nyx.popups
-import nyx.util.tracker
-import nyx.util.ui_tools
-
-from nyx.util import panel, tor_controller, ui_tools
-
-from stem.control import Listener
-from stem.util import datetime_to_unix, conf, connection, enum, str_tools
-
-try:
- # added in python 3.2
- from functools import lru_cache
-except ImportError:
- from stem.util.lru_cache import lru_cache
-
-# height of the detail panel content, not counting top and bottom border
-
-DETAILS_HEIGHT = 7
-
-EXIT_USAGE_WIDTH = 15
-UPDATE_RATE = 5 # rate in seconds at which we refresh
-
-# cached information from our last _update() call
-
-LAST_RETRIEVED_HS_CONF = None
-LAST_RETRIEVED_CIRCUITS = None
-
-# 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 (nyx, vidalia, etc).
-
-Category = enum.Enum('INBOUND', 'OUTBOUND', 'EXIT', 'HIDDEN', 'SOCKS', 'CIRCUIT', 'DIRECTORY', 'CONTROL')
-SortAttr = enum.Enum('CATEGORY', 'UPTIME', 'IP_ADDRESS', 'PORT', 'FINGERPRINT', 'NICKNAME', 'COUNTRY')
-LineType = enum.Enum('CONNECTION', 'CIRCUIT_HEADER', 'CIRCUIT')
-
-Line = collections.namedtuple('Line', [
- 'entry',
- 'line_type',
- 'connection',
- 'circuit',
- 'fingerprint',
- 'nickname',
- 'locale',
-])
-
-
-def conf_handler(key, value):
- if key == 'features.connection.order':
- return conf.parse_enum_csv(key, value[0], SortAttr, 3)
-
-
-CONFIG = conf.config_dict('nyx', {
- 'attr.connection.category_color': {},
- 'attr.connection.sort_color': {},
- 'features.connection.resolveApps': True,
- 'features.connection.order': [SortAttr.CATEGORY, SortAttr.IP_ADDRESS, SortAttr.UPTIME],
- 'features.connection.showIps': True,
-}, conf_handler)
-
-
-class Entry(object):
- @staticmethod
- @lru_cache()
- def from_connection(connection):
- return ConnectionEntry(connection)
-
- @staticmethod
- @lru_cache()
- def from_circuit(circuit):
- return CircuitEntry(circuit)
-
- def get_lines(self):
- """
- Provides individual lines of connection information.
-
- :returns: **list** of **ConnectionLine** concerning this entry
- """
-
- raise NotImplementedError('should be implemented by subclasses')
-
- def get_type(self):
- """
- Provides our best guess at the type of connection this is.
-
- :returns: **Category** for the connection's type
- """
-
- raise NotImplementedError('should be implemented by subclasses')
-
- def is_private(self):
- """
- Checks if information about this endpoint should be scrubbed. 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!
-
- :returns: **bool** indicating if connection information is sensive or not
- """
-
- raise NotImplementedError('should be implemented by subclasses')
-
- def sort_value(self, attr):
- """
- Provides a heuristic for sorting by a given value.
-
- :param SortAttr attr: sort attribute to provide a heuristic for
-
- :returns: comparable value for sorting
- """
-
- line = self.get_lines()[0]
- at_end = 'z' * 20
-
- if attr == SortAttr.IP_ADDRESS:
- if self.is_private():
- return 255 ** 4 # orders at the end
-
- ip_value = 0
-
- for octet in line.connection.remote_address.split('.'):
- ip_value = ip_value * 255 + int(octet)
-
- return ip_value * 65536 + line.connection.remote_port
- elif attr == SortAttr.PORT:
- return line.connection.remote_port
- elif attr == SortAttr.FINGERPRINT:
- return line.fingerprint if line.fingerprint else at_end
- elif attr == SortAttr.NICKNAME:
- return line.nickname if line.nickname else at_end
- elif attr == SortAttr.CATEGORY:
- return Category.index_of(self.get_type())
- elif attr == SortAttr.UPTIME:
- return line.connection.start_time
- elif attr == SortAttr.COUNTRY:
- return line.locale if (line.locale and not self.is_private()) else at_end
- else:
- return ''
-
-
-class ConnectionEntry(Entry):
- def __init__(self, connection):
- self._connection = connection
-
- @lru_cache()
- def get_lines(self):
- fingerprint, nickname, locale = None, None, None
-
- if self.get_type() in (Category.OUTBOUND, Category.CIRCUIT, Category.DIRECTORY, Category.EXIT):
- fingerprint = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address).get(self._connection.remote_port)
-
- if fingerprint:
- nickname = nyx.util.tracker.get_consensus_tracker().get_relay_nickname(fingerprint)
- locale = tor_controller().get_info('ip-to-country/%s' % self._connection.remote_address, None)
-
- return [Line(self, LineType.CONNECTION, self._connection, None, fingerprint, nickname, locale)]
-
- @lru_cache()
- def get_type(self):
- controller = tor_controller()
-
- if self._connection.local_port in controller.get_ports(Listener.OR, []):
- return Category.INBOUND
- elif self._connection.local_port in controller.get_ports(Listener.DIR, []):
- return Category.INBOUND
- elif self._connection.local_port in controller.get_ports(Listener.SOCKS, []):
- return Category.SOCKS
- elif self._connection.local_port in controller.get_ports(Listener.CONTROL, []):
- return Category.CONTROL
-
- if LAST_RETRIEVED_HS_CONF:
- for hs_config in LAST_RETRIEVED_HS_CONF.values():
- if self._connection.remote_port == hs_config['HiddenServicePort']:
- return Category.HIDDEN
-
- fingerprint = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address).get(self._connection.remote_port)
-
- if fingerprint and LAST_RETRIEVED_CIRCUITS:
- for circ in LAST_RETRIEVED_CIRCUITS:
- if circ.path and len(circ.path) == 1 and circ.path[0][0] == fingerprint and circ.status == 'BUILT':
- return Category.DIRECTORY # one-hop circuit to retrieve directory information
- else:
- # not a known relay, might be an exit connection
-
- exit_policy = controller.get_exit_policy(None)
-
- if exit_policy and exit_policy.can_exit_to(self._connection.remote_address, self._connection.remote_port):
- return Category.EXIT
-
- return Category.OUTBOUND
-
- @lru_cache()
- def is_private(self):
- if not CONFIG['features.connection.showIps']:
- return True
-
- if self.get_type() == Category.INBOUND:
- controller = tor_controller()
-
- if controller.is_user_traffic_allowed().inbound:
- return len(nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address)) == 0
- elif self.get_type() == Category.EXIT:
- # DNS connections exiting us aren't private (since they're hitting our
- # resolvers). Everything else is.
-
- return not (self._connection.remote_port == 53 and self._connection.protocol == 'udp')
-
- return False # for everything else this isn't a concern
-
-
-class CircuitEntry(Entry):
- def __init__(self, circuit):
- self._circuit = circuit
-
- @lru_cache()
- def get_lines(self):
- def line(fingerprint, line_type):
- address, port, nickname, locale = '0.0.0.0', 0, None, None
- consensus_tracker = nyx.util.tracker.get_consensus_tracker()
-
- if fingerprint is not None:
- address, port = consensus_tracker.get_relay_address(fingerprint, ('192.168.0.1', 0))
- nickname = consensus_tracker.get_relay_nickname(fingerprint)
- locale = tor_controller().get_info('ip-to-country/%s' % address, None)
-
- connection = nyx.util.tracker.Connection(datetime_to_unix(self._circuit.created), False, '127.0.0.1', 0, address, port, 'tcp', False)
- return Line(self, line_type, connection, self._circuit, fingerprint, nickname, locale)
-
- header_line = line(self._circuit.path[-1][0] if self._circuit.status == 'BUILT' else None, LineType.CIRCUIT_HEADER)
- return [header_line] + [line(fp, LineType.CIRCUIT) for fp, _ in self._circuit.path]
-
- def get_type(self):
- return Category.CIRCUIT
-
- def is_private(self):
- return False
-
-
-class ConnectionPanel(panel.Panel, threading.Thread):
- """
- Listing of connections tor is making, with information correlated against
- the current consensus and other data sources.
- """
-
- def __init__(self, stdscr):
- panel.Panel.__init__(self, stdscr, 'connections', 0)
- threading.Thread.__init__(self)
- self.setDaemon(True)
-
- self._scroller = ui_tools.Scroller(True)
- self._entries = [] # last fetched display entries
- self._show_details = False # presents the details panel if true
- self._sort_order = CONFIG['features.connection.order']
-
- self._last_resource_fetch = -1 # timestamp of the last ConnectionResolver results used
-
- self._pause_condition = threading.Condition()
- self._halt = False # terminates thread if true
-
- # Tracks exiting port and client country statistics
-
- self._client_locale_usage = {}
- self._exit_port_usage = {}
- self._counted_connections = set()
-
- # If we're a bridge and been running over a day then prepopulates with the
- # last day's clients.
-
- bridge_clients = tor_controller().get_info('status/clients-seen', None)
-
- if bridge_clients:
- # Response has a couple arguments...
- # TimeStarted="2011-08-17 15:50:49" CountrySummary=us=16,de=8,uk=8
-
- country_summary = None
-
- for line in bridge_clients.split():
- if line.startswith('CountrySummary='):
- country_summary = line[15:]
- break
-
- if country_summary:
- for entry in country_summary.split(','):
- if re.match('^..=[0-9]+$', entry):
- locale, count = entry.split('=', 1)
- self._client_locale_usage[locale] = int(count)
-
- def show_sort_dialog(self):
- """
- Provides a dialog for sorting our connections.
- """
-
- sort_colors = dict([(attr, CONFIG['attr.connection.sort_color'].get(attr, 'white')) for attr in SortAttr])
- results = nyx.popups.show_sort_dialog('Connection Ordering:', SortAttr, self._sort_order, sort_colors)
-
- if results:
- self._sort_order = results
- self._entries = sorted(self._entries, key = lambda entry: [entry.sort_value(attr) for attr in self._sort_order])
-
- def handle_key(self, key):
- user_traffic_allowed = tor_controller().is_user_traffic_allowed()
-
- if key.is_scroll():
- page_height = self.get_preferred_size()[0] - 1
-
- if self._show_details:
- page_height -= (DETAILS_HEIGHT + 1)
-
- lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in self._entries]))
- is_changed = self._scroller.handle_key(key, lines, page_height)
-
- if is_changed:
- self.redraw(True)
- elif key.is_selection():
- self._show_details = not self._show_details
- self.redraw(True)
- elif key.match('s'):
- self.show_sort_dialog()
- elif key.match('r'):
- connection_tracker = nyx.util.tracker.get_connection_tracker()
- options = ['auto'] + list(connection.Resolver) + list(nyx.util.tracker.CustomResolver)
-
- resolver = connection_tracker.get_custom_resolver()
- selected_index = 0 if resolver is None else options.index(resolver)
- selection = nyx.popups.show_menu('Connection Resolver:', options, selected_index)
-
- if selection != -1:
- connection_tracker.set_custom_resolver(None if selection == 0 else options[selection])
- elif key.match('d'):
- self.set_title_visible(False)
- self.redraw(True)
- entries = self._entries
-
- while True:
- lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in entries]))
- selection = self._scroller.get_cursor_selection(lines)
-
- if not selection:
- break
-
- def is_close_key(key):
- return key.is_selection() or key.match('d') or key.match('left') or key.match('right')
-
- color = CONFIG['attr.connection.category_color'].get(selection.entry.get_type(), 'white')
- key = nyx.popups.show_descriptor_popup(selection.fingerprint, color, self.max_x, is_close_key)
-
- if not key or key.is_selection() or key.match('d'):
- break # closes popup
- elif key.match('left'):
- self.handle_key(panel.KeyInput(curses.KEY_UP))
- elif key.match('right'):
- self.handle_key(panel.KeyInput(curses.KEY_DOWN))
-
- self.set_title_visible(True)
- self.redraw(True)
- elif key.match('c') and user_traffic_allowed.inbound:
- nyx.popups.show_count_dialog('Client Locales', self._client_locale_usage)
- elif key.match('e') and user_traffic_allowed.outbound:
- counts = {}
- key_width = max(map(len, self._exit_port_usage.keys()))
-
- for k, v in self._exit_port_usage.items():
- usage = connection.port_usage(k)
-
- if usage:
- k = k.ljust(key_width + 3) + usage.ljust(EXIT_USAGE_WIDTH)
-
- counts[k] = v
-
- nyx.popups.show_count_dialog('Exiting Port Usage', counts)
- else:
- return False
-
- return True
-
- def run(self):
- """
- Keeps connections listing updated, checking for new entries at a set rate.
- """
-
- last_ran = -1
-
- while not self._halt:
- if self.is_paused() or not tor_controller().is_alive() or (time.time() - last_ran) < UPDATE_RATE:
- with self._pause_condition:
- if not self._halt:
- self._pause_condition.wait(0.2)
-
- continue # done waiting, try again
-
- self._update()
- self.redraw(True)
-
- # If this is our first run then fill in our fingerprint tracker. This
- # requires fetching all the router status entries which takes a few
- # seconds, so best done when we're finished with the rest of the first
- # iteration to hide the lag.
-
- if last_ran == -1:
- nyx.util.tracker.get_consensus_tracker().update(tor_controller().get_network_statuses([]))
-
- last_ran = time.time()
-
- def get_help(self):
- resolver = nyx.util.tracker.get_connection_tracker().get_custom_resolver()
- user_traffic_allowed = tor_controller().is_user_traffic_allowed()
-
- options = [
- ('up arrow', 'scroll up a line', None),
- ('down arrow', 'scroll down a line', None),
- ('page up', 'scroll up a page', None),
- ('page down', 'scroll down a page', None),
- ('enter', 'show connection details', None),
- ('d', 'raw consensus descriptor', None),
- ('s', 'sort ordering', None),
- ('r', 'connection resolver', 'auto' if resolver is None else resolver),
- ]
-
- if user_traffic_allowed.inbound:
- options.append(('c', 'client locale usage summary', None))
-
- if user_traffic_allowed.outbound:
- options.append(('e', 'exit port usage summary', None))
-
- return options
-
- def draw(self, width, height):
- controller = tor_controller()
- entries = self._entries
-
- lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in entries]))
- selected = self._scroller.get_cursor_selection(lines)
-
- if self.is_paused():
- current_time = self.get_pause_time()
- elif not controller.is_alive():
- current_time = controller.connection_time()
- else:
- current_time = time.time()
-
- is_showing_details = self._show_details and selected
- details_offset = DETAILS_HEIGHT + 1 if is_showing_details else 0
-
- is_scrollbar_visible = len(lines) > height - details_offset - 1
- scroll_offset = 2 if is_scrollbar_visible else 0
- scroll_location = self._scroller.get_scroll_location(lines, height - details_offset - 1)
-
- if self.is_title_visible():
- self._draw_title(entries, self._show_details)
-
- if is_showing_details:
- self._draw_details(selected, width, is_scrollbar_visible)
-
- if is_scrollbar_visible:
- self.add_scroll_bar(scroll_location, scroll_location + height - details_offset - 1, len(lines), 1 + details_offset)
-
- for line_number in range(scroll_location, len(lines)):
- y = line_number + details_offset + 1 - scroll_location
- self._draw_line(scroll_offset, y, lines[line_number], lines[line_number] == selected, width - scroll_offset, current_time)
-
- if y >= height:
- break
-
- def _draw_title(self, entries, showing_details):
- """
- Panel title with the number of connections we presently have.
- """
-
- if showing_details:
- self.addstr(0, 0, 'Connection Details:', curses.A_STANDOUT)
- elif not entries:
- self.addstr(0, 0, 'Connections:', curses.A_STANDOUT)
- else:
- counts = collections.Counter([entry.get_type() for entry in entries])
- count_labels = ['%i %s' % (counts[category], category.lower()) for category in Category if counts[category]]
- self.addstr(0, 0, 'Connections (%s):' % ', '.join(count_labels), curses.A_STANDOUT)
-
- def _draw_details(self, selected, width, is_scrollbar_visible):
- """
- Shows detailed information about the selected connection.
- """
-
- attr = (CONFIG['attr.connection.category_color'].get(selected.entry.get_type(), 'white'), curses.A_BOLD)
-
- if selected.line_type == LineType.CIRCUIT_HEADER and selected.circuit.status != 'BUILT':
- self.addstr(1, 2, 'Building Circuit...', *attr)
- else:
- address = '<scrubbed>' if selected.entry.is_private() else selected.connection.remote_address
- self.addstr(1, 2, 'address: %s:%s' % (address, selected.connection.remote_port), *attr)
- self.addstr(2, 2, 'locale: %s' % ('??' if selected.entry.is_private() else (selected.locale if selected.locale else '??')), *attr)
-
- matches = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(selected.connection.remote_address)
-
- if not matches:
- self.addstr(3, 2, 'No consensus data found', *attr)
- elif len(matches) == 1 or selected.connection.remote_port in matches:
- controller = tor_controller()
- fingerprint = matches.values()[0] if len(matches) == 1 else matches[selected.connection.remote_port]
- router_status_entry = controller.get_network_status(fingerprint, None)
-
- self.addstr(2, 15, 'fingerprint: %s' % fingerprint, *attr)
-
- if router_status_entry:
- dir_port_label = 'dirport: %s' % router_status_entry.dir_port if router_status_entry.dir_port else ''
- self.addstr(3, 2, 'nickname: %-25s orport: %-10s %s' % (router_status_entry.nickname, router_status_entry.or_port, dir_port_label), *attr)
- self.addstr(4, 2, 'published: %s' % router_status_entry.published.strftime("%H:%M %m/%d/%Y"), *attr)
- self.addstr(5, 2, 'flags: %s' % ', '.join(router_status_entry.flags), *attr)
-
- server_descriptor = controller.get_server_descriptor(fingerprint, None)
-
- if server_descriptor:
- policy_label = server_descriptor.exit_policy.summary() if server_descriptor.exit_policy else 'unknown'
- self.addstr(6, 2, 'exit policy: %s' % policy_label, *attr)
- self.addstr(4, 38, 'os: %-14s version: %s' % (server_descriptor.operating_system, server_descriptor.tor_version), *attr)
-
- if server_descriptor.contact:
- self.addstr(7, 2, 'contact: %s' % server_descriptor.contact, *attr)
- else:
- self.addstr(3, 2, 'Multiple matches, possible fingerprints are:', *attr)
-
- for i, port in enumerate(sorted(matches.keys())):
- is_last_line, remaining_relays = i == 3, len(matches) - i
-
- if not is_last_line or remaining_relays == 1:
- self.addstr(4 + i, 2, '%i. or port: %-5s fingerprint: %s' % (i + 1, port, matches[port]), *attr)
- else:
- self.addstr(4 + i, 2, '... %i more' % remaining_relays, *attr)
-
- if is_last_line:
- break
-
- # draw the border, with a 'T' pipe if connecting with the scrollbar
-
- ui_tools.draw_box(self, 0, 0, width, DETAILS_HEIGHT + 2)
-
- if is_scrollbar_visible:
- self.addch(DETAILS_HEIGHT + 1, 1, curses.ACS_TTEE)
-
- def _draw_line(self, x, y, line, is_selected, width, current_time):
- attr = nyx.util.ui_tools.get_color(CONFIG['attr.connection.category_color'].get(line.entry.get_type(), 'white'))
- attr |= curses.A_STANDOUT if is_selected else curses.A_NORMAL
-
- self.addstr(y, x, ' ' * (width - x), attr)
-
- if line.line_type == LineType.CIRCUIT:
- if line.circuit.path[-1][0] == line.fingerprint:
- prefix = (ord(' '), curses.ACS_LLCORNER, curses.ACS_HLINE, ord(' '))
- else:
- prefix = (ord(' '), curses.ACS_VLINE, ord(' '), ord(' '))
-
- for char in prefix:
- x = self.addch(y, x, char)
- else:
- x += 1 # offset from edge
-
- self._draw_address_column(x, y, line, attr)
- self._draw_line_details(57, y, line, width - 57 - 20, attr)
- self._draw_right_column(width - 18, y, line, current_time, attr)
-
- def _draw_address_column(self, x, y, line, attr):
- src = tor_controller().get_info('address', line.connection.local_address)
- src += ':%s' % line.connection.local_port if line.line_type == LineType.CONNECTION else ''
-
- if line.line_type == LineType.CIRCUIT_HEADER and line.circuit.status != 'BUILT':
- dst = 'Building...'
- else:
- dst = '<scrubbed>' if line.entry.is_private() else line.connection.remote_address
- dst += ':%s' % line.connection.remote_port
-
- if line.entry.get_type() == Category.EXIT:
- purpose = connection.port_usage(line.connection.remote_port)
-
- if purpose:
- dst += ' (%s)' % str_tools.crop(purpose, 26 - len(dst) - 3)
- elif not tor_controller().is_geoip_unavailable() and not line.entry.is_private():
- dst += ' (%s)' % (line.locale if line.locale else '??')
-
- if line.entry.get_type() in (Category.INBOUND, Category.SOCKS, Category.CONTROL):
- dst, src = src, dst
-
- if line.line_type == LineType.CIRCUIT:
- self.addstr(y, x, dst, attr)
- else:
- self.addstr(y, x, '%-21s --> %-26s' % (src, dst), attr)
-
- def _draw_line_details(self, x, y, line, width, attr):
- if line.line_type == LineType.CIRCUIT_HEADER:
- comp = ['Purpose: %s' % line.circuit.purpose.capitalize(), ', Circuit ID: %s' % line.circuit.id]
- elif line.entry.get_type() in (Category.SOCKS, Category.HIDDEN, Category.CONTROL):
- try:
- port = line.connection.local_port if line.entry.get_type() == Category.HIDDEN else line.connection.remote_port
- process = nyx.util.tracker.get_port_usage_tracker().fetch(port)
- comp = ['%s (%s)' % (process.name, process.pid) if process.pid else process.name]
- except nyx.util.tracker.UnresolvedResult:
- comp = ['resolving...']
- except nyx.util.tracker.UnknownApplication:
- comp = ['UNKNOWN']
- else:
- comp = ['%-40s' % (line.fingerprint if line.fingerprint else 'UNKNOWN'), ' ' + (line.nickname if line.nickname else 'UNKNOWN')]
-
- for entry in comp:
- if width >= x + len(entry):
- x = self.addstr(y, x, entry, attr)
- else:
- return
-
- def _draw_right_column(self, x, y, line, current_time, attr):
- if line.line_type == LineType.CIRCUIT:
- circ_path = [fp for fp, _ in line.circuit.path]
- circ_index = circ_path.index(line.fingerprint)
-
- if circ_index == len(circ_path) - 1:
- placement_type = 'Exit' if line.circuit.status == 'BUILT' else 'Extending'
- elif circ_index == 0:
- placement_type = 'Guard'
- else:
- placement_type = 'Middle'
-
- self.addstr(y, x + 4, '%i / %s' % (circ_index + 1, placement_type), attr)
- else:
- x = self.addstr(y, x, '+' if line.connection.is_legacy else ' ', attr)
- x = self.addstr(y, x, '%5s' % str_tools.time_label(current_time - line.connection.start_time, 1), attr)
- x = self.addstr(y, x, ' (', attr)
- x = self.addstr(y, x, line.entry.get_type().upper(), attr | curses.A_BOLD)
- x = self.addstr(y, x, ')', attr)
-
- def stop(self):
- """
- Halts further resolutions and terminates the thread.
- """
-
- with self._pause_condition:
- self._halt = True
- self._pause_condition.notifyAll()
-
- def _update(self):
- """
- Fetches the newest resolved connections.
- """
-
- global LAST_RETRIEVED_CIRCUITS, LAST_RETRIEVED_HS_CONF
-
- controller = tor_controller()
- LAST_RETRIEVED_CIRCUITS = controller.get_circuits([])
- LAST_RETRIEVED_HS_CONF = controller.get_hidden_service_conf({})
-
- conn_resolver = nyx.util.tracker.get_connection_tracker()
- current_resolution_count = conn_resolver.run_counter()
-
- if not conn_resolver.is_alive():
- return # if we're not fetching connections then this is a no-op
- elif current_resolution_count == self._last_resource_fetch:
- return # no new connections to process
-
- new_entries = [Entry.from_connection(conn) for conn in conn_resolver.get_value()]
-
- for circ in LAST_RETRIEVED_CIRCUITS:
- # Skips established single-hop circuits (these are for directory
- # fetches, not client circuits)
-
- if not (circ.status == 'BUILT' and len(circ.path) == 1):
- new_entries.append(Entry.from_circuit(circ))
-
- # update stats for client and exit connections
-
- for entry in new_entries:
- line = entry.get_lines()[0]
-
- if entry.is_private() and line.connection not in self._counted_connections:
- if entry.get_type() == Category.INBOUND and line.locale:
- self._client_locale_usage[line.locale] = self._client_locale_usage.get(line.locale, 0) + 1
- elif entry.get_type() == Category.EXIT:
- self._exit_port_usage[line.connection.remote_port] = self._exit_port_usage.get(line.connection.remote_port, 0) + 1
-
- self._counted_connections.add(line.connection)
-
- self._entries = sorted(new_entries, key = lambda entry: [entry.sort_value(attr) for attr in self._sort_order])
- self._last_resource_fetch = current_resolution_count
-
- if CONFIG['features.connection.resolveApps']:
- local_ports, remote_ports = [], []
-
- for entry in new_entries:
- line = entry.get_lines()[0]
-
- if entry.get_type() in (Category.SOCKS, Category.CONTROL):
- local_ports.append(line.connection.remote_port)
- elif entry.get_type() == Category.HIDDEN:
- remote_ports.append(line.connection.local_port)
-
- nyx.util.tracker.get_port_usage_tracker().query(local_ports, remote_ports)
diff --git a/nyx/controller.py b/nyx/controller.py
index e143b49..318df4b 100644
--- a/nyx/controller.py
+++ b/nyx/controller.py
@@ -9,14 +9,15 @@ import threading
import nyx.menu.menu
import nyx.popups
-import nyx.header_panel
-import nyx.log_panel
-import nyx.config_panel
-import nyx.torrc_panel
-import nyx.graph_panel
-import nyx.connection_panel
import nyx.util.tracker
+import nyx.panel.config
+import nyx.panel.connection
+import nyx.panel.graph
+import nyx.panel.header
+import nyx.panel.log
+import nyx.panel.torrc
+
import stem
from stem.util import conf, log
@@ -102,29 +103,29 @@ class Controller:
self._screen = stdscr
self._sticky_panels = [
- nyx.header_panel.HeaderPanel(stdscr),
+ nyx.panel.header.HeaderPanel(stdscr),
LabelPanel(stdscr),
]
self._page_panels, first_page_panels = [], []
if CONFIG['features.panels.show.graph']:
- first_page_panels.append(nyx.graph_panel.GraphPanel(stdscr))
+ first_page_panels.append(nyx.panel.graph.GraphPanel(stdscr))
if CONFIG['features.panels.show.log']:
- first_page_panels.append(nyx.log_panel.LogPanel(stdscr))
+ first_page_panels.append(nyx.panel.log.LogPanel(stdscr))
if first_page_panels:
self._page_panels.append(first_page_panels)
if CONFIG['features.panels.show.connection']:
- self._page_panels.append([nyx.connection_panel.ConnectionPanel(stdscr)])
+ self._page_panels.append([nyx.panel.connection.ConnectionPanel(stdscr)])
if CONFIG['features.panels.show.config']:
- self._page_panels.append([nyx.config_panel.ConfigPanel(stdscr)])
+ self._page_panels.append([nyx.panel.config.ConfigPanel(stdscr)])
if CONFIG['features.panels.show.torrc']:
- self._page_panels.append([nyx.torrc_panel.TorrcPanel(stdscr)])
+ self._page_panels.append([nyx.panel.torrc.TorrcPanel(stdscr)])
self.quit_signal = False
self._page = 0
diff --git a/nyx/graph_panel.py b/nyx/graph_panel.py
deleted file mode 100644
index 086d04d..0000000
--- a/nyx/graph_panel.py
+++ /dev/null
@@ -1,753 +0,0 @@
-"""
-Graphs of tor related statistics. For example...
-
-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 collections
-import copy
-import curses
-import time
-
-import nyx.controller
-import nyx.popups
-import nyx.util.tracker
-
-from stem.control import EventType, Listener
-from stem.util import conf, enum, log, str_tools, system
-from nyx.util import join, msg, panel, tor_controller
-
-GraphStat = enum.Enum(('BANDWIDTH', 'bandwidth'), ('CONNECTIONS', 'connections'), ('SYSTEM_RESOURCES', 'resources'))
-Interval = enum.Enum(('EACH_SECOND', 'each second'), ('FIVE_SECONDS', '5 seconds'), ('THIRTY_SECONDS', '30 seconds'), ('MINUTELY', 'minutely'), ('FIFTEEN_MINUTE', '15 minute'), ('THIRTY_MINUTE', '30 minute'), ('HOURLY', 'hourly'), ('DAILY', 'daily'))
-Bounds = enum.Enum(('GLOBAL_MAX', 'global_max'), ('LOCAL_MAX', 'local_max'), ('TIGHT', 'tight'))
-
-DrawAttributes = collections.namedtuple('DrawAttributes', ('stat', 'subgraph_height', 'subgraph_width', 'interval', 'bounds_type', 'accounting'))
-
-INTERVAL_SECONDS = {
- Interval.EACH_SECOND: 1,
- Interval.FIVE_SECONDS: 5,
- Interval.THIRTY_SECONDS: 30,
- Interval.MINUTELY: 60,
- Interval.FIFTEEN_MINUTE: 900,
- Interval.THIRTY_MINUTE: 1800,
- Interval.HOURLY: 3600,
- Interval.DAILY: 86400,
-}
-
-PRIMARY_COLOR, SECONDARY_COLOR = 'green', 'cyan'
-
-ACCOUNTING_RATE = 5
-DEFAULT_CONTENT_HEIGHT = 4 # space needed for labeling above and below the graph
-WIDE_LABELING_GRAPH_COL = 50 # minimum graph columns to use wide spacing for x-axis labels
-COLLAPSE_WIDTH = 135 # width at which to move optional stats from the title to x-axis label
-
-
-def conf_handler(key, value):
- if key == 'features.graph.height':
- return max(1, value)
- elif key == 'features.graph.max_width':
- return max(1, value)
- elif key == 'features.graph.type':
- if value != 'none' and value not in GraphStat:
- log.warn("'%s' isn't a valid graph type, options are: none, %s" % (CONFIG['features.graph.type'], ', '.join(GraphStat)))
- return CONFIG['features.graph.type'] # keep the default
- elif key == 'features.graph.interval':
- if value not in Interval:
- log.warn("'%s' isn't a valid graphing interval, options are: %s" % (value, ', '.join(Interval)))
- return CONFIG['features.graph.interval'] # keep the default
- elif key == 'features.graph.bound':
- if value not in Bounds:
- log.warn("'%s' isn't a valid graph bounds, options are: %s" % (value, ', '.join(Bounds)))
- return CONFIG['features.graph.bound'] # keep the default
-
-
-CONFIG = conf.config_dict('nyx', {
- 'attr.hibernate_color': {},
- 'attr.graph.title': {},
- 'attr.graph.header.primary': {},
- 'attr.graph.header.secondary': {},
- 'features.graph.height': 7,
- 'features.graph.type': GraphStat.BANDWIDTH,
- 'features.graph.interval': Interval.EACH_SECOND,
- 'features.graph.bound': Bounds.LOCAL_MAX,
- 'features.graph.max_width': 300, # we need some sort of max size so we know how much graph data to retain
- 'features.panels.show.connection': True,
- 'features.graph.bw.transferInBytes': False,
- 'features.graph.bw.accounting.show': True,
-}, conf_handler)
-
-
-class GraphData(object):
- """
- Graphable statistical information.
-
- :var int latest_value: last value we recorded
- :var int total: sum of all values we've recorded
- :var int tick: number of events we've processed
- :var dict values: mapping of intervals to an array of samplings from newest to oldest
- :var dict max_value: mapping of intervals to the maximum value it has had
- """
-
- def __init__(self, clone = None, category = None, is_primary = True):
- if clone:
- self.latest_value = clone.latest_value
- self.total = clone.total
- self.tick = clone.tick
- self.values = copy.deepcopy(clone.values)
- self.max_value = dict(clone.max_value)
-
- self._category = clone._category
- self._is_primary = clone._is_primary
- self._in_process_value = dict(clone._in_process_value)
- else:
- self.latest_value = 0
- self.total = 0
- self.tick = 0
- self.values = dict([(i, CONFIG['features.graph.max_width'] * [0]) for i in Interval])
- self.max_value = dict([(i, 0) for i in Interval])
-
- self._category = category
- self._is_primary = is_primary
- self._in_process_value = dict([(i, 0) for i in Interval])
-
- def average(self):
- return self.total / max(1, self.tick)
-
- def update(self, new_value):
- self.latest_value = new_value
- self.total += new_value
- self.tick += 1
-
- for interval in Interval:
- interval_seconds = INTERVAL_SECONDS[interval]
- self._in_process_value[interval] += new_value
-
- if self.tick % interval_seconds == 0:
- new_entry = self._in_process_value[interval] / interval_seconds
- self.values[interval] = [new_entry] + self.values[interval][:-1]
- self.max_value[interval] = max(self.max_value[interval], new_entry)
- self._in_process_value[interval] = 0
-
- def header(self, width):
- """
- Provides the description above a subgraph.
-
- :param int width: maximum length of the header
-
- :returns: **str** with our graph header
- """
-
- return self._category._header(width, self._is_primary)
-
- def y_axis_label(self, value):
- """
- Provides the label we should display on our y-axis.
-
- :param int value: value being shown on the y-axis
-
- :returns: **str** with our y-axis label
- """
-
- return self._category._y_axis_label(value, self._is_primary)
-
-
-class GraphCategory(object):
- """
- Category for the graph. This maintains two subgraphs, updating them each
- second with updated stats.
-
- :var GraphData primary: first subgraph
- :var GraphData secondary: second subgraph
- :var float start_time: unix timestamp for when we started
- """
-
- def __init__(self, clone = None):
- if clone:
- self.primary = GraphData(clone.primary)
- self.secondary = GraphData(clone.secondary)
- self.start_time = clone.start_time
- self._title_stats = list(clone._title_stats)
- self._primary_header_stats = list(clone._primary_header_stats)
- self._secondary_header_stats = list(clone._secondary_header_stats)
- else:
- self.primary = GraphData(category = self, is_primary = True)
- self.secondary = GraphData(category = self, is_primary = False)
- self.start_time = time.time()
- self._title_stats = []
- self._primary_header_stats = []
- self._secondary_header_stats = []
-
- def stat_type(self):
- """
- Provides the GraphStat this graph is for.
-
- :returns: **GraphStat** of this graph
- """
-
- raise NotImplementedError('Should be implemented by subclasses')
-
- def title(self, width):
- """
- Provides a graph title that fits in the given width.
-
- :param int width: maximum length of the title
-
- :returns: **str** with our title
- """
-
- title = CONFIG['attr.graph.title'].get(self.stat_type(), '')
- title_stats = join(self._title_stats, ', ', width - len(title) - 4)
- return '%s (%s):' % (title, title_stats) if title_stats else title + ':'
-
- def bandwidth_event(self, event):
- """
- 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 per second).
- """
-
- pass
-
- def _header(self, is_primary, width):
- if is_primary:
- header = CONFIG['attr.graph.header.primary'].get(self.stat_type(), '')
- header_stats = self._primary_header_stats
- else:
- header = CONFIG['attr.graph.header.secondary'].get(self.stat_type(), '')
- header_stats = self._secondary_header_stats
-
- header_stats = join(header_stats, '', width - len(header) - 4)
- return '%s (%s):' % (header, header_stats) if header_stats else '%s:' % header
-
- def _y_axis_label(self, value, is_primary):
- return str(value)
-
-
-class BandwidthStats(GraphCategory):
- """
- Tracks tor's bandwidth usage.
- """
-
- def __init__(self, clone = None):
- GraphCategory.__init__(self, clone)
-
- if not clone:
- # fill in past bandwidth information
-
- controller = tor_controller()
- bw_entries, is_successful = controller.get_info('bw-event-cache', None), True
-
- if bw_entries:
- for entry in bw_entries.split():
- entry_comp = entry.split(',')
-
- if len(entry_comp) != 2 or not entry_comp[0].isdigit() or not entry_comp[1].isdigit():
- log.warn(msg('panel.graphing.bw_event_cache_malformed', response = bw_entries))
- is_successful = False
- break
-
- self.primary.update(int(entry_comp[0]))
- self.secondary.update(int(entry_comp[1]))
-
- if is_successful:
- log.info(msg('panel.graphing.prepopulation_successful', duration = str_tools.time_label(len(bw_entries.split()), is_long = True)))
-
- read_total = controller.get_info('traffic/read', None)
- write_total = controller.get_info('traffic/written', None)
- start_time = system.start_time(controller.get_pid(None))
-
- if read_total and write_total and start_time:
- self.primary.total = int(read_total)
- self.secondary.total = int(write_total)
- self.start_time = start_time
-
- def stat_type(self):
- return GraphStat.BANDWIDTH
-
- def _y_axis_label(self, value, is_primary):
- return _size_label(value, 0)
-
- def bandwidth_event(self, event):
- self.primary.update(event.read)
- self.secondary.update(event.written)
-
- self._primary_header_stats = [
- '%-14s' % ('%s/sec' % _size_label(self.primary.latest_value)),
- '- avg: %s/sec' % _size_label(self.primary.total / (time.time() - self.start_time)),
- ', total: %s' % _size_label(self.primary.total),
- ]
-
- self._secondary_header_stats = [
- '%-14s' % ('%s/sec' % _size_label(self.secondary.latest_value)),
- '- avg: %s/sec' % _size_label(self.secondary.total / (time.time() - self.start_time)),
- ', total: %s' % _size_label(self.secondary.total),
- ]
-
- controller = tor_controller()
-
- stats = []
- bw_rate = controller.get_effective_rate(None)
- bw_burst = controller.get_effective_rate(None, burst = True)
-
- if bw_rate and bw_burst:
- bw_rate_label = _size_label(bw_rate)
- bw_burst_label = _size_label(bw_burst)
-
- # if both are using rounded values then strip off the '.0' decimal
-
- if '.0' in bw_rate_label and '.0' in bw_burst_label:
- bw_rate_label = bw_rate_label.split('.', 1)[0]
- bw_burst_label = bw_burst_label.split('.', 1)[0]
-
- stats.append('limit: %s/s' % bw_rate_label)
- stats.append('burst: %s/s' % bw_burst_label)
-
- my_router_status_entry = controller.get_network_status(default = None)
- measured_bw = getattr(my_router_status_entry, 'bandwidth', None)
-
- if measured_bw:
- stats.append('measured: %s/s' % _size_label(measured_bw))
- else:
- my_server_descriptor = controller.get_server_descriptor(default = None)
- observed_bw = getattr(my_server_descriptor, 'observed_bandwidth', None)
-
- if observed_bw:
- stats.append('observed: %s/s' % _size_label(observed_bw))
-
- self._title_stats = stats
-
-
-class ConnectionStats(GraphCategory):
- """
- Tracks number of inbound and outbound connections.
- """
-
- def stat_type(self):
- return GraphStat.CONNECTIONS
-
- def bandwidth_event(self, event):
- inbound_count, outbound_count = 0, 0
-
- controller = tor_controller()
- or_ports = controller.get_ports(Listener.OR, [])
- dir_ports = controller.get_ports(Listener.DIR, [])
- control_ports = controller.get_ports(Listener.CONTROL, [])
-
- for entry in nyx.util.tracker.get_connection_tracker().get_value():
- if entry.local_port in or_ports or entry.local_port in dir_ports:
- inbound_count += 1
- elif entry.local_port in control_ports:
- pass # control connection
- else:
- outbound_count += 1
-
- self.primary.update(inbound_count)
- self.secondary.update(outbound_count)
-
- self._primary_header_stats = [str(self.primary.latest_value), ', avg: %s' % self.primary.average()]
- self._secondary_header_stats = [str(self.secondary.latest_value), ', avg: %s' % self.secondary.average()]
-
-
-class ResourceStats(GraphCategory):
- """
- Tracks cpu and memory usage of the tor process.
- """
-
- def stat_type(self):
- return GraphStat.SYSTEM_RESOURCES
-
- def _y_axis_label(self, value, is_primary):
- return '%i%%' % value if is_primary else str_tools.size_label(value)
-
- def bandwidth_event(self, event):
- resources = nyx.util.tracker.get_resource_tracker().get_value()
- self.primary.update(resources.cpu_sample * 100) # decimal percentage to whole numbers
- self.secondary.update(resources.memory_bytes)
-
- self._primary_header_stats = ['%0.1f%%' % self.primary.latest_value, ', avg: %0.1f%%' % self.primary.average()]
- self._secondary_header_stats = [str_tools.size_label(self.secondary.latest_value, 1), ', avg: %s' % str_tools.size_label(self.secondary.average(), 1)]
-
-
-class GraphPanel(panel.Panel):
- """
- Panel displaying graphical information of GraphCategory instances.
- """
-
- def __init__(self, stdscr):
- panel.Panel.__init__(self, stdscr, 'graph', 0)
-
- self._displayed_stat = None if CONFIG['features.graph.type'] == 'none' else CONFIG['features.graph.type']
- self._update_interval = CONFIG['features.graph.interval']
- self._bounds = CONFIG['features.graph.bound']
- self._graph_height = CONFIG['features.graph.height']
-
- self._accounting_stats = None
-
- self._stats = {
- GraphStat.BANDWIDTH: BandwidthStats(),
- GraphStat.SYSTEM_RESOURCES: ResourceStats(),
- }
-
- if CONFIG['features.panels.show.connection']:
- self._stats[GraphStat.CONNECTIONS] = ConnectionStats()
- elif self._displayed_stat == GraphStat.CONNECTIONS:
- log.warn("The connection graph is unavailble when you set 'features.panels.show.connection false'.")
- self._displayed_stat = GraphStat.BANDWIDTH
-
- self.set_pause_attr('_stats')
- self.set_pause_attr('_accounting_stats')
-
- controller = tor_controller()
- controller.add_event_listener(self._update_accounting, EventType.BW)
- controller.add_event_listener(self._update_stats, EventType.BW)
- controller.add_status_listener(lambda *args: self.redraw(True))
-
- @property
- def displayed_stat(self):
- return self._displayed_stat
-
- @displayed_stat.setter
- def displayed_stat(self, value):
- if value is not None and value not in self._stats.keys():
- raise ValueError("%s isn't a graphed statistic" % value)
-
- self._displayed_stat = value
-
- def stat_options(self):
- return self._stats.keys()
-
- @property
- def update_interval(self):
- return self._update_interval
-
- @update_interval.setter
- def update_interval(self, value):
- if value not in Interval:
- raise ValueError("%s isn't a valid graphing update interval" % value)
-
- self._update_interval = value
-
- @property
- def bounds_type(self):
- return self._bounds
-
- @bounds_type.setter
- def bounds_type(self, value):
- if value not in Bounds:
- raise ValueError("%s isn't a valid type of bounds" % value)
-
- self._bounds = value
-
- def get_height(self):
- """
- Provides the height of the content.
- """
-
- if not self.displayed_stat:
- return 0
-
- height = DEFAULT_CONTENT_HEIGHT + self._graph_height
-
- if self.displayed_stat == GraphStat.BANDWIDTH and self._accounting_stats:
- height += 3
-
- return height
-
- def set_graph_height(self, new_graph_height):
- self._graph_height = max(1, new_graph_height)
-
- def resize_graph(self):
- """
- Prompts for user input to resize the graph panel. Options include...
-
- * down arrow - grow graph
- * up arrow - shrink graph
- * enter / space - set size
- """
-
- control = nyx.controller.get_controller()
-
- with panel.CURSES_LOCK:
- try:
- while True:
- msg = 'press the down/up to resize the graph, and enter when done'
- control.set_msg(msg, curses.A_BOLD, True)
- curses.cbreak()
- key = control.key_input()
-
- if key.match('down'):
- # don't grow the graph if it's already consuming the whole display
- # (plus an extra line for the graph/log gap)
-
- max_height = self.parent.getmaxyx()[0] - self.top
- current_height = self.get_height()
-
- if current_height < max_height + 1:
- self.set_graph_height(self._graph_height + 1)
- elif key.match('up'):
- self.set_graph_height(self._graph_height - 1)
- elif key.is_selection():
- break
-
- control.redraw()
- finally:
- control.set_msg()
-
- def handle_key(self, key):
- if key.match('r'):
- self.resize_graph()
- elif key.match('b'):
- # uses the next boundary type
- self.bounds_type = Bounds.next(self.bounds_type)
- self.redraw(True)
- elif key.match('s'):
- # provides a menu to pick the graphed stats
-
- available_stats = sorted(self.stat_options())
- options = ['None'] + [stat.capitalize() for stat in available_stats]
- initial_selection = available_stats.index(self.displayed_stat) + 1 if self.displayed_stat else 0
-
- selection = nyx.popups.show_menu('Graphed Stats:', options, initial_selection)
-
- # applies new setting
-
- if selection == 0:
- self.displayed_stat = None
- elif selection != -1:
- self.displayed_stat = available_stats[selection - 1]
- elif key.match('i'):
- # provides menu to pick graph panel update interval
-
- selection = nyx.popups.show_menu('Update Interval:', list(Interval), list(Interval).index(self.update_interval))
-
- if selection != -1:
- self.update_interval = list(Interval)[selection]
- else:
- return False
-
- return True
-
- def get_help(self):
- return [
- ('r', 'resize graph', None),
- ('s', 'graphed stats', self.displayed_stat if self.displayed_stat else 'none'),
- ('b', 'graph bounds', self.bounds_type.replace('_', ' ')),
- ('i', 'graph update interval', self.update_interval),
- ]
-
- def draw(self, width, height):
- if not self.displayed_stat:
- return
-
- stat = self.get_attr('_stats')[self.displayed_stat]
-
- attr = DrawAttributes(
- stat = type(stat)(stat), # clone the GraphCategory
- subgraph_height = self._graph_height + 2, # graph rows + header + x-axis label
- subgraph_width = min(width / 2, CONFIG['features.graph.max_width']),
- interval = self.update_interval,
- bounds_type = self.bounds_type,
- accounting = self.get_attr('_accounting_stats'),
- )
-
- if self.is_title_visible():
- self.addstr(0, 0, attr.stat.title(width), curses.A_STANDOUT)
-
- self._draw_subgraph(attr, attr.stat.primary, 0, PRIMARY_COLOR)
- self._draw_subgraph(attr, attr.stat.secondary, attr.subgraph_width, SECONDARY_COLOR)
-
- if attr.stat.stat_type() == GraphStat.BANDWIDTH:
- if width <= COLLAPSE_WIDTH:
- self._draw_bandwidth_stats(attr, width)
-
- if attr.accounting:
- self._draw_accounting_stats(attr)
-
- def _draw_subgraph(self, attr, data, x, color):
- # Concering our subgraph colums, the y-axis label can be at most six
- # characters, with two spaces of padding on either side of the graph.
- # Starting with the smallest size, then possibly raise it after determing
- # the y_axis_labels.
-
- subgraph_columns = attr.subgraph_width - 8
- min_bound, max_bound = self._get_graph_bounds(attr, data, subgraph_columns)
-
- x_axis_labels = self._get_x_axis_labels(attr, subgraph_columns)
- y_axis_labels = self._get_y_axis_labels(attr, data, min_bound, max_bound)
- subgraph_columns = max(subgraph_columns, attr.subgraph_width - max([len(label) for label in y_axis_labels.values()]) - 2)
- axis_offset = max([len(label) for label in y_axis_labels.values()])
-
- self.addstr(1, x, data.header(attr.subgraph_width), curses.A_BOLD, color)
-
- for x_offset, label in x_axis_labels.items():
- self.addstr(attr.subgraph_height, x + x_offset + axis_offset, label, color)
-
- for y, label in y_axis_labels.items():
- self.addstr(y, x, label, color)
-
- for col in range(subgraph_columns):
- column_count = int(data.values[attr.interval][col]) - min_bound
- column_height = int(min(attr.subgraph_height - 2, (attr.subgraph_height - 2) * column_count / (max(1, max_bound) - min_bound)))
-
- for row in range(column_height):
- self.addstr(attr.subgraph_height - 1 - row, x + col + axis_offset + 1, ' ', curses.A_STANDOUT, color)
-
- def _get_graph_bounds(self, attr, data, subgraph_columns):
- """
- Provides the range the graph shows (ie, its minimum and maximum value).
- """
-
- min_bound, max_bound = 0, 0
- values = data.values[attr.interval][:subgraph_columns]
-
- if attr.bounds_type == Bounds.GLOBAL_MAX:
- max_bound = data.max_value[attr.interval]
- elif subgraph_columns > 0:
- max_bound = max(values) # local maxima
-
- if attr.bounds_type == Bounds.TIGHT and subgraph_columns > 0:
- min_bound = min(values)
-
- # if the max = min pick zero so we still display something
-
- if min_bound == max_bound:
- min_bound = 0
-
- return min_bound, max_bound
-
- def _get_y_axis_labels(self, attr, data, min_bound, max_bound):
- """
- Provides the labels for the y-axis. This is a mapping of the position it
- should be drawn at to its text.
- """
-
- y_axis_labels = {
- 2: data.y_axis_label(max_bound),
- attr.subgraph_height - 1: data.y_axis_label(min_bound),
- }
-
- ticks = (attr.subgraph_height - 5) / 2
-
- for i in range(ticks):
- row = attr.subgraph_height - (2 * i) - 5
-
- if attr.subgraph_height % 2 == 0 and i >= (ticks / 2):
- row -= 1 # make extra gap be in the middle when we're an even size
-
- val = (max_bound - min_bound) * (attr.subgraph_height - row - 3) / (attr.subgraph_height - 3)
-
- if val not in (min_bound, max_bound):
- y_axis_labels[row + 2] = data.y_axis_label(val)
-
- return y_axis_labels
-
- def _get_x_axis_labels(self, attr, subgraph_columns):
- """
- Provides the labels for the x-axis. We include the units for only its first
- value, then bump the precision for subsequent units. For example...
-
- 10s, 20, 30, 40, 50, 1m, 1.1, 1.3, 1.5
- """
-
- x_axis_labels = {}
-
- interval_sec = INTERVAL_SECONDS[attr.interval]
- interval_spacing = 10 if subgraph_columns >= WIDE_LABELING_GRAPH_COL else 5
- units_label, decimal_precision = None, 0
-
- for i in range((subgraph_columns - 4) / interval_spacing):
- x = (i + 1) * interval_spacing
- time_label = str_tools.time_label(x * interval_sec, decimal_precision)
-
- if not units_label:
- units_label = time_label[-1]
- elif units_label != time_label[-1]:
- # upped scale so also up precision of future measurements
- units_label = time_label[-1]
- decimal_precision += 1
- else:
- # if constrained on space then strips labeling since already provided
- time_label = time_label[:-1]
-
- x_axis_labels[x] = time_label
-
- return x_axis_labels
-
- def _draw_bandwidth_stats(self, attr, width):
- """
- Replaces the x-axis labeling with bandwidth stats. This is done on small
- screens since this information otherwise wouldn't fit.
- """
-
- labeling_line = DEFAULT_CONTENT_HEIGHT + attr.subgraph_height - 4
- self.addstr(labeling_line, 0, ' ' * width) # clear line
-
- runtime = time.time() - attr.stat.start_time
- primary_footer = 'total: %s, avg: %s/sec' % (_size_label(attr.stat.primary.total), _size_label(attr.stat.primary.total / runtime))
- secondary_footer = 'total: %s, avg: %s/sec' % (_size_label(attr.stat.secondary.total), _size_label(attr.stat.secondary.total / runtime))
-
- self.addstr(labeling_line, 1, primary_footer, PRIMARY_COLOR)
- self.addstr(labeling_line, attr.subgraph_width + 1, secondary_footer, SECONDARY_COLOR)
-
- def _draw_accounting_stats(self, attr):
- y = DEFAULT_CONTENT_HEIGHT + attr.subgraph_height - 2
-
- if tor_controller().is_alive():
- hibernate_color = CONFIG['attr.hibernate_color'].get(attr.accounting.status, 'red')
-
- x = self.addstr(y, 0, 'Accounting (', curses.A_BOLD)
- x = self.addstr(y, x, attr.accounting.status, curses.A_BOLD, hibernate_color)
- x = self.addstr(y, x, ')', curses.A_BOLD)
-
- self.addstr(y, 35, 'Time to reset: %s' % str_tools.short_time_label(attr.accounting.time_until_reset))
-
- self.addstr(y + 1, 2, '%s / %s' % (attr.accounting.read_bytes, attr.accounting.read_limit), PRIMARY_COLOR)
- self.addstr(y + 1, 37, '%s / %s' % (attr.accounting.written_bytes, attr.accounting.write_limit), SECONDARY_COLOR)
- else:
- self.addstr(y, 0, 'Accounting:', curses.A_BOLD)
- self.addstr(y, 12, 'Connection Closed...')
-
- def copy_attr(self, attr):
- if attr == '_stats':
- return dict([(key, type(self._stats[key])(self._stats[key])) for key in self._stats])
- else:
- return panel.Panel.copy_attr(self, attr)
-
- def _update_accounting(self, event):
- if not CONFIG['features.graph.bw.accounting.show']:
- self._accounting_stats = None
- elif not self._accounting_stats or time.time() - self._accounting_stats.retrieved >= ACCOUNTING_RATE:
- old_accounting_stats = self._accounting_stats
- self._accounting_stats = tor_controller().get_accounting_stats(None)
-
- # if we either added or removed accounting info then redraw the whole
- # screen to account for resizing
-
- if bool(old_accounting_stats) != bool(self._accounting_stats):
- nyx.controller.get_controller().redraw()
-
- def _update_stats(self, event):
- for stat in self._stats.values():
- stat.bandwidth_event(event)
-
- if self.displayed_stat:
- param = self.get_attr('_stats')[self.displayed_stat]
- update_rate = INTERVAL_SECONDS[self.update_interval]
-
- if param.primary.tick % update_rate == 0:
- self.redraw(True)
-
-
-def _size_label(byte_count, decimal = 1):
- """
- Alias for str_tools.size_label() that accounts for if the user prefers bits
- or bytes.
- """
-
- return str_tools.size_label(byte_count, decimal, is_bytes = CONFIG['features.graph.bw.transferInBytes'])
diff --git a/nyx/header_panel.py b/nyx/header_panel.py
deleted file mode 100644
index 0d6591a..0000000
--- a/nyx/header_panel.py
+++ /dev/null
@@ -1,496 +0,0 @@
-"""
-Top panel for every page, containing basic system and tor related information.
-This expands the information it presents to two columns if there's room
-available.
-"""
-
-import collections
-import os
-import time
-import curses
-import threading
-
-import stem
-
-import nyx.controller
-import nyx.popups
-
-from stem.control import Listener, State
-from stem.util import conf, log, proc, str_tools, system
-from nyx.util import msg, tor_controller, panel, tracker
-
-MIN_DUAL_COL_WIDTH = 141 # minimum width where we'll show two columns
-SHOW_FD_THRESHOLD = 60 # show file descriptor usage if usage is over this percentage
-UPDATE_RATE = 5 # rate in seconds at which we refresh
-
-CONFIG = conf.config_dict('nyx', {
- 'attr.flag_colors': {},
- 'attr.version_status_colors': {},
-})
-
-
-class HeaderPanel(panel.Panel, threading.Thread):
- """
- Top area containing tor settings and system information.
- """
-
- def __init__(self, stdscr):
- panel.Panel.__init__(self, stdscr, 'header', 0)
- threading.Thread.__init__(self)
- self.setDaemon(True)
-
- self._vals = get_sampling()
-
- self._pause_condition = threading.Condition()
- self._halt = False # terminates thread if true
- self._reported_inactive = False
-
- tor_controller().add_status_listener(self.reset_listener)
-
- def is_wide(self, width = None):
- """
- True if we should show two columns of information, False otherwise.
- """
-
- if width is None:
- width = self.get_parent().getmaxyx()[1]
-
- return width >= MIN_DUAL_COL_WIDTH
-
- def get_height(self):
- """
- Provides the height of the content, which is dynamically determined by the
- panel's maximum width.
- """
-
- if self._vals.is_relay:
- return 4 if self.is_wide() else 6
- else:
- return 3 if self.is_wide() else 4
-
- def send_newnym(self):
- """
- Requests a new identity and provides a visual queue.
- """
-
- controller = tor_controller()
-
- if not controller.is_newnym_available():
- return
-
- controller.signal(stem.Signal.NEWNYM)
-
- # If we're wide then the newnym label in this panel will give an
- # indication that the signal was sent. Otherwise use a msg.
-
- if not self.is_wide():
- nyx.popups.show_msg('Requesting a new identity', 1)
-
- def handle_key(self, key):
- if key.match('n'):
- self.send_newnym()
- elif key.match('r') and not self._vals.is_connected:
- # TODO: This is borked. Not quite sure why but our attempt to call
- # PROTOCOLINFO fails with a socket error, followed by completely freezing
- # nyx. This is exposing two bugs...
- #
- # * This should be working. That's a stem issue.
- # * Our interface shouldn't be locking up. That's an nyx issue.
-
- return True
-
- controller = tor_controller()
-
- try:
- controller.connect()
-
- try:
- controller.authenticate() # TODO: should account for our chroot
- except stem.connection.MissingPassword:
- password = nyx.popups.input_prompt('Controller Password: ')
-
- if password:
- controller.authenticate(password)
-
- log.notice("Reconnected to Tor's control port")
- nyx.popups.show_msg('Tor reconnected', 1)
- except Exception as exc:
- nyx.popups.show_msg('Unable to reconnect (%s)' % exc, 3)
- controller.close()
- else:
- return False
-
- return True
-
- def draw(self, width, height):
- vals = self._vals # local reference to avoid concurrency concerns
- is_wide = self.is_wide(width)
-
- # space available for content
-
- left_width = max(width / 2, 77) if is_wide else width
- right_width = width - left_width
-
- self._draw_platform_section(0, 0, left_width, vals)
-
- if vals.is_connected:
- self._draw_ports_section(0, 1, left_width, vals)
- else:
- self._draw_disconnected(0, 1, left_width, vals)
-
- if is_wide:
- self._draw_resource_usage(left_width, 0, right_width, vals)
-
- if vals.is_relay:
- self._draw_fingerprint_and_fd_usage(left_width, 1, right_width, vals)
- self._draw_flags(0, 2, left_width, vals)
- self._draw_exit_policy(left_width, 2, right_width, vals)
- elif vals.is_connected:
- self._draw_newnym_option(left_width, 1, right_width, vals)
- else:
- self._draw_resource_usage(0, 2, left_width, vals)
-
- if vals.is_relay:
- self._draw_fingerprint_and_fd_usage(0, 3, left_width, vals)
- self._draw_flags(0, 4, left_width, vals)
-
- def _draw_platform_section(self, x, y, width, vals):
- """
- Section providing the user's hostname, platform, and version information...
-
- nyx - odin (Linux 3.5.0-52-generic) Tor 0.2.5.1-alpha-dev (unrecommended)
- |------ platform (40 characters) ------| |----------- tor version -----------|
- """
-
- initial_x, space_left = x, min(width, 40)
-
- x = self.addstr(y, x, vals.format('nyx - {hostname}', space_left))
- space_left -= x - initial_x
-
- if space_left >= 10:
- self.addstr(y, x, ' (%s)' % vals.format('{platform}', space_left - 3))
-
- x, space_left = initial_x + 43, width - 43
-
- if vals.version != 'Unknown' and space_left >= 10:
- x = self.addstr(y, x, vals.format('Tor {version}', space_left))
- space_left -= x - 43 - initial_x
-
- if space_left >= 7 + len(vals.version_status):
- version_color = CONFIG['attr.version_status_colors'].get(vals.version_status, 'white')
-
- x = self.addstr(y, x, ' (')
- x = self.addstr(y, x, vals.version_status, version_color)
- self.addstr(y, x, ')')
-
- def _draw_ports_section(self, x, y, width, vals):
- """
- Section providing our nickname, address, and port information...
-
- Unnamed - 0.0.0.0:7000, Control Port (cookie): 9051
- """
-
- if not vals.is_relay:
- x = self.addstr(y, x, 'Relaying Disabled', 'cyan')
- else:
- x = self.addstr(y, x, vals.format('{nickname} - {address}:{or_port}'))
-
- if vals.dir_port != '0':
- x = self.addstr(y, x, vals.format(', Dir Port: {dir_port}'))
-
- if vals.control_port:
- if width >= x + 19 + len(vals.control_port) + len(vals.auth_type):
- auth_color = 'red' if vals.auth_type == 'open' else 'green'
-
- x = self.addstr(y, x, ', Control Port (')
- x = self.addstr(y, x, vals.auth_type, auth_color)
- self.addstr(y, x, vals.format('): {control_port}'))
- else:
- self.addstr(y, x, vals.format(', Control Port: {control_port}'))
- elif vals.socket_path:
- self.addstr(y, x, vals.format(', Control Socket: {socket_path}'))
-
- def _draw_disconnected(self, x, y, width, vals):
- """
- Message indicating that tor is disconnected...
-
- Tor Disconnected (15:21 07/13/2014, press r to reconnect)
- """
-
- x = self.addstr(y, x, 'Tor Disconnected', curses.A_BOLD, 'red')
- last_heartbeat = time.strftime('%H:%M %m/%d/%Y', time.localtime(vals.last_heartbeat))
- self.addstr(y, x, ' (%s, press r to reconnect)' % last_heartbeat)
-
- def _draw_resource_usage(self, x, y, width, vals):
- """
- System resource usage of the tor process...
-
- cpu: 0.0% tor, 1.0% nyx mem: 0 (0.0%) pid: 16329 uptime: 12-20:42:07
- """
-
- if vals.start_time:
- if not vals.is_connected:
- now = vals.connection_time
- elif self.is_paused():
- now = self.get_pause_time()
- else:
- now = time.time()
-
- uptime = str_tools.short_time_label(now - vals.start_time)
- else:
- uptime = ''
-
- sys_fields = (
- (0, vals.format('cpu: {tor_cpu}% tor, {nyx_cpu}% nyx')),
- (27, vals.format('mem: {memory} ({memory_percent}%)')),
- (47, vals.format('pid: {pid}')),
- (59, 'uptime: %s' % uptime),
- )
-
- for (start, label) in sys_fields:
- if width >= start + len(label):
- self.addstr(y, x + start, label)
- else:
- break
-
- def _draw_fingerprint_and_fd_usage(self, x, y, width, vals):
- """
- Presents our fingerprint, and our file descriptor usage if we're running
- out...
-
- fingerprint: 1A94D1A794FCB2F8B6CBC179EF8FDD4008A98D3B, file desc: 900 / 1000 (90%)
- """
-
- initial_x, space_left = x, width
-
- x = self.addstr(y, x, vals.format('fingerprint: {fingerprint}', width))
- space_left -= x - initial_x
-
- if space_left >= 30 and vals.fd_used and vals.fd_limit != -1:
- fd_percent = 100 * vals.fd_used / vals.fd_limit
-
- if fd_percent >= SHOW_FD_THRESHOLD:
- if fd_percent >= 95:
- percentage_format = (curses.A_BOLD, 'red')
- elif fd_percent >= 90:
- percentage_format = ('red',)
- elif fd_percent >= 60:
- percentage_format = ('yellow',)
- else:
- percentage_format = ()
-
- x = self.addstr(y, x, ', file descriptors' if space_left >= 37 else ', file desc')
- x = self.addstr(y, x, vals.format(': {fd_used} / {fd_limit} ('))
- x = self.addstr(y, x, '%i%%' % fd_percent, *percentage_format)
- self.addstr(y, x, ')')
-
- def _draw_flags(self, x, y, width, vals):
- """
- Presents flags held by our relay...
-
- flags: Running, Valid
- """
-
- x = self.addstr(y, x, 'flags: ')
-
- if vals.flags:
- for i, flag in enumerate(vals.flags):
- flag_color = CONFIG['attr.flag_colors'].get(flag, 'white')
- x = self.addstr(y, x, flag, curses.A_BOLD, flag_color)
-
- if i < len(vals.flags) - 1:
- x = self.addstr(y, x, ', ')
- else:
- self.addstr(y, x, 'none', curses.A_BOLD, 'cyan')
-
- def _draw_exit_policy(self, x, y, width, vals):
- """
- Presents our exit policy...
-
- exit policy: reject *:*
- """
-
- x = self.addstr(y, x, 'exit policy: ')
-
- if not vals.exit_policy:
- return
-
- rules = list(vals.exit_policy.strip_private().strip_default())
-
- for i, rule in enumerate(rules):
- policy_color = 'green' if rule.is_accept else 'red'
- x = self.addstr(y, x, str(rule), curses.A_BOLD, policy_color)
-
- if i < len(rules) - 1:
- x = self.addstr(y, x, ', ')
-
- if vals.exit_policy.has_default():
- if rules:
- x = self.addstr(y, x, ', ')
-
- self.addstr(y, x, '<default>', curses.A_BOLD, 'cyan')
-
- def _draw_newnym_option(self, x, y, width, vals):
- """
- Provide a notice for requiesting a new identity, and time until it's next
- available if in the process of building circuits.
- """
-
- if vals.newnym_wait == 0:
- self.addstr(y, x, "press 'n' for a new identity")
- else:
- plural = 's' if vals.newnym_wait > 1 else ''
- self.addstr(y, x, 'building circuits, available again in %i second%s' % (vals.newnym_wait, plural))
-
- def run(self):
- """
- Keeps stats updated, checking for new information at a set rate.
- """
-
- last_ran = -1
-
- while not self._halt:
- if self.is_paused() or not self._vals.is_connected or (time.time() - last_ran) < UPDATE_RATE:
- with self._pause_condition:
- if not self._halt:
- self._pause_condition.wait(0.2)
-
- continue # done waiting, try again
-
- self._update()
- last_ran = time.time()
-
- def stop(self):
- """
- Halts further resolutions and terminates the thread.
- """
-
- with self._pause_condition:
- self._halt = True
- self._pause_condition.notifyAll()
-
- def reset_listener(self, controller, event_type, _):
- self._update()
-
- if event_type == State.CLOSED:
- log.notice('Tor control port closed')
-
- def _update(self):
- previous_height = self.get_height()
- self._vals = get_sampling(self._vals)
-
- if self._vals.fd_used and self._vals.fd_limit != -1:
- fd_percent = 100 * self._vals.fd_used / self._vals.fd_limit
-
- if fd_percent >= 90:
- log_msg = msg('panel.header.fd_used_at_ninety_percent', percentage = fd_percent)
- log.log_once('fd_used_at_ninety_percent', log.WARN, log_msg)
- log.DEDUPLICATION_MESSAGE_IDS.add('fd_used_at_sixty_percent')
- elif fd_percent >= 60:
- log_msg = msg('panel.header.fd_used_at_sixty_percent', percentage = fd_percent)
- log.log_once('fd_used_at_sixty_percent', log.NOTICE, log_msg)
-
- if self._vals.is_connected:
- if not self._reported_inactive and (time.time() - self._vals.last_heartbeat) >= 10:
- self._reported_inactive = True
- log.notice('Relay unresponsive (last heartbeat: %s)' % time.ctime(self._vals.last_heartbeat))
- elif self._reported_inactive and (time.time() - self._vals.last_heartbeat) < 10:
- self._reported_inactive = False
- log.notice('Relay resumed')
-
- if previous_height != self.get_height():
- # We're toggling between being a relay and client, causing the height
- # of this panel to change. Redraw all content so we don't get
- # overlapping content.
-
- nyx.controller.get_controller().redraw()
- else:
- self.redraw(True) # just need to redraw ourselves
-
-
-def get_sampling(last_sampling = None):
- controller = tor_controller()
- retrieved = time.time()
-
- pid = controller.get_pid('')
- tor_resources = tracker.get_resource_tracker().get_value()
- nyx_total_cpu_time = sum(os.times()[:3])
-
- or_listeners = controller.get_listeners(Listener.OR, [])
- control_listeners = controller.get_listeners(Listener.CONTROL, [])
-
- if controller.get_conf('HashedControlPassword', None):
- auth_type = 'password'
- elif controller.get_conf('CookieAuthentication', None) == '1':
- auth_type = 'cookie'
- else:
- auth_type = 'open'
-
- try:
- fd_used = proc.file_descriptors_used(pid)
- except IOError:
- fd_used = None
-
- if last_sampling:
- nyx_cpu_delta = nyx_total_cpu_time - last_sampling.nyx_total_cpu_time
- nyx_time_delta = retrieved - last_sampling.retrieved
-
- python_cpu_time = nyx_cpu_delta / nyx_time_delta
- sys_call_cpu_time = 0.0 # TODO: add a wrapper around call() to get this
-
- nyx_cpu = python_cpu_time + sys_call_cpu_time
- else:
- nyx_cpu = 0.0
-
- attr = {
- 'retrieved': retrieved,
- 'is_connected': controller.is_alive(),
- 'connection_time': controller.connection_time(),
- 'last_heartbeat': controller.get_latest_heartbeat(),
-
- 'fingerprint': controller.get_info('fingerprint', 'Unknown'),
- 'nickname': controller.get_conf('Nickname', ''),
- 'newnym_wait': controller.get_newnym_wait(),
- 'exit_policy': controller.get_exit_policy(None),
- 'flags': getattr(controller.get_network_status(default = None), 'flags', []),
-
- 'version': str(controller.get_version('Unknown')).split()[0],
- 'version_status': controller.get_info('status/version/current', 'Unknown'),
-
- 'address': or_listeners[0][0] if (or_listeners and or_listeners[0][0] != '0.0.0.0') else controller.get_info('address', 'Unknown'),
- 'or_port': or_listeners[0][1] if or_listeners else '',
- 'dir_port': controller.get_conf('DirPort', '0'),
- 'control_port': str(control_listeners[0][1]) if control_listeners else None,
- 'socket_path': controller.get_conf('ControlSocket', None),
- 'is_relay': bool(or_listeners),
-
- 'auth_type': auth_type,
- 'pid': pid,
- 'start_time': system.start_time(pid),
- 'fd_limit': int(controller.get_info('process/descriptor-limit', '-1')),
- 'fd_used': fd_used,
-
- 'nyx_total_cpu_time': nyx_total_cpu_time,
- 'tor_cpu': '%0.1f' % (100 * tor_resources.cpu_sample),
- 'nyx_cpu': nyx_cpu,
- 'memory': str_tools.size_label(tor_resources.memory_bytes) if tor_resources.memory_bytes > 0 else 0,
- 'memory_percent': '%0.1f' % (100 * tor_resources.memory_percent),
-
- 'hostname': os.uname()[1],
- 'platform': '%s %s' % (os.uname()[0], os.uname()[2]), # [platform name] [version]
- }
-
- class Sampling(collections.namedtuple('Sampling', attr.keys())):
- def __init__(self, **attr):
- super(Sampling, self).__init__(**attr)
- self._attr = attr
-
- def format(self, message, crop_width = None):
- formatted_msg = message.format(**self._attr)
-
- if crop_width:
- formatted_msg = str_tools.crop(formatted_msg, crop_width)
-
- return formatted_msg
-
- return Sampling(**attr)
diff --git a/nyx/log_panel.py b/nyx/log_panel.py
deleted file mode 100644
index 708a679..0000000
--- a/nyx/log_panel.py
+++ /dev/null
@@ -1,454 +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 os
-import time
-import curses
-import threading
-
-import stem.response.events
-
-import nyx.arguments
-import nyx.popups
-import nyx.util.log
-
-from nyx.util import join, panel, tor_controller, ui_tools
-from stem.util import conf, log
-
-
-def conf_handler(key, value):
- if key == 'features.log.prepopulateReadLimit':
- return max(0, value)
- elif key == 'features.log.maxRefreshRate':
- return max(10, value)
- elif key == 'cache.log_panel.size':
- return max(1000, value)
-
-
-CONFIG = conf.config_dict('nyx', {
- 'attr.log_color': {},
- 'cache.log_panel.size': 1000,
- 'features.logFile': '',
- 'features.log.showDuplicateEntries': False,
- 'features.log.prepopulate': True,
- 'features.log.prepopulateReadLimit': 5000,
- 'features.log.maxRefreshRate': 300,
- 'features.log.regex': [],
- 'msg.misc.event_types': '',
- 'startup.events': 'N3',
-}, conf_handler)
-
-# 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
-
-# Log buffer so we start collecting stem/nyx events when imported. This is used
-# to make our LogPanel when curses initializes.
-
-stem_logger = log.get_logger()
-NYX_LOGGER = log.LogBuffer(log.Runlevel.DEBUG, yield_records = True)
-stem_logger.addHandler(NYX_LOGGER)
-
-
-class LogPanel(panel.Panel, threading.Thread):
- """
- Listens for and displays tor, nyx, and stem events. This prepopulates
- from tor's log file if it exists.
- """
-
- def __init__(self, stdscr):
- panel.Panel.__init__(self, stdscr, 'log', 0)
- threading.Thread.__init__(self)
- self.setDaemon(True)
-
- logged_events = nyx.arguments.expand_events(CONFIG['startup.events'])
- self._event_log = nyx.util.log.LogGroup(CONFIG['cache.log_panel.size'], group_by_day = True)
- self._event_types = nyx.util.log.listen_for_events(self._register_tor_event, logged_events)
- self._log_file = nyx.util.log.LogFileOutput(CONFIG['features.logFile'])
- self._filter = nyx.util.log.LogFilters(initial_filters = CONFIG['features.log.regex'])
- self._show_duplicates = CONFIG['features.log.showDuplicateEntries']
-
- self.set_pause_attr('_event_log')
-
- self._halt = False # terminates thread if true
- self._pause_condition = threading.Condition()
- self._has_new_event = False
-
- # fetches past tor events from log file, if available
-
- if CONFIG['features.log.prepopulate']:
- log_location = nyx.util.log.log_file_path(tor_controller())
-
- if log_location:
- try:
- for entry in reversed(list(nyx.util.log.read_tor_log(log_location, CONFIG['features.log.prepopulateReadLimit']))):
- if entry.type in self._event_types:
- self._event_log.add(entry)
- except IOError as exc:
- log.info('Unable to read log located at %s: %s' % (log_location, exc))
- except ValueError as exc:
- log.info(str(exc))
-
- self._last_content_height = len(self._event_log) # height of the rendered content when last drawn
- self._scroll = 0
-
- # merge NYX_LOGGER into us, and listen for its future events
-
- for event in NYX_LOGGER:
- self._register_nyx_event(event)
-
- NYX_LOGGER.emit = self._register_nyx_event
-
- def set_duplicate_visability(self, is_visible):
- """
- Sets if duplicate log entries are collaped or expanded.
-
- :param bool is_visible: if **True** all log entries are shown, otherwise
- they're deduplicated
- """
-
- self._show_duplicates = is_visible
-
- def get_filter(self):
- """
- Provides our currently selected regex filter.
- """
-
- return self._filter
-
- def show_filter_prompt(self):
- """
- Prompts the user to add a new regex filter.
- """
-
- regex_input = nyx.popups.input_prompt('Regular expression: ')
-
- if regex_input:
- self._filter.select(regex_input)
-
- def show_event_selection_prompt(self):
- """
- Prompts the user to select the events being listened for.
- """
-
- # allow user to enter new types of events to log - unchanged if left blank
-
- with nyx.popups.popup_window(16, 80) as (popup, width, height):
- if popup:
- # displays the available flags
-
- popup.win.box()
- popup.addstr(0, 0, 'Event Types:', curses.A_STANDOUT)
- event_lines = CONFIG['msg.misc.event_types'].split('\n')
-
- for i in range(len(event_lines)):
- popup.addstr(i + 1, 1, event_lines[i][6:])
-
- popup.win.refresh()
-
- user_input = nyx.popups.input_prompt('Events to log: ')
-
- if user_input:
- try:
- user_input = user_input.replace(' ', '') # strip spaces
- event_types = nyx.arguments.expand_events(user_input)
-
- if event_types != self._event_types:
- self._event_types = nyx.util.log.listen_for_events(self._register_tor_event, event_types)
- self.redraw(True)
- except ValueError as exc:
- nyx.popups.show_msg('Invalid flags: %s' % str(exc), 2)
-
- def show_snapshot_prompt(self):
- """
- Lets user enter a path to take a snapshot, canceling if left blank.
- """
-
- path_input = nyx.popups.input_prompt('Path to save log snapshot: ')
-
- if path_input:
- try:
- self.save_snapshot(path_input)
- nyx.popups.show_msg('Saved: %s' % path_input, 2)
- except IOError as exc:
- nyx.popups.show_msg('Unable to save snapshot: %s' % exc, 2)
-
- def clear(self):
- """
- Clears the contents of the event log.
- """
-
- self._event_log = nyx.util.log.LogGroup(CONFIG['cache.log_panel.size'], group_by_day = True)
- self.redraw(True)
-
- def save_snapshot(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.
-
- :param str path: path where to save the log snapshot
-
- :raises: **IOError** if unsuccessful
- """
-
- path = os.path.abspath(os.path.expanduser(path))
-
- # make dir if the path doesn't already exist
-
- base_dir = os.path.dirname(path)
-
- try:
- if not os.path.exists(base_dir):
- os.makedirs(base_dir)
- except OSError as exc:
- raise IOError("unable to make directory '%s'" % base_dir)
-
- event_log = list(self._event_log)
- event_filter = self._filter.clone()
-
- with open(path, 'w') as snapshot_file:
- try:
- for entry in reversed(event_log):
- if event_filter.match(entry.display_message):
- snapshot_file.write(entry.display_message + '\n')
- except Exception as exc:
- raise IOError("unable to write to '%s': %s" % (path, exc))
-
- def handle_key(self, key):
- if key.is_scroll():
- page_height = self.get_preferred_size()[0] - 1
- new_scroll = ui_tools.get_scroll_position(key, self._scroll, page_height, self._last_content_height)
-
- if self._scroll != new_scroll:
- self._scroll = new_scroll
- self.redraw(True)
- elif key.match('u'):
- self.set_duplicate_visability(not self._show_duplicates)
- self.redraw(True)
- elif key.match('c'):
- msg = 'This will clear the log. Are you sure (c again to confirm)?'
- key_press = nyx.popups.show_msg(msg, attr = curses.A_BOLD)
-
- if key_press.match('c'):
- self.clear()
- elif key.match('f'):
- with panel.CURSES_LOCK:
- initial_selection = 1 if self._filter.selection() else 0
- options = ['None'] + self._filter.latest_selections() + ['New...']
- selection = nyx.popups.show_menu('Log Filter:', options, initial_selection)
-
- if selection == 0:
- self._filter.select(None)
- elif selection == len(options) - 1:
- # selected 'New...' option - prompt user to input regular expression
- self.show_filter_prompt()
- elif selection != -1:
- self._filter.select(self._filter.latest_selections()[selection - 1])
- elif key.match('e'):
- self.show_event_selection_prompt()
- elif key.match('a'):
- self.show_snapshot_prompt()
- else:
- return False
-
- return True
-
- def get_help(self):
- return [
- ('up arrow', 'scroll log up a line', None),
- ('down arrow', 'scroll log down a line', None),
- ('a', 'save snapshot of the log', None),
- ('e', 'change logged events', None),
- ('f', 'log regex filter', 'enabled' if self._filter.selection() else 'disabled'),
- ('u', 'duplicate log entries', 'visible' if self._show_duplicates else 'hidden'),
- ('c', 'clear event log', None),
- ]
-
- def draw(self, width, height):
- self._scroll = max(0, min(self._scroll, self._last_content_height - height + 1))
-
- event_log = list(self.get_attr('_event_log'))
- event_filter = self._filter.clone()
- event_types = list(self._event_types)
- scroll = int(self._scroll)
- last_content_height = self._last_content_height
- show_duplicates = self._show_duplicates
-
- is_scrollbar_visible = last_content_height > height - 1
-
- if is_scrollbar_visible:
- self.add_scroll_bar(scroll, scroll + height - 1, last_content_height, 1)
-
- x, y = 3 if is_scrollbar_visible else 1, 1 - scroll
-
- # group entries by date, filtering out those that aren't visible
-
- day_to_entries, today = {}, nyx.util.log.day_count(time.time())
-
- for entry in event_log:
- if entry.is_duplicate and not show_duplicates:
- continue # deduplicated message
- elif not event_filter.match(entry.display_message):
- continue # filter doesn't match log message
-
- day_to_entries.setdefault(entry.day_count(), []).append(entry)
-
- for day in sorted(day_to_entries.keys(), reverse = True):
- if day == today:
- for entry in day_to_entries[day]:
- y = self._draw_entry(x, y, width, entry, show_duplicates)
- else:
- original_y, y = y, y + 1
-
- for entry in day_to_entries[day]:
- y = self._draw_entry(x, y, width, entry, show_duplicates)
-
- ui_tools.draw_box(self, original_y, x - 1, width - x + 1, y - original_y + 1, curses.A_BOLD, 'yellow')
- time_label = time.strftime(' %B %d, %Y ', time.localtime(day_to_entries[day][0].timestamp))
- self.addstr(original_y, x + 1, time_label, curses.A_BOLD, curses.A_BOLD, 'yellow')
-
- y += 1
-
- # drawing the title after the content, so we'll clear content from the top line
-
- if self.is_title_visible():
- self._draw_title(width, event_types, event_filter)
-
- # redraw the display if...
- # - last_content_height was off by too much
- # - we're off the bottom of the page
-
- new_content_height = y + scroll - 1
- content_height_delta = abs(last_content_height - new_content_height)
- force_redraw, force_redraw_reason = True, ''
-
- if content_height_delta >= CONTENT_HEIGHT_REDRAW_THRESHOLD:
- force_redraw_reason = 'estimate was off by %i' % content_height_delta
- elif new_content_height > height and self._scroll + height - 1 > new_content_height:
- force_redraw_reason = 'scrolled off the bottom of the page'
- elif not is_scrollbar_visible and new_content_height > height - 1:
- force_redraw_reason = "scroll bar wasn't previously visible"
- elif is_scrollbar_visible and new_content_height <= height - 1:
- force_redraw_reason = "scroll bar shouldn't be visible"
- else:
- force_redraw = False
-
- self._last_content_height = new_content_height
- self._has_new_event = False
-
- if force_redraw:
- log.debug('redrawing the log panel with the corrected content height (%s)' % force_redraw_reason)
- self.redraw(True)
-
- def _draw_title(self, width, event_types, event_filter):
- """
- Panel title with the event types we're logging and our regex filter if set.
- """
-
- self.addstr(0, 0, ' ' * width) # clear line
- title_comp = list(nyx.util.log.condense_runlevels(*event_types))
-
- if event_filter.selection():
- title_comp.append('filter: %s' % event_filter.selection())
-
- title_comp_str = join(title_comp, ', ', width - 10)
- title = 'Events (%s):' % title_comp_str if title_comp_str else 'Events:'
-
- self.addstr(0, 0, title, curses.A_STANDOUT)
-
- def _draw_entry(self, x, y, width, entry, show_duplicates):
- """
- Presents a log entry with line wrapping.
- """
-
- min_x, msg = x + 2, entry.display_message
- boldness = curses.A_BOLD if 'ERR' in entry.type else curses.A_NORMAL # emphasize ERR messages
- color = CONFIG['attr.log_color'].get(entry.type, 'white')
-
- for line in msg.splitlines():
- x, y = self.addstr_wrap(y, x, line, width, min_x, boldness, color)
-
- if entry.duplicates and not show_duplicates:
- duplicate_count = len(entry.duplicates) - 1
- plural = 's' if duplicate_count > 1 else ''
- duplicate_msg = ' [%i duplicate%s hidden]' % (duplicate_count, plural)
- x, y = self.addstr_wrap(y, x, duplicate_msg, width, min_x, curses.A_BOLD, 'green')
-
- return y + 1
-
- 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.
- """
-
- last_ran, last_day = -1, nyx.util.log.day_count(time.time())
-
- while not self._halt:
- current_day = nyx.util.log.day_count(time.time())
- time_since_reset = time.time() - last_ran
- max_log_update_rate = CONFIG['features.log.maxRefreshRate'] / 1000.0
-
- sleep_time = 0
-
- if (not self._has_new_event and last_day == current_day) or self.is_paused():
- sleep_time = 5
- elif time_since_reset < max_log_update_rate:
- sleep_time = max(0.05, max_log_update_rate - time_since_reset)
-
- if sleep_time:
- with self._pause_condition:
- if not self._halt:
- self._pause_condition.wait(sleep_time)
-
- continue
-
- last_ran, last_day = time.time(), current_day
- self.redraw(True)
-
- def stop(self):
- """
- Halts further updates and terminates the thread.
- """
-
- with self._pause_condition:
- self._halt = True
- self._pause_condition.notifyAll()
-
- def _register_tor_event(self, event):
- msg = ' '.join(str(event).split(' ')[1:])
-
- if isinstance(event, stem.response.events.BandwidthEvent):
- msg = 'READ: %i, WRITTEN: %i' % (event.read, event.written)
- elif isinstance(event, stem.response.events.LogEvent):
- msg = event.message
-
- self._register_event(nyx.util.log.LogEntry(event.arrived_at, event.type, msg))
-
- def _register_nyx_event(self, record):
- if record.levelname == 'WARNING':
- record.levelname = 'WARN'
-
- self._register_event(nyx.util.log.LogEntry(int(record.created), 'NYX_%s' % record.levelname, record.msg))
-
- def _register_event(self, event):
- if event.type not in self._event_types:
- return
-
- self._event_log.add(event)
- self._log_file.write(event.display_message)
-
- # notifies the display that it has new content
-
- if self._filter.match(event.display_message):
- self._has_new_event = True
-
- with self._pause_condition:
- self._pause_condition.notifyAll()
diff --git a/nyx/menu/actions.py b/nyx/menu/actions.py
index 0105615..222e3e7 100644
--- a/nyx/menu/actions.py
+++ b/nyx/menu/actions.py
@@ -4,10 +4,10 @@ Generates the menu for nyx, binding options with their related actions.
import functools
-import nyx.popups
import nyx.controller
+import nyx.panel.graph
+import nyx.popups
import nyx.menu.item
-import nyx.graph_panel
import nyx.util.tracker
from nyx.util import tor_controller, ui_tools
@@ -167,7 +167,7 @@ def make_graph_menu(graph_panel):
interval_menu = nyx.menu.item.Submenu('Interval')
interval_group = nyx.menu.item.SelectionGroup(functools.partial(setattr, graph_panel, 'update_interval'), graph_panel.update_interval)
- for interval in nyx.graph_panel.Interval:
+ for interval in nyx.panel.graph.Interval:
interval_menu.add(nyx.menu.item.SelectionMenuItem(interval, interval_group, interval))
graph_menu.add(interval_menu)
@@ -177,7 +177,7 @@ def make_graph_menu(graph_panel):
bounds_menu = nyx.menu.item.Submenu('Bounds')
bounds_group = nyx.menu.item.SelectionGroup(functools.partial(setattr, graph_panel, 'bounds_type'), graph_panel.bounds_type)
- for bounds_type in nyx.graph_panel.Bounds:
+ for bounds_type in nyx.panel.graph.Bounds:
bounds_menu.add(nyx.menu.item.SelectionMenuItem(bounds_type, bounds_group, bounds_type))
graph_menu.add(bounds_menu)
diff --git a/nyx/panel/__init__.py b/nyx/panel/__init__.py
new file mode 100644
index 0000000..0f25fac
--- /dev/null
+++ b/nyx/panel/__init__.py
@@ -0,0 +1,11 @@
+"""
+Panels consisting the nyx interface.
+"""
+
+__all__ = [
+ 'config',
+ 'connection',
+ 'header',
+ 'log',
+ 'torrc',
+]
diff --git a/nyx/panel/config.py b/nyx/panel/config.py
new file mode 100644
index 0000000..2352fe7
--- /dev/null
+++ b/nyx/panel/config.py
@@ -0,0 +1,357 @@
+"""
+Panel presenting the configuration state for tor or nyx. Options can be edited
+and the resulting configuration files saved.
+"""
+
+import curses
+import os
+
+import nyx.controller
+import nyx.popups
+
+import stem.control
+import stem.manual
+
+from nyx.util import DATA_DIR, panel, tor_controller, ui_tools
+
+from stem.util import conf, enum, log, str_tools
+
+SortAttr = enum.Enum('NAME', 'VALUE', 'VALUE_TYPE', 'CATEGORY', 'USAGE', 'SUMMARY', 'DESCRIPTION', 'MAN_PAGE_ENTRY', 'IS_SET')
+
+DETAILS_HEIGHT = 8
+NAME_WIDTH = 25
+VALUE_WIDTH = 15
+
+
+def conf_handler(key, value):
+ if key == 'features.config.order':
+ return conf.parse_enum_csv(key, value[0], SortAttr, 3)
+
+
+CONFIG = conf.config_dict('nyx', {
+ 'attr.config.category_color': {},
+ 'attr.config.sort_color': {},
+ 'features.config.order': [SortAttr.MAN_PAGE_ENTRY, SortAttr.NAME, SortAttr.IS_SET],
+ 'features.config.state.showPrivateOptions': False,
+ 'features.config.state.showVirtualOptions': False,
+}, conf_handler)
+
+
+class ConfigEntry(object):
+ """
+ Configuration option presented in the panel.
+
+ :var str name: name of the configuration option
+ :var str value_type: type of value
+ :var stem.manual.ConfigOption manual: manual information about the option
+ """
+
+ def __init__(self, name, value_type, manual):
+ self.name = name
+ self.value_type = value_type
+ self.manual = manual.config_options.get(name, stem.manual.ConfigOption(name))
+ self._index = manual.config_options.keys().index(name) if name in manual.config_options else 99999
+
+ def value(self):
+ """
+ Provides the value of this configuration option.
+
+ :returns: **str** representation of the current config value
+ """
+
+ values = tor_controller().get_conf(self.name, [], True)
+
+ if not values:
+ return '<none>'
+ elif self.value_type == 'Boolean' and values[0] in ('0', '1'):
+ return 'False' if values[0] == '0' else 'True'
+ elif self.value_type == 'DataSize' and values[0].isdigit():
+ return str_tools.size_label(int(values[0]))
+ elif self.value_type == 'TimeInterval' and values[0].isdigit():
+ return str_tools.time_label(int(values[0]), is_long = True)
+ else:
+ return ', '.join(values)
+
+ def is_set(self):
+ """
+ Checks if the configuration option has a custom value.
+
+ :returns: **True** if the option has a custom value, **False** otherwise
+ """
+
+ return tor_controller().is_set(self.name, False)
+
+ def sort_value(self, attr):
+ """
+ Provides a heuristic for sorting by a given value.
+
+ :param SortAttr attr: sort attribute to provide a heuristic for
+
+ :returns: comparable value for sorting
+ """
+
+ if attr == SortAttr.CATEGORY:
+ return self.manual.category
+ elif attr == SortAttr.NAME:
+ return self.name
+ elif attr == SortAttr.VALUE:
+ return self.value()
+ elif attr == SortAttr.VALUE_TYPE:
+ return self.value_type
+ elif attr == SortAttr.USAGE:
+ return self.manual.usage
+ elif attr == SortAttr.SUMMARY:
+ return self.manual.summary
+ elif attr == SortAttr.DESCRIPTION:
+ return self.manual.description
+ elif attr == SortAttr.MAN_PAGE_ENTRY:
+ return self._index
+ elif attr == SortAttr.IS_SET:
+ return not self.is_set()
+
+
+class ConfigPanel(panel.Panel):
+ """
+ Editor for tor's configuration.
+ """
+
+ def __init__(self, stdscr):
+ panel.Panel.__init__(self, stdscr, 'configuration', 0)
+
+ self._contents = []
+ self._scroller = ui_tools.Scroller(True)
+ self._sort_order = CONFIG['features.config.order']
+ self._show_all = False # show all options, or just the important ones
+
+ cached_manual_path = os.path.join(DATA_DIR, 'manual')
+
+ if os.path.exists(cached_manual_path):
+ manual = stem.manual.Manual.from_cache(cached_manual_path)
+ else:
+ try:
+ manual = stem.manual.Manual.from_man()
+
+ try:
+ manual.save(cached_manual_path)
+ except IOError as exc:
+ log.debug("Unable to cache manual information to '%s'. This is fine, but means starting Nyx takes a little longer than usual: " % (cached_manual_path, exc))
+ except IOError as exc:
+ log.debug("Unable to use 'man tor' to get information about config options (%s), using bundled information instead" % exc)
+ manual = stem.manual.Manual.from_cache()
+
+ try:
+ for line in tor_controller().get_info('config/names').splitlines():
+ # Lines of the form "<option> <type>[ <documentation>]". Documentation
+ # was apparently only in old tor versions like 0.2.1.25.
+
+ if ' ' not in line:
+ continue
+
+ line_comp = line.split()
+ name, value_type = line_comp[0], line_comp[1]
+
+ # skips private and virtual entries if not configured to show them
+
+ if name.startswith('__') and not CONFIG['features.config.state.showPrivateOptions']:
+ continue
+ elif value_type == 'Virtual' and not CONFIG['features.config.state.showVirtualOptions']:
+ continue
+
+ self._contents.append(ConfigEntry(name, value_type, manual))
+
+ self._contents = sorted(self._contents, key = lambda entry: [entry.sort_value(field) for field in self._sort_order])
+ except stem.ControllerError as exc:
+ log.warn('Unable to determine the configuration options tor supports: %s' % exc)
+
+ def show_sort_dialog(self):
+ """
+ Provides the dialog for sorting our configuration options.
+ """
+
+ sort_colors = dict([(attr, CONFIG['attr.config.sort_color'].get(attr, 'white')) for attr in SortAttr])
+ results = nyx.popups.show_sort_dialog('Config Option Ordering:', SortAttr, self._sort_order, sort_colors)
+
+ if results:
+ self._sort_order = results
+ self._contents = sorted(self._contents, key = lambda entry: [entry.sort_value(field) for field in self._sort_order])
+
+ def show_write_dialog(self):
+ """
+ Confirmation dialog for saving tor's configuration.
+ """
+
+ selection, controller = 1, tor_controller()
+ config_text = controller.get_info('config-text', None)
+ config_lines = config_text.splitlines() if config_text else []
+
+ with nyx.popups.popup_window(len(config_lines) + 2) as (popup, width, height):
+ if not popup or height <= 2:
+ return
+
+ while True:
+ height, width = popup.get_preferred_size() # allow us to be resized
+ popup.win.erase()
+
+ for i, full_line in enumerate(config_lines):
+ line = str_tools.crop(full_line, width - 2)
+ option, arg = line.split(' ', 1) if ' ' in line else (line, '')
+
+ popup.addstr(i + 1, 1, option, curses.A_BOLD, 'green')
+ popup.addstr(i + 1, len(option) + 2, arg, curses.A_BOLD, 'cyan')
+
+ x = width - 16
+
+ for i, option in enumerate(['Save', 'Cancel']):
+ x = popup.addstr(height - 2, x, '[')
+ x = popup.addstr(height - 2, x, option, curses.A_BOLD, curses.A_STANDOUT if i == selection else curses.A_NORMAL)
+ x = popup.addstr(height - 2, x, '] ')
+
+ popup.win.box()
+ popup.addstr(0, 0, 'Torrc to save:', curses.A_STANDOUT)
+ popup.win.refresh()
+
+ key = nyx.controller.get_controller().key_input()
+
+ if key.match('left'):
+ selection = max(0, selection - 1)
+ elif key.match('right'):
+ selection = min(1, selection + 1)
+ elif key.is_selection():
+ if selection == 0:
+ try:
+ controller.save_conf()
+ nyx.popups.show_msg('Saved configuration to %s' % controller.get_info('config-file', '<unknown>'), 2)
+ except IOError as exc:
+ nyx.popups.show_msg('Unable to save configuration (%s)' % exc.strerror, 2)
+
+ break
+ elif key.match('esc'):
+ break # esc - cancel
+
+ def handle_key(self, key):
+ if key.is_scroll():
+ page_height = self.get_preferred_size()[0] - DETAILS_HEIGHT
+ is_changed = self._scroller.handle_key(key, self._get_config_options(), page_height)
+
+ if is_changed:
+ self.redraw(True)
+ elif key.is_selection():
+ selection = self._scroller.get_cursor_selection(self._get_config_options())
+ initial_value = selection.value() if selection.is_set() else ''
+ new_value = nyx.popups.input_prompt('%s Value (esc to cancel): ' % selection.name, initial_value)
+
+ if new_value != initial_value:
+ try:
+ if selection.value_type == 'Boolean':
+ # if the value's a boolean then allow for 'true' and 'false' inputs
+
+ if new_value.lower() == 'true':
+ new_value = '1'
+ elif new_value.lower() == 'false':
+ new_value = '0'
+ elif selection.value_type == 'LineList':
+ new_value = new_value.split(',') # set_conf accepts list inputs
+
+ tor_controller().set_conf(selection.name, new_value)
+ self.redraw(True)
+ except Exception as exc:
+ nyx.popups.show_msg('%s (press any key)' % exc)
+ elif key.match('a'):
+ self._show_all = not self._show_all
+ self.redraw(True)
+ elif key.match('s'):
+ self.show_sort_dialog()
+ elif key.match('w'):
+ self.show_write_dialog()
+ else:
+ return False
+
+ return True
+
+ def get_help(self):
+ return [
+ ('up arrow', 'scroll up a line', None),
+ ('down arrow', 'scroll down a line', None),
+ ('page up', 'scroll up a page', None),
+ ('page down', 'scroll down a page', None),
+ ('enter', 'edit configuration option', None),
+ ('w', 'write torrc', None),
+ ('a', 'toggle filtering', None),
+ ('s', 'sort ordering', None),
+ ]
+
+ def draw(self, width, height):
+ contents = self._get_config_options()
+ selection = self._scroller.get_cursor_selection(contents)
+ scroll_location = self._scroller.get_scroll_location(contents, height - DETAILS_HEIGHT)
+ is_scrollbar_visible = len(contents) > height - DETAILS_HEIGHT
+
+ if selection is not None:
+ self._draw_selection_details(selection, width)
+
+ if self.is_title_visible():
+ hidden_msg = "press 'a' to hide most options" if self._show_all else "press 'a' to show all options"
+ self.addstr(0, 0, 'Tor Configuration (%s):' % hidden_msg, curses.A_STANDOUT)
+
+ scroll_offset = 1
+
+ if is_scrollbar_visible:
+ scroll_offset = 3
+ self.add_scroll_bar(scroll_location, scroll_location + height - DETAILS_HEIGHT, len(contents), DETAILS_HEIGHT)
+
+ if selection is not None:
+ self.addch(DETAILS_HEIGHT - 1, 1, curses.ACS_TTEE)
+
+ # Description column can grow up to eighty characters. After that any extra
+ # space goes to the value.
+
+ description_width = max(0, width - scroll_offset - NAME_WIDTH - VALUE_WIDTH - 2)
+
+ if description_width > 80:
+ value_width = VALUE_WIDTH + (description_width - 80)
+ description_width = 80
+ else:
+ value_width = VALUE_WIDTH
+
+ for i, entry in enumerate(contents[scroll_location:]):
+ attr = nyx.util.ui_tools.get_color(CONFIG['attr.config.category_color'].get(entry.manual.category, 'white'))
+ attr |= curses.A_BOLD if entry.is_set() else curses.A_NORMAL
+ attr |= curses.A_STANDOUT if entry == selection else curses.A_NORMAL
+
+ option_label = str_tools.crop(entry.name, NAME_WIDTH).ljust(NAME_WIDTH + 1)
+ value_label = str_tools.crop(entry.value(), value_width).ljust(value_width + 1)
+ summary_label = str_tools.crop(entry.manual.summary, description_width).ljust(description_width)
+
+ self.addstr(DETAILS_HEIGHT + i, scroll_offset, option_label + value_label + summary_label, attr)
+
+ if DETAILS_HEIGHT + i >= height:
+ break
+
+ def _get_config_options(self):
+ return self._contents if self._show_all else filter(lambda entry: stem.manual.is_important(entry.name) or entry.is_set(), self._contents)
+
+ def _draw_selection_details(self, selection, width):
+ """
+ Shows details of the currently selected option.
+ """
+
+ description = 'Description: %s' % (selection.manual.description)
+ attr = ', '.join(('custom' if selection.is_set() else 'default', selection.value_type, 'usage: %s' % selection.manual.usage))
+ selected_color = CONFIG['attr.config.category_color'].get(selection.manual.category, 'white')
+ ui_tools.draw_box(self, 0, 0, width, DETAILS_HEIGHT)
+
+ self.addstr(1, 2, '%s (%s Option)' % (selection.name, selection.manual.category), curses.A_BOLD, selected_color)
+ self.addstr(2, 2, 'Value: %s (%s)' % (selection.value(), str_tools.crop(attr, width - len(selection.value()) - 13)), curses.A_BOLD, selected_color)
+
+ for i in range(DETAILS_HEIGHT - 4):
+ if not description:
+ break # done writing description
+
+ line, description = description.split('\n', 1) if '\n' in description else (description, '')
+
+ if i < DETAILS_HEIGHT - 5:
+ line, remainder = str_tools.crop(line, width - 3, 4, 4, str_tools.Ending.HYPHEN, True)
+ description = ' ' + remainder.strip() + description
+ self.addstr(3 + i, 2, line, curses.A_BOLD, selected_color)
+ else:
+ self.addstr(3 + i, 2, str_tools.crop(line, width - 3, 4, 4), curses.A_BOLD, selected_color)
diff --git a/nyx/panel/connection.py b/nyx/panel/connection.py
new file mode 100644
index 0000000..e28f531
--- /dev/null
+++ b/nyx/panel/connection.py
@@ -0,0 +1,706 @@
+"""
+Listing of the currently established connections tor has made.
+"""
+
+import re
+import time
+import collections
+import curses
+import itertools
+import threading
+
+import nyx.popups
+import nyx.util.tracker
+import nyx.util.ui_tools
+
+from nyx.util import panel, tor_controller, ui_tools
+
+from stem.control import Listener
+from stem.util import datetime_to_unix, conf, connection, enum, str_tools
+
+try:
+ # added in python 3.2
+ from functools import lru_cache
+except ImportError:
+ from stem.util.lru_cache import lru_cache
+
+# height of the detail panel content, not counting top and bottom border
+
+DETAILS_HEIGHT = 7
+
+EXIT_USAGE_WIDTH = 15
+UPDATE_RATE = 5 # rate in seconds at which we refresh
+
+# cached information from our last _update() call
+
+LAST_RETRIEVED_HS_CONF = None
+LAST_RETRIEVED_CIRCUITS = None
+
+# 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 (nyx, vidalia, etc).
+
+Category = enum.Enum('INBOUND', 'OUTBOUND', 'EXIT', 'HIDDEN', 'SOCKS', 'CIRCUIT', 'DIRECTORY', 'CONTROL')
+SortAttr = enum.Enum('CATEGORY', 'UPTIME', 'IP_ADDRESS', 'PORT', 'FINGERPRINT', 'NICKNAME', 'COUNTRY')
+LineType = enum.Enum('CONNECTION', 'CIRCUIT_HEADER', 'CIRCUIT')
+
+Line = collections.namedtuple('Line', [
+ 'entry',
+ 'line_type',
+ 'connection',
+ 'circuit',
+ 'fingerprint',
+ 'nickname',
+ 'locale',
+])
+
+
+def conf_handler(key, value):
+ if key == 'features.connection.order':
+ return conf.parse_enum_csv(key, value[0], SortAttr, 3)
+
+
+CONFIG = conf.config_dict('nyx', {
+ 'attr.connection.category_color': {},
+ 'attr.connection.sort_color': {},
+ 'features.connection.resolveApps': True,
+ 'features.connection.order': [SortAttr.CATEGORY, SortAttr.IP_ADDRESS, SortAttr.UPTIME],
+ 'features.connection.showIps': True,
+}, conf_handler)
+
+
+class Entry(object):
+ @staticmethod
+ @lru_cache()
+ def from_connection(connection):
+ return ConnectionEntry(connection)
+
+ @staticmethod
+ @lru_cache()
+ def from_circuit(circuit):
+ return CircuitEntry(circuit)
+
+ def get_lines(self):
+ """
+ Provides individual lines of connection information.
+
+ :returns: **list** of **ConnectionLine** concerning this entry
+ """
+
+ raise NotImplementedError('should be implemented by subclasses')
+
+ def get_type(self):
+ """
+ Provides our best guess at the type of connection this is.
+
+ :returns: **Category** for the connection's type
+ """
+
+ raise NotImplementedError('should be implemented by subclasses')
+
+ def is_private(self):
+ """
+ Checks if information about this endpoint should be scrubbed. 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!
+
+ :returns: **bool** indicating if connection information is sensive or not
+ """
+
+ raise NotImplementedError('should be implemented by subclasses')
+
+ def sort_value(self, attr):
+ """
+ Provides a heuristic for sorting by a given value.
+
+ :param SortAttr attr: sort attribute to provide a heuristic for
+
+ :returns: comparable value for sorting
+ """
+
+ line = self.get_lines()[0]
+ at_end = 'z' * 20
+
+ if attr == SortAttr.IP_ADDRESS:
+ if self.is_private():
+ return 255 ** 4 # orders at the end
+
+ ip_value = 0
+
+ for octet in line.connection.remote_address.split('.'):
+ ip_value = ip_value * 255 + int(octet)
+
+ return ip_value * 65536 + line.connection.remote_port
+ elif attr == SortAttr.PORT:
+ return line.connection.remote_port
+ elif attr == SortAttr.FINGERPRINT:
+ return line.fingerprint if line.fingerprint else at_end
+ elif attr == SortAttr.NICKNAME:
+ return line.nickname if line.nickname else at_end
+ elif attr == SortAttr.CATEGORY:
+ return Category.index_of(self.get_type())
+ elif attr == SortAttr.UPTIME:
+ return line.connection.start_time
+ elif attr == SortAttr.COUNTRY:
+ return line.locale if (line.locale and not self.is_private()) else at_end
+ else:
+ return ''
+
+
+class ConnectionEntry(Entry):
+ def __init__(self, connection):
+ self._connection = connection
+
+ @lru_cache()
+ def get_lines(self):
+ fingerprint, nickname, locale = None, None, None
+
+ if self.get_type() in (Category.OUTBOUND, Category.CIRCUIT, Category.DIRECTORY, Category.EXIT):
+ fingerprint = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address).get(self._connection.remote_port)
+
+ if fingerprint:
+ nickname = nyx.util.tracker.get_consensus_tracker().get_relay_nickname(fingerprint)
+ locale = tor_controller().get_info('ip-to-country/%s' % self._connection.remote_address, None)
+
+ return [Line(self, LineType.CONNECTION, self._connection, None, fingerprint, nickname, locale)]
+
+ @lru_cache()
+ def get_type(self):
+ controller = tor_controller()
+
+ if self._connection.local_port in controller.get_ports(Listener.OR, []):
+ return Category.INBOUND
+ elif self._connection.local_port in controller.get_ports(Listener.DIR, []):
+ return Category.INBOUND
+ elif self._connection.local_port in controller.get_ports(Listener.SOCKS, []):
+ return Category.SOCKS
+ elif self._connection.local_port in controller.get_ports(Listener.CONTROL, []):
+ return Category.CONTROL
+
+ if LAST_RETRIEVED_HS_CONF:
+ for hs_config in LAST_RETRIEVED_HS_CONF.values():
+ if self._connection.remote_port == hs_config['HiddenServicePort']:
+ return Category.HIDDEN
+
+ fingerprint = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address).get(self._connection.remote_port)
+
+ if fingerprint and LAST_RETRIEVED_CIRCUITS:
+ for circ in LAST_RETRIEVED_CIRCUITS:
+ if circ.path and len(circ.path) == 1 and circ.path[0][0] == fingerprint and circ.status == 'BUILT':
+ return Category.DIRECTORY # one-hop circuit to retrieve directory information
+ else:
+ # not a known relay, might be an exit connection
+
+ exit_policy = controller.get_exit_policy(None)
+
+ if exit_policy and exit_policy.can_exit_to(self._connection.remote_address, self._connection.remote_port):
+ return Category.EXIT
+
+ return Category.OUTBOUND
+
+ @lru_cache()
+ def is_private(self):
+ if not CONFIG['features.connection.showIps']:
+ return True
+
+ if self.get_type() == Category.INBOUND:
+ controller = tor_controller()
+
+ if controller.is_user_traffic_allowed().inbound:
+ return len(nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address)) == 0
+ elif self.get_type() == Category.EXIT:
+ # DNS connections exiting us aren't private (since they're hitting our
+ # resolvers). Everything else is.
+
+ return not (self._connection.remote_port == 53 and self._connection.protocol == 'udp')
+
+ return False # for everything else this isn't a concern
+
+
+class CircuitEntry(Entry):
+ def __init__(self, circuit):
+ self._circuit = circuit
+
+ @lru_cache()
+ def get_lines(self):
+ def line(fingerprint, line_type):
+ address, port, nickname, locale = '0.0.0.0', 0, None, None
+ consensus_tracker = nyx.util.tracker.get_consensus_tracker()
+
+ if fingerprint is not None:
+ address, port = consensus_tracker.get_relay_address(fingerprint, ('192.168.0.1', 0))
+ nickname = consensus_tracker.get_relay_nickname(fingerprint)
+ locale = tor_controller().get_info('ip-to-country/%s' % address, None)
+
+ connection = nyx.util.tracker.Connection(datetime_to_unix(self._circuit.created), False, '127.0.0.1', 0, address, port, 'tcp', False)
+ return Line(self, line_type, connection, self._circuit, fingerprint, nickname, locale)
+
+ header_line = line(self._circuit.path[-1][0] if self._circuit.status == 'BUILT' else None, LineType.CIRCUIT_HEADER)
+ return [header_line] + [line(fp, LineType.CIRCUIT) for fp, _ in self._circuit.path]
+
+ def get_type(self):
+ return Category.CIRCUIT
+
+ def is_private(self):
+ return False
+
+
+class ConnectionPanel(panel.Panel, threading.Thread):
+ """
+ Listing of connections tor is making, with information correlated against
+ the current consensus and other data sources.
+ """
+
+ def __init__(self, stdscr):
+ panel.Panel.__init__(self, stdscr, 'connections', 0)
+ threading.Thread.__init__(self)
+ self.setDaemon(True)
+
+ self._scroller = ui_tools.Scroller(True)
+ self._entries = [] # last fetched display entries
+ self._show_details = False # presents the details panel if true
+ self._sort_order = CONFIG['features.connection.order']
+
+ self._last_resource_fetch = -1 # timestamp of the last ConnectionResolver results used
+
+ self._pause_condition = threading.Condition()
+ self._halt = False # terminates thread if true
+
+ # Tracks exiting port and client country statistics
+
+ self._client_locale_usage = {}
+ self._exit_port_usage = {}
+ self._counted_connections = set()
+
+ # If we're a bridge and been running over a day then prepopulates with the
+ # last day's clients.
+
+ bridge_clients = tor_controller().get_info('status/clients-seen', None)
+
+ if bridge_clients:
+ # Response has a couple arguments...
+ # TimeStarted="2011-08-17 15:50:49" CountrySummary=us=16,de=8,uk=8
+
+ country_summary = None
+
+ for line in bridge_clients.split():
+ if line.startswith('CountrySummary='):
+ country_summary = line[15:]
+ break
+
+ if country_summary:
+ for entry in country_summary.split(','):
+ if re.match('^..=[0-9]+$', entry):
+ locale, count = entry.split('=', 1)
+ self._client_locale_usage[locale] = int(count)
+
+ def show_sort_dialog(self):
+ """
+ Provides a dialog for sorting our connections.
+ """
+
+ sort_colors = dict([(attr, CONFIG['attr.connection.sort_color'].get(attr, 'white')) for attr in SortAttr])
+ results = nyx.popups.show_sort_dialog('Connection Ordering:', SortAttr, self._sort_order, sort_colors)
+
+ if results:
+ self._sort_order = results
+ self._entries = sorted(self._entries, key = lambda entry: [entry.sort_value(attr) for attr in self._sort_order])
+
+ def handle_key(self, key):
+ user_traffic_allowed = tor_controller().is_user_traffic_allowed()
+
+ if key.is_scroll():
+ page_height = self.get_preferred_size()[0] - 1
+
+ if self._show_details:
+ page_height -= (DETAILS_HEIGHT + 1)
+
+ lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in self._entries]))
+ is_changed = self._scroller.handle_key(key, lines, page_height)
+
+ if is_changed:
+ self.redraw(True)
+ elif key.is_selection():
+ self._show_details = not self._show_details
+ self.redraw(True)
+ elif key.match('s'):
+ self.show_sort_dialog()
+ elif key.match('r'):
+ connection_tracker = nyx.util.tracker.get_connection_tracker()
+ options = ['auto'] + list(connection.Resolver) + list(nyx.util.tracker.CustomResolver)
+
+ resolver = connection_tracker.get_custom_resolver()
+ selected_index = 0 if resolver is None else options.index(resolver)
+ selection = nyx.popups.show_menu('Connection Resolver:', options, selected_index)
+
+ if selection != -1:
+ connection_tracker.set_custom_resolver(None if selection == 0 else options[selection])
+ elif key.match('d'):
+ self.set_title_visible(False)
+ self.redraw(True)
+ entries = self._entries
+
+ while True:
+ lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in entries]))
+ selection = self._scroller.get_cursor_selection(lines)
+
+ if not selection:
+ break
+
+ def is_close_key(key):
+ return key.is_selection() or key.match('d') or key.match('left') or key.match('right')
+
+ color = CONFIG['attr.connection.category_color'].get(selection.entry.get_type(), 'white')
+ key = nyx.popups.show_descriptor_popup(selection.fingerprint, color, self.max_x, is_close_key)
+
+ if not key or key.is_selection() or key.match('d'):
+ break # closes popup
+ elif key.match('left'):
+ self.handle_key(panel.KeyInput(curses.KEY_UP))
+ elif key.match('right'):
+ self.handle_key(panel.KeyInput(curses.KEY_DOWN))
+
+ self.set_title_visible(True)
+ self.redraw(True)
+ elif key.match('c') and user_traffic_allowed.inbound:
+ nyx.popups.show_count_dialog('Client Locales', self._client_locale_usage)
+ elif key.match('e') and user_traffic_allowed.outbound:
+ counts = {}
+ key_width = max(map(len, self._exit_port_usage.keys()))
+
+ for k, v in self._exit_port_usage.items():
+ usage = connection.port_usage(k)
+
+ if usage:
+ k = k.ljust(key_width + 3) + usage.ljust(EXIT_USAGE_WIDTH)
+
+ counts[k] = v
+
+ nyx.popups.show_count_dialog('Exiting Port Usage', counts)
+ else:
+ return False
+
+ return True
+
+ def run(self):
+ """
+ Keeps connections listing updated, checking for new entries at a set rate.
+ """
+
+ last_ran = -1
+
+ while not self._halt:
+ if self.is_paused() or not tor_controller().is_alive() or (time.time() - last_ran) < UPDATE_RATE:
+ with self._pause_condition:
+ if not self._halt:
+ self._pause_condition.wait(0.2)
+
+ continue # done waiting, try again
+
+ self._update()
+ self.redraw(True)
+
+ # If this is our first run then fill in our fingerprint tracker. This
+ # requires fetching all the router status entries which takes a few
+ # seconds, so best done when we're finished with the rest of the first
+ # iteration to hide the lag.
+
+ if last_ran == -1:
+ nyx.util.tracker.get_consensus_tracker().update(tor_controller().get_network_statuses([]))
+
+ last_ran = time.time()
+
+ def get_help(self):
+ resolver = nyx.util.tracker.get_connection_tracker().get_custom_resolver()
+ user_traffic_allowed = tor_controller().is_user_traffic_allowed()
+
+ options = [
+ ('up arrow', 'scroll up a line', None),
+ ('down arrow', 'scroll down a line', None),
+ ('page up', 'scroll up a page', None),
+ ('page down', 'scroll down a page', None),
+ ('enter', 'show connection details', None),
+ ('d', 'raw consensus descriptor', None),
+ ('s', 'sort ordering', None),
+ ('r', 'connection resolver', 'auto' if resolver is None else resolver),
+ ]
+
+ if user_traffic_allowed.inbound:
+ options.append(('c', 'client locale usage summary', None))
+
+ if user_traffic_allowed.outbound:
+ options.append(('e', 'exit port usage summary', None))
+
+ return options
+
+ def draw(self, width, height):
+ controller = tor_controller()
+ entries = self._entries
+
+ lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in entries]))
+ selected = self._scroller.get_cursor_selection(lines)
+
+ if self.is_paused():
+ current_time = self.get_pause_time()
+ elif not controller.is_alive():
+ current_time = controller.connection_time()
+ else:
+ current_time = time.time()
+
+ is_showing_details = self._show_details and selected
+ details_offset = DETAILS_HEIGHT + 1 if is_showing_details else 0
+
+ is_scrollbar_visible = len(lines) > height - details_offset - 1
+ scroll_offset = 2 if is_scrollbar_visible else 0
+ scroll_location = self._scroller.get_scroll_location(lines, height - details_offset - 1)
+
+ if self.is_title_visible():
+ self._draw_title(entries, self._show_details)
+
+ if is_showing_details:
+ self._draw_details(selected, width, is_scrollbar_visible)
+
+ if is_scrollbar_visible:
+ self.add_scroll_bar(scroll_location, scroll_location + height - details_offset - 1, len(lines), 1 + details_offset)
+
+ for line_number in range(scroll_location, len(lines)):
+ y = line_number + details_offset + 1 - scroll_location
+ self._draw_line(scroll_offset, y, lines[line_number], lines[line_number] == selected, width - scroll_offset, current_time)
+
+ if y >= height:
+ break
+
+ def _draw_title(self, entries, showing_details):
+ """
+ Panel title with the number of connections we presently have.
+ """
+
+ if showing_details:
+ self.addstr(0, 0, 'Connection Details:', curses.A_STANDOUT)
+ elif not entries:
+ self.addstr(0, 0, 'Connections:', curses.A_STANDOUT)
+ else:
+ counts = collections.Counter([entry.get_type() for entry in entries])
+ count_labels = ['%i %s' % (counts[category], category.lower()) for category in Category if counts[category]]
+ self.addstr(0, 0, 'Connections (%s):' % ', '.join(count_labels), curses.A_STANDOUT)
+
+ def _draw_details(self, selected, width, is_scrollbar_visible):
+ """
+ Shows detailed information about the selected connection.
+ """
+
+ attr = (CONFIG['attr.connection.category_color'].get(selected.entry.get_type(), 'white'), curses.A_BOLD)
+
+ if selected.line_type == LineType.CIRCUIT_HEADER and selected.circuit.status != 'BUILT':
+ self.addstr(1, 2, 'Building Circuit...', *attr)
+ else:
+ address = '<scrubbed>' if selected.entry.is_private() else selected.connection.remote_address
+ self.addstr(1, 2, 'address: %s:%s' % (address, selected.connection.remote_port), *attr)
+ self.addstr(2, 2, 'locale: %s' % ('??' if selected.entry.is_private() else (selected.locale if selected.locale else '??')), *attr)
+
+ matches = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(selected.connection.remote_address)
+
+ if not matches:
+ self.addstr(3, 2, 'No consensus data found', *attr)
+ elif len(matches) == 1 or selected.connection.remote_port in matches:
+ controller = tor_controller()
+ fingerprint = matches.values()[0] if len(matches) == 1 else matches[selected.connection.remote_port]
+ router_status_entry = controller.get_network_status(fingerprint, None)
+
+ self.addstr(2, 15, 'fingerprint: %s' % fingerprint, *attr)
+
+ if router_status_entry:
+ dir_port_label = 'dirport: %s' % router_status_entry.dir_port if router_status_entry.dir_port else ''
+ self.addstr(3, 2, 'nickname: %-25s orport: %-10s %s' % (router_status_entry.nickname, router_status_entry.or_port, dir_port_label), *attr)
+ self.addstr(4, 2, 'published: %s' % router_status_entry.published.strftime("%H:%M %m/%d/%Y"), *attr)
+ self.addstr(5, 2, 'flags: %s' % ', '.join(router_status_entry.flags), *attr)
+
+ server_descriptor = controller.get_server_descriptor(fingerprint, None)
+
+ if server_descriptor:
+ policy_label = server_descriptor.exit_policy.summary() if server_descriptor.exit_policy else 'unknown'
+ self.addstr(6, 2, 'exit policy: %s' % policy_label, *attr)
+ self.addstr(4, 38, 'os: %-14s version: %s' % (server_descriptor.operating_system, server_descriptor.tor_version), *attr)
+
+ if server_descriptor.contact:
+ self.addstr(7, 2, 'contact: %s' % server_descriptor.contact, *attr)
+ else:
+ self.addstr(3, 2, 'Multiple matches, possible fingerprints are:', *attr)
+
+ for i, port in enumerate(sorted(matches.keys())):
+ is_last_line, remaining_relays = i == 3, len(matches) - i
+
+ if not is_last_line or remaining_relays == 1:
+ self.addstr(4 + i, 2, '%i. or port: %-5s fingerprint: %s' % (i + 1, port, matches[port]), *attr)
+ else:
+ self.addstr(4 + i, 2, '... %i more' % remaining_relays, *attr)
+
+ if is_last_line:
+ break
+
+ # draw the border, with a 'T' pipe if connecting with the scrollbar
+
+ ui_tools.draw_box(self, 0, 0, width, DETAILS_HEIGHT + 2)
+
+ if is_scrollbar_visible:
+ self.addch(DETAILS_HEIGHT + 1, 1, curses.ACS_TTEE)
+
+ def _draw_line(self, x, y, line, is_selected, width, current_time):
+ attr = nyx.util.ui_tools.get_color(CONFIG['attr.connection.category_color'].get(line.entry.get_type(), 'white'))
+ attr |= curses.A_STANDOUT if is_selected else curses.A_NORMAL
+
+ self.addstr(y, x, ' ' * (width - x), attr)
+
+ if line.line_type == LineType.CIRCUIT:
+ if line.circuit.path[-1][0] == line.fingerprint:
+ prefix = (ord(' '), curses.ACS_LLCORNER, curses.ACS_HLINE, ord(' '))
+ else:
+ prefix = (ord(' '), curses.ACS_VLINE, ord(' '), ord(' '))
+
+ for char in prefix:
+ x = self.addch(y, x, char)
+ else:
+ x += 1 # offset from edge
+
+ self._draw_address_column(x, y, line, attr)
+ self._draw_line_details(57, y, line, width - 57 - 20, attr)
+ self._draw_right_column(width - 18, y, line, current_time, attr)
+
+ def _draw_address_column(self, x, y, line, attr):
+ src = tor_controller().get_info('address', line.connection.local_address)
+ src += ':%s' % line.connection.local_port if line.line_type == LineType.CONNECTION else ''
+
+ if line.line_type == LineType.CIRCUIT_HEADER and line.circuit.status != 'BUILT':
+ dst = 'Building...'
+ else:
+ dst = '<scrubbed>' if line.entry.is_private() else line.connection.remote_address
+ dst += ':%s' % line.connection.remote_port
+
+ if line.entry.get_type() == Category.EXIT:
+ purpose = connection.port_usage(line.connection.remote_port)
+
+ if purpose:
+ dst += ' (%s)' % str_tools.crop(purpose, 26 - len(dst) - 3)
+ elif not tor_controller().is_geoip_unavailable() and not line.entry.is_private():
+ dst += ' (%s)' % (line.locale if line.locale else '??')
+
+ if line.entry.get_type() in (Category.INBOUND, Category.SOCKS, Category.CONTROL):
+ dst, src = src, dst
+
+ if line.line_type == LineType.CIRCUIT:
+ self.addstr(y, x, dst, attr)
+ else:
+ self.addstr(y, x, '%-21s --> %-26s' % (src, dst), attr)
+
+ def _draw_line_details(self, x, y, line, width, attr):
+ if line.line_type == LineType.CIRCUIT_HEADER:
+ comp = ['Purpose: %s' % line.circuit.purpose.capitalize(), ', Circuit ID: %s' % line.circuit.id]
+ elif line.entry.get_type() in (Category.SOCKS, Category.HIDDEN, Category.CONTROL):
+ try:
+ port = line.connection.local_port if line.entry.get_type() == Category.HIDDEN else line.connection.remote_port
+ process = nyx.util.tracker.get_port_usage_tracker().fetch(port)
+ comp = ['%s (%s)' % (process.name, process.pid) if process.pid else process.name]
+ except nyx.util.tracker.UnresolvedResult:
+ comp = ['resolving...']
+ except nyx.util.tracker.UnknownApplication:
+ comp = ['UNKNOWN']
+ else:
+ comp = ['%-40s' % (line.fingerprint if line.fingerprint else 'UNKNOWN'), ' ' + (line.nickname if line.nickname else 'UNKNOWN')]
+
+ for entry in comp:
+ if width >= x + len(entry):
+ x = self.addstr(y, x, entry, attr)
+ else:
+ return
+
+ def _draw_right_column(self, x, y, line, current_time, attr):
+ if line.line_type == LineType.CIRCUIT:
+ circ_path = [fp for fp, _ in line.circuit.path]
+ circ_index = circ_path.index(line.fingerprint)
+
+ if circ_index == len(circ_path) - 1:
+ placement_type = 'Exit' if line.circuit.status == 'BUILT' else 'Extending'
+ elif circ_index == 0:
+ placement_type = 'Guard'
+ else:
+ placement_type = 'Middle'
+
+ self.addstr(y, x + 4, '%i / %s' % (circ_index + 1, placement_type), attr)
+ else:
+ x = self.addstr(y, x, '+' if line.connection.is_legacy else ' ', attr)
+ x = self.addstr(y, x, '%5s' % str_tools.time_label(current_time - line.connection.start_time, 1), attr)
+ x = self.addstr(y, x, ' (', attr)
+ x = self.addstr(y, x, line.entry.get_type().upper(), attr | curses.A_BOLD)
+ x = self.addstr(y, x, ')', attr)
+
+ def stop(self):
+ """
+ Halts further resolutions and terminates the thread.
+ """
+
+ with self._pause_condition:
+ self._halt = True
+ self._pause_condition.notifyAll()
+
+ def _update(self):
+ """
+ Fetches the newest resolved connections.
+ """
+
+ global LAST_RETRIEVED_CIRCUITS, LAST_RETRIEVED_HS_CONF
+
+ controller = tor_controller()
+ LAST_RETRIEVED_CIRCUITS = controller.get_circuits([])
+ LAST_RETRIEVED_HS_CONF = controller.get_hidden_service_conf({})
+
+ conn_resolver = nyx.util.tracker.get_connection_tracker()
+ current_resolution_count = conn_resolver.run_counter()
+
+ if not conn_resolver.is_alive():
+ return # if we're not fetching connections then this is a no-op
+ elif current_resolution_count == self._last_resource_fetch:
+ return # no new connections to process
+
+ new_entries = [Entry.from_connection(conn) for conn in conn_resolver.get_value()]
+
+ for circ in LAST_RETRIEVED_CIRCUITS:
+ # Skips established single-hop circuits (these are for directory
+ # fetches, not client circuits)
+
+ if not (circ.status == 'BUILT' and len(circ.path) == 1):
+ new_entries.append(Entry.from_circuit(circ))
+
+ # update stats for client and exit connections
+
+ for entry in new_entries:
+ line = entry.get_lines()[0]
+
+ if entry.is_private() and line.connection not in self._counted_connections:
+ if entry.get_type() == Category.INBOUND and line.locale:
+ self._client_locale_usage[line.locale] = self._client_locale_usage.get(line.locale, 0) + 1
+ elif entry.get_type() == Category.EXIT:
+ self._exit_port_usage[line.connection.remote_port] = self._exit_port_usage.get(line.connection.remote_port, 0) + 1
+
+ self._counted_connections.add(line.connection)
+
+ self._entries = sorted(new_entries, key = lambda entry: [entry.sort_value(attr) for attr in self._sort_order])
+ self._last_resource_fetch = current_resolution_count
+
+ if CONFIG['features.connection.resolveApps']:
+ local_ports, remote_ports = [], []
+
+ for entry in new_entries:
+ line = entry.get_lines()[0]
+
+ if entry.get_type() in (Category.SOCKS, Category.CONTROL):
+ local_ports.append(line.connection.remote_port)
+ elif entry.get_type() == Category.HIDDEN:
+ remote_ports.append(line.connection.local_port)
+
+ nyx.util.tracker.get_port_usage_tracker().query(local_ports, remote_ports)
diff --git a/nyx/panel/graph.py b/nyx/panel/graph.py
new file mode 100644
index 0000000..086d04d
--- /dev/null
+++ b/nyx/panel/graph.py
@@ -0,0 +1,753 @@
+"""
+Graphs of tor related statistics. For example...
+
+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 collections
+import copy
+import curses
+import time
+
+import nyx.controller
+import nyx.popups
+import nyx.util.tracker
+
+from stem.control import EventType, Listener
+from stem.util import conf, enum, log, str_tools, system
+from nyx.util import join, msg, panel, tor_controller
+
+GraphStat = enum.Enum(('BANDWIDTH', 'bandwidth'), ('CONNECTIONS', 'connections'), ('SYSTEM_RESOURCES', 'resources'))
+Interval = enum.Enum(('EACH_SECOND', 'each second'), ('FIVE_SECONDS', '5 seconds'), ('THIRTY_SECONDS', '30 seconds'), ('MINUTELY', 'minutely'), ('FIFTEEN_MINUTE', '15 minute'), ('THIRTY_MINUTE', '30 minute'), ('HOURLY', 'hourly'), ('DAILY', 'daily'))
+Bounds = enum.Enum(('GLOBAL_MAX', 'global_max'), ('LOCAL_MAX', 'local_max'), ('TIGHT', 'tight'))
+
+DrawAttributes = collections.namedtuple('DrawAttributes', ('stat', 'subgraph_height', 'subgraph_width', 'interval', 'bounds_type', 'accounting'))
+
+INTERVAL_SECONDS = {
+ Interval.EACH_SECOND: 1,
+ Interval.FIVE_SECONDS: 5,
+ Interval.THIRTY_SECONDS: 30,
+ Interval.MINUTELY: 60,
+ Interval.FIFTEEN_MINUTE: 900,
+ Interval.THIRTY_MINUTE: 1800,
+ Interval.HOURLY: 3600,
+ Interval.DAILY: 86400,
+}
+
+PRIMARY_COLOR, SECONDARY_COLOR = 'green', 'cyan'
+
+ACCOUNTING_RATE = 5
+DEFAULT_CONTENT_HEIGHT = 4 # space needed for labeling above and below the graph
+WIDE_LABELING_GRAPH_COL = 50 # minimum graph columns to use wide spacing for x-axis labels
+COLLAPSE_WIDTH = 135 # width at which to move optional stats from the title to x-axis label
+
+
+def conf_handler(key, value):
+ if key == 'features.graph.height':
+ return max(1, value)
+ elif key == 'features.graph.max_width':
+ return max(1, value)
+ elif key == 'features.graph.type':
+ if value != 'none' and value not in GraphStat:
+ log.warn("'%s' isn't a valid graph type, options are: none, %s" % (CONFIG['features.graph.type'], ', '.join(GraphStat)))
+ return CONFIG['features.graph.type'] # keep the default
+ elif key == 'features.graph.interval':
+ if value not in Interval:
+ log.warn("'%s' isn't a valid graphing interval, options are: %s" % (value, ', '.join(Interval)))
+ return CONFIG['features.graph.interval'] # keep the default
+ elif key == 'features.graph.bound':
+ if value not in Bounds:
+ log.warn("'%s' isn't a valid graph bounds, options are: %s" % (value, ', '.join(Bounds)))
+ return CONFIG['features.graph.bound'] # keep the default
+
+
+CONFIG = conf.config_dict('nyx', {
+ 'attr.hibernate_color': {},
+ 'attr.graph.title': {},
+ 'attr.graph.header.primary': {},
+ 'attr.graph.header.secondary': {},
+ 'features.graph.height': 7,
+ 'features.graph.type': GraphStat.BANDWIDTH,
+ 'features.graph.interval': Interval.EACH_SECOND,
+ 'features.graph.bound': Bounds.LOCAL_MAX,
+ 'features.graph.max_width': 300, # we need some sort of max size so we know how much graph data to retain
+ 'features.panels.show.connection': True,
+ 'features.graph.bw.transferInBytes': False,
+ 'features.graph.bw.accounting.show': True,
+}, conf_handler)
+
+
+class GraphData(object):
+ """
+ Graphable statistical information.
+
+ :var int latest_value: last value we recorded
+ :var int total: sum of all values we've recorded
+ :var int tick: number of events we've processed
+ :var dict values: mapping of intervals to an array of samplings from newest to oldest
+ :var dict max_value: mapping of intervals to the maximum value it has had
+ """
+
+ def __init__(self, clone = None, category = None, is_primary = True):
+ if clone:
+ self.latest_value = clone.latest_value
+ self.total = clone.total
+ self.tick = clone.tick
+ self.values = copy.deepcopy(clone.values)
+ self.max_value = dict(clone.max_value)
+
+ self._category = clone._category
+ self._is_primary = clone._is_primary
+ self._in_process_value = dict(clone._in_process_value)
+ else:
+ self.latest_value = 0
+ self.total = 0
+ self.tick = 0
+ self.values = dict([(i, CONFIG['features.graph.max_width'] * [0]) for i in Interval])
+ self.max_value = dict([(i, 0) for i in Interval])
+
+ self._category = category
+ self._is_primary = is_primary
+ self._in_process_value = dict([(i, 0) for i in Interval])
+
+ def average(self):
+ return self.total / max(1, self.tick)
+
+ def update(self, new_value):
+ self.latest_value = new_value
+ self.total += new_value
+ self.tick += 1
+
+ for interval in Interval:
+ interval_seconds = INTERVAL_SECONDS[interval]
+ self._in_process_value[interval] += new_value
+
+ if self.tick % interval_seconds == 0:
+ new_entry = self._in_process_value[interval] / interval_seconds
+ self.values[interval] = [new_entry] + self.values[interval][:-1]
+ self.max_value[interval] = max(self.max_value[interval], new_entry)
+ self._in_process_value[interval] = 0
+
+ def header(self, width):
+ """
+ Provides the description above a subgraph.
+
+ :param int width: maximum length of the header
+
+ :returns: **str** with our graph header
+ """
+
+ return self._category._header(width, self._is_primary)
+
+ def y_axis_label(self, value):
+ """
+ Provides the label we should display on our y-axis.
+
+ :param int value: value being shown on the y-axis
+
+ :returns: **str** with our y-axis label
+ """
+
+ return self._category._y_axis_label(value, self._is_primary)
+
+
+class GraphCategory(object):
+ """
+ Category for the graph. This maintains two subgraphs, updating them each
+ second with updated stats.
+
+ :var GraphData primary: first subgraph
+ :var GraphData secondary: second subgraph
+ :var float start_time: unix timestamp for when we started
+ """
+
+ def __init__(self, clone = None):
+ if clone:
+ self.primary = GraphData(clone.primary)
+ self.secondary = GraphData(clone.secondary)
+ self.start_time = clone.start_time
+ self._title_stats = list(clone._title_stats)
+ self._primary_header_stats = list(clone._primary_header_stats)
+ self._secondary_header_stats = list(clone._secondary_header_stats)
+ else:
+ self.primary = GraphData(category = self, is_primary = True)
+ self.secondary = GraphData(category = self, is_primary = False)
+ self.start_time = time.time()
+ self._title_stats = []
+ self._primary_header_stats = []
+ self._secondary_header_stats = []
+
+ def stat_type(self):
+ """
+ Provides the GraphStat this graph is for.
+
+ :returns: **GraphStat** of this graph
+ """
+
+ raise NotImplementedError('Should be implemented by subclasses')
+
+ def title(self, width):
+ """
+ Provides a graph title that fits in the given width.
+
+ :param int width: maximum length of the title
+
+ :returns: **str** with our title
+ """
+
+ title = CONFIG['attr.graph.title'].get(self.stat_type(), '')
+ title_stats = join(self._title_stats, ', ', width - len(title) - 4)
+ return '%s (%s):' % (title, title_stats) if title_stats else title + ':'
+
+ def bandwidth_event(self, event):
+ """
+ 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 per second).
+ """
+
+ pass
+
+ def _header(self, is_primary, width):
+ if is_primary:
+ header = CONFIG['attr.graph.header.primary'].get(self.stat_type(), '')
+ header_stats = self._primary_header_stats
+ else:
+ header = CONFIG['attr.graph.header.secondary'].get(self.stat_type(), '')
+ header_stats = self._secondary_header_stats
+
+ header_stats = join(header_stats, '', width - len(header) - 4)
+ return '%s (%s):' % (header, header_stats) if header_stats else '%s:' % header
+
+ def _y_axis_label(self, value, is_primary):
+ return str(value)
+
+
+class BandwidthStats(GraphCategory):
+ """
+ Tracks tor's bandwidth usage.
+ """
+
+ def __init__(self, clone = None):
+ GraphCategory.__init__(self, clone)
+
+ if not clone:
+ # fill in past bandwidth information
+
+ controller = tor_controller()
+ bw_entries, is_successful = controller.get_info('bw-event-cache', None), True
+
+ if bw_entries:
+ for entry in bw_entries.split():
+ entry_comp = entry.split(',')
+
+ if len(entry_comp) != 2 or not entry_comp[0].isdigit() or not entry_comp[1].isdigit():
+ log.warn(msg('panel.graphing.bw_event_cache_malformed', response = bw_entries))
+ is_successful = False
+ break
+
+ self.primary.update(int(entry_comp[0]))
+ self.secondary.update(int(entry_comp[1]))
+
+ if is_successful:
+ log.info(msg('panel.graphing.prepopulation_successful', duration = str_tools.time_label(len(bw_entries.split()), is_long = True)))
+
+ read_total = controller.get_info('traffic/read', None)
+ write_total = controller.get_info('traffic/written', None)
+ start_time = system.start_time(controller.get_pid(None))
+
+ if read_total and write_total and start_time:
+ self.primary.total = int(read_total)
+ self.secondary.total = int(write_total)
+ self.start_time = start_time
+
+ def stat_type(self):
+ return GraphStat.BANDWIDTH
+
+ def _y_axis_label(self, value, is_primary):
+ return _size_label(value, 0)
+
+ def bandwidth_event(self, event):
+ self.primary.update(event.read)
+ self.secondary.update(event.written)
+
+ self._primary_header_stats = [
+ '%-14s' % ('%s/sec' % _size_label(self.primary.latest_value)),
+ '- avg: %s/sec' % _size_label(self.primary.total / (time.time() - self.start_time)),
+ ', total: %s' % _size_label(self.primary.total),
+ ]
+
+ self._secondary_header_stats = [
+ '%-14s' % ('%s/sec' % _size_label(self.secondary.latest_value)),
+ '- avg: %s/sec' % _size_label(self.secondary.total / (time.time() - self.start_time)),
+ ', total: %s' % _size_label(self.secondary.total),
+ ]
+
+ controller = tor_controller()
+
+ stats = []
+ bw_rate = controller.get_effective_rate(None)
+ bw_burst = controller.get_effective_rate(None, burst = True)
+
+ if bw_rate and bw_burst:
+ bw_rate_label = _size_label(bw_rate)
+ bw_burst_label = _size_label(bw_burst)
+
+ # if both are using rounded values then strip off the '.0' decimal
+
+ if '.0' in bw_rate_label and '.0' in bw_burst_label:
+ bw_rate_label = bw_rate_label.split('.', 1)[0]
+ bw_burst_label = bw_burst_label.split('.', 1)[0]
+
+ stats.append('limit: %s/s' % bw_rate_label)
+ stats.append('burst: %s/s' % bw_burst_label)
+
+ my_router_status_entry = controller.get_network_status(default = None)
+ measured_bw = getattr(my_router_status_entry, 'bandwidth', None)
+
+ if measured_bw:
+ stats.append('measured: %s/s' % _size_label(measured_bw))
+ else:
+ my_server_descriptor = controller.get_server_descriptor(default = None)
+ observed_bw = getattr(my_server_descriptor, 'observed_bandwidth', None)
+
+ if observed_bw:
+ stats.append('observed: %s/s' % _size_label(observed_bw))
+
+ self._title_stats = stats
+
+
+class ConnectionStats(GraphCategory):
+ """
+ Tracks number of inbound and outbound connections.
+ """
+
+ def stat_type(self):
+ return GraphStat.CONNECTIONS
+
+ def bandwidth_event(self, event):
+ inbound_count, outbound_count = 0, 0
+
+ controller = tor_controller()
+ or_ports = controller.get_ports(Listener.OR, [])
+ dir_ports = controller.get_ports(Listener.DIR, [])
+ control_ports = controller.get_ports(Listener.CONTROL, [])
+
+ for entry in nyx.util.tracker.get_connection_tracker().get_value():
+ if entry.local_port in or_ports or entry.local_port in dir_ports:
+ inbound_count += 1
+ elif entry.local_port in control_ports:
+ pass # control connection
+ else:
+ outbound_count += 1
+
+ self.primary.update(inbound_count)
+ self.secondary.update(outbound_count)
+
+ self._primary_header_stats = [str(self.primary.latest_value), ', avg: %s' % self.primary.average()]
+ self._secondary_header_stats = [str(self.secondary.latest_value), ', avg: %s' % self.secondary.average()]
+
+
+class ResourceStats(GraphCategory):
+ """
+ Tracks cpu and memory usage of the tor process.
+ """
+
+ def stat_type(self):
+ return GraphStat.SYSTEM_RESOURCES
+
+ def _y_axis_label(self, value, is_primary):
+ return '%i%%' % value if is_primary else str_tools.size_label(value)
+
+ def bandwidth_event(self, event):
+ resources = nyx.util.tracker.get_resource_tracker().get_value()
+ self.primary.update(resources.cpu_sample * 100) # decimal percentage to whole numbers
+ self.secondary.update(resources.memory_bytes)
+
+ self._primary_header_stats = ['%0.1f%%' % self.primary.latest_value, ', avg: %0.1f%%' % self.primary.average()]
+ self._secondary_header_stats = [str_tools.size_label(self.secondary.latest_value, 1), ', avg: %s' % str_tools.size_label(self.secondary.average(), 1)]
+
+
+class GraphPanel(panel.Panel):
+ """
+ Panel displaying graphical information of GraphCategory instances.
+ """
+
+ def __init__(self, stdscr):
+ panel.Panel.__init__(self, stdscr, 'graph', 0)
+
+ self._displayed_stat = None if CONFIG['features.graph.type'] == 'none' else CONFIG['features.graph.type']
+ self._update_interval = CONFIG['features.graph.interval']
+ self._bounds = CONFIG['features.graph.bound']
+ self._graph_height = CONFIG['features.graph.height']
+
+ self._accounting_stats = None
+
+ self._stats = {
+ GraphStat.BANDWIDTH: BandwidthStats(),
+ GraphStat.SYSTEM_RESOURCES: ResourceStats(),
+ }
+
+ if CONFIG['features.panels.show.connection']:
+ self._stats[GraphStat.CONNECTIONS] = ConnectionStats()
+ elif self._displayed_stat == GraphStat.CONNECTIONS:
+ log.warn("The connection graph is unavailble when you set 'features.panels.show.connection false'.")
+ self._displayed_stat = GraphStat.BANDWIDTH
+
+ self.set_pause_attr('_stats')
+ self.set_pause_attr('_accounting_stats')
+
+ controller = tor_controller()
+ controller.add_event_listener(self._update_accounting, EventType.BW)
+ controller.add_event_listener(self._update_stats, EventType.BW)
+ controller.add_status_listener(lambda *args: self.redraw(True))
+
+ @property
+ def displayed_stat(self):
+ return self._displayed_stat
+
+ @displayed_stat.setter
+ def displayed_stat(self, value):
+ if value is not None and value not in self._stats.keys():
+ raise ValueError("%s isn't a graphed statistic" % value)
+
+ self._displayed_stat = value
+
+ def stat_options(self):
+ return self._stats.keys()
+
+ @property
+ def update_interval(self):
+ return self._update_interval
+
+ @update_interval.setter
+ def update_interval(self, value):
+ if value not in Interval:
+ raise ValueError("%s isn't a valid graphing update interval" % value)
+
+ self._update_interval = value
+
+ @property
+ def bounds_type(self):
+ return self._bounds
+
+ @bounds_type.setter
+ def bounds_type(self, value):
+ if value not in Bounds:
+ raise ValueError("%s isn't a valid type of bounds" % value)
+
+ self._bounds = value
+
+ def get_height(self):
+ """
+ Provides the height of the content.
+ """
+
+ if not self.displayed_stat:
+ return 0
+
+ height = DEFAULT_CONTENT_HEIGHT + self._graph_height
+
+ if self.displayed_stat == GraphStat.BANDWIDTH and self._accounting_stats:
+ height += 3
+
+ return height
+
+ def set_graph_height(self, new_graph_height):
+ self._graph_height = max(1, new_graph_height)
+
+ def resize_graph(self):
+ """
+ Prompts for user input to resize the graph panel. Options include...
+
+ * down arrow - grow graph
+ * up arrow - shrink graph
+ * enter / space - set size
+ """
+
+ control = nyx.controller.get_controller()
+
+ with panel.CURSES_LOCK:
+ try:
+ while True:
+ msg = 'press the down/up to resize the graph, and enter when done'
+ control.set_msg(msg, curses.A_BOLD, True)
+ curses.cbreak()
+ key = control.key_input()
+
+ if key.match('down'):
+ # don't grow the graph if it's already consuming the whole display
+ # (plus an extra line for the graph/log gap)
+
+ max_height = self.parent.getmaxyx()[0] - self.top
+ current_height = self.get_height()
+
+ if current_height < max_height + 1:
+ self.set_graph_height(self._graph_height + 1)
+ elif key.match('up'):
+ self.set_graph_height(self._graph_height - 1)
+ elif key.is_selection():
+ break
+
+ control.redraw()
+ finally:
+ control.set_msg()
+
+ def handle_key(self, key):
+ if key.match('r'):
+ self.resize_graph()
+ elif key.match('b'):
+ # uses the next boundary type
+ self.bounds_type = Bounds.next(self.bounds_type)
+ self.redraw(True)
+ elif key.match('s'):
+ # provides a menu to pick the graphed stats
+
+ available_stats = sorted(self.stat_options())
+ options = ['None'] + [stat.capitalize() for stat in available_stats]
+ initial_selection = available_stats.index(self.displayed_stat) + 1 if self.displayed_stat else 0
+
+ selection = nyx.popups.show_menu('Graphed Stats:', options, initial_selection)
+
+ # applies new setting
+
+ if selection == 0:
+ self.displayed_stat = None
+ elif selection != -1:
+ self.displayed_stat = available_stats[selection - 1]
+ elif key.match('i'):
+ # provides menu to pick graph panel update interval
+
+ selection = nyx.popups.show_menu('Update Interval:', list(Interval), list(Interval).index(self.update_interval))
+
+ if selection != -1:
+ self.update_interval = list(Interval)[selection]
+ else:
+ return False
+
+ return True
+
+ def get_help(self):
+ return [
+ ('r', 'resize graph', None),
+ ('s', 'graphed stats', self.displayed_stat if self.displayed_stat else 'none'),
+ ('b', 'graph bounds', self.bounds_type.replace('_', ' ')),
+ ('i', 'graph update interval', self.update_interval),
+ ]
+
+ def draw(self, width, height):
+ if not self.displayed_stat:
+ return
+
+ stat = self.get_attr('_stats')[self.displayed_stat]
+
+ attr = DrawAttributes(
+ stat = type(stat)(stat), # clone the GraphCategory
+ subgraph_height = self._graph_height + 2, # graph rows + header + x-axis label
+ subgraph_width = min(width / 2, CONFIG['features.graph.max_width']),
+ interval = self.update_interval,
+ bounds_type = self.bounds_type,
+ accounting = self.get_attr('_accounting_stats'),
+ )
+
+ if self.is_title_visible():
+ self.addstr(0, 0, attr.stat.title(width), curses.A_STANDOUT)
+
+ self._draw_subgraph(attr, attr.stat.primary, 0, PRIMARY_COLOR)
+ self._draw_subgraph(attr, attr.stat.secondary, attr.subgraph_width, SECONDARY_COLOR)
+
+ if attr.stat.stat_type() == GraphStat.BANDWIDTH:
+ if width <= COLLAPSE_WIDTH:
+ self._draw_bandwidth_stats(attr, width)
+
+ if attr.accounting:
+ self._draw_accounting_stats(attr)
+
+ def _draw_subgraph(self, attr, data, x, color):
+ # Concering our subgraph colums, the y-axis label can be at most six
+ # characters, with two spaces of padding on either side of the graph.
+ # Starting with the smallest size, then possibly raise it after determing
+ # the y_axis_labels.
+
+ subgraph_columns = attr.subgraph_width - 8
+ min_bound, max_bound = self._get_graph_bounds(attr, data, subgraph_columns)
+
+ x_axis_labels = self._get_x_axis_labels(attr, subgraph_columns)
+ y_axis_labels = self._get_y_axis_labels(attr, data, min_bound, max_bound)
+ subgraph_columns = max(subgraph_columns, attr.subgraph_width - max([len(label) for label in y_axis_labels.values()]) - 2)
+ axis_offset = max([len(label) for label in y_axis_labels.values()])
+
+ self.addstr(1, x, data.header(attr.subgraph_width), curses.A_BOLD, color)
+
+ for x_offset, label in x_axis_labels.items():
+ self.addstr(attr.subgraph_height, x + x_offset + axis_offset, label, color)
+
+ for y, label in y_axis_labels.items():
+ self.addstr(y, x, label, color)
+
+ for col in range(subgraph_columns):
+ column_count = int(data.values[attr.interval][col]) - min_bound
+ column_height = int(min(attr.subgraph_height - 2, (attr.subgraph_height - 2) * column_count / (max(1, max_bound) - min_bound)))
+
+ for row in range(column_height):
+ self.addstr(attr.subgraph_height - 1 - row, x + col + axis_offset + 1, ' ', curses.A_STANDOUT, color)
+
+ def _get_graph_bounds(self, attr, data, subgraph_columns):
+ """
+ Provides the range the graph shows (ie, its minimum and maximum value).
+ """
+
+ min_bound, max_bound = 0, 0
+ values = data.values[attr.interval][:subgraph_columns]
+
+ if attr.bounds_type == Bounds.GLOBAL_MAX:
+ max_bound = data.max_value[attr.interval]
+ elif subgraph_columns > 0:
+ max_bound = max(values) # local maxima
+
+ if attr.bounds_type == Bounds.TIGHT and subgraph_columns > 0:
+ min_bound = min(values)
+
+ # if the max = min pick zero so we still display something
+
+ if min_bound == max_bound:
+ min_bound = 0
+
+ return min_bound, max_bound
+
+ def _get_y_axis_labels(self, attr, data, min_bound, max_bound):
+ """
+ Provides the labels for the y-axis. This is a mapping of the position it
+ should be drawn at to its text.
+ """
+
+ y_axis_labels = {
+ 2: data.y_axis_label(max_bound),
+ attr.subgraph_height - 1: data.y_axis_label(min_bound),
+ }
+
+ ticks = (attr.subgraph_height - 5) / 2
+
+ for i in range(ticks):
+ row = attr.subgraph_height - (2 * i) - 5
+
+ if attr.subgraph_height % 2 == 0 and i >= (ticks / 2):
+ row -= 1 # make extra gap be in the middle when we're an even size
+
+ val = (max_bound - min_bound) * (attr.subgraph_height - row - 3) / (attr.subgraph_height - 3)
+
+ if val not in (min_bound, max_bound):
+ y_axis_labels[row + 2] = data.y_axis_label(val)
+
+ return y_axis_labels
+
+ def _get_x_axis_labels(self, attr, subgraph_columns):
+ """
+ Provides the labels for the x-axis. We include the units for only its first
+ value, then bump the precision for subsequent units. For example...
+
+ 10s, 20, 30, 40, 50, 1m, 1.1, 1.3, 1.5
+ """
+
+ x_axis_labels = {}
+
+ interval_sec = INTERVAL_SECONDS[attr.interval]
+ interval_spacing = 10 if subgraph_columns >= WIDE_LABELING_GRAPH_COL else 5
+ units_label, decimal_precision = None, 0
+
+ for i in range((subgraph_columns - 4) / interval_spacing):
+ x = (i + 1) * interval_spacing
+ time_label = str_tools.time_label(x * interval_sec, decimal_precision)
+
+ if not units_label:
+ units_label = time_label[-1]
+ elif units_label != time_label[-1]:
+ # upped scale so also up precision of future measurements
+ units_label = time_label[-1]
+ decimal_precision += 1
+ else:
+ # if constrained on space then strips labeling since already provided
+ time_label = time_label[:-1]
+
+ x_axis_labels[x] = time_label
+
+ return x_axis_labels
+
+ def _draw_bandwidth_stats(self, attr, width):
+ """
+ Replaces the x-axis labeling with bandwidth stats. This is done on small
+ screens since this information otherwise wouldn't fit.
+ """
+
+ labeling_line = DEFAULT_CONTENT_HEIGHT + attr.subgraph_height - 4
+ self.addstr(labeling_line, 0, ' ' * width) # clear line
+
+ runtime = time.time() - attr.stat.start_time
+ primary_footer = 'total: %s, avg: %s/sec' % (_size_label(attr.stat.primary.total), _size_label(attr.stat.primary.total / runtime))
+ secondary_footer = 'total: %s, avg: %s/sec' % (_size_label(attr.stat.secondary.total), _size_label(attr.stat.secondary.total / runtime))
+
+ self.addstr(labeling_line, 1, primary_footer, PRIMARY_COLOR)
+ self.addstr(labeling_line, attr.subgraph_width + 1, secondary_footer, SECONDARY_COLOR)
+
+ def _draw_accounting_stats(self, attr):
+ y = DEFAULT_CONTENT_HEIGHT + attr.subgraph_height - 2
+
+ if tor_controller().is_alive():
+ hibernate_color = CONFIG['attr.hibernate_color'].get(attr.accounting.status, 'red')
+
+ x = self.addstr(y, 0, 'Accounting (', curses.A_BOLD)
+ x = self.addstr(y, x, attr.accounting.status, curses.A_BOLD, hibernate_color)
+ x = self.addstr(y, x, ')', curses.A_BOLD)
+
+ self.addstr(y, 35, 'Time to reset: %s' % str_tools.short_time_label(attr.accounting.time_until_reset))
+
+ self.addstr(y + 1, 2, '%s / %s' % (attr.accounting.read_bytes, attr.accounting.read_limit), PRIMARY_COLOR)
+ self.addstr(y + 1, 37, '%s / %s' % (attr.accounting.written_bytes, attr.accounting.write_limit), SECONDARY_COLOR)
+ else:
+ self.addstr(y, 0, 'Accounting:', curses.A_BOLD)
+ self.addstr(y, 12, 'Connection Closed...')
+
+ def copy_attr(self, attr):
+ if attr == '_stats':
+ return dict([(key, type(self._stats[key])(self._stats[key])) for key in self._stats])
+ else:
+ return panel.Panel.copy_attr(self, attr)
+
+ def _update_accounting(self, event):
+ if not CONFIG['features.graph.bw.accounting.show']:
+ self._accounting_stats = None
+ elif not self._accounting_stats or time.time() - self._accounting_stats.retrieved >= ACCOUNTING_RATE:
+ old_accounting_stats = self._accounting_stats
+ self._accounting_stats = tor_controller().get_accounting_stats(None)
+
+ # if we either added or removed accounting info then redraw the whole
+ # screen to account for resizing
+
+ if bool(old_accounting_stats) != bool(self._accounting_stats):
+ nyx.controller.get_controller().redraw()
+
+ def _update_stats(self, event):
+ for stat in self._stats.values():
+ stat.bandwidth_event(event)
+
+ if self.displayed_stat:
+ param = self.get_attr('_stats')[self.displayed_stat]
+ update_rate = INTERVAL_SECONDS[self.update_interval]
+
+ if param.primary.tick % update_rate == 0:
+ self.redraw(True)
+
+
+def _size_label(byte_count, decimal = 1):
+ """
+ Alias for str_tools.size_label() that accounts for if the user prefers bits
+ or bytes.
+ """
+
+ return str_tools.size_label(byte_count, decimal, is_bytes = CONFIG['features.graph.bw.transferInBytes'])
diff --git a/nyx/panel/header.py b/nyx/panel/header.py
new file mode 100644
index 0000000..0d6591a
--- /dev/null
+++ b/nyx/panel/header.py
@@ -0,0 +1,496 @@
+"""
+Top panel for every page, containing basic system and tor related information.
+This expands the information it presents to two columns if there's room
+available.
+"""
+
+import collections
+import os
+import time
+import curses
+import threading
+
+import stem
+
+import nyx.controller
+import nyx.popups
+
+from stem.control import Listener, State
+from stem.util import conf, log, proc, str_tools, system
+from nyx.util import msg, tor_controller, panel, tracker
+
+MIN_DUAL_COL_WIDTH = 141 # minimum width where we'll show two columns
+SHOW_FD_THRESHOLD = 60 # show file descriptor usage if usage is over this percentage
+UPDATE_RATE = 5 # rate in seconds at which we refresh
+
+CONFIG = conf.config_dict('nyx', {
+ 'attr.flag_colors': {},
+ 'attr.version_status_colors': {},
+})
+
+
+class HeaderPanel(panel.Panel, threading.Thread):
+ """
+ Top area containing tor settings and system information.
+ """
+
+ def __init__(self, stdscr):
+ panel.Panel.__init__(self, stdscr, 'header', 0)
+ threading.Thread.__init__(self)
+ self.setDaemon(True)
+
+ self._vals = get_sampling()
+
+ self._pause_condition = threading.Condition()
+ self._halt = False # terminates thread if true
+ self._reported_inactive = False
+
+ tor_controller().add_status_listener(self.reset_listener)
+
+ def is_wide(self, width = None):
+ """
+ True if we should show two columns of information, False otherwise.
+ """
+
+ if width is None:
+ width = self.get_parent().getmaxyx()[1]
+
+ return width >= MIN_DUAL_COL_WIDTH
+
+ def get_height(self):
+ """
+ Provides the height of the content, which is dynamically determined by the
+ panel's maximum width.
+ """
+
+ if self._vals.is_relay:
+ return 4 if self.is_wide() else 6
+ else:
+ return 3 if self.is_wide() else 4
+
+ def send_newnym(self):
+ """
+ Requests a new identity and provides a visual queue.
+ """
+
+ controller = tor_controller()
+
+ if not controller.is_newnym_available():
+ return
+
+ controller.signal(stem.Signal.NEWNYM)
+
+ # If we're wide then the newnym label in this panel will give an
+ # indication that the signal was sent. Otherwise use a msg.
+
+ if not self.is_wide():
+ nyx.popups.show_msg('Requesting a new identity', 1)
+
+ def handle_key(self, key):
+ if key.match('n'):
+ self.send_newnym()
+ elif key.match('r') and not self._vals.is_connected:
+ # TODO: This is borked. Not quite sure why but our attempt to call
+ # PROTOCOLINFO fails with a socket error, followed by completely freezing
+ # nyx. This is exposing two bugs...
+ #
+ # * This should be working. That's a stem issue.
+ # * Our interface shouldn't be locking up. That's an nyx issue.
+
+ return True
+
+ controller = tor_controller()
+
+ try:
+ controller.connect()
+
+ try:
+ controller.authenticate() # TODO: should account for our chroot
+ except stem.connection.MissingPassword:
+ password = nyx.popups.input_prompt('Controller Password: ')
+
+ if password:
+ controller.authenticate(password)
+
+ log.notice("Reconnected to Tor's control port")
+ nyx.popups.show_msg('Tor reconnected', 1)
+ except Exception as exc:
+ nyx.popups.show_msg('Unable to reconnect (%s)' % exc, 3)
+ controller.close()
+ else:
+ return False
+
+ return True
+
+ def draw(self, width, height):
+ vals = self._vals # local reference to avoid concurrency concerns
+ is_wide = self.is_wide(width)
+
+ # space available for content
+
+ left_width = max(width / 2, 77) if is_wide else width
+ right_width = width - left_width
+
+ self._draw_platform_section(0, 0, left_width, vals)
+
+ if vals.is_connected:
+ self._draw_ports_section(0, 1, left_width, vals)
+ else:
+ self._draw_disconnected(0, 1, left_width, vals)
+
+ if is_wide:
+ self._draw_resource_usage(left_width, 0, right_width, vals)
+
+ if vals.is_relay:
+ self._draw_fingerprint_and_fd_usage(left_width, 1, right_width, vals)
+ self._draw_flags(0, 2, left_width, vals)
+ self._draw_exit_policy(left_width, 2, right_width, vals)
+ elif vals.is_connected:
+ self._draw_newnym_option(left_width, 1, right_width, vals)
+ else:
+ self._draw_resource_usage(0, 2, left_width, vals)
+
+ if vals.is_relay:
+ self._draw_fingerprint_and_fd_usage(0, 3, left_width, vals)
+ self._draw_flags(0, 4, left_width, vals)
+
+ def _draw_platform_section(self, x, y, width, vals):
+ """
+ Section providing the user's hostname, platform, and version information...
+
+ nyx - odin (Linux 3.5.0-52-generic) Tor 0.2.5.1-alpha-dev (unrecommended)
+ |------ platform (40 characters) ------| |----------- tor version -----------|
+ """
+
+ initial_x, space_left = x, min(width, 40)
+
+ x = self.addstr(y, x, vals.format('nyx - {hostname}', space_left))
+ space_left -= x - initial_x
+
+ if space_left >= 10:
+ self.addstr(y, x, ' (%s)' % vals.format('{platform}', space_left - 3))
+
+ x, space_left = initial_x + 43, width - 43
+
+ if vals.version != 'Unknown' and space_left >= 10:
+ x = self.addstr(y, x, vals.format('Tor {version}', space_left))
+ space_left -= x - 43 - initial_x
+
+ if space_left >= 7 + len(vals.version_status):
+ version_color = CONFIG['attr.version_status_colors'].get(vals.version_status, 'white')
+
+ x = self.addstr(y, x, ' (')
+ x = self.addstr(y, x, vals.version_status, version_color)
+ self.addstr(y, x, ')')
+
+ def _draw_ports_section(self, x, y, width, vals):
+ """
+ Section providing our nickname, address, and port information...
+
+ Unnamed - 0.0.0.0:7000, Control Port (cookie): 9051
+ """
+
+ if not vals.is_relay:
+ x = self.addstr(y, x, 'Relaying Disabled', 'cyan')
+ else:
+ x = self.addstr(y, x, vals.format('{nickname} - {address}:{or_port}'))
+
+ if vals.dir_port != '0':
+ x = self.addstr(y, x, vals.format(', Dir Port: {dir_port}'))
+
+ if vals.control_port:
+ if width >= x + 19 + len(vals.control_port) + len(vals.auth_type):
+ auth_color = 'red' if vals.auth_type == 'open' else 'green'
+
+ x = self.addstr(y, x, ', Control Port (')
+ x = self.addstr(y, x, vals.auth_type, auth_color)
+ self.addstr(y, x, vals.format('): {control_port}'))
+ else:
+ self.addstr(y, x, vals.format(', Control Port: {control_port}'))
+ elif vals.socket_path:
+ self.addstr(y, x, vals.format(', Control Socket: {socket_path}'))
+
+ def _draw_disconnected(self, x, y, width, vals):
+ """
+ Message indicating that tor is disconnected...
+
+ Tor Disconnected (15:21 07/13/2014, press r to reconnect)
+ """
+
+ x = self.addstr(y, x, 'Tor Disconnected', curses.A_BOLD, 'red')
+ last_heartbeat = time.strftime('%H:%M %m/%d/%Y', time.localtime(vals.last_heartbeat))
+ self.addstr(y, x, ' (%s, press r to reconnect)' % last_heartbeat)
+
+ def _draw_resource_usage(self, x, y, width, vals):
+ """
+ System resource usage of the tor process...
+
+ cpu: 0.0% tor, 1.0% nyx mem: 0 (0.0%) pid: 16329 uptime: 12-20:42:07
+ """
+
+ if vals.start_time:
+ if not vals.is_connected:
+ now = vals.connection_time
+ elif self.is_paused():
+ now = self.get_pause_time()
+ else:
+ now = time.time()
+
+ uptime = str_tools.short_time_label(now - vals.start_time)
+ else:
+ uptime = ''
+
+ sys_fields = (
+ (0, vals.format('cpu: {tor_cpu}% tor, {nyx_cpu}% nyx')),
+ (27, vals.format('mem: {memory} ({memory_percent}%)')),
+ (47, vals.format('pid: {pid}')),
+ (59, 'uptime: %s' % uptime),
+ )
+
+ for (start, label) in sys_fields:
+ if width >= start + len(label):
+ self.addstr(y, x + start, label)
+ else:
+ break
+
+ def _draw_fingerprint_and_fd_usage(self, x, y, width, vals):
+ """
+ Presents our fingerprint, and our file descriptor usage if we're running
+ out...
+
+ fingerprint: 1A94D1A794FCB2F8B6CBC179EF8FDD4008A98D3B, file desc: 900 / 1000 (90%)
+ """
+
+ initial_x, space_left = x, width
+
+ x = self.addstr(y, x, vals.format('fingerprint: {fingerprint}', width))
+ space_left -= x - initial_x
+
+ if space_left >= 30 and vals.fd_used and vals.fd_limit != -1:
+ fd_percent = 100 * vals.fd_used / vals.fd_limit
+
+ if fd_percent >= SHOW_FD_THRESHOLD:
+ if fd_percent >= 95:
+ percentage_format = (curses.A_BOLD, 'red')
+ elif fd_percent >= 90:
+ percentage_format = ('red',)
+ elif fd_percent >= 60:
+ percentage_format = ('yellow',)
+ else:
+ percentage_format = ()
+
+ x = self.addstr(y, x, ', file descriptors' if space_left >= 37 else ', file desc')
+ x = self.addstr(y, x, vals.format(': {fd_used} / {fd_limit} ('))
+ x = self.addstr(y, x, '%i%%' % fd_percent, *percentage_format)
+ self.addstr(y, x, ')')
+
+ def _draw_flags(self, x, y, width, vals):
+ """
+ Presents flags held by our relay...
+
+ flags: Running, Valid
+ """
+
+ x = self.addstr(y, x, 'flags: ')
+
+ if vals.flags:
+ for i, flag in enumerate(vals.flags):
+ flag_color = CONFIG['attr.flag_colors'].get(flag, 'white')
+ x = self.addstr(y, x, flag, curses.A_BOLD, flag_color)
+
+ if i < len(vals.flags) - 1:
+ x = self.addstr(y, x, ', ')
+ else:
+ self.addstr(y, x, 'none', curses.A_BOLD, 'cyan')
+
+ def _draw_exit_policy(self, x, y, width, vals):
+ """
+ Presents our exit policy...
+
+ exit policy: reject *:*
+ """
+
+ x = self.addstr(y, x, 'exit policy: ')
+
+ if not vals.exit_policy:
+ return
+
+ rules = list(vals.exit_policy.strip_private().strip_default())
+
+ for i, rule in enumerate(rules):
+ policy_color = 'green' if rule.is_accept else 'red'
+ x = self.addstr(y, x, str(rule), curses.A_BOLD, policy_color)
+
+ if i < len(rules) - 1:
+ x = self.addstr(y, x, ', ')
+
+ if vals.exit_policy.has_default():
+ if rules:
+ x = self.addstr(y, x, ', ')
+
+ self.addstr(y, x, '<default>', curses.A_BOLD, 'cyan')
+
+ def _draw_newnym_option(self, x, y, width, vals):
+ """
+ Provide a notice for requiesting a new identity, and time until it's next
+ available if in the process of building circuits.
+ """
+
+ if vals.newnym_wait == 0:
+ self.addstr(y, x, "press 'n' for a new identity")
+ else:
+ plural = 's' if vals.newnym_wait > 1 else ''
+ self.addstr(y, x, 'building circuits, available again in %i second%s' % (vals.newnym_wait, plural))
+
+ def run(self):
+ """
+ Keeps stats updated, checking for new information at a set rate.
+ """
+
+ last_ran = -1
+
+ while not self._halt:
+ if self.is_paused() or not self._vals.is_connected or (time.time() - last_ran) < UPDATE_RATE:
+ with self._pause_condition:
+ if not self._halt:
+ self._pause_condition.wait(0.2)
+
+ continue # done waiting, try again
+
+ self._update()
+ last_ran = time.time()
+
+ def stop(self):
+ """
+ Halts further resolutions and terminates the thread.
+ """
+
+ with self._pause_condition:
+ self._halt = True
+ self._pause_condition.notifyAll()
+
+ def reset_listener(self, controller, event_type, _):
+ self._update()
+
+ if event_type == State.CLOSED:
+ log.notice('Tor control port closed')
+
+ def _update(self):
+ previous_height = self.get_height()
+ self._vals = get_sampling(self._vals)
+
+ if self._vals.fd_used and self._vals.fd_limit != -1:
+ fd_percent = 100 * self._vals.fd_used / self._vals.fd_limit
+
+ if fd_percent >= 90:
+ log_msg = msg('panel.header.fd_used_at_ninety_percent', percentage = fd_percent)
+ log.log_once('fd_used_at_ninety_percent', log.WARN, log_msg)
+ log.DEDUPLICATION_MESSAGE_IDS.add('fd_used_at_sixty_percent')
+ elif fd_percent >= 60:
+ log_msg = msg('panel.header.fd_used_at_sixty_percent', percentage = fd_percent)
+ log.log_once('fd_used_at_sixty_percent', log.NOTICE, log_msg)
+
+ if self._vals.is_connected:
+ if not self._reported_inactive and (time.time() - self._vals.last_heartbeat) >= 10:
+ self._reported_inactive = True
+ log.notice('Relay unresponsive (last heartbeat: %s)' % time.ctime(self._vals.last_heartbeat))
+ elif self._reported_inactive and (time.time() - self._vals.last_heartbeat) < 10:
+ self._reported_inactive = False
+ log.notice('Relay resumed')
+
+ if previous_height != self.get_height():
+ # We're toggling between being a relay and client, causing the height
+ # of this panel to change. Redraw all content so we don't get
+ # overlapping content.
+
+ nyx.controller.get_controller().redraw()
+ else:
+ self.redraw(True) # just need to redraw ourselves
+
+
+def get_sampling(last_sampling = None):
+ controller = tor_controller()
+ retrieved = time.time()
+
+ pid = controller.get_pid('')
+ tor_resources = tracker.get_resource_tracker().get_value()
+ nyx_total_cpu_time = sum(os.times()[:3])
+
+ or_listeners = controller.get_listeners(Listener.OR, [])
+ control_listeners = controller.get_listeners(Listener.CONTROL, [])
+
+ if controller.get_conf('HashedControlPassword', None):
+ auth_type = 'password'
+ elif controller.get_conf('CookieAuthentication', None) == '1':
+ auth_type = 'cookie'
+ else:
+ auth_type = 'open'
+
+ try:
+ fd_used = proc.file_descriptors_used(pid)
+ except IOError:
+ fd_used = None
+
+ if last_sampling:
+ nyx_cpu_delta = nyx_total_cpu_time - last_sampling.nyx_total_cpu_time
+ nyx_time_delta = retrieved - last_sampling.retrieved
+
+ python_cpu_time = nyx_cpu_delta / nyx_time_delta
+ sys_call_cpu_time = 0.0 # TODO: add a wrapper around call() to get this
+
+ nyx_cpu = python_cpu_time + sys_call_cpu_time
+ else:
+ nyx_cpu = 0.0
+
+ attr = {
+ 'retrieved': retrieved,
+ 'is_connected': controller.is_alive(),
+ 'connection_time': controller.connection_time(),
+ 'last_heartbeat': controller.get_latest_heartbeat(),
+
+ 'fingerprint': controller.get_info('fingerprint', 'Unknown'),
+ 'nickname': controller.get_conf('Nickname', ''),
+ 'newnym_wait': controller.get_newnym_wait(),
+ 'exit_policy': controller.get_exit_policy(None),
+ 'flags': getattr(controller.get_network_status(default = None), 'flags', []),
+
+ 'version': str(controller.get_version('Unknown')).split()[0],
+ 'version_status': controller.get_info('status/version/current', 'Unknown'),
+
+ 'address': or_listeners[0][0] if (or_listeners and or_listeners[0][0] != '0.0.0.0') else controller.get_info('address', 'Unknown'),
+ 'or_port': or_listeners[0][1] if or_listeners else '',
+ 'dir_port': controller.get_conf('DirPort', '0'),
+ 'control_port': str(control_listeners[0][1]) if control_listeners else None,
+ 'socket_path': controller.get_conf('ControlSocket', None),
+ 'is_relay': bool(or_listeners),
+
+ 'auth_type': auth_type,
+ 'pid': pid,
+ 'start_time': system.start_time(pid),
+ 'fd_limit': int(controller.get_info('process/descriptor-limit', '-1')),
+ 'fd_used': fd_used,
+
+ 'nyx_total_cpu_time': nyx_total_cpu_time,
+ 'tor_cpu': '%0.1f' % (100 * tor_resources.cpu_sample),
+ 'nyx_cpu': nyx_cpu,
+ 'memory': str_tools.size_label(tor_resources.memory_bytes) if tor_resources.memory_bytes > 0 else 0,
+ 'memory_percent': '%0.1f' % (100 * tor_resources.memory_percent),
+
+ 'hostname': os.uname()[1],
+ 'platform': '%s %s' % (os.uname()[0], os.uname()[2]), # [platform name] [version]
+ }
+
+ class Sampling(collections.namedtuple('Sampling', attr.keys())):
+ def __init__(self, **attr):
+ super(Sampling, self).__init__(**attr)
+ self._attr = attr
+
+ def format(self, message, crop_width = None):
+ formatted_msg = message.format(**self._attr)
+
+ if crop_width:
+ formatted_msg = str_tools.crop(formatted_msg, crop_width)
+
+ return formatted_msg
+
+ return Sampling(**attr)
diff --git a/nyx/panel/log.py b/nyx/panel/log.py
new file mode 100644
index 0000000..708a679
--- /dev/null
+++ b/nyx/panel/log.py
@@ -0,0 +1,454 @@
+"""
+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 os
+import time
+import curses
+import threading
+
+import stem.response.events
+
+import nyx.arguments
+import nyx.popups
+import nyx.util.log
+
+from nyx.util import join, panel, tor_controller, ui_tools
+from stem.util import conf, log
+
+
+def conf_handler(key, value):
+ if key == 'features.log.prepopulateReadLimit':
+ return max(0, value)
+ elif key == 'features.log.maxRefreshRate':
+ return max(10, value)
+ elif key == 'cache.log_panel.size':
+ return max(1000, value)
+
+
+CONFIG = conf.config_dict('nyx', {
+ 'attr.log_color': {},
+ 'cache.log_panel.size': 1000,
+ 'features.logFile': '',
+ 'features.log.showDuplicateEntries': False,
+ 'features.log.prepopulate': True,
+ 'features.log.prepopulateReadLimit': 5000,
+ 'features.log.maxRefreshRate': 300,
+ 'features.log.regex': [],
+ 'msg.misc.event_types': '',
+ 'startup.events': 'N3',
+}, conf_handler)
+
+# 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
+
+# Log buffer so we start collecting stem/nyx events when imported. This is used
+# to make our LogPanel when curses initializes.
+
+stem_logger = log.get_logger()
+NYX_LOGGER = log.LogBuffer(log.Runlevel.DEBUG, yield_records = True)
+stem_logger.addHandler(NYX_LOGGER)
+
+
+class LogPanel(panel.Panel, threading.Thread):
+ """
+ Listens for and displays tor, nyx, and stem events. This prepopulates
+ from tor's log file if it exists.
+ """
+
+ def __init__(self, stdscr):
+ panel.Panel.__init__(self, stdscr, 'log', 0)
+ threading.Thread.__init__(self)
+ self.setDaemon(True)
+
+ logged_events = nyx.arguments.expand_events(CONFIG['startup.events'])
+ self._event_log = nyx.util.log.LogGroup(CONFIG['cache.log_panel.size'], group_by_day = True)
+ self._event_types = nyx.util.log.listen_for_events(self._register_tor_event, logged_events)
+ self._log_file = nyx.util.log.LogFileOutput(CONFIG['features.logFile'])
+ self._filter = nyx.util.log.LogFilters(initial_filters = CONFIG['features.log.regex'])
+ self._show_duplicates = CONFIG['features.log.showDuplicateEntries']
+
+ self.set_pause_attr('_event_log')
+
+ self._halt = False # terminates thread if true
+ self._pause_condition = threading.Condition()
+ self._has_new_event = False
+
+ # fetches past tor events from log file, if available
+
+ if CONFIG['features.log.prepopulate']:
+ log_location = nyx.util.log.log_file_path(tor_controller())
+
+ if log_location:
+ try:
+ for entry in reversed(list(nyx.util.log.read_tor_log(log_location, CONFIG['features.log.prepopulateReadLimit']))):
+ if entry.type in self._event_types:
+ self._event_log.add(entry)
+ except IOError as exc:
+ log.info('Unable to read log located at %s: %s' % (log_location, exc))
+ except ValueError as exc:
+ log.info(str(exc))
+
+ self._last_content_height = len(self._event_log) # height of the rendered content when last drawn
+ self._scroll = 0
+
+ # merge NYX_LOGGER into us, and listen for its future events
+
+ for event in NYX_LOGGER:
+ self._register_nyx_event(event)
+
+ NYX_LOGGER.emit = self._register_nyx_event
+
+ def set_duplicate_visability(self, is_visible):
+ """
+ Sets if duplicate log entries are collaped or expanded.
+
+ :param bool is_visible: if **True** all log entries are shown, otherwise
+ they're deduplicated
+ """
+
+ self._show_duplicates = is_visible
+
+ def get_filter(self):
+ """
+ Provides our currently selected regex filter.
+ """
+
+ return self._filter
+
+ def show_filter_prompt(self):
+ """
+ Prompts the user to add a new regex filter.
+ """
+
+ regex_input = nyx.popups.input_prompt('Regular expression: ')
+
+ if regex_input:
+ self._filter.select(regex_input)
+
+ def show_event_selection_prompt(self):
+ """
+ Prompts the user to select the events being listened for.
+ """
+
+ # allow user to enter new types of events to log - unchanged if left blank
+
+ with nyx.popups.popup_window(16, 80) as (popup, width, height):
+ if popup:
+ # displays the available flags
+
+ popup.win.box()
+ popup.addstr(0, 0, 'Event Types:', curses.A_STANDOUT)
+ event_lines = CONFIG['msg.misc.event_types'].split('\n')
+
+ for i in range(len(event_lines)):
+ popup.addstr(i + 1, 1, event_lines[i][6:])
+
+ popup.win.refresh()
+
+ user_input = nyx.popups.input_prompt('Events to log: ')
+
+ if user_input:
+ try:
+ user_input = user_input.replace(' ', '') # strip spaces
+ event_types = nyx.arguments.expand_events(user_input)
+
+ if event_types != self._event_types:
+ self._event_types = nyx.util.log.listen_for_events(self._register_tor_event, event_types)
+ self.redraw(True)
+ except ValueError as exc:
+ nyx.popups.show_msg('Invalid flags: %s' % str(exc), 2)
+
+ def show_snapshot_prompt(self):
+ """
+ Lets user enter a path to take a snapshot, canceling if left blank.
+ """
+
+ path_input = nyx.popups.input_prompt('Path to save log snapshot: ')
+
+ if path_input:
+ try:
+ self.save_snapshot(path_input)
+ nyx.popups.show_msg('Saved: %s' % path_input, 2)
+ except IOError as exc:
+ nyx.popups.show_msg('Unable to save snapshot: %s' % exc, 2)
+
+ def clear(self):
+ """
+ Clears the contents of the event log.
+ """
+
+ self._event_log = nyx.util.log.LogGroup(CONFIG['cache.log_panel.size'], group_by_day = True)
+ self.redraw(True)
+
+ def save_snapshot(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.
+
+ :param str path: path where to save the log snapshot
+
+ :raises: **IOError** if unsuccessful
+ """
+
+ path = os.path.abspath(os.path.expanduser(path))
+
+ # make dir if the path doesn't already exist
+
+ base_dir = os.path.dirname(path)
+
+ try:
+ if not os.path.exists(base_dir):
+ os.makedirs(base_dir)
+ except OSError as exc:
+ raise IOError("unable to make directory '%s'" % base_dir)
+
+ event_log = list(self._event_log)
+ event_filter = self._filter.clone()
+
+ with open(path, 'w') as snapshot_file:
+ try:
+ for entry in reversed(event_log):
+ if event_filter.match(entry.display_message):
+ snapshot_file.write(entry.display_message + '\n')
+ except Exception as exc:
+ raise IOError("unable to write to '%s': %s" % (path, exc))
+
+ def handle_key(self, key):
+ if key.is_scroll():
+ page_height = self.get_preferred_size()[0] - 1
+ new_scroll = ui_tools.get_scroll_position(key, self._scroll, page_height, self._last_content_height)
+
+ if self._scroll != new_scroll:
+ self._scroll = new_scroll
+ self.redraw(True)
+ elif key.match('u'):
+ self.set_duplicate_visability(not self._show_duplicates)
+ self.redraw(True)
+ elif key.match('c'):
+ msg = 'This will clear the log. Are you sure (c again to confirm)?'
+ key_press = nyx.popups.show_msg(msg, attr = curses.A_BOLD)
+
+ if key_press.match('c'):
+ self.clear()
+ elif key.match('f'):
+ with panel.CURSES_LOCK:
+ initial_selection = 1 if self._filter.selection() else 0
+ options = ['None'] + self._filter.latest_selections() + ['New...']
+ selection = nyx.popups.show_menu('Log Filter:', options, initial_selection)
+
+ if selection == 0:
+ self._filter.select(None)
+ elif selection == len(options) - 1:
+ # selected 'New...' option - prompt user to input regular expression
+ self.show_filter_prompt()
+ elif selection != -1:
+ self._filter.select(self._filter.latest_selections()[selection - 1])
+ elif key.match('e'):
+ self.show_event_selection_prompt()
+ elif key.match('a'):
+ self.show_snapshot_prompt()
+ else:
+ return False
+
+ return True
+
+ def get_help(self):
+ return [
+ ('up arrow', 'scroll log up a line', None),
+ ('down arrow', 'scroll log down a line', None),
+ ('a', 'save snapshot of the log', None),
+ ('e', 'change logged events', None),
+ ('f', 'log regex filter', 'enabled' if self._filter.selection() else 'disabled'),
+ ('u', 'duplicate log entries', 'visible' if self._show_duplicates else 'hidden'),
+ ('c', 'clear event log', None),
+ ]
+
+ def draw(self, width, height):
+ self._scroll = max(0, min(self._scroll, self._last_content_height - height + 1))
+
+ event_log = list(self.get_attr('_event_log'))
+ event_filter = self._filter.clone()
+ event_types = list(self._event_types)
+ scroll = int(self._scroll)
+ last_content_height = self._last_content_height
+ show_duplicates = self._show_duplicates
+
+ is_scrollbar_visible = last_content_height > height - 1
+
+ if is_scrollbar_visible:
+ self.add_scroll_bar(scroll, scroll + height - 1, last_content_height, 1)
+
+ x, y = 3 if is_scrollbar_visible else 1, 1 - scroll
+
+ # group entries by date, filtering out those that aren't visible
+
+ day_to_entries, today = {}, nyx.util.log.day_count(time.time())
+
+ for entry in event_log:
+ if entry.is_duplicate and not show_duplicates:
+ continue # deduplicated message
+ elif not event_filter.match(entry.display_message):
+ continue # filter doesn't match log message
+
+ day_to_entries.setdefault(entry.day_count(), []).append(entry)
+
+ for day in sorted(day_to_entries.keys(), reverse = True):
+ if day == today:
+ for entry in day_to_entries[day]:
+ y = self._draw_entry(x, y, width, entry, show_duplicates)
+ else:
+ original_y, y = y, y + 1
+
+ for entry in day_to_entries[day]:
+ y = self._draw_entry(x, y, width, entry, show_duplicates)
+
+ ui_tools.draw_box(self, original_y, x - 1, width - x + 1, y - original_y + 1, curses.A_BOLD, 'yellow')
+ time_label = time.strftime(' %B %d, %Y ', time.localtime(day_to_entries[day][0].timestamp))
+ self.addstr(original_y, x + 1, time_label, curses.A_BOLD, curses.A_BOLD, 'yellow')
+
+ y += 1
+
+ # drawing the title after the content, so we'll clear content from the top line
+
+ if self.is_title_visible():
+ self._draw_title(width, event_types, event_filter)
+
+ # redraw the display if...
+ # - last_content_height was off by too much
+ # - we're off the bottom of the page
+
+ new_content_height = y + scroll - 1
+ content_height_delta = abs(last_content_height - new_content_height)
+ force_redraw, force_redraw_reason = True, ''
+
+ if content_height_delta >= CONTENT_HEIGHT_REDRAW_THRESHOLD:
+ force_redraw_reason = 'estimate was off by %i' % content_height_delta
+ elif new_content_height > height and self._scroll + height - 1 > new_content_height:
+ force_redraw_reason = 'scrolled off the bottom of the page'
+ elif not is_scrollbar_visible and new_content_height > height - 1:
+ force_redraw_reason = "scroll bar wasn't previously visible"
+ elif is_scrollbar_visible and new_content_height <= height - 1:
+ force_redraw_reason = "scroll bar shouldn't be visible"
+ else:
+ force_redraw = False
+
+ self._last_content_height = new_content_height
+ self._has_new_event = False
+
+ if force_redraw:
+ log.debug('redrawing the log panel with the corrected content height (%s)' % force_redraw_reason)
+ self.redraw(True)
+
+ def _draw_title(self, width, event_types, event_filter):
+ """
+ Panel title with the event types we're logging and our regex filter if set.
+ """
+
+ self.addstr(0, 0, ' ' * width) # clear line
+ title_comp = list(nyx.util.log.condense_runlevels(*event_types))
+
+ if event_filter.selection():
+ title_comp.append('filter: %s' % event_filter.selection())
+
+ title_comp_str = join(title_comp, ', ', width - 10)
+ title = 'Events (%s):' % title_comp_str if title_comp_str else 'Events:'
+
+ self.addstr(0, 0, title, curses.A_STANDOUT)
+
+ def _draw_entry(self, x, y, width, entry, show_duplicates):
+ """
+ Presents a log entry with line wrapping.
+ """
+
+ min_x, msg = x + 2, entry.display_message
+ boldness = curses.A_BOLD if 'ERR' in entry.type else curses.A_NORMAL # emphasize ERR messages
+ color = CONFIG['attr.log_color'].get(entry.type, 'white')
+
+ for line in msg.splitlines():
+ x, y = self.addstr_wrap(y, x, line, width, min_x, boldness, color)
+
+ if entry.duplicates and not show_duplicates:
+ duplicate_count = len(entry.duplicates) - 1
+ plural = 's' if duplicate_count > 1 else ''
+ duplicate_msg = ' [%i duplicate%s hidden]' % (duplicate_count, plural)
+ x, y = self.addstr_wrap(y, x, duplicate_msg, width, min_x, curses.A_BOLD, 'green')
+
+ return y + 1
+
+ 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.
+ """
+
+ last_ran, last_day = -1, nyx.util.log.day_count(time.time())
+
+ while not self._halt:
+ current_day = nyx.util.log.day_count(time.time())
+ time_since_reset = time.time() - last_ran
+ max_log_update_rate = CONFIG['features.log.maxRefreshRate'] / 1000.0
+
+ sleep_time = 0
+
+ if (not self._has_new_event and last_day == current_day) or self.is_paused():
+ sleep_time = 5
+ elif time_since_reset < max_log_update_rate:
+ sleep_time = max(0.05, max_log_update_rate - time_since_reset)
+
+ if sleep_time:
+ with self._pause_condition:
+ if not self._halt:
+ self._pause_condition.wait(sleep_time)
+
+ continue
+
+ last_ran, last_day = time.time(), current_day
+ self.redraw(True)
+
+ def stop(self):
+ """
+ Halts further updates and terminates the thread.
+ """
+
+ with self._pause_condition:
+ self._halt = True
+ self._pause_condition.notifyAll()
+
+ def _register_tor_event(self, event):
+ msg = ' '.join(str(event).split(' ')[1:])
+
+ if isinstance(event, stem.response.events.BandwidthEvent):
+ msg = 'READ: %i, WRITTEN: %i' % (event.read, event.written)
+ elif isinstance(event, stem.response.events.LogEvent):
+ msg = event.message
+
+ self._register_event(nyx.util.log.LogEntry(event.arrived_at, event.type, msg))
+
+ def _register_nyx_event(self, record):
+ if record.levelname == 'WARNING':
+ record.levelname = 'WARN'
+
+ self._register_event(nyx.util.log.LogEntry(int(record.created), 'NYX_%s' % record.levelname, record.msg))
+
+ def _register_event(self, event):
+ if event.type not in self._event_types:
+ return
+
+ self._event_log.add(event)
+ self._log_file.write(event.display_message)
+
+ # notifies the display that it has new content
+
+ if self._filter.match(event.display_message):
+ self._has_new_event = True
+
+ with self._pause_condition:
+ self._pause_condition.notifyAll()
diff --git a/nyx/panel/torrc.py b/nyx/panel/torrc.py
new file mode 100644
index 0000000..f0f66b4
--- /dev/null
+++ b/nyx/panel/torrc.py
@@ -0,0 +1,171 @@
+"""
+Panel displaying the torrc or nyxrc with the validation done against it.
+"""
+
+import math
+import curses
+
+from nyx.util import expand_path, msg, panel, tor_controller, ui_tools
+
+from stem import ControllerError
+from stem.control import State
+
+
+class TorrcPanel(panel.Panel):
+ """
+ Renders the current torrc or nyxrc with syntax highlighting in a scrollable
+ area.
+ """
+
+ def __init__(self, stdscr):
+ panel.Panel.__init__(self, stdscr, 'torrc', 0)
+
+ self._scroll = 0
+ self._show_line_numbers = True # shows left aligned line numbers
+ self._show_comments = True # shows comments and extra whitespace
+ self._last_content_height = 0
+
+ self._torrc_location = None
+ self._torrc_content = None
+ self._torrc_load_error = None
+
+ controller = tor_controller()
+ controller.add_status_listener(self.reset_listener)
+ self.reset_listener(controller, State.RESET, None)
+
+ def reset_listener(self, controller, event_type, _):
+ """
+ Reloads and displays the torrc on tor reload (sighup) events.
+ """
+
+ if event_type == State.RESET:
+ try:
+ self._torrc_location = expand_path(controller.get_info('config-file'))
+
+ with open(self._torrc_location) as torrc_file:
+ self._torrc_content = [ui_tools.get_printable(line.replace('\t', ' ')).rstrip() for line in torrc_file.readlines()]
+ except ControllerError as exc:
+ self._torrc_load_error = msg('panel.torrc.unable_to_find_torrc', error = exc)
+ self._torrc_location = None
+ self._torrc_content = None
+ except Exception as exc:
+ self._torrc_load_error = msg('panel.torrc.unable_to_load_torrc', error = exc.strerror)
+ self._torrc_content = None
+
+ def set_comments_visible(self, is_visible):
+ """
+ Sets if comments and blank lines are shown or stripped.
+
+ :var bool is_visible: shows comments if true, strips otherwise
+ """
+
+ self._show_comments = is_visible
+ self.redraw(True)
+
+ def set_line_number_visible(self, is_visible):
+ """
+ Sets if line numbers are shown or hidden.
+
+ :var bool is_visible: displays line numbers if true, hides otherwise
+ """
+
+ self._show_line_numbers = is_visible
+ self.redraw(True)
+
+ def handle_key(self, key):
+ if key.is_scroll():
+ page_height = self.get_preferred_size()[0] - 1
+ new_scroll = ui_tools.get_scroll_position(key, self._scroll, page_height, self._last_content_height)
+
+ if self._scroll != new_scroll:
+ self._scroll = new_scroll
+ self.redraw(True)
+ elif key.match('l'):
+ self.set_line_number_visible(not self._show_line_numbers)
+ elif key.match('s'):
+ self.set_comments_visible(not self._show_comments)
+ else:
+ return False
+
+ return True
+
+ def get_help(self):
+ return [
+ ('up arrow', 'scroll up a line', None),
+ ('down arrow', 'scroll down a line', None),
+ ('page up', 'scroll up a page', None),
+ ('page down', 'scroll down a page', None),
+ ('s', 'comment stripping', 'off' if self._show_comments else 'on'),
+ ('l', 'line numbering', 'on' if self._show_line_numbers else 'off'),
+ ('x', 'reset tor (issue sighup)', None),
+ ]
+
+ def draw(self, width, height):
+ if self._torrc_content is None:
+ self.addstr(1, 0, self._torrc_load_error, 'red', curses.A_BOLD)
+ new_content_height = 1
+ else:
+ self._scroll = max(0, min(self._scroll, self._last_content_height - height + 1))
+
+ if not self._show_line_numbers:
+ line_number_offset = 0
+ elif len(self._torrc_content) == 0:
+ line_number_offset = 2
+ else:
+ line_number_offset = int(math.log10(len(self._torrc_content))) + 2
+
+ scroll_offset = 0
+
+ if self._last_content_height > height - 1:
+ scroll_offset = 3
+ self.add_scroll_bar(self._scroll, self._scroll + height - 1, self._last_content_height, 1)
+
+ y = 1 - self._scroll
+ is_multiline = False # true if we're in the middle of a multiline torrc entry
+
+ for line_number, line in enumerate(self._torrc_content):
+ if not self._show_comments:
+ line = line[:line.find('#')].rstrip() if '#' in line else line
+
+ if not line:
+ continue # skip blank lines
+
+ if '#' in line:
+ line, comment = line.split('#', 1)
+ comment = '#' + comment
+ else:
+ comment = ''
+
+ if is_multiline:
+ option, argument = '', line # previous line ended with a '\'
+ elif ' ' not in line.strip():
+ option, argument = line, '' # no argument
+ else:
+ whitespace = ' ' * (len(line) - len(line.strip()))
+ option, argument = line.strip().split(' ', 1)
+ option = whitespace + option + ' '
+
+ is_multiline = line.endswith('\\') # next line's part of a multi-line entry
+
+ if self._show_line_numbers:
+ self.addstr(y, scroll_offset, str(line_number + 1).rjust(line_number_offset - 1), curses.A_BOLD, 'yellow')
+
+ x = line_number_offset + scroll_offset
+ min_x = line_number_offset + scroll_offset
+
+ x, y = self.addstr_wrap(y, x, option, width, min_x, curses.A_BOLD, 'green')
+ x, y = self.addstr_wrap(y, x, argument, width, min_x, curses.A_BOLD, 'cyan')
+ x, y = self.addstr_wrap(y, x, comment, width, min_x, 'white')
+
+ y += 1
+
+ new_content_height = y + self._scroll - 1
+
+ if self.is_title_visible():
+ self.addstr(0, 0, ' ' * width) # clear line
+ location = ' (%s)' % self._torrc_location if self._torrc_location else ''
+ self.addstr(0, 0, 'Tor Configuration File%s:' % location, curses.A_STANDOUT)
+
+ if self._last_content_height != new_content_height:
+ self._last_content_height = new_content_height
+ self.redraw(True)
diff --git a/nyx/torrc_panel.py b/nyx/torrc_panel.py
deleted file mode 100644
index f0f66b4..0000000
--- a/nyx/torrc_panel.py
+++ /dev/null
@@ -1,171 +0,0 @@
-"""
-Panel displaying the torrc or nyxrc with the validation done against it.
-"""
-
-import math
-import curses
-
-from nyx.util import expand_path, msg, panel, tor_controller, ui_tools
-
-from stem import ControllerError
-from stem.control import State
-
-
-class TorrcPanel(panel.Panel):
- """
- Renders the current torrc or nyxrc with syntax highlighting in a scrollable
- area.
- """
-
- def __init__(self, stdscr):
- panel.Panel.__init__(self, stdscr, 'torrc', 0)
-
- self._scroll = 0
- self._show_line_numbers = True # shows left aligned line numbers
- self._show_comments = True # shows comments and extra whitespace
- self._last_content_height = 0
-
- self._torrc_location = None
- self._torrc_content = None
- self._torrc_load_error = None
-
- controller = tor_controller()
- controller.add_status_listener(self.reset_listener)
- self.reset_listener(controller, State.RESET, None)
-
- def reset_listener(self, controller, event_type, _):
- """
- Reloads and displays the torrc on tor reload (sighup) events.
- """
-
- if event_type == State.RESET:
- try:
- self._torrc_location = expand_path(controller.get_info('config-file'))
-
- with open(self._torrc_location) as torrc_file:
- self._torrc_content = [ui_tools.get_printable(line.replace('\t', ' ')).rstrip() for line in torrc_file.readlines()]
- except ControllerError as exc:
- self._torrc_load_error = msg('panel.torrc.unable_to_find_torrc', error = exc)
- self._torrc_location = None
- self._torrc_content = None
- except Exception as exc:
- self._torrc_load_error = msg('panel.torrc.unable_to_load_torrc', error = exc.strerror)
- self._torrc_content = None
-
- def set_comments_visible(self, is_visible):
- """
- Sets if comments and blank lines are shown or stripped.
-
- :var bool is_visible: shows comments if true, strips otherwise
- """
-
- self._show_comments = is_visible
- self.redraw(True)
-
- def set_line_number_visible(self, is_visible):
- """
- Sets if line numbers are shown or hidden.
-
- :var bool is_visible: displays line numbers if true, hides otherwise
- """
-
- self._show_line_numbers = is_visible
- self.redraw(True)
-
- def handle_key(self, key):
- if key.is_scroll():
- page_height = self.get_preferred_size()[0] - 1
- new_scroll = ui_tools.get_scroll_position(key, self._scroll, page_height, self._last_content_height)
-
- if self._scroll != new_scroll:
- self._scroll = new_scroll
- self.redraw(True)
- elif key.match('l'):
- self.set_line_number_visible(not self._show_line_numbers)
- elif key.match('s'):
- self.set_comments_visible(not self._show_comments)
- else:
- return False
-
- return True
-
- def get_help(self):
- return [
- ('up arrow', 'scroll up a line', None),
- ('down arrow', 'scroll down a line', None),
- ('page up', 'scroll up a page', None),
- ('page down', 'scroll down a page', None),
- ('s', 'comment stripping', 'off' if self._show_comments else 'on'),
- ('l', 'line numbering', 'on' if self._show_line_numbers else 'off'),
- ('x', 'reset tor (issue sighup)', None),
- ]
-
- def draw(self, width, height):
- if self._torrc_content is None:
- self.addstr(1, 0, self._torrc_load_error, 'red', curses.A_BOLD)
- new_content_height = 1
- else:
- self._scroll = max(0, min(self._scroll, self._last_content_height - height + 1))
-
- if not self._show_line_numbers:
- line_number_offset = 0
- elif len(self._torrc_content) == 0:
- line_number_offset = 2
- else:
- line_number_offset = int(math.log10(len(self._torrc_content))) + 2
-
- scroll_offset = 0
-
- if self._last_content_height > height - 1:
- scroll_offset = 3
- self.add_scroll_bar(self._scroll, self._scroll + height - 1, self._last_content_height, 1)
-
- y = 1 - self._scroll
- is_multiline = False # true if we're in the middle of a multiline torrc entry
-
- for line_number, line in enumerate(self._torrc_content):
- if not self._show_comments:
- line = line[:line.find('#')].rstrip() if '#' in line else line
-
- if not line:
- continue # skip blank lines
-
- if '#' in line:
- line, comment = line.split('#', 1)
- comment = '#' + comment
- else:
- comment = ''
-
- if is_multiline:
- option, argument = '', line # previous line ended with a '\'
- elif ' ' not in line.strip():
- option, argument = line, '' # no argument
- else:
- whitespace = ' ' * (len(line) - len(line.strip()))
- option, argument = line.strip().split(' ', 1)
- option = whitespace + option + ' '
-
- is_multiline = line.endswith('\\') # next line's part of a multi-line entry
-
- if self._show_line_numbers:
- self.addstr(y, scroll_offset, str(line_number + 1).rjust(line_number_offset - 1), curses.A_BOLD, 'yellow')
-
- x = line_number_offset + scroll_offset
- min_x = line_number_offset + scroll_offset
-
- x, y = self.addstr_wrap(y, x, option, width, min_x, curses.A_BOLD, 'green')
- x, y = self.addstr_wrap(y, x, argument, width, min_x, curses.A_BOLD, 'cyan')
- x, y = self.addstr_wrap(y, x, comment, width, min_x, 'white')
-
- y += 1
-
- new_content_height = y + self._scroll - 1
-
- if self.is_title_visible():
- self.addstr(0, 0, ' ' * width) # clear line
- location = ' (%s)' % self._torrc_location if self._torrc_location else ''
- self.addstr(0, 0, 'Tor Configuration File%s:' % location, curses.A_STANDOUT)
-
- if self._last_content_height != new_content_height:
- self._last_content_height = new_content_height
- self.redraw(True)
diff --git a/nyx/util/log.py b/nyx/util/log.py
index bf9fe82..e4f962b 100644
--- a/nyx/util/log.py
+++ b/nyx/util/log.py
@@ -4,7 +4,7 @@ runlevels.
::
- trace - logs a message at the TRACE runlevel
+ trace - logs a message at the TRACE runlevel
debug - logs a message at the DEBUG runlevel
info - logs a message at the INFO runlevel
notice - logs a message at the NOTICE runlevel
diff --git a/setup.py b/setup.py
index 13ae6af..326cc61 100644
--- a/setup.py
+++ b/setup.py
@@ -99,7 +99,7 @@ setup(
author = nyx.__author__,
author_email = nyx.__contact__,
url = nyx.__url__,
- packages = ['nyx', 'nyx.menu', 'nyx.util'],
+ packages = ['nyx', 'nyx.panel', 'nyx.menu', 'nyx.util'],
keywords = 'tor onion controller',
install_requires = ['stem>=1.4.1'],
package_data = {'nyx': ['settings/*', 'resources/*']},
_______________________________________________
tor-commits mailing list
tor-commits@xxxxxxxxxxxxxxxxxxxx
https://lists.torproject.org/cgi-bin/mailman/listinfo/tor-commits