1import os
2import sys
3
4import stem
5from stem.descriptor.hidden_service import HiddenServiceDescriptorV3
6
7import onionbalance.common.instance
8
9from onionbalance.common import log
10
11from onionbalance.common import util
12from onionbalance.hs_v3 import manager
13
14from onionbalance.hs_v3 import stem_controller
15from onionbalance.hs_v3 import service as ob_service
16from onionbalance.hs_v3 import consensus as ob_consensus
17
18logger = log.get_logger()
19
20
21class Onionbalance(object):
22    """
23    Onionbalance singleton that represents this onionbalance runtime.
24
25    Contains various objects that are useful to other onionbalance modules so
26    this is imported from all over the codebase.
27    """
28
29    def __init__(self):
30        # This is kept minimal so that it's quick (it's executed at program
31        # launch because of the onionbalance singleton). The actual init work
32        # happens in init_subsystems()
33
34        # True if this onionbalance operates in a testnet (e.g. chutney)
35        self.is_testnet = False
36
37    def init_subsystems(self, args):
38        """
39        Initialize subsystems (this is resource intensive)
40        """
41        self.args = args
42        self.config_path = os.path.abspath(self.args.config)
43        self.config_data = self.load_config_file()
44        self.is_testnet = args.is_testnet
45
46        if self.is_testnet:
47            logger.warning("Onionbalance configured on a testnet!")
48
49        # Create stem controller and connect to the Tor network
50        self.controller = stem_controller.StemController(address=args.ip, port=args.port, socket=args.socket)
51        self.consensus = ob_consensus.Consensus()
52
53        # Initialize our service
54        self.services = self.initialize_services_from_config_data()
55
56        # Catch interesting events (like receiving descriptors etc.)
57        self.controller.add_event_listeners()
58
59        logger.warning("Onionbalance initialized (stem version: %s) (tor version: %s)!",
60                       stem.__version__, self.controller.controller.get_version())
61        logger.warning("=" * 80)
62
63    def initialize_services_from_config_data(self):
64        services = []
65        try:
66            for service in self.config_data['services']:
67                services.append(ob_service.OnionBalanceService(service, self.config_path))
68        except ob_service.BadServiceInit:
69            sys.exit(1)
70
71        if len(services) > 1:
72            # We don't know how to handle more than a single service right now
73            raise NotImplementedError
74
75        return services
76
77    def load_config_file(self):
78        config_data = util.read_config_data_from_file(self.config_path)
79        logger.debug("Onionbalance config data: %s", config_data)
80
81        # Do some basic validation
82        if "services" not in config_data:
83            raise ConfigError("Config file is bad. 'services' is missing. Did you make it with onionbalance-config?")
84
85        # More validation
86        for service in config_data["services"]:
87            if "key" not in service:
88                raise ConfigError("Config file is bad. 'key' is missing. Did you make it with onionbalance-config?")
89
90            if "instances" not in service:
91                raise ConfigError("Config file is bad. 'instances' is missing. Did you make it with "
92                                  "onionbalance-config?")
93
94            if not service["instances"]:
95                raise ConfigError("Config file is bad. No backend instances are set. Onionbalance needs at least 1.")
96
97            for instance in service["instances"]:
98                if "address" not in instance:
99                    raise ConfigError("Config file is wrong. 'address' missing from instance.")
100
101                if not instance["address"]:
102                    raise ConfigError("Config file is bad. Address field is not set.")
103
104                # Validate that the onion address is legit
105                try:
106                    _ = HiddenServiceDescriptorV3.identity_key_from_address(instance["address"])
107                except ValueError:
108                    raise ConfigError("Cannot load instance with address: '{}'. If you are trying to run onionbalance "
109                                      "for v2 onions, please use the --hs-version=v2 switch".format(instance["address"]))
110
111        return config_data
112
113    def fetch_instance_descriptors(self):
114        logger.info("[*] fetch_instance_descriptors() called [*]")
115
116        # TODO: Don't do this here. Instead do it on a specialized function
117        self.controller.mark_tor_as_active()
118
119        if not self.consensus.is_live():
120            logger.warning("No live consensus. Waiting before fetching descriptors...")
121            return
122
123        all_instances = self._get_all_instances()
124
125        onionbalance.common.instance.helper_fetch_all_instance_descriptors(self.controller.controller,
126                                                                           all_instances)
127
128    def handle_new_desc_content_event(self, desc_content_event):
129        """
130        Parse HS_DESC_CONTENT response events for descriptor content
131
132        Update the HS instance object with the data from the new descriptor.
133        """
134        onion_address = desc_content_event.address
135        logger.debug("Received descriptor for %s.onion from %s",
136                     onion_address, desc_content_event.directory)
137
138        #  Check that the HSDir returned a descriptor that is not empty
139        descriptor_text = str(desc_content_event.descriptor).encode('utf-8')
140
141        # HSDirs provide a HS_DESC_CONTENT response with either one or two
142        # CRLF lines when they do not have a matching descriptor. Using
143        # len() < 5 should ensure all empty HS_DESC_CONTENT events are matched.
144        if len(descriptor_text) < 5:
145            logger.debug("Empty descriptor received for %s.onion", onion_address)
146            return None
147
148        # OK this descriptor seems plausible: Find the instances that this
149        # descriptor belongs to:
150        for instance in self._get_all_instances():
151            if instance.onion_address == onion_address:
152                instance.register_descriptor(descriptor_text, onion_address)
153
154    def publish_all_descriptors(self):
155        """
156        For each service attempt to publish all descriptors
157        """
158        logger.info("[*] publish_all_descriptors() called [*]")
159
160        if not self.consensus.is_live():
161            logger.info("No live consensus. Waiting before publishing descriptors...")
162            return
163
164        for service in self.services:
165            service.publish_descriptors()
166
167    def _get_all_instances(self):
168        """
169        Get all instances for all services
170        """
171        instances = []
172
173        for service in self.services:
174            instances.extend(service.instances)
175
176        return instances
177
178    def handle_new_status_event(self, status_event):
179        """
180        Parse Tor status events such as "STATUS_GENERAL"
181        """
182        # pylint: disable=no-member
183        if status_event.action == "CONSENSUS_ARRIVED":
184            logger.info("Received new consensus!")
185            self.consensus.refresh()
186            # Call all callbacks in case we just got a live consensus
187            my_onionbalance.publish_all_descriptors()
188            my_onionbalance.fetch_instance_descriptors()
189
190    def _address_is_instance(self, onion_address):
191        """
192        Return True if 'onion_address' is one of our instances.
193        """
194        for service in self.services:
195            for instance in service.instances:
196                if instance.has_onion_address(onion_address):
197                    return True
198        return False
199
200    def _address_is_frontend(self, onion_address):
201        for service in self.services:
202            if service.has_onion_address(onion_address):
203                return True
204        return False
205
206    def handle_new_desc_event(self, desc_event):
207        """
208        Parse HS_DESC response events
209        """
210        action = desc_event.action
211
212        if action == "RECEIVED":
213            pass  # We already log in HS_DESC_CONTENT so no need to do it here too
214        elif action == "UPLOADED":
215            logger.debug("Successfully uploaded descriptor for %s to %s", desc_event.address, desc_event.directory)
216        elif action == "FAILED":
217            if self._address_is_instance(desc_event.address):
218                logger.info("Descriptor fetch failed for instance %s from %s (%s)",
219                            desc_event.address, desc_event.directory, desc_event.reason)
220            elif self._address_is_frontend(desc_event.address):
221                logger.warning("Descriptor upload failed for frontend %s to %s (%s)",
222                               desc_event.address, desc_event.directory, desc_event.reason)
223            else:
224                logger.warning("Descriptor action failed for unknown service %s to %s (%s)",
225                               desc_event.address, desc_event.directory, desc_event.reason)
226        elif action == "REQUESTED":
227            logger.debug("Requested descriptor for %s from %s...", desc_event.address, desc_event.directory)
228        else:
229            pass
230
231    def reload_config(self):
232        """
233        Reload configuration and reset job scheduler
234        """
235
236        try:
237            self.init_subsystems(self.args)
238            manager.init_scheduler(self)
239        except ConfigError as err:
240            logger.error("%s", err)
241            sys.exit(1)
242
243
244class ConfigError(Exception):
245    pass
246
247
248my_onionbalance = Onionbalance()
249