commit 970cb5191f77b1da0d656e6441d77d42d53afb95 Author: Arturo Filastò arturo@filasto.net Date: Wed Jun 29 16:38:09 2016 +0200
Implementing bridge failover in ooniprobe (#540)
* Implementing bridge failover in ooniprobe
We support failing over to obfs4 and meek when vanilla tor does not work.
* This implements #538
* Reset the DataDirectory when we the data_dir is not set
Otherwise txtorcon will delete the datadirectory after it shutsdown and not re-create it. --- data/ooniprobe.conf.sample | 4 + ooni/__init__.py | 2 - ooni/constants.py | 76 ++++++++++++++++++ ooni/director.py | 58 ++----------- ooni/oonicli.py | 5 +- ooni/report/cli.py | 4 +- ooni/report/tool.py | 4 +- ooni/tests/test_director.py | 4 +- ooni/tests/test_onion.py | 45 ++++++++++- ooni/utils/onion.py | 192 +++++++++++++++++++++++++++++++++++++++++++- 10 files changed, 326 insertions(+), 68 deletions(-)
diff --git a/data/ooniprobe.conf.sample b/data/ooniprobe.conf.sample index fec1026..be35364 100644 --- a/data/ooniprobe.conf.sample +++ b/data/ooniprobe.conf.sample @@ -58,6 +58,10 @@ tor: # This should be set to something to avoid having Tor download each time # the descriptors and consensus data. #data_dir: ~/.tor/ + # + # This is the timeout after which we consider to to not have + # bootstrapped properly. + timeout: 200 torrc: #HTTPProxy: host:port #HTTPProxyAuthenticator: user:password diff --git a/ooni/__init__.py b/ooni/__init__.py index 5280b1e..6f6e78f 100644 --- a/ooni/__init__.py +++ b/ooni/__init__.py @@ -5,8 +5,6 @@ __version__ = "1.5.2.dev1" # This is the version number of resources to be downloaded # when a release is made it should be aligned to __version__ __resources_version__ = "1.5.1" -canonical_bouncer = 'httpo://nkvphnp3p6agi5qq.onion' -
__all__ = [ 'common', diff --git a/ooni/constants.py b/ooni/constants.py new file mode 100644 index 0000000..882edb4 --- /dev/null +++ b/ooni/constants.py @@ -0,0 +1,76 @@ +CANONICAL_BOUNCER_ONION = 'httpo://nkvphnp3p6agi5qq.onion' + +MEEK_BRIDGES = [ + ("meek 0.0.2.0:2 B9E7141C594AF25699E0079C1F0146F409495296 " + "url=https://d2zfqthxsdq309.cloudfront.net/ front=a0.awsstatic.com"), + ("meek 0.0.2.0:3 A2C13B7DFCAB1CBF3A884B6EB99A98067AB6EF44 " + "url=https://az786092.vo.msecnd.net/ front=ajax.aspnetcdn.com") +] + +# These are bridges taken from TBB +OBFS4_BRIDGES = [ + ("obfs4 154.35.22.10:41835 8FB9F4319E89E5C6223052AA525A192AFBC85D55 " + "cert=GGGS1TX4R81m3r0HBl79wKy1OtPPNR2CZUIrHjkRg65Vc2VR8fOyo64f9kmT1UAFG7j0HQ iat-mode=0"), + + ("obfs4 198.245.60.50:443 752CF7825B3B9EA6A98C83AC41F7099D67007EA5 " + "cert=xpmQtKUqQ/6v5X7ijgYE/f03+l2/EuQ1dexjyUhh16wQlu" + "/cpXUGalmhDIlhuiQPNEKmKw iat-mode=0"), + + ("obfs4 192.99.11.54:443 7B126FAB960E5AC6A629C729434FF84FB5074EC2 " + "cert=VW5f8+IBUWpPFxF+rsiVy2wXkyTQG7vEd" + "+rHeN2jV5LIDNu8wMNEOqZXPwHdwMVEBdqXEw iat-mode=0"), + + ("obfs4 109.105.109.165:10527 8DFCD8FB3285E855F5A55EDDA35696C743ABFC4E " + "cert=Bvg/itxeL4TWKLP6N1MaQzSOC6tcRIBv6q57DYAZc3b2AzuM" + "+/TfB7mqTFEfXILCjEwzVA iat-mode=0"), + + ("obfs4 83.212.101.3:41213 A09D536DD1752D542E1FBB3C9CE4449D51298239 " + "cert=lPRQ/MXdD1t5SRZ9MquYQNT9m5DV757jtdXdlePmRCudUU9CFUOX1Tm7" + "/meFSyPOsud7Cw iat-mode=0"), + + ("obfs4 104.131.108.182:56880 EF577C30B9F788B0E1801CF7E433B3B77792B77A " + "cert=0SFhfDQrKjUJP8Qq6wrwSICEPf3Vl" + "/nJRsYxWbg3QRoSqhl2EB78MPS2lQxbXY4EW1wwXA iat-mode=0"), + + ("obfs4 109.105.109.147:13764 BBB28DF0F201E706BE564EFE690FE9577DD8386D " + "cert=KfMQN/tNMFdda61hMgpiMI7pbwU1T+wxjTulYnfw" + "+4sgvG0zSH7N7fwT10BI8MUdAD7iJA iat-mode=0"), + + ("obfs4 154.35.22.11:49868 A832D176ECD5C7C6B58825AE22FC4C90FA249637 " + "cert=YPbQqXPiqTUBfjGFLpm9JYEFTBvnzEJDKJxXG5Sxzrr" + "/v2qrhGU4Jls9lHjLAhqpXaEfZw iat-mode=0"), + + ("obfs4 154.35.22.12:80 00DC6C4FA49A65BD1472993CF6730D54F11E0DBB " + "cert=N86E9hKXXXVz6G7w2z8wFfhIDztDAzZ" + "/3poxVePHEYjbKDWzjkRDccFMAnhK75fc65pYSg iat-mode=0"), + + ("obfs4 154.35.22.13:443 FE7840FE1E21FE0A0639ED176EDA00A3ECA1E34D " + "cert=fKnzxr+m+jWXXQGCaXe4f2gGoPXMzbL+bTBbXMYXuK0tMotd" + "+nXyS33y2mONZWU29l81CA iat-mode=0"), + + ("obfs4 154.35.22.10:80 8FB9F4319E89E5C6223052AA525A192AFBC85D55 " + "cert=GGGS1TX4R81m3r0HBl79wKy1OtPPNR2CZUIrHjkRg65Vc2VR8fOyo64f9kmT1UAFG7j0HQ iat-mode=0"), + + ("obfs4 154.35.22.10:443 8FB9F4319E89E5C6223052AA525A192AFBC85D55 " + "cert=GGGS1TX4R81m3r0HBl79wKy1OtPPNR2CZUIrHjkRg65Vc2VR8fOyo64f9kmT1UAFG7j0HQ iat-mode=0"), + + ("obfs4 154.35.22.11:443 A832D176ECD5C7C6B58825AE22FC4C90FA249637 " + "cert=YPbQqXPiqTUBfjGFLpm9JYEFTBvnzEJDKJxXG5Sxzrr" + "/v2qrhGU4Jls9lHjLAhqpXaEfZw iat-mode=0"), + + ("obfs4 154.35.22.11:80 A832D176ECD5C7C6B58825AE22FC4C90FA249637 " + "cert=YPbQqXPiqTUBfjGFLpm9JYEFTBvnzEJDKJxXG5Sxzrr" + "/v2qrhGU4Jls9lHjLAhqpXaEfZw iat-mode=0"), + + ("obfs4 154.35.22.9:60873 C73ADBAC8ADFDBF0FC0F3F4E8091C0107D093716 " + "cert=gEGKc5WN/bSjFa6UkG9hOcft1tuK" + "+cV8hbZ0H6cqXiMPLqSbCh2Q3PHe5OOr6oMVORhoJA iat-mode=0"), + + ("obfs4 154.35.22.9:80 C73ADBAC8ADFDBF0FC0F3F4E8091C0107D093716 " + "cert=gEGKc5WN/bSjFa6UkG9hOcft1tuK" + "+cV8hbZ0H6cqXiMPLqSbCh2Q3PHe5OOr6oMVORhoJA iat-mode=0"), + + ("obfs4 154.35.22.9:443 C73ADBAC8ADFDBF0FC0F3F4E8091C0107D093716 " + "cert=gEGKc5WN/bSjFa6UkG9hOcft1tuK" + "+cV8hbZ0H6cqXiMPLqSbCh2Q3PHe5OOr6oMVORhoJA iat-mode=0") +] diff --git a/ooni/director.py b/ooni/director.py index e6f864e..02b6743 100644 --- a/ooni/director.py +++ b/ooni/director.py @@ -7,13 +7,11 @@ from ooni.utils import log, generate_filename from ooni.utils.net import randomFreePort from ooni.nettest import NetTest, getNetTestInformation from ooni.settings import config -from ooni import errors from ooni.nettest import test_class_name_to_name
-from txtorcon import TorConfig, TorState, launch_tor, build_tor_connection +from ooni.utils.onion import start_tor, connect_to_control_port
-from twisted.internet import defer, reactor -from twisted.internet.endpoints import TCP4ClientEndpoint +from twisted.internet import defer
class Director(object): @@ -133,8 +131,7 @@ class Director(object): if config.advanced.start_tor and config.tor_state is None: yield self.startTor() elif config.tor.control_port and config.tor_state is None: - log.msg("Connecting to Tor Control Port...") - yield self.getTorState() + yield connect_to_control_port()
if config.global_options['no-geoip']: aux = [False] @@ -299,11 +296,6 @@ class Director(object): config.scapyFactory.registerProtocol(sniffer) log.msg("Starting packet capture to: %s" % filename_pcap)
- @defer.inlineCallbacks - def getTorState(self): - connection = TCP4ClientEndpoint(reactor, '127.0.0.1', - config.tor.control_port) - config.tor_state = yield build_tor_connection(connection)
def startTor(self): """ Starts Tor @@ -312,37 +304,7 @@ class Director(object): """ log.msg("Starting Tor...")
- @defer.inlineCallbacks - def state_complete(state): - config.tor_state = state - log.msg("Successfully bootstrapped Tor") - log.debug("We now have the following circuits: ") - for circuit in state.circuits.values(): - log.debug(" * %s" % circuit) - - socks_port = yield state.protocol.get_conf("SocksPort") - control_port = yield state.protocol.get_conf("ControlPort") - - config.tor.socks_port = int(socks_port.values()[0]) - config.tor.control_port = int(control_port.values()[0]) - - def setup_failed(failure): - log.exception(failure) - raise errors.UnableToStartTor - - def setup_complete(proto): - """ - Called when we read from stdout that Tor has reached 100%. - """ - log.debug("Building a TorState") - config.tor.protocol = proto - state = TorState(proto.tor_protocol) - state.post_bootstrap.addCallback(state_complete) - state.post_bootstrap.addErrback(setup_failed) - return state.post_bootstrap - - def updates(prog, tag, summary): - log.msg("%d%%: %s" % (prog, summary)) + from txtorcon import TorConfig
tor_config = TorConfig() if config.tor.control_port is None: @@ -388,14 +350,4 @@ class Director(object): tor_config.save() log.debug("Setting control port as %s" % tor_config.ControlPort) log.debug("Setting SOCKS port as %s" % tor_config.SocksPort) - - if config.advanced.tor_binary: - d = launch_tor(tor_config, reactor, - tor_binary=config.advanced.tor_binary, - progress_updates=updates) - else: - d = launch_tor(tor_config, reactor, - progress_updates=updates) - d.addCallback(setup_complete) - d.addErrback(setup_failed) - return d + return start_tor(tor_config) diff --git a/ooni/oonicli.py b/ooni/oonicli.py index 56b331d..74bf9ac 100644 --- a/ooni/oonicli.py +++ b/ooni/oonicli.py @@ -10,7 +10,8 @@ import urlparse from twisted.python import usage from twisted.internet import defer
-from ooni import errors, __version__, canonical_bouncer +from ooni import errors, __version__ +from ooni.constants import CANONICAL_BOUNCER_ONION from ooni.settings import config from ooni.utils import log from backend_client import CollectorClient @@ -44,7 +45,7 @@ class Options(usage.Options): ["collector", "c", None, "Specify the address of the collector for " "test results. In most cases a user will " "prefer to specify a bouncer over this."], - ["bouncer", "b", canonical_bouncer, "Specify the bouncer used to " + ["bouncer", "b", CANONICAL_BOUNCER_ONION, "Specify the bouncer used to " "obtain the address of the " "collector and test helpers."], ["logfile", "l", None, "Write to this logs to this filename."], diff --git a/ooni/report/cli.py b/ooni/report/cli.py index 12ea83c..485ab52 100644 --- a/ooni/report/cli.py +++ b/ooni/report/cli.py @@ -3,7 +3,7 @@ from __future__ import print_function import os import sys
-from ooni import canonical_bouncer +from ooni.constants import CANONICAL_BOUNCER_ONION from ooni.report import __version__ from ooni.report import tool from ooni.settings import config @@ -73,7 +73,7 @@ def run(args=sys.argv[1:]): config.read_config_file()
if options['default-collector']: - options['bouncer'] = canonical_bouncer + options['bouncer'] = CANONICAL_BOUNCER_ONION
if options['command'] == "upload" and options['report_file']: tor_check() diff --git a/ooni/report/tool.py b/ooni/report/tool.py index faa407f..f8af132 100644 --- a/ooni/report/tool.py +++ b/ooni/report/tool.py @@ -4,7 +4,7 @@ import sys
from twisted.internet import defer
-from ooni import canonical_bouncer +from ooni.constants import CANONICAL_BOUNCER_ONION from ooni.reporter import OONIBReporter, OONIBReportLog
from ooni.utils import log @@ -62,7 +62,7 @@ def upload(report_file, collector=None, bouncer=None): log.msg("Could not find %s in reporting.yaml. Looking up " "collector with canonical bouncer." % report_file) collector_client = yield lookup_collector_client(report.header, - canonical_bouncer) + CANONICAL_BOUNCER_ONION)
oonib_reporter = OONIBReporter(report.header, collector_client) log.msg("Creating report for %s with %s" % (report_file, diff --git a/ooni/tests/test_director.py b/ooni/tests/test_director.py index d2c4c82..0cc740a 100644 --- a/ooni/tests/test_director.py +++ b/ooni/tests/test_director.py @@ -68,8 +68,8 @@ class TestDirector(ConfigTestCase): assert 'http_header_field_manipulation' in nettests assert 'traceroute' in nettests
- @patch('ooni.director.TorState', mock_TorState) - @patch('ooni.director.launch_tor', mock_launch_tor) + @patch('ooni.utils.onion.TorState', mock_TorState) + @patch('ooni.utils.onion.launch_tor', mock_launch_tor) def test_start_tor(self): @defer.inlineCallbacks def director_start_tor(): diff --git a/ooni/tests/test_onion.py b/ooni/tests/test_onion.py index 944f8f5..732614f 100644 --- a/ooni/tests/test_onion.py +++ b/ooni/tests/test_onion.py @@ -1,6 +1,9 @@ +from twisted.internet import defer from twisted.trial import unittest + from ooni.utils import onion from mock import Mock, patch +from txtorcon.interface import ITorControlProtocol
sample_transport_lines = { 'fte': 'fte exec /fakebin --managed', @@ -10,14 +13,28 @@ sample_transport_lines = { 'obfs4': 'obfs4 exec /fakebin --enableLogging=true --logLevel=INFO' }
+class MockTorState(object): + def __init__(self): + self.protocol = Mock() + self.protocol.get_state = lambda x: 8080 + self.protocol.post_bootstrap = defer.succeed(self) + +class MockSuccessTorProtocol(object): + def __init__(self): + self.tor_protocol = Mock(ITorControlProtocol) + self.tor_protocol.post_bootstrap = defer.succeed(MockTorState()) + class TestOnion(unittest.TestCase): def test_tor_details(self): assert isinstance(onion.tor_details, dict) assert onion.tor_details['version'] assert onion.tor_details['binary'] + def test_transport_dicts(self): - self.assertEqual( set(onion.transport_bin_name.keys()), - set(onion._transport_line_templates.keys()) ) + + self.assertEqual(set(onion.transport_bin_name.keys()), + set(onion._transport_line_templates.keys())) + def test_bridge_line(self): self.assertRaises(onion.UnrecognizedTransport, onion.bridge_line, 'rot13', '/log.txt') @@ -64,3 +81,27 @@ class TestOnion(unittest.TestCase):
self.assertEqual(onion.is_onion_address( 'http://thirteenchars123.com'), False) + + def test_launcher_fail_once(self): + from ooni.utils.onion import TorLauncherWithRetries + from txtorcon import TorConfig + tor_config = TorConfig() + tor_launcher = TorLauncherWithRetries(tor_config) + + self.failures = 0 + def _launch_tor_fail_once(): + self.failures += 1 + if self.failures <= 1: + return defer.fail(Exception("Failed once")) + return defer.succeed(MockSuccessTorProtocol()) + + def _mock_setup_complete(protocol): + self.assertIsInstance(protocol, MockSuccessTorProtocol) + self.assertTrue( + tor_launcher.tor_config.ClientTransportPlugin.startswith("obfs4") + ) + tor_launcher.started.callback(None) + + tor_launcher._launch_tor = _launch_tor_fail_once + tor_launcher._setup_complete = _mock_setup_complete + return tor_launcher.launch() diff --git a/ooni/utils/onion.py b/ooni/utils/onion.py index fea75c2..e18a6ee 100644 --- a/ooni/utils/onion.py +++ b/ooni/utils/onion.py @@ -1,16 +1,28 @@ +import os import re import string +import StringIO import subprocess from distutils.spawn import find_executable from distutils.version import LooseVersion
+from twisted.internet import reactor, defer +from twisted.internet.endpoints import TCP4ClientEndpoint + +from txtorcon import TorConfig, TorState, launch_tor, build_tor_connection from txtorcon.util import find_tor_binary as tx_find_tor_binary
+from ooni import constants +from ooni import errors +from ooni.utils import log from ooni.settings import config
ONION_ADDRESS_REGEXP = re.compile("^((httpo|http|https)://)?" "[a-z0-9]{16}.onion")
+TBB_PT_PATHS = ("/Applications/TorBrowser.app/Contents/MacOS/Tor" + "/PluggableTransports/",) + class TorVersion(LooseVersion): pass
@@ -64,14 +76,22 @@ def transport_name(address): transport_name_chars = string.ascii_letters + string.digits if all(c in transport_name_chars for c in transport_name): return transport_name - else: - return None + return None
def is_onion_address(address): return ONION_ADDRESS_REGEXP.match(address) != None
+def find_pt_executable(name): + bin_loc = find_executable(name) + if bin_loc: + return bin_loc + for path in TBB_PT_PATHS: + bin_loc = os.path.join(path, name) + if os.path.isfile(bin_loc): + return bin_loc + return None
tor_details = { 'binary': find_tor_binary(), @@ -107,7 +127,9 @@ _transport_line_templates = { _pyobfsproxy_line('obfs3', bin_loc, log_file),
'obfs4': lambda bin_loc, log_file: \ - "obfs4 exec %s --enableLogging=true --logLevel=INFO" % bin_loc } + "obfs4 exec %s --enableLogging=true --logLevel=INFO" % bin_loc, + +}
class UnrecognizedTransport(Exception): pass @@ -139,3 +161,167 @@ def bridge_line(transport, log_file): raise OutdatedTor
return _transport_line_templates[transport](bin_loc, log_file) + +pt_config = { + 'meek': [ + { + 'executable': 'obfs4proxy', + 'minimum_version': '0.0.6', + 'version_parse': lambda x: x.split('-')[1], + 'client_transport_line': 'meek exec {bin_loc}' + }, + { + 'executable': 'meek-client', + 'minimum_version': None, + 'client_transport_line': 'meek exec {bin_loc}' + } + ], + + 'obfs4': [ + { + 'executable': 'obfs4proxy', + 'minimum_version': None, + 'client_transport_line': 'obfs4 exec {bin_loc}' + } + ] + +} + +def get_client_transport(transport): + """ + + :param transport: + :return: client_transport_line + """ + + try: + pts = pt_config[transport] + except KeyError: + raise UnrecognizedTransport + + for pt in pts: + bin_loc = find_pt_executable(pt['executable']) + if bin_loc is None: + continue + if pt['minimum_version'] is not None: + pt_version = executable_version(bin_loc, pt['version_parse']) + if (pt_version is None or + pt_version < LooseVersion(pt['minimum_version'])): + continue + return pt['client_transport_line'].format(bin_loc=bin_loc) + + raise UninstalledTransport + + +class TorLauncherWithRetries(object): + def __init__(self, tor_config, timeout=config.tor.timeout): + self.retry_with = ["obfs4", "meek"] + self.started = defer.Deferred() + self.tor_output = StringIO.StringIO() + self.tor_config = tor_config + if timeout is None: + # XXX we will want to move setting the default inside of the + # config object. + timeout = 200 + self.timeout = timeout + + def _reset_tor_config(self): + """ + This is used to reset the Tor configuration to before launch_tor + modified it. This is in particular used to force the regeneration of the + DataDirectory. + """ + new_tor_config = TorConfig() + for key in self.tor_config: + if config.tor.data_dir is None and key == "DataDirectory": + continue + setattr(new_tor_config, key, getattr(self.tor_config, key)) + self.tor_config = new_tor_config + + def _progress_updates(self, prog, tag, summary): + log.msg("%d%%: %s" % (prog, summary)) + + @defer.inlineCallbacks + def _state_complete(self, state): + config.tor_state = state + log.msg("Successfully bootstrapped Tor") + log.debug("We now have the following circuits: ") + for circuit in state.circuits.values(): + log.debug(" * %s" % circuit) + + socks_port = yield state.protocol.get_conf("SocksPort") + control_port = yield state.protocol.get_conf("ControlPort") + + config.tor.socks_port = int(socks_port.values()[0]) + config.tor.control_port = int(control_port.values()[0]) + self.started.callback(state) + + def _setup_failed(self, failure): + self.tor_output.seek(0) + map(log.debug, self.tor_output.readlines()) + self.tor_output.seek(0) + + if len(self.retry_with) == 0: + self.started.errback(errors.UnableToStartTor()) + return + + while len(self.retry_with) > 0: + self._reset_tor_config() + self.tor_config.UseBridges = 1 + transport = self.retry_with.pop(0) + log.msg("Failed to start Tor. Retrying with {0}".format(transport)) + + try: + bridge_lines = getattr(constants, + '{0}_BRIDGES'.format(transport).upper()) + except AttributeError: + continue + + try: + self.tor_config.ClientTransportPlugin = get_client_transport(transport) + except UninstalledTransport: + log.err("Pluggable transport {0} is not installed".format( + transport)) + continue + except UnrecognizedTransport: + log.err("Unrecognized transport type") + continue + + self.tor_config.Bridge = bridge_lines + self.launch() + break + + def _setup_complete(self, proto): + """ + Called when we read from stdout that Tor has reached 100%. + """ + log.debug("Building a TorState") + config.tor.protocol = proto + state = TorState(proto.tor_protocol) + state.post_bootstrap.addCallbacks(self._state_complete, + self._setup_failed) + + def _launch_tor(self): + return launch_tor(self.tor_config, reactor, + tor_binary=config.advanced.tor_binary, + progress_updates=self._progress_updates, + stdout=self.tor_output, + timeout=self.timeout, + stderr=self.tor_output) + + def launch(self): + self._launched = self._launch_tor() + self._launched.addCallbacks(self._setup_complete, self._setup_failed) + return self.started + + +def start_tor(tor_config): + tor_launcher = TorLauncherWithRetries(tor_config) + return tor_launcher.launch() + + +@defer.inlineCallbacks +def connect_to_control_port(): + connection = TCP4ClientEndpoint(reactor, '127.0.0.1', + config.tor.control_port) + config.tor_state = yield build_tor_connection(connection)
tor-commits@lists.torproject.org