source: trunk/src/allmydata/util/tor_provider.py

Last change on this file was 57facc6, checked in by Jean-Paul Calderone <exarkun@…>, at 2023-07-20T18:19:12Z

narrow the type of cli_config a bit

This has unfortunate interactions with the "stdout" attribute but I'm punting
on that.

  • Property mode set to 100644
File size: 15.0 KB
Line 
1# -*- coding: utf-8 -*-
2from __future__ import annotations
3
4from typing import Any
5from typing_extensions import Literal
6import os
7
8from zope.interface import (
9    implementer,
10)
11
12from twisted.internet.defer import inlineCallbacks, returnValue
13from twisted.internet.endpoints import clientFromString, TCP4ServerEndpoint
14from twisted.internet.error import ConnectionRefusedError, ConnectError
15from twisted.application import service
16from twisted.python.usage import Options
17
18from .observer import OneShotObserverList
19from .iputil import allocate_tcp_port
20from ..interfaces import (
21    IAddressFamily,
22)
23from ..listeners import ListenerConfig
24
25
26def _import_tor():
27    try:
28        from foolscap.connections import tor
29        return tor
30    except ImportError: # pragma: no cover
31        return None
32
33def _import_txtorcon():
34    try:
35        import txtorcon
36        return txtorcon
37    except ImportError: # pragma: no cover
38        return None
39
40def can_hide_ip() -> Literal[True]:
41    return True
42
43def is_available() -> bool:
44    return not (_import_tor() is None or _import_txtorcon() is None)
45
46def create(reactor, config, import_tor=None, import_txtorcon=None) -> _Provider:
47    """
48    Create a new _Provider service (this is an IService so must be
49    hooked up to a parent or otherwise started).
50
51    If foolscap.connections.tor or txtorcon are not installed, then
52    Provider.get_tor_handler() will return None.  If tahoe.cfg wants
53    to start an onion service too, then this `create()` method will
54    throw a nice error (and startService will throw an ugly error).
55    """
56    if import_tor is None:
57        import_tor = _import_tor
58    if import_txtorcon is None:
59        import_txtorcon = _import_txtorcon
60    provider = _Provider(config, reactor, import_tor(), import_txtorcon())
61    provider.check_onion_config()
62    return provider
63
64
65def data_directory(private_dir):
66    return os.path.join(private_dir, "tor-statedir")
67
68# different ways we might approach this:
69
70# 1: get an ITorControlProtocol, make a
71# txtorcon.EphemeralHiddenService(ports), yield ehs.add_to_tor(tcp), store
72# ehs.hostname and ehs.private_key, yield ehs.remove_from_tor(tcp)
73
74def _try_to_connect(reactor, endpoint_desc, stdout, txtorcon):
75    # yields a TorState, or None
76    ep = clientFromString(reactor, endpoint_desc)
77    d = txtorcon.build_tor_connection(ep)
78    def _failed(f):
79        # depending upon what's listening at that endpoint, we might get
80        # various errors. If this list is too short, we might expose an
81        # exception to the user (causing "tahoe create-node" to fail messily)
82        # when we're supposed to just try the next potential port instead.
83        # But I don't want to catch everything, because that may hide actual
84        # coding errrors.
85        f.trap(ConnectionRefusedError, # nothing listening on TCP
86               ConnectError, # missing unix socket, or permission denied
87               #ValueError,
88               # connecting to e.g. an HTTP server causes an
89               # UnhandledException (around a ValueError) when the handshake
90               # fails to parse, but that's not something we can catch. The
91               # attempt hangs, so don't do that.
92               RuntimeError, # authentication failure
93               )
94        if stdout:
95            stdout.write("Unable to reach Tor at '%s': %s\n" %
96                         (endpoint_desc, f.value))
97        return None
98    d.addErrback(_failed)
99    return d
100
101@inlineCallbacks
102def _launch_tor(reactor, tor_executable, private_dir, txtorcon):
103    """
104    Launches Tor, returns a corresponding ``(control endpoint string,
105    txtorcon.Tor instance)`` tuple.
106    """
107    # TODO: handle default tor-executable
108    # TODO: it might be a good idea to find exactly which Tor we used,
109    # and record it's absolute path into tahoe.cfg . This would protect
110    # us against one Tor being on $PATH at create-node time, but then a
111    # different Tor being present at node startup. OTOH, maybe we don't
112    # need to worry about it.
113
114    # unix-domain control socket
115    tor_control_endpoint_desc = "unix:" + os.path.join(private_dir, "tor.control")
116
117    tor = yield txtorcon.launch(
118        reactor,
119        control_port=tor_control_endpoint_desc,
120        data_directory=data_directory(private_dir),
121        tor_binary=tor_executable,
122        socks_port=allocate_tcp_port(),
123        # can be useful when debugging; mirror Tor's output to ours
124        # stdout=sys.stdout,
125        # stderr=sys.stderr,
126    )
127
128    # How/when to shut down the new process? for normal usage, the child
129    # tor will exit when it notices its parent (us) quit. Unit tests will
130    # mock out txtorcon.launch_tor(), so there will never be a real Tor
131    # process. So I guess we don't need to track the process.
132
133    # If we do want to do anything with it, we can call tpp.quit()
134    # (because it's a TorProcessProtocol) which returns a Deferred
135    # that fires when Tor has actually exited.
136
137    returnValue((tor_control_endpoint_desc, tor))
138
139
140@inlineCallbacks
141def _connect_to_tor(reactor, cli_config, txtorcon):
142    # we assume tor is already running
143    ports_to_try = ["unix:/var/run/tor/control",
144                    "tcp:127.0.0.1:9051",
145                    "tcp:127.0.0.1:9151", # TorBrowserBundle
146                    ]
147    if cli_config["tor-control-port"]:
148        ports_to_try = [cli_config["tor-control-port"]]
149    for port in ports_to_try:
150        tor_state = yield _try_to_connect(reactor, port, cli_config.stdout,
151                                          txtorcon)
152        if tor_state:
153            tor_control_proto = tor_state.protocol
154            returnValue((port, tor_control_proto)) ; break # helps editor
155    else:
156        raise ValueError("unable to reach any default Tor control port")
157
158async def create_config(reactor: Any, cli_config: Options) -> ListenerConfig:
159    txtorcon = _import_txtorcon()
160    if not txtorcon:
161        raise ValueError("Cannot create onion without txtorcon. "
162                         "Please 'pip install tahoe-lafs[tor]' to fix this.")
163    tahoe_config_tor = [] # written into tahoe.cfg:[tor]
164    private_dir = os.path.abspath(os.path.join(cli_config["basedir"], "private"))
165    # XXX We shouldn't carry stdout around by jamming it into the Options
166    # value.  See https://tahoe-lafs.org/trac/tahoe-lafs/ticket/4048
167    stdout = cli_config.stdout # type: ignore[attr-defined]
168    if cli_config["tor-launch"]:
169        tahoe_config_tor.append(("launch", "true"))
170        tor_executable = cli_config["tor-executable"]
171        if tor_executable:
172            tahoe_config_tor.append(("tor.executable", tor_executable))
173        print("launching Tor (to allocate .onion address)..", file=stdout)
174        (_, tor) = await _launch_tor(
175            reactor, tor_executable, private_dir, txtorcon)
176        tor_control_proto = tor.protocol
177        print("Tor launched", file=stdout)
178    else:
179        print("connecting to Tor (to allocate .onion address)..", file=stdout)
180        (port, tor_control_proto) = await _connect_to_tor(
181            reactor, cli_config, txtorcon)
182        print("Tor connection established", file=stdout)
183        tahoe_config_tor.append(("control.port", port))
184
185    external_port = 3457 # TODO: pick this randomly? there's no contention.
186
187    local_port = allocate_tcp_port()
188    ehs = txtorcon.EphemeralHiddenService(
189        "%d 127.0.0.1:%d" % (external_port, local_port)
190    )
191    print("allocating .onion address (takes ~40s)..", file=stdout)
192    await ehs.add_to_tor(tor_control_proto)
193    print(".onion address allocated", file=stdout)
194    tor_port = "tcp:%d:interface=127.0.0.1" % local_port
195    tor_location = "tor:%s:%d" % (ehs.hostname, external_port)
196    privkey = ehs.private_key
197    await ehs.remove_from_tor(tor_control_proto)
198
199    # in addition to the "how to launch/connect-to tor" keys above, we also
200    # record information about the onion service into tahoe.cfg.
201    # * "local_port" is a server endpont string, which should match
202    #   "tor_port" (which will be added to tahoe.cfg [node] tub.port)
203    # * "external_port" is the random "public onion port" (integer), which
204    #   (when combined with the .onion address) should match "tor_location"
205    #   (which will be added to tub.location)
206    # * "private_key_file" points to the on-disk copy of the private key
207    #   material (although we always write it to the same place)
208
209    tahoe_config_tor.extend([
210        ("onion", "true"),
211        ("onion.local_port", str(local_port)),
212        ("onion.external_port", str(external_port)),
213        ("onion.private_key_file", os.path.join("private", "tor_onion.privkey")),
214    ])
215    privkeyfile = os.path.join(private_dir, "tor_onion.privkey")
216    with open(privkeyfile, "wb") as f:
217        if isinstance(privkey, str):
218            privkey = privkey.encode("ascii")
219        f.write(privkey)
220
221    # tahoe_config_tor: this is a dictionary of keys/values to add to the
222    # "[tor]" section of tahoe.cfg, which tells the new node how to launch
223    # Tor in the right way.
224
225    # tor_port: a server endpoint string, it will be added to tub.port=
226
227    # tor_location: a foolscap connection hint, "tor:ONION:EXTERNAL_PORT"
228
229    # We assume/require that the Node gives us the same data_directory=
230    # at both create-node and startup time. The data directory is not
231    # recorded in tahoe.cfg
232
233    return ListenerConfig(
234        [tor_port],
235        [tor_location],
236        {"tor": tahoe_config_tor},
237    )
238
239
240@implementer(IAddressFamily)
241class _Provider(service.MultiService):
242    def __init__(self, config, reactor, tor, txtorcon):
243        service.MultiService.__init__(self)
244        self._config = config
245        self._tor_launched = None
246        self._onion_ehs = None
247        self._onion_tor_control_proto = None
248        self._tor = tor
249        self._txtorcon = txtorcon
250        self._reactor = reactor
251
252    def _get_tor_config(self, *args, **kwargs):
253        return self._config.get_config("tor", *args, **kwargs)
254
255    def get_listener(self):
256        local_port = int(self._get_tor_config("onion.local_port"))
257        ep = TCP4ServerEndpoint(self._reactor, local_port, interface="127.0.0.1")
258        return ep
259
260    def get_client_endpoint(self):
261        """
262        Get an ``IStreamClientEndpoint`` which will set up a connection using Tor.
263
264        If Tor is not enabled or the dependencies are not available, return
265        ``None`` instead.
266        """
267        enabled = self._get_tor_config("enabled", True, boolean=True)
268        if not enabled:
269            return None
270        if not self._tor:
271            return None
272
273        if self._get_tor_config("launch", False, boolean=True):
274            if not self._txtorcon:
275                return None
276            return self._tor.control_endpoint_maker(self._make_control_endpoint,
277                                                    takes_status=True)
278
279        socks_endpoint_desc = self._get_tor_config("socks.port", None)
280        if socks_endpoint_desc:
281            socks_ep = clientFromString(self._reactor, socks_endpoint_desc)
282            return self._tor.socks_endpoint(socks_ep)
283
284        controlport = self._get_tor_config("control.port", None)
285        if controlport:
286            ep = clientFromString(self._reactor, controlport)
287            return self._tor.control_endpoint(ep)
288
289        return self._tor.default_socks()
290
291    # Backwards compatibility alias
292    get_tor_handler = get_client_endpoint
293
294    @inlineCallbacks
295    def _make_control_endpoint(self, reactor, update_status):
296        # this will only be called when tahoe.cfg has "[tor] launch = true"
297        update_status("launching Tor")
298        with self._tor.add_context(update_status, "launching Tor"):
299            (endpoint_desc, _) = yield self._get_launched_tor(reactor)
300        tor_control_endpoint = clientFromString(reactor, endpoint_desc)
301        returnValue(tor_control_endpoint)
302
303    def _get_launched_tor(self, reactor):
304        # this fires with a tuple of (control_endpoint, txtorcon.Tor instance)
305        if not self._tor_launched:
306            self._tor_launched = OneShotObserverList()
307            private_dir = self._config.get_config_path("private")
308            tor_binary = self._get_tor_config("tor.executable", None)
309            d = _launch_tor(reactor, tor_binary, private_dir, self._txtorcon)
310            d.addBoth(self._tor_launched.fire)
311        return self._tor_launched.when_fired()
312
313    def check_onion_config(self):
314        if self._get_tor_config("onion", False, boolean=True):
315            if not self._txtorcon:
316                raise ValueError("Cannot create onion without txtorcon. "
317                                 "Please 'pip install tahoe-lafs[tor]' to fix.")
318
319            # to start an onion server, we either need a Tor control port, or
320            # we need to launch tor
321            launch = self._get_tor_config("launch", False, boolean=True)
322            controlport = self._get_tor_config("control.port", None)
323            if not launch and not controlport:
324                raise ValueError("[tor] onion = true, but we have neither "
325                                 "launch=true nor control.port=")
326            # check that all the expected onion-specific keys are present
327            def require(name):
328                if not self._get_tor_config("onion.%s" % name, None):
329                    raise ValueError("[tor] onion = true,"
330                                     " but onion.%s= is missing" % name)
331            require("local_port")
332            require("external_port")
333            require("private_key_file")
334
335    def get_tor_instance(self, reactor: object):
336        """Return a ``Deferred`` that fires with a ``txtorcon.Tor`` instance."""
337        # launch tor, if necessary
338        if self._get_tor_config("launch", False, boolean=True):
339            return self._get_launched_tor(reactor).addCallback(lambda t: t[1])
340        else:
341            controlport = self._get_tor_config("control.port", None)
342            tcep = clientFromString(reactor, controlport)
343            return self._txtorcon.connect(reactor, tcep)
344
345    @inlineCallbacks
346    def _start_onion(self, reactor):
347        tor_instance = yield self.get_tor_instance(reactor)
348        tor_control_proto = tor_instance.protocol
349        local_port = int(self._get_tor_config("onion.local_port"))
350        external_port = int(self._get_tor_config("onion.external_port"))
351
352        fn = self._get_tor_config("onion.private_key_file")
353        privkeyfile = self._config.get_config_path(fn)
354        with open(privkeyfile, "rb") as f:
355            privkey = f.read()
356        ehs = self._txtorcon.EphemeralHiddenService(
357            "%d 127.0.0.1:%d" % (external_port, local_port), privkey)
358        yield ehs.add_to_tor(tor_control_proto)
359        self._onion_ehs = ehs
360        self._onion_tor_control_proto = tor_control_proto
361
362
363    def startService(self):
364        service.MultiService.startService(self)
365        # if we need to start an onion service, now is the time
366        if self._get_tor_config("onion", False, boolean=True):
367            return self._start_onion(self._reactor) # so tests can synchronize
368
369    @inlineCallbacks
370    def stopService(self):
371        if self._onion_ehs and self._onion_tor_control_proto:
372            yield self._onion_ehs.remove_from_tor(self._onion_tor_control_proto)
373        # TODO: can we also stop tor?
374        yield service.MultiService.stopService(self)
Note: See TracBrowser for help on using the repository browser.