From ab565b3c89656f284f780b57bc117b6fce55cf6e Mon Sep 17 00:00:00 2001 From: 4vun1t <4vun1t@mail.i2p> Date: Sat, 6 Jun 2026 21:14:20 +0200 Subject: [PATCH 1/3] Bump version to 0.4.1, add daemonization, use system temp dir, add service files * Fix SafeConfigParser import error on Python 3.13 (use ConfigParser) * Replace deprecated readfp() with read_file() * Default state/config files to system temp dir via tempfile.gettempdir() * Fix logger to clear handlers on re-init to avoid duplicates * Add --daemonize / -d flag to fork into background daemon * Auto-log to PREFIX/var/log/vanguards.log when daemonizing without --logfile * Add systemd service file (service/vanguards.service) * Add termux-services run file (service/termux/run) * Install service files via setup.py data_files on pip install --- MANIFEST.in | 1 + service/termux/run | 2 ++ service/vanguards.service | 13 +++++++++++ setup.py | 4 ++++ src/vanguards/__init__.py | 2 +- src/vanguards/config.py | 45 +++++++++++++++++++++++++++++---------- src/vanguards/logger.py | 2 ++ src/vanguards/main.py | 10 +++++++++ 8 files changed, 67 insertions(+), 12 deletions(-) create mode 100755 service/termux/run create mode 100644 service/vanguards.service diff --git a/MANIFEST.in b/MANIFEST.in index 71d7e7b..e592e6c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -12,3 +12,4 @@ recursive-include tests *.mock recursive-include tests *.conf recursive-include tests *.py recursive-include tests cached-microdesc-consensus +recursive-include service * diff --git a/service/termux/run b/service/termux/run new file mode 100755 index 0000000..40810ae --- /dev/null +++ b/service/termux/run @@ -0,0 +1,2 @@ +#!/data/data/com.termux/files/usr/bin/sh +exec vanguards --logfile /data/data/com.termux/files/usr/var/log/vanguards.log diff --git a/service/vanguards.service b/service/vanguards.service new file mode 100644 index 0000000..65c51c0 --- /dev/null +++ b/service/vanguards.service @@ -0,0 +1,13 @@ +[Unit] +Description=Vanguards Tor guard protection daemon +After=network.target tor.service +Wants=tor.service + +[Service] +Type=simple +ExecStart=vanguards --logfile /var/log/vanguards.log +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/setup.py b/setup.py index 3954706..5a2fbac 100644 --- a/setup.py +++ b/setup.py @@ -55,6 +55,10 @@ def read(*names, **kwargs): "console_scripts": [ 'vanguards = vanguards.main:main', ]}, + data_files=[ + ('lib/systemd/system', ['service/vanguards.service']), + ('var/service/vanguards', ['service/termux/run']), + ], description="Vanguards help guard you from getting vanned...", long_description=DESCRIPTION, include_package_data=True, diff --git a/src/vanguards/__init__.py b/src/vanguards/__init__.py index 0acd5fc..5a34dd8 100644 --- a/src/vanguards/__init__.py +++ b/src/vanguards/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -__version__ = "0.4.0-dev1" +__version__ = "0.4.1" __author__ = "Mike Perry" __license__ = "MIT/Expat" __url__ = "https://github.com/mikeperry-tor/vanguards" diff --git a/src/vanguards/config.py b/src/vanguards/config.py index 1c33391..8922269 100644 --- a/src/vanguards/config.py +++ b/src/vanguards/config.py @@ -6,6 +6,7 @@ import os import socket import sys +import tempfile from . import bandguards from . import rendguard @@ -15,10 +16,7 @@ from . import logger from .logger import plog -try: - from configparser import SafeConfigParser, Error -except ImportError: - from ConfigParser import SafeConfigParser, Error +from configparser import ConfigParser, Error ################# Global options ################## @@ -35,10 +33,10 @@ ENABLE_PATHVERIFY=False # State file location -STATE_FILE = "vanguards.state" +STATE_FILE = os.path.join(tempfile.gettempdir(), "vanguards.state") # Config file location -_CONFIG_FILE = "vanguards.conf" +_CONFIG_FILE = os.path.join(tempfile.gettempdir(), "vanguards.conf") # Loglevel LOGLEVEL = "NOTICE" @@ -58,12 +56,31 @@ _RETRY_LIMIT = None +DAEMONIZE = False + +def daemonize(): + pid = os.fork() + if pid > 0: + sys.exit(0) + os.setsid() + pid = os.fork() + if pid > 0: + sys.exit(0) + sys.stdout.flush() + sys.stderr.flush() + with open(os.devnull, "r") as f: + os.dup2(f.fileno(), sys.stdin.fileno()) + with open(os.devnull, "w") as f: + os.dup2(f.fileno(), sys.stdout.fileno()) + os.dup2(f.fileno(), sys.stderr.fileno()) + def setup_options(): global CONTROL_IP, CONTROL_PORT, CONTROL_SOCKET, CONTROL_PASS, STATE_FILE global ENABLE_BANDGUARDS, ENABLE_RENDGUARD, ENABLE_LOGGUARD, ENABLE_CBTVERIFY global ENABLE_PATHVERIFY global LOGLEVEL, LOGFILE global ONE_SHOT_VANGUARDS, ENABLE_VANGUARDS + global DAEMONIZE parser = argparse.ArgumentParser() @@ -138,17 +155,23 @@ def setup_options(): help="Enable path selection monitoring") parser.set_defaults(pathverify_enabled=ENABLE_PATHVERIFY) + parser.add_argument("--daemonize", "-d", dest="daemonize", + action="store_true", + help="Run vanguards as a background daemon") + options = parser.parse_args() (STATE_FILE, CONTROL_IP, CONTROL_PORT, CONTROL_SOCKET, CONTROL_PASS, ENABLE_BANDGUARDS, ENABLE_RENDGUARD, ENABLE_LOGGUARD, ENABLE_CBTVERIFY, - ENABLE_PATHVERIFY, ONE_SHOT_VANGUARDS, ENABLE_VANGUARDS) = \ + ENABLE_PATHVERIFY, ONE_SHOT_VANGUARDS, ENABLE_VANGUARDS, + DAEMONIZE) = \ (options.state_file, options.control_ip, options.control_port, options.control_socket, options.control_pass, options.bandguards_enabled, options.rendguard_enabled, options.logguard_enabled, options.cbtverify_enabled, options.pathverify_enabled, - options.one_shot_vanguards, options.vanguards_enabled) + options.one_shot_vanguards, options.vanguards_enabled, + options.daemonize) if options.loglevel != None: LOGLEVEL = options.loglevel @@ -209,7 +232,7 @@ def set_options_from_module(config, module, section): config.set(section, param, str(val)) def generate_config(): - config = SafeConfigParser(allow_no_value=True) + config = ConfigParser(allow_no_value=True) set_options_from_module(config, sys.modules[__name__], "Global") set_options_from_module(config, vanguards, "Vanguards") set_options_from_module(config, bandguards, "Bandguards") @@ -219,9 +242,9 @@ def generate_config(): return config def apply_config(config_file): - config = SafeConfigParser(allow_no_value=True) + config = ConfigParser(allow_no_value=True) - config.readfp(open(config_file, "r")) + config.read_file(open(config_file, "r")) get_options_for_module(config, sys.modules[__name__], "Global") get_options_for_module(config, vanguards, "Vanguards") diff --git a/src/vanguards/logger.py b/src/vanguards/logger.py index 6eeba05..66dbdbb 100644 --- a/src/vanguards/logger.py +++ b/src/vanguards/logger.py @@ -42,6 +42,8 @@ def logger_init(): # Default behavior = log to stdout if TorUtil.logfile is None, # or to the open file specified otherwise. logger = logging.getLogger("TorCtl") + for handler in logger.handlers[:]: + logger.removeHandler(handler) if logfile == ":syslog:": if os.path.exists("/dev/log"): diff --git a/src/vanguards/main.py b/src/vanguards/main.py index ddbbf6e..f2297db 100644 --- a/src/vanguards/main.py +++ b/src/vanguards/main.py @@ -1,5 +1,7 @@ import functools +import os import stem +import tempfile import time import sys @@ -16,6 +18,7 @@ from . import config from .logger import plog +from . import logger _MIN_TOR_VERSION_FOR_BW = stem.version.Version("0.3.4.10") @@ -44,6 +47,13 @@ def run_main(): sys.exit(1) options = config.setup_options() + if config.DAEMONIZE: + if config.LOGFILE == "": + logpath = os.path.join(tempfile.gettempdir(), "vanguards.log") + config.LOGFILE = logpath + logger.set_logfile(logpath) + config.daemonize() + try: # TODO: Use tor's data directory.. or our own state = vanguards.VanguardState.read_from_file(config.STATE_FILE) From aad9f75bca4e318252d92fb86a01cd67f5f39cbe Mon Sep 17 00:00:00 2001 From: 4vun1t <4vun1t@mail.i2p> Date: Sat, 6 Jun 2026 21:21:40 +0200 Subject: [PATCH 2/3] fix module not found ConfigParser Error Bump version to 0.4.1 --- .gitignore | 3 + build/lib/vanguards/NodeSelection.py | 166 ++++++ build/lib/vanguards/__init__.py | 6 + build/lib/vanguards/bandguards.py | 524 ++++++++++++++++++ build/lib/vanguards/cbtverify.py | 113 ++++ build/lib/vanguards/config.py | 261 +++++++++ build/lib/vanguards/control.py | 47 ++ build/lib/vanguards/logger.py | 77 +++ build/lib/vanguards/logguard.py | 88 +++ build/lib/vanguards/main.py | 264 +++++++++ build/lib/vanguards/pathverify.py | 265 +++++++++ build/lib/vanguards/rendguard.py | 135 +++++ build/lib/vanguards/vanguards.py | 328 +++++++++++ service/termux/run | 4 +- service/vanguards.service | 4 +- setup.sh | 8 +- src/vanguards.egg-info/PKG-INFO | 31 ++ src/vanguards.egg-info/SOURCES.txt | 53 ++ src/vanguards.egg-info/dependency_links.txt | 1 + src/vanguards.egg-info/entry_points.txt | 2 + src/vanguards.egg-info/requires.txt | 5 + src/vanguards.egg-info/top_level.txt | 1 + .../__pycache__/NodeSelection.cpython-313.pyc | Bin 0 -> 8988 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 321 bytes .../__pycache__/bandguards.cpython-313.pyc | Bin 0 -> 28633 bytes .../__pycache__/cbtverify.cpython-313.pyc | Bin 0 -> 6278 bytes .../__pycache__/config.cpython-313.pyc | Bin 0 -> 11768 bytes .../__pycache__/control.cpython-313.pyc | Bin 0 -> 2999 bytes .../__pycache__/logger.cpython-313.pyc | Bin 0 -> 3429 bytes .../__pycache__/logguard.cpython-313.pyc | Bin 0 -> 4816 bytes .../__pycache__/main.cpython-313.pyc | Bin 0 -> 15020 bytes .../__pycache__/pathverify.cpython-313.pyc | Bin 0 -> 15869 bytes .../__pycache__/rendguard.cpython-313.pyc | Bin 0 -> 6565 bytes .../__pycache__/vanguards.cpython-313.pyc | Bin 0 -> 18842 bytes 34 files changed, 2383 insertions(+), 3 deletions(-) create mode 100644 .gitignore create mode 100644 build/lib/vanguards/NodeSelection.py create mode 100644 build/lib/vanguards/__init__.py create mode 100644 build/lib/vanguards/bandguards.py create mode 100644 build/lib/vanguards/cbtverify.py create mode 100644 build/lib/vanguards/config.py create mode 100644 build/lib/vanguards/control.py create mode 100644 build/lib/vanguards/logger.py create mode 100644 build/lib/vanguards/logguard.py create mode 100644 build/lib/vanguards/main.py create mode 100644 build/lib/vanguards/pathverify.py create mode 100644 build/lib/vanguards/rendguard.py create mode 100644 build/lib/vanguards/vanguards.py create mode 100644 src/vanguards.egg-info/PKG-INFO create mode 100644 src/vanguards.egg-info/SOURCES.txt create mode 100644 src/vanguards.egg-info/dependency_links.txt create mode 100644 src/vanguards.egg-info/entry_points.txt create mode 100644 src/vanguards.egg-info/requires.txt create mode 100644 src/vanguards.egg-info/top_level.txt create mode 100644 src/vanguards/__pycache__/NodeSelection.cpython-313.pyc create mode 100644 src/vanguards/__pycache__/__init__.cpython-313.pyc create mode 100644 src/vanguards/__pycache__/bandguards.cpython-313.pyc create mode 100644 src/vanguards/__pycache__/cbtverify.cpython-313.pyc create mode 100644 src/vanguards/__pycache__/config.cpython-313.pyc create mode 100644 src/vanguards/__pycache__/control.cpython-313.pyc create mode 100644 src/vanguards/__pycache__/logger.cpython-313.pyc create mode 100644 src/vanguards/__pycache__/logguard.cpython-313.pyc create mode 100644 src/vanguards/__pycache__/main.cpython-313.pyc create mode 100644 src/vanguards/__pycache__/pathverify.cpython-313.pyc create mode 100644 src/vanguards/__pycache__/rendguard.cpython-313.pyc create mode 100644 src/vanguards/__pycache__/vanguards.cpython-313.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c45df77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/* +__pycache__/* +vanguards.state diff --git a/build/lib/vanguards/NodeSelection.py b/build/lib/vanguards/NodeSelection.py new file mode 100644 index 0000000..1402e4a --- /dev/null +++ b/build/lib/vanguards/NodeSelection.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python + +import copy +import random + +from .logger import plog + +class RestrictionError(Exception): + "Error raised for issues with applying restrictions" + pass + +class NoNodesRemain(RestrictionError): + "Error raised for issues with applying restrictions" + pass + +class NodeRestriction: + "Interface for node restriction policies" + def r_is_ok(self, r): + "Returns true if Router 'r' is acceptable for this restriction" + return True + +class FlagsRestriction(NodeRestriction): + "Restriction for mandatory and forbidden router flags" + def __init__(self, mandatory, forbidden=[]): + """Constructor. 'mandatory' and 'forbidden' are both lists of router + flags as strings.""" + self.mandatory = mandatory + self.forbidden = forbidden + + def r_is_ok(self, router): + for m in self.mandatory: + if not m in router.flags: return False + for f in self.forbidden: + if f in router.flags: return False + return True + +class MetaNodeRestriction(NodeRestriction): + """Interface for a NodeRestriction that is an expression consisting of + multiple other NodeRestrictions""" + def next_rstr(self): raise NotImplemented() + +class NodeRestrictionList(MetaNodeRestriction): + "Class to manage a list of NodeRestrictions" + def __init__(self, restrictions): + "Constructor. 'restrictions' is a list of NodeRestriction instances" + self.restrictions = restrictions + + def r_is_ok(self, r): + "Returns true of Router 'r' passes all of the contained restrictions" + for rs in self.restrictions: + if not rs.r_is_ok(r): return False + return True + +class NodeGenerator: + "Interface for node generation" + def __init__(self, sorted_r, rstr_list): + """Constructor. Takes a bandwidth-sorted list of Routers 'sorted_r' + and a NodeRestrictionList 'rstr_list'""" + self.rstr_list = rstr_list + self.rebuild(sorted_r) + self.rewind() + + def rewind(self): + "Rewind the generator to the 'beginning'" + self.routers = copy.copy(self.rstr_routers) + if not self.routers: + plog("NOTICE", "No routers left after restrictions applied: "+str(self.rstr_list)) + raise NoNodesRemain(str(self.rstr_list)) + + def rebuild(self, sorted_r=None): + """ Extra step to be performed when new routers are added or when + the restrictions change. """ + if sorted_r != None: + self.sorted_r = sorted_r + self.rstr_routers = list(filter(lambda r: self.rstr_list.r_is_ok(r), + self.sorted_r)) + + if not self.rstr_routers: + plog("NOTICE", "No routers left after restrictions applied: "+str(self.rstr_list)) + raise NoNodesRemain(str(self.rstr_list)) + + def generate(self): + "Return a python generator that yields routers according to the policy" + raise NotImplemented() + +class BwWeightedGenerator(NodeGenerator): + POSITION_GUARD = 'g' + POSITION_MIDDLE = 'm' + POSITION_EXIT = 'e' + + def flag_to_weight(self, node): + if 'Guard' in node.flags and "Exit" in node.flags: + return self.bw_weights[u'W'+self.position+'d']/self.WEIGHT_SCALE + + if 'Exit' in node.flags: + return self.bw_weights[u'W'+self.position+'e']/self.WEIGHT_SCALE + + if "Guard" in node.flags: + return self.bw_weights[u'W'+self.position+'g']/self.WEIGHT_SCALE + + return self.bw_weights[u'Wmm']/self.WEIGHT_SCALE + + + # This function handles https://github.com/mikeperry-tor/vanguards/issues/24 + # + # Exit nodes got their weights set to 0 by the BwWeightedGenerator, + # because we told it we were selecting for middle. Since they can + # still be used as RPs in normal client cannibalized circuits, + # we need to set their upper bound to be their exit selection + # probability. + # + # Note that we deliberately *don't* re-normalize weight_total + # since we don't want to lower the upper bound of the rest + # of the nodes due to this madness. But we do want a separate + # exit_total for use with these Exit nodes (since it gives their + # selection probability for cannibalized circs). + def repair_exits(self): + oldpos = self.position + self.position = BwWeightedGenerator.POSITION_EXIT + self.exit_total = 0 + + i = 0 + rlen = len(self.rstr_routers) + while i < rlen: + r = self.rstr_routers[i] + if "Exit" in r.flags: + self.node_weights[i] = r.measured*self.flag_to_weight(r) + self.exit_total += self.node_weights[i] + + i+=1 + + self.position = oldpos + + def rebuild(self, sorted_r=None): + NodeGenerator.rebuild(self, sorted_r) + NodeGenerator.rewind(self) + # TODO: Use consensus param + self.WEIGHT_SCALE = 10000.0 + + self.node_weights = [] + for r in self.rstr_routers: + self.node_weights.append(r.measured*self.flag_to_weight(r)) + + self.weight_total = sum(self.node_weights) + + def __init__(self, sorted_r, rstr_list, bw_weights, position): + self.position = position + self.bw_weights = bw_weights + self.node_weights = [] + NodeGenerator.__init__(self, sorted_r, rstr_list) + + def generate(self): + while True: + choice_val = random.uniform(0, self.weight_total) + choose_total = 0 + choice_idx = 0 + while choose_total < choice_val: + choose_total += self.node_weights[choice_idx] + choice_idx += 1 + yield self.rstr_routers[choice_idx-1] + +# FIXME: FlagsRestriction: Uptime, capacity (NodeRestriction: always want) +# FIXME: Subnet16Restriction: Set restriction: at least one be different +# FIXME: FamilyRestriction: Set restriction: at least one must be different + + diff --git a/build/lib/vanguards/__init__.py b/build/lib/vanguards/__init__.py new file mode 100644 index 0000000..5a34dd8 --- /dev/null +++ b/build/lib/vanguards/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +__version__ = "0.4.1" +__author__ = "Mike Perry" +__license__ = "MIT/Expat" +__url__ = "https://github.com/mikeperry-tor/vanguards" diff --git a/build/lib/vanguards/bandguards.py b/build/lib/vanguards/bandguards.py new file mode 100644 index 0000000..ea0e2cb --- /dev/null +++ b/build/lib/vanguards/bandguards.py @@ -0,0 +1,524 @@ +""" Simple checks against bandwidth side channels """ +import time +import stem + +from . import control + +from .logger import plog + +############ BandGuard Options ################# + +# Kill a circuit if this many read+write bytes have been exceeded. +# Very loud application circuits could be used to introduce timing +# side channels. +# Warning: if your application has large resources that cannot be +# split up over multiple requests (such as large HTTP posts for eg: +# securedrop, or sharing large files via onionshare), you must set +# this high enough for those uploads not to get truncated! +CIRC_MAX_MEGABYTES = 0 + +# Kill circuits older than this many seconds. +# Really old circuits will continue to use old guards after the TLS connection +# has rotated, which means they will be alone on old TLS links. This lack +# of multiplexing may allow an adversary to use netflow records to determine +# the path through the Tor network to a hidden service. +CIRC_MAX_AGE_HOURS = 24 # 1 day + +# Maximum size for an hsdesc fetch (including setup+get+dropped cells) +CIRC_MAX_HSDESC_KILOBYTES = 30 + +# Maximum number of bytes allowed on a service intro circ before close +CIRC_MAX_SERV_INTRO_KILOBYTES = 0 + +# Warn if Tor can't build or use circuits for this many seconds +CIRC_MAX_DISCONNECTED_SECS = 30 + +# Warn if Tor has no connections for this many seconds +CONN_MAX_DISCONNECTED_SECS = 15 + +############ Constants ############### +_CELL_PAYLOAD_SIZE = 509 +_RELAY_HEADER_SIZE = 11 +_RELAY_PAYLOAD_SIZE = _CELL_PAYLOAD_SIZE - _RELAY_HEADER_SIZE +_CELL_DATA_RATE = (float(_RELAY_PAYLOAD_SIZE)/_CELL_PAYLOAD_SIZE) + +_SECS_PER_HOUR = 60*60 +_BYTES_PER_KB = 1024 +_BYTES_PER_MB = 1024*_BYTES_PER_KB + +# Because we have to map circuits to guard destroy events, we need. +# The event really should arrive in the same second, but let's +# give it until the next couple in case there is a scheduled events hiccup +_MAX_CIRC_DESTROY_LAG_SECS = 2 + +class BwCircuitStat: + def __init__(self, circ_id, is_hs): + self.circ_id = circ_id + self.is_hs = is_hs + self.is_service = 1 + self.is_hsdir = 0 + self.is_serv_intro = 0 + self.dropped_cells_allowed = 0 + self.purpose = None + self.hs_state = None + self.old_purpose = None + self.old_hs_state = None + self.in_use = 0 + self.built = 0 + self.created_at = time.time() + self.read_bytes = 0 + self.sent_bytes = 0 + self.delivered_read_bytes = 0 + self.delivered_sent_bytes = 0 + self.overhead_read_bytes = 0 + self.overhead_sent_bytes = 0 + self.guard_fp = None + self.possibly_destroyed_at = None + + def total_bytes(self): + return self.read_bytes + self.sent_bytes + + def dropped_read_cells(self): + return self.read_bytes/_CELL_PAYLOAD_SIZE - \ + (self.delivered_read_bytes+self.overhead_read_bytes)/_RELAY_PAYLOAD_SIZE + +class BwGuardStat: + def __init__(self, guard_fp): + self.to_guard = guard_fp + self.killed_conns = 0 + self.killed_conn_at = 0 + self.killed_conn_pending = False + self.conns_made = 0 + self.close_reasons = {} # key=reason val=count + +class BandwidthStats: + def __init__(self, controller): + self.controller = controller + self.circs = {} # key=circid val=BwCircStat + self.live_guard_conns = {} # key=connid val=BwGuardStat + self.guards = {} # key=guardfp val=BwGuardStat + self.circs_destroyed_total = 0 + self.no_conns_since = int(time.time()) + self.no_circs_since = None + self.network_down_since = None + self.max_fake_id = -1 + self.disconnected_circs = False + self.disconnected_conns = False + self._orconn_init(controller) + self._network_liveness_init(controller) + + def _network_liveness_init(self, controller): + if controller.get_info("network-liveness") != "up": + self.network_down_since = int(time.time()) + + # Load in our current orconns. orconn-status does not + # tell us IDs, so we have to fake it and keep track of fakes :/ + def _orconn_init(self, controller): + fake_id = 0 + for l in controller.get_info("orconn-status").split("\n"): + if len(l): + self.orconn_event( + stem.response.ControlMessage.from_str( + "650 ORCONN "+l+" ID="+str(fake_id)+"\r\n", "EVENT")) + fake_id += 1 + self.max_fake_id = fake_id - 1 + + # We need to scan for our fake_id conns here and fixup + # the event.id accordingly... + def _fixup_orconn_event(self, event): + guard_fp = event.endpoint_fingerprint + fake_id = self.max_fake_id + while fake_id >= 0: + if str(fake_id) in self.live_guard_conns and \ + self.live_guard_conns[str(fake_id)].to_guard == guard_fp: + event.id = str(fake_id) + fake_id -= 1 + + # We watch orconn events so that when one closes, we can mark + # the circuits that might have been alive on it and watch for + # their close messages later. We have to do this dance because + # the CIRC event doesn't tell us which hop killed the circuit. + # + # We also keep additional stats on the number of connections, to + # monitor overall guard use for debugging. + def orconn_event(self, event): + guard_fp = event.endpoint_fingerprint + if not event.endpoint_fingerprint in self.guards: + self.guards[guard_fp] = BwGuardStat(guard_fp) + + if event.status == "CONNECTED": + if self.disconnected_conns: + disconnected_secs = event.arrived_at - self.no_conns_since + plog("NOTICE", "Reconnected to the Tor network after %d seconds.", + disconnected_secs) + self.live_guard_conns[event.id] = self.guards[guard_fp] + self.guards[guard_fp].conns_made += 1 + self.no_conns_since = 0 + self.disconnected_conns = False + elif event.status == "CLOSED" or event.status == "FAILED": + if event.id not in self.live_guard_conns: + self._fixup_orconn_event(event) + + if event.id in self.live_guard_conns: + # Scan the circuit list for any circuits that might + # be using this guard and that are in use. This is to + # watch for their close later. + for c in self.circs.values(): + if c.in_use and c.guard_fp == guard_fp: + c.possibly_destroyed_at = event.arrived_at + self.live_guard_conns[event.id].killed_conn_at = event.arrived_at + plog("INFO", "Marking possibly destroyed circ %s at %d", + c.circ_id, event.arrived_at) + + del self.live_guard_conns[event.id] + if len(self.live_guard_conns) == 0 and \ + not self.no_conns_since: + self.no_conns_since = event.arrived_at + # Keep stats on CLOSED reasons. We don't do anything with these atm + if event.status == "CLOSED": + if not event.reason in self.guards[guard_fp].close_reasons: + self.guards[guard_fp].close_reasons[event.reason] = 0 + self.guards[guard_fp].close_reasons[event.reason] += 1 + plog("INFO", event.raw_content()) + + def circuit_destroyed(self, event): + self.circs_destroyed_total += 1 + guardfp = event.path[0][0] + if event.arrived_at - self.guards[guardfp].killed_conn_at \ + <= _MAX_CIRC_DESTROY_LAG_SECS: + self.guards[guardfp].killed_conn_at = 0 + self.guards[guardfp].killed_conns += 1 + # FIXME: Limit to warn after N killed connections? + plog("NOTICE", "The connection to guard "+guardfp+" was closed with "+\ + "a live circuit.") + + plog("INFO", "The connection to guard "+guardfp+" was closed with "+\ + "circuit "+event.id+" on it.") + + def any_circuits_pending(self, except_id=None): + for c in self.circs.values(): + if not c.built and c.circ_id != except_id: + return True + return False # All circuits in use + + def circ_event(self, event): + # Failed circuits mean the network could be down: + if event.status == stem.CircStatus.FAILED and \ + not self.no_circs_since and self.any_circuits_pending(event.id): + self.no_circs_since = event.arrived_at + + # Sometimes circuits get multiple FAILED+CLOSED events, + # so we must check that first... + if (event.status == stem.CircStatus.FAILED or \ + event.status == stem.CircStatus.CLOSED): + if event.id in self.circs: + # If the circuit was in use, and possibly closed due to a guard + # connection closure recently, and this event says it died due to + # a channel closure, then record that. + if self.circs[event.id].in_use and \ + self.circs[event.id].possibly_destroyed_at: + if event.arrived_at - self.circs[event.id].possibly_destroyed_at \ + <= _MAX_CIRC_DESTROY_LAG_SECS and \ + event.remote_reason == "CHANNEL_CLOSED": + self.circuit_destroyed(event) + else: + plog("INFO", + "Circuit %s possibly destroyed, but outside of the time window (%d - %d)", + event.id, event.arrived_at, self.circs[event.id].possibly_destroyed_at) + plog("DEBUG", "Closed hs circ for "+event.raw_content()) + del self.circs[event.id] + return + + if event.id not in self.circs: + self.circs[event.id] = BwCircuitStat(event.id, + event.hs_state or event.purpose[0:2] == "HS") + + # Handle direct build purpose settings + if event.purpose[0:9] == "HS_CLIENT": + self.circs[event.id].is_service = 0 + elif event.purpose[0:10] == "HS_SERVICE": + self.circs[event.id].is_service = 1 + if event.purpose == "HS_CLIENT_HSDIR" or \ + event.purpose == "HS_SERVICE_HSDIR": + self.circs[event.id].is_hsdir = 1 + elif event.purpose == "HS_SERVICE_INTRO": + self.circs[event.id].is_serv_intro = 1 + plog("DEBUG", "Added circ for "+event.raw_content()) + + # Debugging + self.circs[event.id].purpose = event.purpose + self.circs[event.id].hs_state = event.hs_state + + # Consider all BUILT circs that have a specific HS purpose + # to be "in_use". + if event.status == stem.CircStatus.BUILT or \ + event.status == "GUARD_WAIT": + self.circs[event.id].built = 1 + + if self.disconnected_circs: + disconnected_secs = event.arrived_at - self.no_circs_since + plog("NOTICE", "Circuit use resumed after %d seconds.", + disconnected_secs) + self.no_circs_since = None + self.disconnected_circs = False + + if event.purpose[0:9] == "HS_CLIENT" or \ + event.purpose[0:10] == "HS_SERVICE": + self.circs[event.id].in_use = 1 + self.circs[event.id].guard_fp = event.path[0][0] + plog("DEBUG", "Circ "+event.id+" now in-use. %d delivered bytes.", + self.circs[event.id].delivered_read_bytes) + # Extending a circuit means the network is OK + elif event.status == "EXTENDED": + if self.disconnected_circs: + disconnected_secs = event.arrived_at - self.no_circs_since + plog("NOTICE", "Circuit use resumed after %d seconds.", + disconnected_secs) + self.no_circs_since = None + self.disconnected_circs = False + + # We need CIRC_MINOR to determine client from service as well + # as recognize cannibalized HSDIR circs + def circ_minor_event(self, event): + if event.id not in self.circs: + return + + self.circs[event.id].purpose = event.purpose + self.circs[event.id].hs_state = event.hs_state + self.circs[event.id].old_purpose = event.old_purpose + self.circs[event.id].old_hs_state = event.old_hs_state + + if event.purpose[0:9] == "HS_CLIENT": + self.circs[event.id].is_service = 0 + elif event.purpose[0:10] == "HS_SERVICE": + self.circs[event.id].is_service = 1 + if event.purpose == "HS_CLIENT_HSDIR" or \ + event.purpose == "HS_SERVICE_HSDIR": + self.circs[event.id].is_hsdir = 1 + elif event.purpose == "HS_SERVICE_INTRO": + self.circs[event.id].is_serv_intro = 1 + + # PURPOSE_CHANGED from HS_VANGUARDS -> in_use + if event.event == stem.CircEvent.PURPOSE_CHANGED: + if event.old_purpose == "HS_VANGUARDS": + self.circs[event.id].in_use = 1 + self.circs[event.id].guard_fp = event.path[0][0] + plog("DEBUG", "Circ "+event.id+" now in-use. %d delivered bytes.", + self.circs[event.id].delivered_read_bytes) + + plog("DEBUG", event.raw_content()) + + def circbw_event(self, event): + # Circuit bandwidth means circuits are working + if self.disconnected_circs: + disconnected_secs = event.arrived_at - self.no_circs_since + plog("NOTICE", "Circuit use resumed after %d seconds.", + disconnected_secs) + self.no_circs_since = None + self.disconnected_circs = False + + if event.id in self.circs: + plog("DEBUG", event.raw_content()) + delivered_read = int(event.keyword_args["DELIVERED_READ"]) + delivered_written = int(event.keyword_args["DELIVERED_WRITTEN"]) + overhead_read = int(event.keyword_args["OVERHEAD_READ"]) + overhead_written = int(event.keyword_args["OVERHEAD_WRITTEN"]) + + if delivered_read + overhead_read > event.read*_CELL_DATA_RATE: + plog("ERROR", + "Application read data exceeds cell data:"+event.raw_content()); + if delivered_written + overhead_written > event.written*_CELL_DATA_RATE: + plog("ERROR", + "Application written data exceeds cell data:"+event.raw_content()); + + self.circs[event.id].read_bytes += event.read + self.circs[event.id].sent_bytes += event.written + + self.circs[event.id].delivered_read_bytes += delivered_read + self.circs[event.id].delivered_sent_bytes += delivered_written + + self.circs[event.id].overhead_read_bytes += overhead_read + self.circs[event.id].overhead_sent_bytes += overhead_written + + self.check_circuit_limits(self.circs[event.id]) + + def check_connectivity(self, now): + if self.no_conns_since: + disconnected_secs = int(now - self.no_conns_since) + + if CONN_MAX_DISCONNECTED_SECS > 0 and \ + disconnected_secs >= CONN_MAX_DISCONNECTED_SECS: + if not self.disconnected_conns or \ + disconnected_secs % CONN_MAX_DISCONNECTED_SECS == 0: + plog("WARN", "We've been disconnected from the Tor network for %d seconds!" + % disconnected_secs) + self.disconnected_conns = True + elif self.no_circs_since: + disconnected_secs = int(now - self.no_circs_since) + + if CIRC_MAX_DISCONNECTED_SECS > 0 and \ + disconnected_secs >= CIRC_MAX_DISCONNECTED_SECS and \ + self.any_circuits_pending(): + if not self.disconnected_circs or \ + disconnected_secs % CIRC_MAX_DISCONNECTED_SECS == 0: + if self.network_down_since: + plog("WARN", "Tor has been disconnected for %d seconds, "+\ + "and failing all circuits for %d seconds!", + now - self.network_down_since, + disconnected_secs) + else: + plog("NOTICE", "Tor has been failing all circuits for %d seconds!" + % disconnected_secs) + self.disconnected_circs = True + + def network_liveness_event(self, event): + plog("DEBUG", event.raw_content()) + if event.status == "UP": + self.network_down_since = None + elif event.status == "DOWN": + self.network_down_since = event.arrived_at + + def check_circ_ages(self, now): + if CIRC_MAX_AGE_HOURS <= 0: + return + + # Unused except to expire circuits -- 1x/sec + # FIXME: This is needless copying on python 2.. + kill_circs = list(filter( + lambda c: now - c.created_at > \ + CIRC_MAX_AGE_HOURS*_SECS_PER_HOUR, + self.circs.values())) + for circ in kill_circs: + control.try_close_circuit(self.controller, circ.circ_id) + self.limit_exceeded("NOTICE", "CIRC_MAX_AGE_HOURS", + circ.circ_id, + (now - circ.created_at)/_SECS_PER_HOUR, + CIRC_MAX_AGE_HOURS) + + # Used for 1x/sec heartbeat only + def bw_event(self, event): + now = time.time() + self.check_connectivity(event.arrived_at) + self.check_circ_ages(now) + + def check_circuit_limits(self, circ): + if circ.dropped_read_cells() > circ.dropped_cells_allowed: + # Workaround for Tor bug #29699 (see also + # https://github.com/mikeperry-tor/vanguards/issues/37). + # Case 2. Intro circs can get dupped cells on retries while in use. + # Demote log to notice, but still close them though, cause fuck it. + # They get rebuilt, and side channels are bad, mmmk. + if circ.purpose == "HS_SERVICE_INTRO" and \ + circ.hs_state == "HSSI_ESTABLISHED": + control.try_close_circuit(self.controller, circ.circ_id) + plog("INFO", "Tor bug #29699: Got %d dropped cell on circ %s "\ + +"(in state %s %s; old state %s %s).", + circ.dropped_read_cells(), circ.circ_id, + str(circ.purpose), str(circ.hs_state), + str(circ.old_purpose), str(circ.old_hs_state)) + elif circ.purpose == "CIRCUIT_PADDING" and \ + circ.old_purpose == "HS_CLIENT_INTRO" and \ + circ.old_hs_state == "HSCI_INTRO_SENT": + control.try_close_circuit(self.controller, circ.circ_id) + plog("INFO", "Tor bug #40359. Got %d dropped cell on circ %s "\ + +"(in state %s %s; old state %s %s)", + circ.dropped_read_cells(), circ.circ_id, + str(circ.purpose), str(circ.hs_state), + str(circ.old_purpose), str(circ.old_hs_state)) + # Workaround for Tor bug #29927 (see also + # https://github.com/mikeperry-tor/vanguards/issues/37). + # Class 4: Mysterious client-side cases of dropped cells + # and protocol errors. + # Always close now, since this is a failure anyway: + # https://github.com/mikeperry-tor/vanguards/issues/69 + elif circ.purpose == "HS_CLIENT_REND" or \ + (circ.purpose == "HS_CLIENT_INTRO" and \ + circ.hs_state == "HSCI_DONE"): + control.try_close_circuit(self.controller, circ.circ_id) + plog("INFO", "Tor bug #29927: Got %d dropped cell on circ %s "\ + +"(in state %s %s; old state %s %s).", + circ.dropped_read_cells(), circ.circ_id, + str(circ.purpose), str(circ.hs_state), + str(circ.old_purpose), str(circ.old_hs_state)) + # Workaround for Tor bug #29700 (see also + # https://github.com/mikeperry-tor/vanguards/issues/37). + # Class 3: Service rend circs can sometimes fail ntor handshake on extend + # Always close now, since this is a failure anyway: + # https://github.com/mikeperry-tor/vanguards/issues/69 + elif circ.purpose == "HS_SERVICE_REND" and \ + circ.hs_state == "HSSR_CONNECTING": + control.try_close_circuit(self.controller, circ.circ_id) + plog("INFO", "Tor bug #29700: Got %d dropped cell on circ %s "\ + +"(in state %s %s; old state %s %s).", + circ.dropped_read_cells(), circ.circ_id, + str(circ.purpose), str(circ.hs_state), + str(circ.old_purpose), str(circ.old_hs_state)) + elif circ.purpose == "PATH_BIAS_TESTING": + # Pathbias circs all seem to have cases of dropped cells. If we + # get any, it probably means the probe failed anyway. Worst case, + # it might even have been withheld until dropped cells can be + # injected. + control.try_close_circuit(self.controller, circ.circ_id) + plog("INFO", "Tor bug #29786: Got a dropped cell on circ %s "\ + +"(in state %s %s; old state %s %s).", circ.circ_id, + str(circ.purpose), str(circ.hs_state), + str(circ.old_purpose), str(circ.old_hs_state)) + elif not circ.built: + plog("INFO", "Tor bug #29927: Got a dropped cell on circ %s "\ + +"(in state %s %s; old state %s %s).", circ.circ_id, + str(circ.purpose), str(circ.hs_state), + str(circ.old_purpose), str(circ.old_hs_state)) + else: + control.try_close_circuit(self.controller, circ.circ_id) + plog("WARN", "Possible Tor bug, or possible attack if very frequent: "\ + +"Got %d dropped cell on circ %s "\ + +"(in state %s %s; old state %s %s)", + circ.dropped_read_cells(), circ.circ_id, + str(circ.purpose), str(circ.hs_state), + str(circ.old_purpose), str(circ.old_hs_state)) + elif circ.dropped_read_cells() > 0: + # Log workaround drop cell cases for completeness + if circ.purpose == "PATH_BIAS_TESTING": + plog("INFO", "Tor bug #29786: Got a dropped cell on circ %s "\ + +"(in state %s %s; old state %s %s).", circ.circ_id, + str(circ.purpose), str(circ.hs_state), + str(circ.old_purpose), str(circ.old_hs_state)) + elif circ.purpose == "HS_SERVICE_REND" and \ + circ.hs_state == "HSSR_CONNECTING": + plog("INFO", "Tor bug #29700: Got a dropped cell on circ %s "\ + +"(in state %s %s; old state %s %s).", circ.circ_id, + str(circ.purpose), str(circ.hs_state), + str(circ.old_purpose), str(circ.old_hs_state)) + else: + plog("INFO", "Tor bug #29927: Got a dropped cell on circ %s "\ + +"(in state %s %s; old state %s %s).", circ.circ_id, + str(circ.purpose), str(circ.hs_state), + str(circ.old_purpose), str(circ.old_hs_state)) + + if CIRC_MAX_MEGABYTES > 0 and \ + circ.total_bytes() > CIRC_MAX_MEGABYTES*_BYTES_PER_MB: + control.try_close_circuit(self.controller, circ.circ_id) + self.limit_exceeded("NOTICE", "CIRC_MAX_MEGABYTES", + circ.circ_id, + circ.total_bytes(), + CIRC_MAX_MEGABYTES*_BYTES_PER_MB) + if CIRC_MAX_HSDESC_KILOBYTES > 0 and \ + circ.is_hsdir and circ.total_bytes() > \ + CIRC_MAX_HSDESC_KILOBYTES*_BYTES_PER_KB: + control.try_close_circuit(self.controller, circ.circ_id) + self.limit_exceeded("WARN", "CIRC_MAX_HSDESC_KILOBYTES", + circ.circ_id, + circ.total_bytes(), + CIRC_MAX_HSDESC_KILOBYTES*_BYTES_PER_KB) + if CIRC_MAX_SERV_INTRO_KILOBYTES > 0 and \ + circ.is_serv_intro and circ.total_bytes() > \ + CIRC_MAX_SERV_INTRO_KILOBYTES*_BYTES_PER_KB: + control.try_close_circuit(self.controller, circ.circ_id) + self.limit_exceeded("WARN", "CIRC_MAX_SERV_INTRO_KILOBYTES", + circ.circ_id, + circ.total_bytes(), + CIRC_MAX_SERV_INTRO_KILOBYTES*_BYTES_PER_KB) + + def limit_exceeded(self, level, str_name, circ_id, cur_val, max_val, extra=""): + plog(level, "Circ "+str(circ_id)+" exceeded "+str_name+": "+str(cur_val)+ + " > "+str(max_val)+". "+extra) diff --git a/build/lib/vanguards/cbtverify.py b/build/lib/vanguards/cbtverify.py new file mode 100644 index 0000000..ae188b9 --- /dev/null +++ b/build/lib/vanguards/cbtverify.py @@ -0,0 +1,113 @@ +""" This code monitors the circuit build timeout. It is non-essential """ +from .logger import plog + +class CircuitStat: + def __init__(self, circ_id, is_hs): + self.circ_id = circ_id + self.is_hs = is_hs + +class TimeoutStats: + def __init__(self): + self.circuits = {} + self.zero_fields() + self.record_timeouts = True + + def zero_fields(self): + self.all_launched = 0 + self.all_built = 0 + self.all_timeout = 0 + self.hs_launched = 0 + self.hs_built = 0 + self.hs_timeout = 0 + + def circ_event(self, event): + is_hs = event.hs_state or event.purpose[0:2] == "HS" + + if is_hs and event.id in self.circuits and \ + self.circuits[event.id].is_hs != is_hs: + plog("ERROR", "Circuit "+event.id+" just changed from non-HS to HS: "\ + +event.raw_content()) + + # Do not record circuits built while we have no timeout + # (ie: after reset but before computed) + if not self.record_timeouts: + return + + # Stages of circuits: + # LAUNCHED -> BUILT + # BUILT -> EXTENDED -> FINISHED + # BUILT -> EXTENDED -> FAILED + # BUILT -> EXTENDED -> TIMEOUT + # LAUNCHED -> TIMEOUT + # TIMEOUT -> MEASURED + # MEASURED -> FINSHED + # MEASURED -> EXPIRED + # LAUNCHED -> FAILED + # LAUNCHED -> CLOSED + # FAILED -> CLOSED + # TIMEOUT -> CLOSED + if event.status == "LAUNCHED": + self.add_circuit(event.id, is_hs) + elif event.status == "BUILT": + self.built_circuit(event.id) + elif event.reason == "TIMEOUT": + self.timeout_circuit(event.id) + elif event.purpose != "MEASURE_TIMEOUT" and \ + (event.status == "CLOSED" or event.status == "FAILED"): + self.closed_circuit(event.id) + + def cbt_event(self, event): + # TODO: Check if this is too high... + plog("INFO", "CBT Timeout rate: "+str(event.timeout_rate)+"; Our measured timeout rate: "+str(self.timeout_rate_all())+"; Hidden service timeout rate: "+str(self.timeout_rate_hs())) + plog("INFO", event.raw_content()) + if event.set_type == "COMPUTED": + plog("INFO", "CBT Timeout computed: "+event.raw_content()) + self.record_timeouts = True + if event.set_type == "RESET": + plog("INFO", "CBT Timeout reset") + self.record_timeouts = False + self.zero_fields() + + + def add_circuit(self, circ_id, is_hs): + if circ_id in self.circuits: + plog("ERROR", "Circuit "+circ_id+" already exists in map!") + self.circuits[circ_id] = CircuitStat(circ_id, is_hs) + self.all_launched += 1 + if is_hs: self.hs_launched += 1 + + def built_circuit(self, circ_id): + if circ_id in self.circuits: + self.all_built += 1 + if self.circuits[circ_id].is_hs: + self.hs_built += 1 + del self.circuits[circ_id] + + def closed_circuit(self, circ_id): + # If we are closed but still in circuits, then we closed + # before being built or timing out. Don't count as a launched circ + if circ_id in self.circuits: + self.all_launched -= 1 + if self.circuits[circ_id].is_hs: + self.hs_launched -= 1 + del self.circuits[circ_id] + + def timeout_circuit(self, circ_id): + if circ_id in self.circuits: + self.all_timeout += 1 + if self.circuits[circ_id].is_hs: + self.hs_timeout += 1 + del self.circuits[circ_id] + + def timeout_rate_all(self): + if self.all_launched: + return float(self.all_timeout)/(self.all_launched) + else: return 0.0 + + def timeout_rate_hs(self): + if self.hs_launched: + return float(self.hs_timeout)/(self.hs_launched) + else: return 0.0 + + + diff --git a/build/lib/vanguards/config.py b/build/lib/vanguards/config.py new file mode 100644 index 0000000..8922269 --- /dev/null +++ b/build/lib/vanguards/config.py @@ -0,0 +1,261 @@ +""" This file contains configuration defaults, options parsing, and config + file code. +""" +import argparse +import ipaddress +import os +import socket +import sys +import tempfile + +from . import bandguards +from . import rendguard +from . import vanguards +from . import control + +from . import logger +from .logger import plog + +from configparser import ConfigParser, Error + +################# Global options ################## + +ENABLE_VANGUARDS=True + +ENABLE_RENDGUARD=True + +ENABLE_BANDGUARDS=True + +ENABLE_LOGGUARD=True + +ENABLE_CBTVERIFY=False + +ENABLE_PATHVERIFY=False + +# State file location +STATE_FILE = os.path.join(tempfile.gettempdir(), "vanguards.state") + +# Config file location +_CONFIG_FILE = os.path.join(tempfile.gettempdir(), "vanguards.conf") + +# Loglevel +LOGLEVEL = "NOTICE" + +# Log to file instead of stdout +LOGFILE = "" + +# If true, write/update vanguards to torrc and then exit +ONE_SHOT_VANGUARDS = False + +CLOSE_CIRCUITS = True + +CONTROL_IP = "127.0.0.1" +CONTROL_PORT = "" +CONTROL_SOCKET = "" +CONTROL_PASS = "" + +_RETRY_LIMIT = None + +DAEMONIZE = False + +def daemonize(): + pid = os.fork() + if pid > 0: + sys.exit(0) + os.setsid() + pid = os.fork() + if pid > 0: + sys.exit(0) + sys.stdout.flush() + sys.stderr.flush() + with open(os.devnull, "r") as f: + os.dup2(f.fileno(), sys.stdin.fileno()) + with open(os.devnull, "w") as f: + os.dup2(f.fileno(), sys.stdout.fileno()) + os.dup2(f.fileno(), sys.stderr.fileno()) + +def setup_options(): + global CONTROL_IP, CONTROL_PORT, CONTROL_SOCKET, CONTROL_PASS, STATE_FILE + global ENABLE_BANDGUARDS, ENABLE_RENDGUARD, ENABLE_LOGGUARD, ENABLE_CBTVERIFY + global ENABLE_PATHVERIFY + global LOGLEVEL, LOGFILE + global ONE_SHOT_VANGUARDS, ENABLE_VANGUARDS + global DAEMONIZE + + parser = argparse.ArgumentParser() + + parser.add_argument("--state", dest="state_file", + default=os.environ.get("VANGUARDS_STATE", STATE_FILE), + help="File to store vanguard state") + + parser.add_argument("--generate_config", dest="write_file", type=str, + help="Write config to a file after applying command args") + + parser.add_argument("--loglevel", dest="loglevel", type=str, + help="Log verbosity (DEBUG, INFO, NOTICE, WARN, or ERROR)") + + parser.add_argument("--logfile", dest="logfile", type=str, + help="Log to LOGFILE instead of stdout") + + parser.add_argument("--config", dest="config_file", + default=os.environ.get("VANGUARDS_CONFIG", _CONFIG_FILE), + help="Location of config file with more advanced settings") + + parser.add_argument("--control_ip", dest="control_ip", default=CONTROL_IP, + help="The IP address of the Tor Control Port to connect to (default: "+ + CONTROL_IP+")") + parser.add_argument("--control_port", type=str, dest="control_port", + default=CONTROL_PORT, + help="The Tor Control Port to connect to (default: "+ + "tries both 9050 and 9151)") + + parser.add_argument("--control_socket", dest="control_socket", + default=CONTROL_SOCKET, + help="The Tor Control Socket path to connect to "+ + "(default: try /run/tor/control, then control port)") + parser.add_argument("--control_pass", dest="control_pass", + default=CONTROL_PASS, + help="The Tor Control Port password (optional) ") + + parser.add_argument("--retry_limit", dest="retry_limit", + default=_RETRY_LIMIT, type=int, + help="Reconnect attempt limit on failure (default: Infinite)") + + parser.add_argument("--one_shot_vanguards", dest="one_shot_vanguards", + action="store_true", + help="Set and write layer2 and layer3 guards to Torrc and exit.") + + parser.add_argument("--disable_vanguards", dest="vanguards_enabled", + action="store_false", + help="Disable setting any layer2 and layer3 guards.") + parser.set_defaults(vanguards_enabled=ENABLE_VANGUARDS) + + parser.add_argument("--disable_bandguards", dest="bandguards_enabled", + action="store_false", + help="Disable circuit side channel checks (may help performance)") + parser.set_defaults(bandguards_eabled=ENABLE_BANDGUARDS) + + parser.add_argument("--disable_logguard", dest="logguard_enabled", + action="store_false", + help="Disable Tor log monitoring (may help performance)") + parser.set_defaults(logguard_enabled=ENABLE_LOGGUARD) + + parser.add_argument("--disable_rendguard", dest="rendguard_enabled", + action="store_false", + help="Disable rendezvous misuse checks (may help performance)") + parser.set_defaults(rendguard_enabled=ENABLE_RENDGUARD) + + parser.add_argument("--enable_cbtverify", dest="cbtverify_enabled", + action="store_true", + help="Enable Circuit Build Time monitoring") + parser.set_defaults(cbtverify_enabled=ENABLE_CBTVERIFY) + + parser.add_argument("--enable_pathverify", dest="pathverify_enabled", + action="store_true", + help="Enable path selection monitoring") + parser.set_defaults(pathverify_enabled=ENABLE_PATHVERIFY) + + parser.add_argument("--daemonize", "-d", dest="daemonize", + action="store_true", + help="Run vanguards as a background daemon") + + options = parser.parse_args() + + (STATE_FILE, CONTROL_IP, CONTROL_PORT, CONTROL_SOCKET, CONTROL_PASS, + ENABLE_BANDGUARDS, ENABLE_RENDGUARD, ENABLE_LOGGUARD, ENABLE_CBTVERIFY, + ENABLE_PATHVERIFY, ONE_SHOT_VANGUARDS, ENABLE_VANGUARDS, + DAEMONIZE) = \ + (options.state_file, options.control_ip, options.control_port, + options.control_socket, options.control_pass, + options.bandguards_enabled, options.rendguard_enabled, + options.logguard_enabled, + options.cbtverify_enabled, options.pathverify_enabled, + options.one_shot_vanguards, options.vanguards_enabled, + options.daemonize) + + if options.loglevel != None: + LOGLEVEL = options.loglevel + logger.set_loglevel(LOGLEVEL) + + if options.logfile != None: + LOGFILE = options.logfile + + if LOGFILE != "": + logger.set_logfile(LOGFILE) + + if options.write_file != None: + config = generate_config() + config.write(open(options.write_file, "w")) + plog("NOTICE", "Wrote config to "+options.write_file) + sys.exit(0) + + # If control_ip is a domain name, try to resolve it. + if options.control_ip != None: + try: + _ = ipaddress.ip_address(options.control_ip) + except ValueError: + try: + # We're fine with AF_INET, stem supports only IPv4 addresses anyway. + addr = socket.getaddrinfo(options.control_ip, None, socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) + CONTROL_IP = addr[0][4][0] + except socket.gaierror: + plog("ERROR", "Failed to resolve hostname "+options.control_ip) + sys.exit(1) + + plog("DEBUG", "Applied command line options") + + return options + +# Avoid a big messy dict of defaults. We already have them. +def get_option(config, section, option, default): + try: + if type(default) == bool: + ret = config.get(section, option) == "True" + else: + ret = type(default)(config.get(section, option)) + except Error as e: + return default + return ret + +def get_options_for_module(config, module, section): + for param in dir(module): + if param.isupper() and param[0] != '_': + val = getattr(module, param) + setattr(module, param, + get_option(config, section, param.lower(), val)) + +def set_options_from_module(config, module, section): + config.add_section(section) + for param in dir(module): + if param.isupper() and param[0] != '_': + val = getattr(module, param) + config.set(section, param, str(val)) + +def generate_config(): + config = ConfigParser(allow_no_value=True) + set_options_from_module(config, sys.modules[__name__], "Global") + set_options_from_module(config, vanguards, "Vanguards") + set_options_from_module(config, bandguards, "Bandguards") + set_options_from_module(config, rendguard, "Rendguard") + set_options_from_module(config, rendguard, "Logguard") + + return config + +def apply_config(config_file): + config = ConfigParser(allow_no_value=True) + + config.read_file(open(config_file, "r")) + + get_options_for_module(config, sys.modules[__name__], "Global") + get_options_for_module(config, vanguards, "Vanguards") + get_options_for_module(config, bandguards, "Bandguards") + get_options_for_module(config, rendguard, "Rendguard") + get_options_for_module(config, rendguard, "Logguard") + + # Special cased CLOSE_CIRCUITS option has to be transfered + # to the control.py module + setattr(control, "_CLOSE_CIRCUITS", CLOSE_CIRCUITS) + + + plog("NOTICE", "Vanguards successfilly applied config options from "+ + config_file) diff --git a/build/lib/vanguards/control.py b/build/lib/vanguards/control.py new file mode 100644 index 0000000..b4c1f46 --- /dev/null +++ b/build/lib/vanguards/control.py @@ -0,0 +1,47 @@ +import stem +import sys +import getpass + +from .logger import plog + +from . import __version__ + +_CLOSE_CIRCUITS = True + +def authenticate_any(controller, passwd=""): + try: + controller.authenticate() + except stem.connection.MissingPassword: + if passwd == "": + passwd = getpass.getpass("Controller password: ") + + try: + controller.authenticate(password=passwd) + except stem.connection.PasswordAuthFailed: + print("Unable to authenticate, password is incorrect") + sys.exit(1) + except stem.connection.AuthenticationFailure as exc: + print("Unable to authenticate: %s" % exc) + sys.exit(1) + + plog("NOTICE", "Vanguards %s connected to Tor %s using stem %s", + __version__, controller.get_version(), stem.__version__) + +def get_consensus_weights(consensus_filename): + parsed_consensus = next(stem.descriptor.parse_file(consensus_filename, + document_handler = + stem.descriptor.DocumentHandler.BARE_DOCUMENT)) + + assert(parsed_consensus.is_consensus) + return parsed_consensus.bandwidth_weights + +def try_close_circuit(controller, circ_id): + if controller._logguard: + controller._logguard.dump_log_queue(circ_id, "Pre") + + if _CLOSE_CIRCUITS: + try: + controller.close_circuit(circ_id) + plog("INFO", "We force-closed circuit "+str(circ_id)) + except stem.InvalidRequest as e: + plog("INFO", "Failed to close circuit "+str(circ_id)+": "+str(e.message)) diff --git a/build/lib/vanguards/logger.py b/build/lib/vanguards/logger.py new file mode 100644 index 0000000..66dbdbb --- /dev/null +++ b/build/lib/vanguards/logger.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python + +import logging.handlers +import sys +import os.path + +logger = None +loglevel = "DEBUG" +logfile = None + +loglevels = { "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "NOTICE": logging.INFO + 5, + "WARN": logging.WARN, + "ERROR": logging.ERROR, + "ERR" : logging.ERROR, + "NONE": logging.ERROR + 5 } + +def set_loglevel(level): + global loglevel + if level not in loglevels: + plog("ERROR", "Invalid loglevel: "+str(level)) + sys.exit(1) + loglevel = level + +def set_logfile(filename): + global logfile + try: + if filename == ":syslog:": + logfile = ":syslog:" + else: + logfile = open(filename, "a") + logger_init() + except Exception as e: + plog("ERROR", "Can't open log file "+str(filename)+": "+str(e)) + sys.exit(1) + +def logger_init(): + global logger, logfile + + # Default init = old TorCtl format + default behavior + # Default behavior = log to stdout if TorUtil.logfile is None, + # or to the open file specified otherwise. + logger = logging.getLogger("TorCtl") + for handler in logger.handlers[:]: + logger.removeHandler(handler) + + if logfile == ":syslog:": + if os.path.exists("/dev/log"): + ch = logging.handlers.SysLogHandler("/dev/log") + elif os.path.exists("/var/run/syslog"): + ch = logging.handlers.SysLogHandler("/var/run/syslog") + else: + ch = logging.handlers.SysLogHandler() + + formatter = logging.Formatter("vanguards %(levelname)s: %(message)s") + else: + formatter = logging.Formatter("%(levelname)s[%(asctime)s]: %(message)s", + "%a %b %d %H:%M:%S %Y") + if not logfile: + logfile = sys.stdout + ch = logging.StreamHandler(logfile) + else: + ch = logging.StreamHandler(logfile) + + ch.setFormatter(formatter) + logger.addHandler(ch) + logger.setLevel(loglevels[loglevel]) + + +def plog(level, msg, *args): + if not logger: + logger_init() + + logger.log(loglevels[level], msg.strip(), *args) + + diff --git a/build/lib/vanguards/logguard.py b/build/lib/vanguards/logguard.py new file mode 100644 index 0000000..d358c31 --- /dev/null +++ b/build/lib/vanguards/logguard.py @@ -0,0 +1,88 @@ +""" Log monitoring for attacks, protocol issues, and debugging """ +import stem +import time +import functools + +from . import control + +from .logger import plog +from .logger import loglevels + +# XXX: Off by default for now +LOG_PROTOCOL_WARNS = True + +LOG_DUMP_LIMIT = 25 + +LOG_DUMP_LEVEL = "NOTICE" + +class LogGuard: + def __init__(self, controller): + self.controller = controller + self.log_buffer = list() + self.log_level = LOG_DUMP_LEVEL.lower() + self.log_limit = LOG_DUMP_LIMIT + + # Upgrade to ProtocolWarns if set. Otherwise, leave whatever torrc set + if LOG_PROTOCOL_WARNS: + controller.set_conf("ProtocolWarnings", "1") + + self._init_logbuffer(controller) + + # Register log events depending on log dump level + def _init_logbuffer(self, controller): + dump_level = loglevels[LOG_DUMP_LEVEL] + + if dump_level <= loglevels["DEBUG"]: + controller.add_event_listener( + functools.partial(LogGuard.log_all_event, self), + stem.control.EventType.DEBUG) + + if dump_level <= loglevels["INFO"]: + controller.add_event_listener( + functools.partial(LogGuard.log_all_event, self), + stem.control.EventType.INFO) + + + if dump_level <= loglevels["NOTICE"]: + controller.add_event_listener( + functools.partial(LogGuard.log_all_event, self), + stem.control.EventType.NOTICE) + + + if dump_level <= loglevels["WARN"]: + controller.add_event_listener( + functools.partial(LogGuard.log_all_event, self), + stem.control.EventType.WARN) + + + if dump_level <= loglevels["ERROR"]: + controller.add_event_listener( + functools.partial(LogGuard.log_all_event, self), + stem.control.EventType.ERR) + + + def log_warn_event(self, event): + plog("NOTICE", "Tor log warn: "+event.message) + + def log_all_event(self, event): + self.log_buffer.append(event) + + # XXX: This might not be efficient, idk + # XXX: This might also be a memleak/memfrag, depending on how list() + # decides to handle this underneath :/ + while len(self.log_buffer) > self.log_limit: + self.log_buffer.pop(0) + + # This is called before and after circuit close. The "when" argument is + # "Pre" before we close a circuit in controller.try_close_circuit(), and "Post" after. + def dump_log_queue(self, circ_id, when): + while len(self.log_buffer): + event = self.log_buffer.pop(0) + plog("NOTICE", when+"-close CIRC ID="+circ_id+" Tor log: TOR_"+event.runlevel+"["+time.ctime(event.arrived_at)+"]: "+event.message) + + # This lets us emit post-close logs that may be relevant (more ProtocolWarns, etc) + def circ_event(self, event): + if (event.status == "CLOSED" or event.status == "FAILED") \ + and event.reason == "REQUESTED": + self.dump_log_queue(event.id, "Post") + diff --git a/build/lib/vanguards/main.py b/build/lib/vanguards/main.py new file mode 100644 index 0000000..f2297db --- /dev/null +++ b/build/lib/vanguards/main.py @@ -0,0 +1,264 @@ +import functools +import os +import stem +import tempfile +import time +import sys + +import stem.response.events + +from . import control +from . import rendguard +from . import vanguards +from . import bandguards +from . import logguard +from . import cbtverify +from . import pathverify + +from . import config + +from .logger import plog +from . import logger + +_MIN_TOR_VERSION_FOR_BW = stem.version.Version("0.3.4.10") + +def main(): + try: + run_main() + except KeyboardInterrupt as e: + plog("NOTICE", "Got CTRL+C. Exiting.") + +def run_main(): + try: + config.apply_config(config._CONFIG_FILE) + except: + pass # Default config can be absent. + options = config.setup_options() + + # If the user specifies a config file, any values there should override + # any previous config file options, but not options on the command line. + if options.config_file != config._CONFIG_FILE: + try: + config.apply_config(options.config_file) + except Exception as e: + plog("ERROR", + "Specified config file "+options.config_file+\ + " can't be read: "+str(e)) + sys.exit(1) + options = config.setup_options() + + if config.DAEMONIZE: + if config.LOGFILE == "": + logpath = os.path.join(tempfile.gettempdir(), "vanguards.log") + config.LOGFILE = logpath + logger.set_logfile(logpath) + config.daemonize() + + try: + # TODO: Use tor's data directory.. or our own + state = vanguards.VanguardState.read_from_file(config.STATE_FILE) + plog("INFO", "Current layer2 guards: "+state.layer2_guardset()) + plog("INFO", "Current layer3 guards: "+state.layer3_guardset()) + except Exception as e: + plog("NOTICE", "Creating new vanguard state file at: "+config.STATE_FILE) + state = vanguards.VanguardState(config.STATE_FILE) + + state.enable_vanguards = config.ENABLE_VANGUARDS + stem.response.events.PARSE_NEWCONSENSUS_EVENTS = False + + reconnects = 0 + last_connected_at = None + connected = False + while options.retry_limit == None or reconnects < options.retry_limit: + ret = control_loop(state) + if not last_connected_at: + last_connected_at = time.time() + + if ret == "closed": + connected = True + # Try once per second but only tell the user every 10 seconds + if ret == "closed" or reconnects % 10 == 0: + if time.time() - last_connected_at > \ + bandguards.CONN_MAX_DISCONNECTED_SECS: + plog("WARN", "Tor daemon connection "+ret+". Trying again...") + else: + plog("NOTICE", "Tor daemon connection "+ret+". Trying again...") + reconnects += 1 + time.sleep(1) + + if not connected: + sys.exit(1) + +def control_loop(state): + if not config.CONTROL_SOCKET and not config.CONTROL_PORT: + try: + controller = \ + stem.control.Controller.from_socket_file("/run/tor/control") + plog("NOTICE", "Connected to Tor via /run/tor/control socket") + except stem.SocketError as e: + try: + controller = stem.control.Controller.from_port(config.CONTROL_IP) + except stem.SocketError as e: + return "failed: "+str(e) + plog("NOTICE", "Connected to Tor via "+config.CONTROL_IP+" control port") + else: + try: + if config.CONTROL_SOCKET != "": + controller = \ + stem.control.Controller.from_socket_file(config.CONTROL_SOCKET) + plog("NOTICE", "Connected to Tor via socket "+config.CONTROL_SOCKET) + else: + if not config.CONTROL_PORT or config.CONTROL_PORT == "default": + controller = stem.control.Controller.from_port(config.CONTROL_IP) + plog("NOTICE", "Connected to Tor via "+config.CONTROL_IP+ + " control port") + else: + controller = stem.control.Controller.from_port(config.CONTROL_IP, + int(config.CONTROL_PORT)) + plog("NOTICE", "Connected to Tor via control port "+ + config.CONTROL_IP+ ":"+config.CONTROL_PORT) + except ValueError as e: + plog("ERROR", "Control port must be an integer or 'default'. Got "+ + str(config.CONTROL_PORT)) + sys.exit(1) + except stem.SocketError as e: + return "failed: "+str(e) + + control.authenticate_any(controller, config.CONTROL_PASS) + + # The new_consensus_event must still get called even if vanguards + # is "disabled", because we also have to parse the consensus to + # update rendguard counts + if config.ENABLE_VANGUARDS or config.ENABLE_RENDGUARD: + try: + state.new_consensus_event(controller, None) + except stem.DescriptorUnavailable as e: + controller.close() + plog("NOTICE", "Tor needs descriptors: "+str(e)+". Trying again...") + return "failed: "+str(e) + + if config.ONE_SHOT_VANGUARDS: + try: + controller.save_conf() + except stem.OperationFailed as e: + plog("NOTICE", "Tor can't save its own config file: "+str(e)) + sys.exit(1) + plog("NOTICE", "Updated vanguards in torrc. Exiting.") + sys.exit(0) + + # Thread-safety: state, timeouts, and bandwidths are effectively + # transferred to the event thread here. They must not be used in + # our thread anymore. + + if config.ENABLE_RENDGUARD: + controller.add_event_listener( + functools.partial(rendguard.RendGuard.circ_event, + state.rendguard, controller), + stem.control.EventType.CIRC) + + # Ok, little low on fucks here. But this is fine. This will work. + # We check for None in control.try_close_circuit() + controller._logguard = None + + if config.ENABLE_LOGGUARD: + logs = logguard.LogGuard(controller) # Also registeres logbuffer events + + # Make the log object available later for log dumping + controller._logguard = logs + + # Always log warns + controller.add_event_listener( + functools.partial(logguard.LogGuard.log_warn_event, + logs), + stem.control.EventType.WARN) + + # For post-close logs + controller.add_event_listener( + functools.partial(logguard.LogGuard.circ_event, logs), + stem.control.EventType.CIRC) + + + if config.ENABLE_BANDGUARDS: + bandwidths = bandguards.BandwidthStats(controller) + + controller.add_event_listener( + functools.partial(bandguards.BandwidthStats.circ_event, bandwidths), + stem.control.EventType.CIRC) + controller.add_event_listener( + functools.partial(bandguards.BandwidthStats.bw_event, bandwidths), + stem.control.EventType.BW) + controller.add_event_listener( + functools.partial(bandguards.BandwidthStats.orconn_event, bandwidths), + stem.control.EventType.ORCONN) + controller.add_event_listener( + functools.partial(bandguards.BandwidthStats.network_liveness_event, + bandwidths), + stem.control.EventType.NETWORK_LIVENESS) + + if controller.get_version() >= _MIN_TOR_VERSION_FOR_BW: + controller.add_event_listener( + functools.partial(bandguards.BandwidthStats.circbw_event, bandwidths), + stem.control.EventType.CIRC_BW) + controller.add_event_listener( + functools.partial(bandguards.BandwidthStats.circ_minor_event, bandwidths), + stem.control.EventType.CIRC_MINOR) + else: + plog("NOTICE", "In order for bandwidth-based protections to be "+ + "enabled, you must use Tor 0.3.4.10 or newer.") + + + if config.ENABLE_CBTVERIFY: + timeouts = cbtverify.TimeoutStats() + + controller.add_event_listener( + functools.partial(cbtverify.TimeoutStats.circ_event, timeouts), + stem.control.EventType.CIRC) + controller.add_event_listener( + functools.partial(cbtverify.TimeoutStats.cbt_event, timeouts), + stem.control.EventType.BUILDTIMEOUT_SET) + + if config.ENABLE_PATHVERIFY: + paths = pathverify.PathVerify(controller, + config.ENABLE_VANGUARDS, + vanguards.NUM_LAYER1_GUARDS, + vanguards.NUM_LAYER2_GUARDS, + vanguards.NUM_LAYER3_GUARDS) + + controller.add_event_listener( + functools.partial(pathverify.PathVerify.circ_event, paths), + stem.control.EventType.CIRC) + controller.add_event_listener( + functools.partial(pathverify.PathVerify.circ_minor_event, paths), + stem.control.EventType.CIRC_MINOR) + controller.add_event_listener( + functools.partial(pathverify.PathVerify.orconn_event, paths), + stem.control.EventType.ORCONN) + controller.add_event_listener( + functools.partial(pathverify.PathVerify.guard_event, paths), + stem.control.EventType.GUARD) + controller.add_event_listener( + functools.partial(pathverify.PathVerify.conf_changed_event, + paths), + stem.control.EventType.CONF_CHANGED) + # After launch pathverify, we send a NEWNYM to get fresh circs and + # vg-lite guards + controller.signal("NEWNYM") + + + # Thread-safety: We're effectively transferring controller to the event + # thread here. + if config.ENABLE_VANGUARDS or config.ENABLE_RENDGUARD: + controller.add_event_listener( + functools.partial(vanguards.VanguardState.new_consensus_event, + state, controller), + stem.control.EventType.NEWCONSENSUS) + controller.add_event_listener( + functools.partial(vanguards.VanguardState.signal_event, + state, controller), + stem.control.EventType.SIGNAL) + + # Blah... + while controller.is_alive(): + time.sleep(1) + + return "closed" diff --git a/build/lib/vanguards/pathverify.py b/build/lib/vanguards/pathverify.py new file mode 100644 index 0000000..e5783cb --- /dev/null +++ b/build/lib/vanguards/pathverify.py @@ -0,0 +1,265 @@ +""" Simple checks against bandwidth side channels """ +import stem + +from . import control + +from .logger import plog + +_ROUTELEN_FOR_PURPOSE = { + "HS_VANGUARDS" : 4, + "HS_CLIENT_HSDIR" : 5, + "HS_CLIENT_INTRO" : 5, + "HS_CLIENT_REND" : 4, + "HS_SERVICE_HSDIR" : 4, + "HS_SERVICE_INTRO" : 4, + "HS_SERVICE_REND" : 5 + } + +# XXX: Hrmm +_ROUTELEN_FOR_PURPOSE_LITE = { + "HS_VANGUARDS" : 3, + "HS_CLIENT_HSDIR" : 4, + "HS_CLIENT_INTRO" : 4, + "HS_CLIENT_REND" : 3, + "HS_SERVICE_HSDIR" : 4, + "HS_SERVICE_INTRO" : 4, + "HS_SERVICE_REND" : 4 + } + +class Layer1Stats: + def __init__(self): + self.use_count = 0 + self.conn_count = 1 + +class Layer1Guards: + def __init__(self, num_layer1): + self.guards = {} + self.num_layer1 = num_layer1 + + def add_conn(self, guard_fp): + if guard_fp in self.guards: + self.guards[guard_fp].conn_count += 1 + else: + self.guards[guard_fp] = Layer1Stats() + + def del_conn(self, guard_fp): + if guard_fp in self.guards: + if self.guards[guard_fp].conn_count > 1: + self.guards[guard_fp].conn_count -= 1 + else: + del self.guards[guard_fp] + + # Returns -1 when fewer than expected, 0 when correct, +1 when too many + # (Retval used only by tests) + def check_conn_counts(self): + ret = 0 + + if len(self.guards) < self.num_layer1: + plog("NOTICE", "Fewer guard connections than configured. Connected to: "+ \ + str(self.guards.keys())) + ret = -1 + elif len(self.guards) > self.num_layer1: + plog("NOTICE", "More guard connections than configured. Connected to: "+ \ + str(self.guards.keys())) + ret = 1 + + for g in self.guards.keys(): + if self.guards[g].conn_count > 1: + plog("NOTICE", "Extra connections to guard "+g+": "+\ + str(self.guards[g].conn_count)) + ret = 1 + return ret + + def add_use_count(self, guard_fp): + if not guard_fp in self.guards: + plog("WARN", "Guard "+guard_fp+" not in "+ \ + str(self.guards.keys())) + else: + self.guards[guard_fp].use_count += 1 + + # Returns -1 when fewer than expected, 0 when correct, +1 when too many + # (Retval used only by tests) + def check_use_counts(self): + ret = 0 + layer1_in_use = list(filter(lambda x: self.guards[x].use_count, + self.guards.keys())) + layer1_counts = list(map(lambda x: + x+": "+str(self.guards[x].use_count), + layer1_in_use)) + + if len(layer1_in_use) > self.num_layer1: + plog("WARN", "Circuits are being used on more guards " + \ + "than configured. Current guard use: "+str(layer1_counts)) + ret = 1 + elif len(layer1_in_use) < self.num_layer1: + plog("NOTICE", "Circuits are being used on fewer guards " + \ + "than configured. Current guard use: "+str(layer1_counts)) + ret = -1 + return ret + +class PathVerify: + def __init__(self, controller, full_vanguards, num_layer1, num_layer2, num_layer3): + self.controller = controller + self.full_vanguards = full_vanguards + self.layer2 = set() + self.layer3 = set() + self.num_layer1 = num_layer1 + self.num_layer2 = num_layer2 + self.num_layer3 = num_layer3 + self._layers_init(controller) + self.layer1 = Layer1Guards(self.num_layer1) + self._orconn_init(controller) + + def _orconn_init(self, controller): + for l in controller.get_info("orconn-status").split("\n"): + if len(l): + guard_fp = l.split("~")[0][1:] + self.layer1.add_conn(guard_fp) + + self.layer1.check_conn_counts() + + def _layers_init(self, controller): + layer2 = controller.get_conf("HSLayer2Nodes", None) + layer3 = controller.get_conf("HSLayer3Nodes", None) + + # These may be empty at startup + if layer2: + self.layer2 = set(layer2.split(",")) + self.full_vanguards = True + + if layer3: + self.layer3 = set(layer3.split(",")) + self.full_vanguards = True + + # If they are empty, and vanguards is disabled by the addon, + # then we're just verifying vg-lite in C-Tor. + if not layer2 and not layer3 and not self.full_vanguards: + plog("NOTICE", "Monitoring vanguards-lite with pathverify.") + # Update our num layer params because they now depend on vg-lite + self.num_layer1 = 1 + self.num_layer2 = 4 + self.num_layer3 = 0 + else: + plog("NOTICE", "Monitoring vanguards with pathverify.") + + self._check_layer_counts() + + def conf_changed_event(self, event): + if "HSLayer2Nodes" in event.changed: + self.layer2 = set(event.changed["HSLayer2Nodes"][0].split(",")) + self.full_vanguards = True + + if "HSLayer3Nodes" in event.changed: + self.layer3 = set(event.changed["HSLayer3Nodes"][0].split(",")) + self.full_vanguards = True + + self._check_layer_counts() + + plog("DEBUG", event.raw_content()) + + # Returns True when right number, False otherwise + def _check_layer_counts(self): + ret = False + # These can become empty briefly on sighup and startup. Aka set(['']) + if len(self.layer2) > 1: + if len(self.layer2) != self.num_layer2: + plog("NOTICE", "Wrong number of layer2 guards. " + \ + str(self.num_layer2)+" vs: "+str(self.layer2)) + ret = False + else: + ret = True + + if len(self.layer3) > 1: + if len(self.layer3) != self.num_layer3: + plog("NOTICE", "Wrong number of layer3 guards. " + \ + str(self.num_layer3)+" vs: "+str(self.layer3)) + ret = False + else: + ret = True + return ret + + def orconn_event(self, event): + if event.status == "CONNECTED": + self.layer1.add_conn(event.endpoint_fingerprint) + elif event.status == "CLOSED" or event.status == "FAILED": + self.layer1.del_conn(event.endpoint_fingerprint) + + self.layer1.check_conn_counts() + + def guard_event(self, event): + if event.status == "GOOD_L2": + self.layer2.add(event.endpoint_fingerprint) + elif event.status == "BAD_L2": + self.layer2.discard(event.endpoint_fingerprint) + + plog("DEBUG", event.raw_content()) + + def routelen_for_purpose(self, purpose): + if self.full_vanguards: + return _ROUTELEN_FOR_PURPOSE[purpose] + else: + return _ROUTELEN_FOR_PURPOSE_LITE[purpose] + + def circ_event(self, event): + if event.purpose[0:3] == "HS_" and (event.status == stem.CircStatus.BUILT or \ + event.status == "GUARD_WAIT"): + if len(event.path) != self.routelen_for_purpose(event.purpose): + if (event.purpose == "HS_SERVICE_HSDIR" and \ + event.hs_state == "HSSI_CONNECTING") or \ + (event.purpose == "HS_CLIENT_INTRO" and \ + event.hs_state == "HSCI_CONNECTING"): + # This can happen when HS_VANGUARDS are cannibalized.. + # XXX: Is that a bug? + # It can also happen if client intros fail and are retried with a + # new hop. That case is not a bug. + plog("INFO", "Tor made a "+str(len(event.path))+ "-hop path, but I wanted a " + \ + str(self.routelen_for_purpose(event.purpose))+ "-hop path for purpose " + \ + event.purpose +":"+str(event.hs_state)+" + " + \ + event.raw_content()) + else: + plog("NOTICE", "Tor made a "+str(len(event.path))+ "-hop path, but I wanted a " + \ + str(self.routelen_for_purpose(event.purpose))+ "-hop path for purpose " + \ + event.purpose +":"+str(event.hs_state)+" + " + \ + event.raw_content()) + + self.layer1.add_use_count(event.path[0][0]) + self.layer1.check_use_counts() + + if not event.path[1][0] in self.layer2: + plog("WARN", "Layer2 "+event.path[1][0]+" not in "+ \ + str(self.layer2)) + + if self.num_layer3 and not event.path[2][0] in self.layer3: + plog("WARN", "Layer3 "+event.path[1][0]+" not in "+ \ + str(self.layer3)) + + if len(self.layer2) != self.num_layer2: + plog("WARN", "Circuit built with different number of layer2 nodes " + \ + "than configured. Currently using: " + str(self.layer2)) + + if len(self.layer3) != self.num_layer3: + plog("WARN", "Circuit built with different number of layer3 nodes " + \ + "than configured. Currently using: " + str(self.layer3)) + + def circ_minor_event(self, event): + if event.purpose[0:3] == "HS_" and event.old_purpose[0:3] != "HS_": + plog("WARN", "Purpose switched from non-hs to hs: "+ \ + str(event.raw_content())) + elif event.purpose[0:3] != "HS_" and event.old_purpose[0:3] == "HS_": + if event.purpose != "CIRCUIT_PADDING" and \ + event.purpose != "MEASURE_TIMEOUT" and \ + event.purpose != "PATH_BIAS_TESTING": + plog("WARN", "Purpose switched from hs to non-hs: "+ \ + str(event.raw_content())) + + if event.purpose[0:3] == "HS_" or event.old_purpose[0:3] == "HS_": + if not event.path[0][0] in self.layer1.guards: + plog("WARN", "Guard "+event.path[0][0]+" not in "+ \ + str(self.layer1.guards.keys())) + if len(event.path) > 1 and not event.path[1][0] in self.layer2: + plog("WARN", "Layer2 "+event.path[1][0]+" not in "+ \ + str(self.layer2)) + if self.num_layer3 and len(event.path) > 2 and not event.path[2][0] in self.layer3: + plog("WARN", "Layer3 "+event.path[1][0]+" not in "+ \ + str(self.layer3)) + diff --git a/build/lib/vanguards/rendguard.py b/build/lib/vanguards/rendguard.py new file mode 100644 index 0000000..35b1627 --- /dev/null +++ b/build/lib/vanguards/rendguard.py @@ -0,0 +1,135 @@ +from . import control + +from .logger import plog + +############## Rendguard options ##################### + +# Minimum number of hops we have to see before applying use stat checks +REND_USE_GLOBAL_START_COUNT = 1000 + +# Number of hops to scale counts down by two at +REND_USE_SCALE_AT_COUNT = 20000 + +# Minimum number of times a relay has to be used before we check it for +# overuse +REND_USE_RELAY_START_COUNT = 100 + +# How many times more than its bandwidth must a relay be used? +REND_USE_MAX_USE_TO_BW_RATIO = 5.0 + +# What is percent of the network weight is not in the consensus right now? +# Put another way, the max number of rend requests not in the consensus is +# REND_USE_MAX_USE_TO_BW_RATIO times this churn rate. +REND_USE_MAX_CONSENSUS_WEIGHT_CHURN = 1.0 + +# Should we close circuits on rend point overuse? +REND_USE_CLOSE_CIRCUITS_ON_OVERUSE = True + +_NOT_IN_CONSENSUS_ID = "NOT_IN_CONSENSUS" + +class RendUseCount: + def __init__(self, idhex, weight): + self.idhex = idhex + self.used = 0 + self.weight = weight + +class RendGuard: + def __init__(self): + self.use_counts = {} + self.total_use_counts = 0.0 + self.pickle_revision = 1.0 + + def valid_rend_use(self, r): + r_name = r + if r not in self.use_counts: + plog("INFO", "Relay "+r+" is not in our consensus.") + r_name = r+" (not in-consensus)" + r = _NOT_IN_CONSENSUS_ID + if r not in self.use_counts: + self.use_counts[r] = RendUseCount(r, 0) + + self.use_counts[r].used += 1.0 + self.total_use_counts += 1.0 + plog("DEBUG", "Relay "+r_name+" used %d times out of %d, "+\ + "for a use rate of %f%%. It has a consensus " + "weight of %f%%", int(self.use_counts[r].used), + int(self.total_use_counts), + (100.0*self.use_counts[r].used)/self.total_use_counts, + 100.0*self.use_counts[r].weight) + + # TODO: Can we base this check on statistical confidence intervals? + if self.total_use_counts >= REND_USE_GLOBAL_START_COUNT and \ + self.use_counts[r].used >= REND_USE_RELAY_START_COUNT and \ + self.use_counts[r].used/self.total_use_counts > \ + self.use_counts[r].weight*REND_USE_MAX_USE_TO_BW_RATIO: + + # Let's warn if they disable ciruit closing. + if REND_USE_CLOSE_CIRCUITS_ON_OVERUSE: + loglevel = "NOTICE" + else: + loglevel = "WARN" + plog(loglevel, "Relay "+r_name+" used %d times out of %d, "+\ + "for a use rate of %f%%. This is above its consensus " + "weight of %f%%", int(self.use_counts[r].used), + int(self.total_use_counts), + (100.0*self.use_counts[r].used)/self.total_use_counts, + 100.0*self.use_counts[r].weight) + return 0 + return 1 + + def xfer_use_counts(self, node_gen): + old_counts = self.use_counts + self.use_counts = {} + for r in node_gen.sorted_r: + self.use_counts[r.fingerprint] = RendUseCount(r.fingerprint, 0) + + if _NOT_IN_CONSENSUS_ID not in old_counts: + old_counts[_NOT_IN_CONSENSUS_ID] = \ + RendUseCount(_NOT_IN_CONSENSUS_ID, + REND_USE_MAX_CONSENSUS_WEIGHT_CHURN/100.0) + + self.use_counts[_NOT_IN_CONSENSUS_ID] = \ + RendUseCount(_NOT_IN_CONSENSUS_ID, + REND_USE_MAX_CONSENSUS_WEIGHT_CHURN/100.0) + + i = 0 + rlen = len(node_gen.rstr_routers) + while i < rlen: + r = node_gen.rstr_routers[i] + + if "Exit" in r.flags: + self.use_counts[r.fingerprint].weight = \ + node_gen.node_weights[i]/node_gen.exit_total + else: + self.use_counts[r.fingerprint].weight = \ + node_gen.node_weights[i]/node_gen.weight_total + i+=1 + + if self.total_use_counts >= REND_USE_SCALE_AT_COUNT: + plog("INFO", "Total use counts %d reached the scale count %d. Scaling.", + self.total_use_counts, REND_USE_SCALE_AT_COUNT) + + # Periodically we divide counts by two, to avoid overcounting + # high-uptime relays vs old ones + for r in old_counts: + if r != _NOT_IN_CONSENSUS_ID and r not in self.use_counts: + continue + if self.total_use_counts >= REND_USE_SCALE_AT_COUNT: + self.use_counts[r].used = old_counts[r].used/2.0 + else: + self.use_counts[r].used = old_counts[r].used + + + self.total_use_counts = sum(map(lambda x: self.use_counts[x].used, + self.use_counts)) + self.total_use_counts = float(self.total_use_counts) + + def circ_event(self, controller, event): + if event.status == "BUILT" and \ + event.purpose == "HS_SERVICE_REND" and \ + event.hs_state == "HSSR_CONNECTING": + if not self.valid_rend_use(event.path[-1][0]): + if REND_USE_CLOSE_CIRCUITS_ON_OVERUSE: + control.try_close_circuit(controller, event.id) + + plog("DEBUG", event.raw_content()) diff --git a/build/lib/vanguards/vanguards.py b/build/lib/vanguards/vanguards.py new file mode 100644 index 0000000..975cf21 --- /dev/null +++ b/build/lib/vanguards/vanguards.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python + +import random +import os +import time +import pickle +import sys +import string + +from ipaddress import ip_network as net + +import stem + +from .NodeSelection import BwWeightedGenerator, NodeRestrictionList +from .NodeSelection import FlagsRestriction +from .logger import plog + +from . import control +from . import rendguard + +# Unicode, damnit +try: + _UNICODE_DAMNIT = bool(type(unicode)) +except NameError: + unicode = str + +################### Vanguard options ################## +# +NUM_LAYER1_GUARDS = 2 # 0 is Tor default +NUM_LAYER2_GUARDS = 4 +NUM_LAYER3_GUARDS = 8 + +# In days: +LAYER1_LIFETIME_DAYS = 0 # Use tor default + +# In hours +MIN_LAYER2_LIFETIME_HOURS = 24*1 +MAX_LAYER2_LIFETIME_HOURS = 24*45 + +# In hours +MIN_LAYER3_LIFETIME_HOURS = 1 +MAX_LAYER3_LIFETIME_HOURS = 48 + +_SEC_PER_HOUR = (60*60) + +class GuardNode: + def __init__(self, idhex, chosen_at, expires_at): + self.idhex = idhex + self.chosen_at = chosen_at + self.expires_at = expires_at + +class ExcludeNodes: + def __init__(self, controller): + self.networks = [] + self.idhexes = set() + self.nicks = set() + self.countries = set() + self.controller = controller + self.exclude_unknowns = controller.get_conf("GeoIPExcludeUnknown") + self._parse_line(controller.get_conf("ExcludeNodes")) + + def _parse_line(self, conf_line): + # We assume Tor has validated the line already. So this parsing + # is very dumb+simple (except for semantics of valid data). + # See routerset_parse() in tor for parser semantic ordering. + if self.exclude_unknowns == "1": + self.countries.add("??") + self.countries.add("a1") + + if conf_line == None: + return + + parts = conf_line.split(",") + for p in parts: + if p[0] == "$": + p = p[1:] + if "~" in p: + p = p.split("~")[0] + if "=" in p: + p = p.split("=")[0] + + if len(p) == 40 and all(c in string.hexdigits for c in p): + self.idhexes.add(p) + elif p[0] == "{" and p[-1] == "}": + self.countries.add(p[1:-1].lower()) + elif ":" in p or "." in p: + self.networks.append(net(unicode(p), strict=False)) + else: + self.nicks.add(p) + plog("INFO", "Honoring ExcludeNodes line: "+conf_line) + if len(self.networks): + plog("INFO", "Excluding networks "+str(self.networks)) + if len(self.idhexes): + plog("INFO", "Excluding idhexes "+str(self.idhexes)) + if len(self.nicks): + plog("INFO", "Excluding nicks "+str(self.nicks)) + if len(self.countries): + if self.exclude_unknowns == "auto": + self.countries.add("??") + self.countries.add("a1") + + if self.controller.get_info("ip-to-country/ipv4-available", "0") == "0": + plog("WARN", "ExcludeNodes contains countries, but Tor has no GeoIP file! "+ + "Tor is not excluding countries!") + else: + plog("INFO", "Excluding countries "+str(self.countries)) + + def router_is_excluded(self, r): + if r.fingerprint in self.idhexes: + return True + if r.nickname in self.nicks: + return True + if "or_addresses" in r.__dict__: # Stem 1.7.0 only + addresses = r.or_addresses + else: + addresses = [(r.address, 9001, False)] + for addr in addresses: + is_ipv6 = addr[2] + if len(self.countries): + country = None + if is_ipv6 and \ + self.controller.get_info("ip-to-country/ipv6-available", "0") == "1": + country = self.controller.get_info("ip-to-country/"+addr[0]) + if not is_ipv6 and \ + self.controller.get_info("ip-to-country/ipv4-available", "0") == "1": + country = self.controller.get_info("ip-to-country/"+addr[0]) + + if country != None and country.lower() in self.countries: + return True + + for network in self.networks: + if is_ipv6: + if network.version == 6 and net(addr[0]+"/128").overlaps(network): + return True + else: + if network.version == 4 and net(addr[0]+"/32").overlaps(network): + return True + return False + +class VanguardState: + def __init__(self, state_file): + self.layer2 = [] + self.layer3 = [] + self.state_file = state_file + self.rendguard = rendguard.RendGuard() + self.pickle_revision = 1 + self.enable_vanguards = True # Set from main, irrelevant to pickle + + def set_state_file(self, state_file): + self.state_file = state_file + + def sort_and_index_routers(self, routers): + sorted_r = list(routers) + dict_r = {} + + for r in sorted_r: + if r.measured == None: + # FIXME: Hrmm... + r.measured = r.bandwidth + sorted_r.sort(key = lambda x: x.measured, reverse = True) + for r in sorted_r: dict_r[r.fingerprint] = r + return (sorted_r, dict_r) + + def consensus_update(self, routers, weights, exclude): + (sorted_r, dict_r) = self.sort_and_index_routers(routers) + ng = BwWeightedGenerator(sorted_r, + NodeRestrictionList( + [FlagsRestriction(["Fast", "Stable", "Valid"], + ["Authority"])]), + weights, BwWeightedGenerator.POSITION_MIDDLE) + gen = ng.generate() + if self.enable_vanguards: + # Remove any nodes that are now down in the consensus + self.remove_down_from_layer(self.layer2, dict_r) + self.remove_down_from_layer(self.layer3, dict_r) + + # Remove any nodes whose rotation times are past due. + # FIXME: We should check this more often... But we also + # need to replenish our layers if they get too low/empty. + # This can be slow (consensus parse required)... :/ + self.remove_expired_from_layer(self.layer2) + self.remove_expired_from_layer(self.layer3) + + # Remove any nodes in case ExcludeNodes changed. + self.remove_excluded_from_layer(self.layer2, dict_r, exclude) + self.remove_excluded_from_layer(self.layer3, dict_r, exclude) + + # Replenish our guard lists with new nodes + self.replenish_layers(gen, exclude) + + ng = BwWeightedGenerator(sorted_r, + NodeRestrictionList( + [FlagsRestriction(["Fast", "Valid"], + ["Authority"])]), + weights, BwWeightedGenerator.POSITION_MIDDLE) + + # Repair Exit-flagged node weights, since they can be chosen + # sometimes by other clients as RPs (when cannibalized) + ng.repair_exits() + # Transfer and scale RP use counts to this consensus + self.rendguard.xfer_use_counts(ng) + + def new_consensus_event(self, controller, event): + routers = controller.get_network_statuses() + + exclude_nodes = ExcludeNodes(controller) + + data_dir = controller.get_conf("DataDirectory") + if data_dir == None: + plog("ERROR", + "You must set a DataDirectory location option in your torrc.") + sys.exit(1) + + consensus_file = os.path.join(controller.get_conf("DataDirectory"), + "cached-microdesc-consensus") + + try: + weights = control.get_consensus_weights(consensus_file) + except IOError as e: + raise stem.DescriptorUnavailable("Cannot read "+consensus_file+": "+str(e)) + + self.consensus_update(routers, weights, exclude_nodes) + + if self.enable_vanguards: + self.configure_tor(controller) + + try: + self.write_to_file(open(self.state_file, "wb")) + except IOError as e: + plog("ERROR", "Cannot write state to "+self.state_file+": "+str(e)) + sys.exit(1) + + def signal_event(self, controller, event): + if event.signal == "RELOAD": + plog("NOTICE", "Tor got SIGHUP. Reapplying vanguards.") + self.configure_tor(controller) + + def configure_tor(self, controller): + if NUM_LAYER1_GUARDS: + controller.set_conf("NumEntryGuards", str(NUM_LAYER1_GUARDS)) + controller.set_conf("NumDirectoryGuards", str(NUM_LAYER1_GUARDS)) + + if LAYER1_LIFETIME_DAYS > 0: + controller.set_conf("GuardLifetime", str(LAYER1_LIFETIME_DAYS)+" days") + + try: + controller.set_conf("HSLayer2Nodes", self.layer2_guardset()) + + if NUM_LAYER3_GUARDS: + controller.set_conf("HSLayer3Nodes", self.layer3_guardset()) + except stem.InvalidArguments: + plog("ERROR", + "Vanguards requires Tor 0.3.3.x (and ideally 0.3.4.x or newer).") + sys.exit(1) + + def write_to_file(self, outfile): + return pickle.dump(self, outfile) + + @staticmethod + def read_from_file(infile): + ret = pickle.load(open(infile, "rb")) + ret.set_state_file(infile) + return ret + + def layer2_guardset(self): + return ",".join(map(lambda g: g.idhex, self.layer2)) + + def layer3_guardset(self): + return ",".join(map(lambda g: g.idhex, self.layer3)) + + # Adds a new layer2 guard + def add_new_layer2(self, generator, excluded): + guard = next(generator) + while guard.fingerprint in map(lambda g: g.idhex, self.layer2) or \ + excluded.router_is_excluded(guard): + guard = next(generator) + + now = time.time() + expires = now + max(random.uniform(MIN_LAYER2_LIFETIME_HOURS*_SEC_PER_HOUR, + MAX_LAYER2_LIFETIME_HOURS*_SEC_PER_HOUR), + random.uniform(MIN_LAYER2_LIFETIME_HOURS*_SEC_PER_HOUR, + MAX_LAYER2_LIFETIME_HOURS*_SEC_PER_HOUR)) + self.layer2.append(GuardNode(guard.fingerprint, now, expires)) + plog("INFO", "New layer2 guard: "+guard.fingerprint) + + def add_new_layer3(self, generator, excluded): + guard = next(generator) + while guard.fingerprint in map(lambda g: g.idhex, self.layer3) or \ + excluded.router_is_excluded(guard): + guard = next(generator) + + now = time.time() + expires = now + max(random.uniform(MIN_LAYER3_LIFETIME_HOURS*_SEC_PER_HOUR, + MAX_LAYER3_LIFETIME_HOURS*_SEC_PER_HOUR), + random.uniform(MIN_LAYER3_LIFETIME_HOURS*_SEC_PER_HOUR, + MAX_LAYER3_LIFETIME_HOURS*_SEC_PER_HOUR)) + self.layer3.append(GuardNode(guard.fingerprint, now, expires)) + plog("INFO", "New layer3 guard: "+guard.fingerprint) + + def remove_excluded_from_layer(self, layer, dict_r, excluded): + for g in list(layer): + if excluded.router_is_excluded(dict_r[g.idhex]): + layer.remove(g) + plog("INFO", "Removing newly-excluded guard "+g.idhex) + + def remove_expired_from_layer(self, layer): + now = time.time() + for g in list(layer): + if g.expires_at < now: + layer.remove(g) + plog("INFO", "Removing expired guard "+g.idhex) + + def remove_down_from_layer(self, layer, dict_r): + for g in list(layer): + if not g.idhex in dict_r: + layer.remove(g) + plog("INFO", "Removing down guard "+g.idhex) + + def replenish_layers(self, generator, excluded): + # Trim layers in case params changed + self.layer2 = self.layer2[:NUM_LAYER2_GUARDS] + self.layer3 = self.layer3[:NUM_LAYER3_GUARDS] + + while len(self.layer2) < NUM_LAYER2_GUARDS: + self.add_new_layer2(generator, excluded) + + while len(self.layer3) < NUM_LAYER3_GUARDS: + self.add_new_layer3(generator, excluded) diff --git a/service/termux/run b/service/termux/run index 40810ae..1ed6523 100755 --- a/service/termux/run +++ b/service/termux/run @@ -1,2 +1,4 @@ #!/data/data/com.termux/files/usr/bin/sh -exec vanguards --logfile /data/data/com.termux/files/usr/var/log/vanguards.log +mkdir -p "$PREFIX/var/lib/vanguards" +rm -f "$PREFIX/var/lib/vanguards/vanguards.state" +exec vanguards --logfile "$PREFIX/var/log/vanguards.log" --state "$PREFIX/var/lib/vanguards/vanguards.state" diff --git a/service/vanguards.service b/service/vanguards.service index 65c51c0..65da17e 100644 --- a/service/vanguards.service +++ b/service/vanguards.service @@ -5,9 +5,11 @@ Wants=tor.service [Service] Type=simple -ExecStart=vanguards --logfile /var/log/vanguards.log +ExecStartPre=/bin/rm -f /var/lib/vanguards/vanguards.state +ExecStart=vanguards --logfile /var/log/vanguards.log --state /var/lib/vanguards/vanguards.state Restart=on-failure RestartSec=5 +StateDirectory=vanguards [Install] WantedBy=multi-user.target diff --git a/setup.sh b/setup.sh index 4ae1b69..8c88e1d 100755 --- a/setup.sh +++ b/setup.sh @@ -43,7 +43,13 @@ pip install --require-hashes -r requirements.txt $(basename $SYS_PYTHON) setup.py install -# 4. Inform user what to do +# 4. Create state directory +mkdir -p /var/lib/vanguards 2>/dev/null || true +if [ -n "$PREFIX" ]; then + mkdir -p "$PREFIX/var/lib/vanguards" +fi + +# 5. Inform user what to do echo echo "If we got this far, everything should be ready!" echo diff --git a/src/vanguards.egg-info/PKG-INFO b/src/vanguards.egg-info/PKG-INFO new file mode 100644 index 0000000..b8eda42 --- /dev/null +++ b/src/vanguards.egg-info/PKG-INFO @@ -0,0 +1,31 @@ +Metadata-Version: 2.4 +Name: vanguards +Version: 0.4.1 +Summary: Vanguards help guard you from getting vanned... +Home-page: https://github.com/mikeperry-tor/vanguards +Author: Mike Perry +License: MIT/Expat +Keywords: tor +Classifier: Development Status :: 4 - Beta +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.5 +License-File: LICENSE +Requires-Dist: setuptools +Requires-Dist: ipaddress>=1.0.17; python_version < "3" +Requires-Dist: stem>=1.7.0 +Dynamic: author +Dynamic: classifier +Dynamic: description +Dynamic: home-page +Dynamic: keywords +Dynamic: license +Dynamic: license-file +Dynamic: requires-dist +Dynamic: summary + + +For the full README and other project information, please see the +`Vanguard project page on github `_. diff --git a/src/vanguards.egg-info/SOURCES.txt b/src/vanguards.egg-info/SOURCES.txt new file mode 100644 index 0000000..4553ecb --- /dev/null +++ b/src/vanguards.egg-info/SOURCES.txt @@ -0,0 +1,53 @@ +.travis.yml +CHANGES.rst +LICENSE +MANIFEST.in +README.md +README_SECURITY.md +README_TECHNICAL.md +README_TESTS.md +TODO.txt +requirements.txt +setup.cfg +setup.py +setup.sh +test-requirements.txt +tox.ini +vanguards-example.conf +vanguards.1 +vanguards_parallel.sh +service/vanguards.service +service/termux/run +src/vanguards.py +src/vanguards/NodeSelection.py +src/vanguards/__init__.py +src/vanguards/bandguards.py +src/vanguards/cbtverify.py +src/vanguards/config.py +src/vanguards/control.py +src/vanguards/logger.py +src/vanguards/logguard.py +src/vanguards/main.py +src/vanguards/pathverify.py +src/vanguards/rendguard.py +src/vanguards/vanguards.py +src/vanguards.egg-info/PKG-INFO +src/vanguards.egg-info/SOURCES.txt +src/vanguards.egg-info/dependency_links.txt +src/vanguards.egg-info/entry_points.txt +src/vanguards.egg-info/requires.txt +src/vanguards.egg-info/top_level.txt +tests/2-3-8-perf.conf +tests/2-4-8-perf.conf +tests/__init__.py +tests/cached-microdesc-consensus +tests/conf.mock +tests/default.conf +tests/state.mock +tests/test_bandguards.py +tests/test_cbtverify.py +tests/test_logguard.py +tests/test_main.py +tests/test_pathverify.py +tests/test_rendguard.py +tests/test_vanguards.py \ No newline at end of file diff --git a/src/vanguards.egg-info/dependency_links.txt b/src/vanguards.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/vanguards.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/vanguards.egg-info/entry_points.txt b/src/vanguards.egg-info/entry_points.txt new file mode 100644 index 0000000..c759502 --- /dev/null +++ b/src/vanguards.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +vanguards = vanguards.main:main diff --git a/src/vanguards.egg-info/requires.txt b/src/vanguards.egg-info/requires.txt new file mode 100644 index 0000000..7893c1a --- /dev/null +++ b/src/vanguards.egg-info/requires.txt @@ -0,0 +1,5 @@ +setuptools +stem>=1.7.0 + +[:python_version < "3"] +ipaddress>=1.0.17 diff --git a/src/vanguards.egg-info/top_level.txt b/src/vanguards.egg-info/top_level.txt new file mode 100644 index 0000000..58ff1a5 --- /dev/null +++ b/src/vanguards.egg-info/top_level.txt @@ -0,0 +1 @@ +vanguards diff --git a/src/vanguards/__pycache__/NodeSelection.cpython-313.pyc b/src/vanguards/__pycache__/NodeSelection.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..76c051c79cade08c911a9e07fc1b0b793b73e3db GIT binary patch literal 8988 zcmcIqU2GfKb-u$H{*$6?{h0do*p?wvD^ba=&4b=-OGer}^_)AL z5lN4C)d0N^&&=HaIX~Yy_l&lKK_7uKn(Mhf+d{~H;)7Y7m7DEQ(h!ta+t)0b`p195r%nV9Cfl<7u341IIUV2tMx#wci2(s>1K64sPkWO zTRlCjHUPE3s#-6rZGhU)6|dFT$7&m)cAqt?pVjS$x~40Bt7jn1MVhy8;n)@jG{Wh^ zVkVbP8hbG)HelHf16d_k2)r(#jyOm8xPuCDo;uS)#Hlw<%37W($-I(N$0*IwEe{O% z)9dVBq>`e^DRDkWMMcw=WKF!GyJ zd=dB&eS9z_`HS$XgyNlhP?M@~KHVzeH6{%+n=VROZFW90-hxy|fq9 zMU;|Bc`+}|WsG(53s6_3FjI^P5r-~la%NuVsD|r_B1MkqZkkZEMDEA>fx35yRvNIE zh6Y|BPy8W+U8pCqh@1N1N*ODIc-Md=gro}zGNE1oXeAU0Hfks$REF1HI2?SVF*i2W zh{EK~b|197EQ&!g#Krk`a-BngfLL5{Cv~S?5x3rWE+eHuJuJi9YK1A5hpK9rWU`V9 z+Mc7gM7Y74IVF{nRgoHOp2t~|C=|2cmY9$_;bxW0G-k{eG9zY8NHByKAIYh(fu$rY z5EXl^wR+iNz1C{*Ol5H{2WFp9w7e$f<|`ZUu?>ilCgO{zX)PK9X?m@hbgwl@gr^;_ z6?z0o4-gHvDVo9t&`?w*4*<|s&zh*&g#6b~k_GbAMOqJj5-YcK|0KqQQ=_RkGh@Qv z!Obd}CX;ZjFHPjVW^ zPudaTW9O!TBghQh$(YD^5)l}abeADtoy+RnyoQScqYSfP3ap1;Vp7E>5EQhxffPtN zxbM!jzrD7?m4glU`akb~6zu+b=^0NNI-giAUf-R}?aU)@``5!pOT~^N4z?e*!PvUz z)#h{*mU^{KiP(w+wOd1bMVor7+}FQD1peTQfYY=s?39F`RZLag-V@93*a}7F*16a^r#HlM`SC2wnw0^KQ|la)y@vfO2Pcpho~x8Bm_4YVS*ci((*`+I|oizx${Mk z21I{SCW9gXJ3t%BLwEzxVKpxR*c=oY!C=9JeRM~>U47oz06#<-t-Z44=wBExbEK($>Um8XOh(^)p z;Ng$H@CYUpVUHTUMvcOMR&f1 zX1Cl`3r#9UVQHfaKv0caN0BlWKu-orE$+H?@Jin(aOSt1Fs!baaD)l~k=%TZSxiYk zMp=t<5O&;9Qu&1eEk^;QEZZ;?Llb+A`ULIuVTAG$yrQl{Gzbuuz}TTT7U6ZT^$0PZ zJh!A|QsD9A8;Y8$sEh6~r={m$+qFu=Y^t?}pO!Vfg}u>Z?1l(}=cgAxzIZphDYTd2 z{=&x>ellU$n1#&H-wi*;;HPi{s14L8cA>*z3fWc+7#UC}`}@d5av zq4&4R8TRU~*rloQk+DDZ$8wb*P!luqd|s61G4QWO0(MALNP+xU(}8<$fARL( zRH-Xk?22wQ4HiZ}xmfmx?wq`H^0CXe^47zak$)cfSK&7e|JJZEdb!wgdDAuXIMDXh zk+sy%)i2eJ_JNH+^r0*I)JXzirYNzR;L_5R{h&YB5RbVycj5*hDzr>pCv$XZuwbji}5I3E{g@f5@gj0~9fyH|6KI$c6! zWM!}8&3QnVxQKK4^4QHhmEcGqFQNv`$>Jg$Hghx!_;F(aPA{r_!_pl*T1arFNr|9u z*y=-#t5Kum0vzb&s2Hn>vydxzOx`{L^fW3lREQxW8z5$ETIxNvWmAPcS&XJh)s7e}|4?v19yy0tqD73Q| zfg{=va7LytOgtz9LyLUha!x`7qbMPY{>3)z9>E4A7$InFKrlu*^xWa(_djoN@`F1K zK7Q~;*ui%g3WR-=@Ux2PdA8q$D!XXK$PUGw5TCd)T7f7f5QkhQL@nO97oL4_$YN4| z+)o2(cO)Ok@ui&Hm z+xQgm#B}F*%&6+Z*i9v`bC-24MI~S_{SgqIznsm2U7#bx7TI935KT~Hz7tv)X44VVf^CHFiAf{}M_zq59{=s$dasB~ng2!H;ehr-ayNQuzT zENO*V5=am#>Wj+FnnrJ%p@MMK8UnUh6;Q-EbdUmF7kiRZoSm(|Y7FjmAc*dB zH^7>P&p}uRSwZ7nfv*$rzSU{eG>Hf?iVUYIBq*MUk1BY74Tv86!KL{4)cB=XVr*u7 zO83cNGN99WDWmflSv7n*vlUNPmb4`*rzi?Y51>7m#uIT?Qh~Wl3d~676kVV=9D@^< z^~RK7B~b*)h($@E30z9kMLVmh?y-bZtOGHUgja80Eu4GY+*@iME;bKCygX5EJph+W zPdwhj*~k8dm3%2A7DM8O|4?DH?DKzitn6zj`8tZejd+*K9-`w!F zmk)|RU;c9WK~JggRI%^W-%A^PXNm{UtOzAfThY_DcBts-EPDcXLVpul8!C157d!eN zykG1XEP4ii7JA}xuk8CcRgj(ri9hhOV^14NOGo9v_KG_{e22kOeS6&s4^xT=h05L7IK2FVJjm5m8NG8n>9$ik3GkJ)qgIti*(Um#zzcVlo= z8-sZ-k_m94n4q4;c$gLY_019R9@gGLogJ|>8?^H4GZ8-vK8zrRg)7&giE2nN*wJ0^ z;ZIgmlwu9jT3%bq>H!1t4K+7&J}fX|>WVgEDl3~?@37ZV7D#^zy|uppQXr3oeI=o- zD73BhYzh(f6fO$kwdPHs`$_m93r?Se+u0THw-oH&40c1t+vO{{4isGnO0Mprt9$K3 zJB9q6>n!HH5AL`~Tj$znsdJ#%Ij|9q7DlZsT(y4SJ%eJ5d>{pLW*1avzO(yfg=vWe z$Fo-OG3kY2b!CQ|c9`FEAq`WBBAo`7EI?<2@gsP56i5XV*g?SvKv^e*o;pORBp&UU zY<^Ao0!Q@#0T5{JM4J~sxm2$3l5x7dD73E)J$U<(Fl_Icj=<>ce*tm?ly8-ESDTi_ z0%)!j_`D7VHEEfUS-+deP~j-8!WU}rHHVn5fvF`u91lfF&e?L#0>d5+_@U4#q#2MPXxgm(l7=e~PuhrngW1LR?I4}18d zN8lQt5gJuXYGrcDi{kx;D5&;IiZMag7h#2X+Wt{}}G(I-U_AJ7>7>b6o%0 Lzzc#`wx$0C$)gbL literal 0 HcmV?d00001 diff --git a/src/vanguards/__pycache__/__init__.cpython-313.pyc b/src/vanguards/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..531d4570e364ff5effa92d9c89f036cd966391ae GIT binary patch literal 321 zcmYjM!AiqG5Z!HBYg3{Z5sDWNUMiSX5D!v3>A^!qi1%Sl)?{mvjkBB5=2Snzi+|uB zm0bOSLVmyvDmsTZ@9~CtGmYoD#MbQPX!dUVXDfb-{*Y$Rk~b0%BmqSlbg&aR=)~P| zuj-xp=l)scPUW*WycdYeYB0ThU^k0`o9Z|*rqGv+#nL3@qaUh_Wp=V?2TzPbHs^U< za*XudxLXea<^r`;IY8|K@X{m-Ew@H0Ly>D?MIWHV6ySq2?Sb_*i@4$6U;}(3Fe?}A zNv1-xL}lVHglP=_DFB&E1K=0SdV7+os7%Eb4(!tQb$wuA<4{UBTV!{CHC%h6mAl>< MS>|56_OWmMFZX9)ga7~l literal 0 HcmV?d00001 diff --git a/src/vanguards/__pycache__/bandguards.cpython-313.pyc b/src/vanguards/__pycache__/bandguards.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e1458bcad457cc97e19bb77529c85e0b2e5385c9 GIT binary patch literal 28633 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 mmK)H!ex literal 0 HcmV?d00001 diff --git a/src/vanguards/__pycache__/cbtverify.cpython-313.pyc b/src/vanguards/__pycache__/cbtverify.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8dbc2fdf7e184b6be1a1e5f31f26a835b49b9d3 GIT binary patch literal 6278 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 Vx7&hW6CmHYRhuoaO^~uh{{zliee(bS literal 0 HcmV?d00001 diff --git a/src/vanguards/__pycache__/config.cpython-313.pyc b/src/vanguards/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cb20962923bfafb0e6d1eb20ee83c6f1620ebea2 GIT binary patch literal 11768 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 literal 0 HcmV?d00001 diff --git a/src/vanguards/__pycache__/logger.cpython-313.pyc b/src/vanguards/__pycache__/logger.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b377dc037aaa1679f0c9fdc2968fe4e6b3066f2c GIT binary patch literal 3429 zcmbtW|8E;b7N6O#wl_{3rw}V`lTE18Y=Br-6hJOLsmY}z3U!3H(G~elXcKRopx91k zH=zjSaHIar9Uafr`6odD)=s!`eh=J7{h2%ZIoyVIT^Nx`n=bYs z64oW4?Ya!KLk|Fr7jZaYFw-?hO zHjz%xq$v*_6;d;)iFwTQn+>iNG1dAtAUDx0$}|@cIuCNj2tpaQ?lc|iT!!uEv{^LT zw8SoDlxuKRl#v=Egwn2)Tn6+5NatNW74};db4U|Xgo$oOrwZ%F@={h^F6Nfab#pnX z-e-Gg5)Ha6iwY|+ro3enAR90@uPxa$;0kD%ijK0yR8=~b$QriMAoIoih;5Sm+O@>O z(z0nK7K?c^v2GM{YX-?$35(3P%)nyKBqJ*uw9PW@m)#A>BVe}+{ubO0Dxte#=v}QU zo~#Lh8{NCn!B4E6(T_JP(O>Ne=W2qyHFuilSO<%|E@TvUcG!oy z$UNDyid|0~@o<}~pKfWi0Wc7pxHfOD*h|F% zVFx1}jIfv|olKdd0j60n@+QTm#Sp8igvBydnhs{`LE z-fW4~bTjrmNf>|^JnyY?8K&#Bv=^N&2QUFY0T4YI1FWDHSr0Y7tdAHKU4~0i0CO29 zO*0vtbCCNrpy?&N6}hDVxjr+{bXe!)2e7rp=IIXpXX zj^3aD9d1lR^m<=-T;Tyvi|&aF+)VC&N=v%z=8yvC`_BE^?Bj6jUMWihCXTjMv1Jby zC`Z~r3nILe;hCOaqBV=tr;nJ2=`82k1@!sY?mI@(i-JRv{Xdn=47j|^8F$2roxc-Q zsz8cLSBhlZUMB2fHUo*Qxt@R=wAlfLgCxjWA<;-_n`gYTqQ-}ug2WP!W+h>oH!aJ^ z!DRF7p|wnW$gt+^B_{X#!-6N`h8mw!<5@L6nT%gf#&tFRlGf$q!`xCKM}s-je!)qG zRB9we8dx+6Sx9IW4HGk8TsJ2hbHbcPxgx8jhP_B7sGyb&Qu>AkCYn+-c&SM8h7ASN zNupG;>}+w(1~HqMM&1=;#nF?bAtRf0R|2r|0;}UB3>y;m&eC`)FE&m=x%ovJTyXcW zSZ-6B37MQ?3XBZG-#QDWyoA1QLv3CA%Befbsasd8%3x`**4h1D-#dMO?yPn?sW`MR z#41AUR`=h9{_l7cJ@dVUr0{mAD)!Xm;MS|qYSDw3f*g#czyfk+&(!C$) zzZ2=-9hj^}rpi+-jOl}cXX`xfOqQnpsYKs;rWWaXFZ50biU>2L-ci&~x~q!TH0;ew zb9XsmD^X5tYkSd6%3NI4&%eA?~K6v23f;EO6c2Q$6Jp=J$~cz zJ-C(O>%)J1X?v{_K7GsjaO3vI`>*YC=XS2_ap#;X(mH8`g$abtNE472c?|3{ur3#^ z5f&SST~gzm48Bg}IFD_@zL>CkbsjDagg?TjDkBbl+4(+2&UTGwM&P-8F}t>G{+e8X zB>*+D)`2|WF~)TX;n+7Q^bKmi8w{7E10`At9E93S^4(b9hvD1d1L)drw>7&ztyW?~ qEv%-1-|gT^O#3b@g`X$|A4Wvn^{_pJBM)P}_-uU)3-~GLTt5PB256K3 literal 0 HcmV?d00001 diff --git a/src/vanguards/__pycache__/logguard.cpython-313.pyc b/src/vanguards/__pycache__/logguard.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..604155801feaa5f7a85a65610c0ad6272458dcc5 GIT binary patch literal 4816 zcmd5=U2GHC6~6Q1vEw8rKR6p)h$av(abZc>EfoTGH-CmE1e^8PC6(!p276-MI-c~- z7ep!w?6i6l@0@$S`<*j>(%Ty*P-d;MpWo;v6?iRDk=GKH zG)a@s%JTslPzmUPv&wvs2Dz?)9-<-8gS1Nv(XiGpmuWyU?DJnCMwO zxnPylWvg0tELyIX)Fq3mhT|B8U)YD$6>2$F!K$cb+pd|=U{s50(Y#bEm2m81Tq*Tf z0GSY2uwYdkYE?j$S1MKs>Mp1%<`uJIXCLo@QA|mtvvV`a0#5V`PRH`(IEb6%A^~d& zS_)Q*F+ycc0vXU`kct)n8PpVzAuR}!TUhE!gjonIIa4!e@$mpGRA`!sD-%3e3I3nl z1#y!s_En(vN*>#JIo7Omq|%z3SDK9^)8%XJdfSYE--j!k8*eHGykt(u z4EpWu8eQx=NhLXwiMIQEEhijn9-B%^Bq60(-}$DmE*P{5&as(rG||n%O{Y{$%H&Gf zc32pEqF<^lEkR3UA$>-l&7C{1FC>4GTwsBUbrqV?Z94bc_7QXE=CaMM@^aZ>133A7 zCY?>sq!;uHQ<;>;LbmDXu;milrI!IyI;`aFl@OV1o0TQH3v96QfK`>vP81Et@I=8{ zKH`{kx%Sb-Qn_N<6PK-JbK;6oEul~C37ZzSJ;A!X{m9D4ETnsT>8yXD34tSi6P~Y~ z0kvA9RHVB5=t-iLONQOWP4>*|GQ<-jBWCW=9E$SxK?LY;rnxhRJiO z(`kw~n90bMERf7((wW3Aip)Xr`mq6{Skxh0R)IHxs?92L@lvf?a4f50v*3zBow8A( zc;i_wlC)8&coTqJ9dnstpkrN0Tq67Nib+*?rlTluXSyE*lVIb7M9~SHj9Iu?TVC<_ z-bM;jRh51ZdwIwnZgc8RMpt2oO(6`ek%#@UFWz(ezrQyBDA@n`PuH)y(S!BiK~ACv zT;;&S!M%;avAcs~e_CwBPrC7wf63Mdrys@>cP`$(=(RNBQ*M0fX+R9l2%8}i8h~Z4 zxY0N3!8c!RnMpT3iOWn2n_+L6kKE{ZJvjbq%S^lRX zO4yW0Xq=9~+3(13@SgDS^CM6o{P;-~Paq-BXaynYZkD&8Zqqr>A~;G`BU#7+0bS}< zNF`3F36bLJEVyjiwox*_5;=BQfFrdT$~jUtQOctkymX%Lr&Q3}O8fj7 z5T!qdC_RM{wd39$(nD=qlJ*_!4K6%4Nq|+rRbq;wM-u_s4;534l@+sEWKzYfQao2C ztyn8`0<`V^p$D;RXopi;3^Id*vmhW-l0%=SKS?*_m@CKDkKL0;pN2_y=DvAj%aee|BP_o?*az(#n&^XPW}U~0@gn&lpC0~XG=16Y0E zw9@C#xFQFl;%Di@-XaaU&>cL6~xGIn-~dt81e4(uD1#m%Qn@Sxy+0@H~a2tL~Y*k z6KXb{(V386m3{`7y^G>k4Tlv)F7fVuh}Np!2Z8LAmrWKZ@G5LjTE1cyb;F@YU~oeA zs3BM=(}G?uGWqIdK;#dg%f`?Ik;MxwEaJr+po?GD%$mvCOtG^gg;T!(0Zb9@^$^$y zkGSEH2ay4UV;*S^;ojoK#^R;Bi_>~y!bTG!Z^Ij{qV?(e1*nxbl(C6_)6%+8JchcvYH~G;D7Xs*l)nWb|!F5;{E&h zP3VAnnAL&DG`Wp0mUN zDbzG_KsBM>8$H*1)3L#+O_QIck zTpv62pu6wJsq3fKPu9C3{D+$a6aVA*AI5K;zONjDDA;olMEqTT_JBM5tn^U38#N@s z6ny`}w_(*-Hg%nab$!_?)+(s?=m0yT((Ku#>q}*7JC$U({2YRn0`atDQhqtT1o90W zg1;2-RFZE_Kfp&!6};#iI^`X}do%}am?7B5L2OEbApC<&|C4m{zrL?Y^lLKwFS2h_ eAw#hZGVrK>a4qtvf6rRvnVb}a!&?L;U-`c*M{BqM literal 0 HcmV?d00001 diff --git a/src/vanguards/__pycache__/main.cpython-313.pyc b/src/vanguards/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..401cd874c1a8b31caa32451874a9d347bf6a5913 GIT binary patch literal 15020 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 literal 0 HcmV?d00001 diff --git a/src/vanguards/__pycache__/pathverify.cpython-313.pyc b/src/vanguards/__pycache__/pathverify.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36f2972c35ada50924c88fc46930f76202385797 GIT binary patch literal 15869 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 literal 0 HcmV?d00001 diff --git a/src/vanguards/__pycache__/rendguard.cpython-313.pyc b/src/vanguards/__pycache__/rendguard.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..79a524037da2a8bc99df47f125307e5a23175afd GIT binary patch literal 6565 zcmd^D-E$My6~C)pY4wS0%QF7LT4eb{fb9SVV?*$#0wy3eS}QnplFi0i+e&50+?9dQ zP|Qr4IMe9_W}3mwWQv*TOw3crQ+Vh@{{$~GmAd0jnJI0`o55t#zO+4eS6bU3$S{5E zoz>Odd(J)goOAD<^SgK5<+39vL+QW=|E@sjujHjzm_lRyJ!pJ^c*Ik$qZ1USIMOz~ zZaP6@8rn2wcm|t!Gq&&+%Xo)ZP;Fo{D`;LBHnSpq)WpNtji;yEGZLPjJ>M~rNJ?_YRC-$KI4hiBuvZ2 pf$ha^ADPnw)f~!~SABdUUsgUh^zm@M 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 C@6pNt literal 0 HcmV?d00001 diff --git a/src/vanguards/__pycache__/vanguards.cpython-313.pyc b/src/vanguards/__pycache__/vanguards.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8e4d94b7967c3bf66cb1e8eeb11a9ad38f6f8af6 GIT binary patch literal 18842 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 literal 0 HcmV?d00001 From 090785375b11d7c233781f200f05416344696181 Mon Sep 17 00:00:00 2001 From: 4vun1t <4vun1t@mail.i2p> Date: Sat, 6 Jun 2026 22:52:45 +0200 Subject: [PATCH 3/3] . --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index d88248b..3c74392 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +# 0.4.1 + + # The Vanguards Onion Service Addon [![Build Status](https://travis-ci.org/mikeperry-tor/vanguards.png?branch=master)](https://travis-ci.org/mikeperry-tor/vanguards) [![Coverage Status](https://coveralls.io/repos/github/mikeperry-tor/vanguards/badge.png?branch=master)](https://coveralls.io/github/mikeperry-tor/vanguards?branch=master)