commit 1294934aa8b2d0bf6bf25ed081ddc54537fad939 Author: Patrick O'Doherty p@trickod.com Date: Mon Oct 27 19:56:04 2014 -0700
Better support for multiple hidden services
Adds the following Controller methods for manipulating hidden services. * get_hidden_services_conf * set_hidden_services_conf * create_new_hidden_service * delete_hidden_service
Fixes issue #12533 where creating new hidden service would clobber the configuration for existing services --- stem/control.py | 174 ++++++++++++++++++++++++++++++++++++++ test/integ/control/controller.py | 73 ++++++++++++++++ 2 files changed, 247 insertions(+)
diff --git a/stem/control.py b/stem/control.py index ed83a00..1282eb3 100644 --- a/stem/control.py +++ b/stem/control.py @@ -224,6 +224,12 @@ import StringIO import threading import time
+try: + # Added in 2.7 + from collections import OrderedDict +except ImportError: + from stem.util.ordereddict import OrderedDict + import stem.descriptor.microdescriptor import stem.descriptor.reader import stem.descriptor.router_status_entry @@ -1888,6 +1894,174 @@ class Controller(BaseController): else: raise exc
+ def get_hidden_services_conf(self, default = UNDEFINED): + """ + This provides a mapping of hidden service directories to their + attribute's key/value pairs. + + { + "/var/lib/tor/hidden_service_empty/": { + "HiddenServicePort": [ + ] + }, + "/var/lib/tor/hidden_service_with_two_ports/": { + "HiddenServiceAuthorizeClient": "stealth a, b", + "HiddenServicePort": [ + "8020 127.0.0.1:8020", # the ports order is kept + "8021 127.0.0.1:8021" + ], + "HiddenServiceVersion": "2" + }, + } + + :raises: + * :class:`stem.ControllerError` if the call fails and we weren't provided + a default response + """ + start_time = time.time() + + try: + response = self.msg('GETCONF HiddenServiceOptions') + stem.response.convert('GETCONF', response) + log.debug('GETCONF HiddenServiceOptions (runtime: %0.4f)' % + (time.time() - start_time)) + except stem.ControllerError as exc: + log.debug('GETCONF HiddenServiceOptions (failed: %s)' % exc) + if default != UNDEFINED: + return default + else: + raise exc + + service_dir_map = OrderedDict() + directory = None + + for status_code, divider, content in response.content(): + if content == 'HiddenServiceOptions': + continue + + if not "=" in content: + continue + + k, v = content.split('=', 1) + + if k == 'HiddenServiceDir': + directory = v + service_dir_map[directory] = {'HiddenServicePort': []} + + elif k == 'HiddenServicePort': + service_dir_map[directory]['HiddenServicePort'].append(v) + + else: + service_dir_map[directory][k] = v + + return service_dir_map + + def set_hidden_services_conf(self, conf): + """Update all the configured hidden services from a dictionary having + the same format as the output of get_hidden_services_conf() + + :param dict conf: configuration dictionary + + :raises: + * :class:`stem.ControllerError` if the call fails + * :class:`stem.InvalidArguments` if configuration options + requested was invalid + * :class:`stem.InvalidRequest` if the configuration setting is + impossible or if there's a syntax error in the configuration values + * :raises: + """ + + # Convert conf dictionary into a list of ordered config tuples + hidden_service_options = [] + for directory in conf: + hidden_service_options.append(('HiddenServiceDir', directory)) + for k, v in conf[directory].iteritems(): + if k == 'HiddenServicePort': + for port in v: + hidden_service_options.append(('HiddenServicePort', port)) + else: + hidden_service_options.append((k, str(v))) + self.set_options(hidden_service_options) + + def create_new_hidden_service(self, dirname, virtport, target=None): + """Create a new hidden service+port. If the directory is already present, a + new port will be added. If the port is already present, return False. + + :param str dirname: directory name + :param int virtport: virtual port + :param str target: optional ipaddr:port target e.g. '127.0.0.1:8080' + :returns: False if the hidden service and port is already in place + True if the creation is successful + """ + if stem.util.connection.is_valid_port(virtport): + virtport = int(virtport) + + else: + raise ValueError("%s isn't a valid port number" % virtport) + + conf = self.get_hidden_services_conf() + + if dirname in conf: + ports = conf[dirname]['HiddenServicePort'] + if target is None: + if str(virtport) in ports: + return False + + if "%d 127.0.0.1:%d" % (virtport, virtport) in ports: + return False + + elif "%d %s" % (virtport, target) in ports: + return False + + else: + conf[dirname] = {'HiddenServicePort': []} + + if target is None: + conf[dirname]['HiddenServicePort'].append("%d" % virtport) + + else: + conf[dirname]['HiddenServicePort'].append("%d %s" % (virtport, target)) + + self.set_hidden_services_conf(conf) + return True + + def delete_hidden_service(self, dirname, virtport, target=None): + """Delete a hidden service+port. + :param str dirname: directory name + :param int virtport: virtual port + :param str target: optional ipaddr:port target e.g. '127.0.0.1:8080' + :raises: + """ + if stem.util.connection.is_valid_port(virtport): + virtport = int(virtport) + + else: + raise ValueError("%s isn't a valid port number" % virtport) + + conf = self.get_hidden_services_conf() + + if dirname not in conf: + raise RuntimeError("HiddenServiceDir %r not found" % dirname) + + ports = conf[dirname]['HiddenServicePort'] + + if target is None: + longport = "%d 127.0.0.1:%d" % (virtport, virtport) + try: + ports.pop(ports.index(str(virtport))) + except ValueError: + raise stem.InvalidArguments + + else: + longport = "%d %s" % (virtport, target) + ports.pop(ports.index(longport)) + + if not ports: + del(conf[dirname]) + + self.set_hidden_services_conf(conf) + return True + def _get_conf_dict_to_response(self, config_dict, default, multiple): """ Translates a dictionary of 'config key => [value1, value2...]' into the diff --git a/test/integ/control/controller.py b/test/integ/control/controller.py index 05ce3f7..4935043 100644 --- a/test/integ/control/controller.py +++ b/test/integ/control/controller.py @@ -455,6 +455,79 @@ class TestController(unittest.TestCase): self.assertEqual({}, controller.get_conf_map('', 'la-di-dah')) self.assertEqual({}, controller.get_conf_map([], 'la-di-dah'))
+ def test_hidden_services_conf(self): + """ + Exercises get_hidden_services_conf with valid and invalid queries. + """ + + if test.runner.require_control(self): + return + + runner = test.runner.get_runner() + + with runner.get_tor_controller() as controller: + + conf = controller.get_hidden_services_conf() + self.assertDictEqual({}, conf) + controller.set_hidden_services_conf(conf) + + initialconf = { + "test_hidden_service1/": { + "HiddenServicePort": [ + "8020 127.0.0.1:8020", + "8021 127.0.0.1:8021" + ], + "HiddenServiceVersion": "2", + }, + "test_hidden_service2/": { + "HiddenServiceAuthorizeClient": "stealth a, b", + "HiddenServicePort": [ + "8030 127.0.0.1:8030", + "8031 127.0.0.1:8031", + "8032 127.0.0.1:8032" + ] + }, + "test_hidden_service_empty/": { + "HiddenServicePort": [] + } + } + controller.set_hidden_services_conf(initialconf) + + conf = controller.get_hidden_services_conf() + self.assertDictEqual(initialconf, dict(conf)) + + # Add already existing services, with/without explicit target + r = controller.create_new_hidden_service('test_hidden_service1/', 8020) + self.assertFalse(r) + r = controller.create_new_hidden_service('test_hidden_service1/', 8021, target="127.0.0.1:8021") + self.assertFalse(r) + + # Add new services, with/without explicit target + r = controller.create_new_hidden_service('test_hidden_serviceX/', 8888) + self.assertTrue(r) + r = controller.create_new_hidden_service('test_hidden_serviceX/', 8989, target="127.0.0.1:8021") + self.assertTrue(r) + + conf = controller.get_hidden_services_conf() + self.assertEqual(len(conf), 4) + ports = conf['test_hidden_serviceX/']['HiddenServicePort'] + self.assertEqual(len(ports), 2) + + # Delete services + controller.delete_hidden_service('test_hidden_serviceX/', 8888) + + # The service dir should be still there + conf = controller.get_hidden_services_conf() + self.assertEqual(len(conf), 4) + + # Delete service + controller.delete_hidden_service('test_hidden_serviceX/', 8989, target="127.0.0.1:8021") + + # The service dir should be gone + conf = controller.get_hidden_services_conf() + self.assertEqual(len(conf), 3) + + def test_set_conf(self): """ Exercises set_conf(), reset_conf(), and set_options() methods with valid