# Copyright 2014, 2015, Nik Kinkel
# See LICENSE for licensing information
'''
.. topic:: PathConstraints
PathConstraints represent constraints on a path through the Tor network.
They are constructed by passing a dict of {'keyword': 'value'} as an
argument for each node. A list of filters is built for each node. Then,
using the satisfy() method, a PathConstraints object can select only
the RelayDescriptors that satisfy the proper constraints.
Path constraint objects can be used as follows (suppose *relays* is a
list of RelayDescriptors):
>>> p = PathConstraints(entry={'ntor': True, 'flags': ['Fast', 'Guard']},
>>> middle={'ntor': True, 'flags': ['Stable'],
>>> exit={'ntor': True, 'flags': ['Exit'],
>>> 'exit_to_port': 443})
>>> entry_candidates = p.satisfy(node='entry', relays)
*entry_candidates* would now be a list of all relays that satisfy the
entry node constraints.
.. topic:: PathSelector
PathSelector objects simplify choosing a full path. PathSelectors take
a PathConstraints object as an argument to the constructor. When
getPath() is called, PathSelectors use these PathConstraints to build
a path through the Tor network, taking care of some additional
concerns including:
- not selecting two relays in the same family
- not selecting two relays in the same /16
- not selecting the same relay in a path twice
getPath() returns a deferred that will fire with the chosen Path object.
'''
import random
from collections import namedtuple
import ipaddress
from twisted.internet import defer
import stem
from stem.descriptor.server_descriptor import DEFAULT_IPV6_EXIT_POLICY
from oppy.path.exceptions import UnknownPathConstraint
from oppy.util.tools import dispatch
Path = namedtuple("Path", ("entry",
"middle",
"exit",))
[docs]class PathConstraints(object):
'''Represent a set of path constraints.'''
keywords = set([
'flags',
'ntor',
'exit_IPv6',
'exit_to_IP',
'exit_to_port',
'exit_to_IP_and_port',
'fingerprint',
])
_build_table = {}
def __init__(self, entry, middle, exit):
'''
:param dict entry: dict of 'keyword: value' arguments to build
filters for the entry node
:param dict middle: dict of 'keyword: value' arguments to build
filters for the middle node
:param dict exit: dict of 'keyword: value' arguments to build
filters for the exit node
'''
self.entry_filters = self._buildFilterList(entry)
self.middle_filters = self._buildFilterList(middle)
self.exit_filters = self._buildFilterList(exit)
self.is_IPv6_exit = 'exit_IPv6' in exit and exit['exit_IPv6'] is True
def _buildFilterList(self, args):
'''
:param dict args: dict of 'keyword: value' args to build relay
filters
:returns: **list, function** a list of filtering functions
'''
filter_list = []
for key in args:
if key not in PathConstraints.keywords:
msg = "Unrecognized path constraint: '{}'.".format(key)
raise UnknownPathConstraint(msg)
build = PathConstraints._build_table[key].__get__(self, type(self))
filter_list.append(build(args[key]))
return filter_list
[docs] def satisfy(self, relays, node, family_fprints=None, subnets=None):
'''Return the subset of *relays* that satisfies the path constraints
for chosen *node*.
:param list relays:
a list (of stem.descriptor.server_descriptor.RelayDescriptor) of
relays to filter
:param str node: the node for which we're filtering ('entry', 'middle',
or 'exit'). determines which filters to use
:param set family_fprints: a set (of str) of fingerprints in use
by "family members" of relays on the current path. do not
choose any relays with a fingerprint in family_fprints
:param set subnets: set (of ipaddress.ip_network) of the current
/16's in use for the path in question. do not choose any relays
that share the same /16 as a relay that's already been chosen.
:returns: **list, stem.descriptor.server_descriptor.RelayDescriptor**
all RelayDescriptors from *relays* that satisfy the path
constraints for the chosen node position
'''
filters = None
if node == 'entry':
filters = self.entry_filters
elif node == 'middle':
filters = self.middle_filters
elif node == 'exit':
filters = self.exit_filters
else:
msg = "Unrecognized node type: '{}'.".format(node)
raise UnknownPathConstraint(msg)
f = lambda x: all([f(x) for f in filters])
node_list = filter(f, relays)
if family_fprints is not None:
f = lambda r: r.fingerprint not in family_fprints
node_list = filter(f, node_list)
if subnets is not None:
f = lambda r: ipaddress.ip_address(r.address) not in subnets
node_list = filter(f, node_list)
return node_list
@dispatch(_build_table, 'flags')
def _buildFlagsFilter(self, flags):
'''Build and return a 'flags' filtering function.
The returned function takes a RelayDescriptor as an argument and
returns **True** if every flag in *flags* is contained in the
RelayDescriptors 'flags' field, and **False** otherwise.
:param **list, str** flags: flags to check
:returns: **function**
'''
for flag in flags[:]:
if flag not in stem.Flag:
msg = "Unrecognized flag: '{}'.".format(flag)
raise UnknownPathConstraint(msg)
# convert to unicode to work with RouterStatusEntry.flags arg
flag = unicode(flag)
flags = set(flags)
return lambda r: flags.issubset(r.flags)
@dispatch(_build_table, 'ntor')
def _buildNTorFilter(self, value):
'''Build and return an 'ntor' filtering function.
The returned function takes a RelayDescriptor as an argument and
returns whether or not the relay's 'ntor_onion_key' field's presence
matches the desired value.
That is, if *value* is True and the relay has an *ntor_onion_key*
field, then return **True**, etc.
:param bool value: desired truth value of statement (for some relay):
'this relay's ntor onion key field is not None'
:returns: **function**
'''
return lambda r: (r.ntor_onion_key is not None) == value
@dispatch(_build_table, 'exit_IPv6')
def _buildExitIPv6Filter(self, value):
'''Build and return an exit IPv6 filter.
:param bool value: desired truth value of statement
:returns: **function**
'''
POLICY = DEFAULT_IPV6_EXIT_POLICY
return lambda r: (r.exit_policy_v6 != POLICY) == value
@dispatch(_build_table, 'exit_to_IP')
def _buildExitToIPFilter(self, IP):
'''Build and return an exit policy filter.
Build a function that takes a RelayDescriptor as an argument and
returns **True** if that relay's exit policy allows exits to IP.
:param str IP: desired IP to exit to
:returns: **function**
'''
return lambda r: r.exit_policy.can_exit_to(address=IP, strict=True)
@dispatch(_build_table, 'exit_to_port')
def _buildExitToPortFilter(self, port):
'''Build and return a function that checks if a relay allows exits
to a certain port.
:param int port: port to check
:return: **function**
'''
return lambda r: r.exit_policy.can_exit_to(port=port, strict=True)
@dispatch(_build_table, 'exit_to_IP_and_port')
def _buildExitToIPAndPortFilter(self, arg):
'''Build and return a function that takes an IP and port in form
'127.0.0.1:443' format and checks if an relay allows exits to both
the desired IP and port.
:param str arg: IP and port, delimited by ':'
:returns: **function**
'''
arg = arg.split(':')
addr = arg[0]
port = int(arg[1])
return lambda r: r.exit_policy.can_exit_to(address=addr, port=port)
@dispatch(_build_table, 'fingerprint')
def _buildFingerprintFilter(self, fingerprint):
'''Build and return a function that takes a RelayDescriptor as an
argument and returns **True** if that relay's fingerprint matches
the value *fingerprint*.
:param str fingerprint: fingerprint to match
:returns: **function**
'''
return lambda r: r.fingerprint == fingerprint
[docs]class PathSelector(object):
'''Select a path based on some path constraints.'''
@defer.inlineCallbacks
[docs] def getPath(self, constraints):
'''
Filter the current set of RelayDescriptors and randomly choose an
entry, middle, and exit node that satisfy the desired path constraints.
We currently just use the absolutely bare minimum path constraints,
namely:
- no two relays in the same family
- no two relays in the same /16
- each relay has the required default flags
.. note: oppy currently only knows how to do an NTor handshake, so
we only choose RelayDescriptors that have an ntor_onion_key.
:param oppy.path.path.PathConstraints constraints: path constraints
to satisfy
:returns: **twisted.internet.defer.Deferred** that fires with an
oppy.path.Path
'''
from oppy.shared import net_status
# used to track the "family" fingerprints of relays in the current path
family_fprints = set()
# /16's of relays in the current path
subnets = set()
# wait until we have a good set of descriptors to choose from
relays = yield net_status.getDescriptors()
relays = relays.values()
entry_list = constraints.satisfy(relays, node='entry')
entry = random.choice(entry_list)
family_fprints |= set([i.strip(u'$') for i in entry.family])
subnets.add(ipaddress.ip_network(entry.address + u'/16', strict=False))
relays.remove(entry)
middle_list = constraints.satisfy(relays, node='middle',
family_fprints=family_fprints,
subnets=subnets)
middle = random.choice(middle_list)
family_fprints |= set([i.strip(u'$') for i in entry.family])
subnets.add(ipaddress.ip_network(entry.address + u'/16', strict=False))
relays.remove(middle)
exit_list = constraints.satisfy(relays, node='exit',
family_fprints=family_fprints,
subnets=subnets)
exit = random.choice(exit_list)
defer.returnValue(Path(entry, middle, exit))