1 | # -*- coding: utf-8 -*- |
---|
2 | from __future__ import annotations |
---|
3 | |
---|
4 | from typing import Any |
---|
5 | from typing_extensions import Literal |
---|
6 | import os |
---|
7 | |
---|
8 | from zope.interface import ( |
---|
9 | implementer, |
---|
10 | ) |
---|
11 | |
---|
12 | from twisted.internet.defer import inlineCallbacks, returnValue |
---|
13 | from twisted.internet.endpoints import clientFromString, TCP4ServerEndpoint |
---|
14 | from twisted.internet.error import ConnectionRefusedError, ConnectError |
---|
15 | from twisted.application import service |
---|
16 | from twisted.python.usage import Options |
---|
17 | |
---|
18 | from .observer import OneShotObserverList |
---|
19 | from .iputil import allocate_tcp_port |
---|
20 | from ..interfaces import ( |
---|
21 | IAddressFamily, |
---|
22 | ) |
---|
23 | from ..listeners import ListenerConfig |
---|
24 | |
---|
25 | |
---|
26 | def _import_tor(): |
---|
27 | try: |
---|
28 | from foolscap.connections import tor |
---|
29 | return tor |
---|
30 | except ImportError: # pragma: no cover |
---|
31 | return None |
---|
32 | |
---|
33 | def _import_txtorcon(): |
---|
34 | try: |
---|
35 | import txtorcon |
---|
36 | return txtorcon |
---|
37 | except ImportError: # pragma: no cover |
---|
38 | return None |
---|
39 | |
---|
40 | def can_hide_ip() -> Literal[True]: |
---|
41 | return True |
---|
42 | |
---|
43 | def is_available() -> bool: |
---|
44 | return not (_import_tor() is None or _import_txtorcon() is None) |
---|
45 | |
---|
46 | def 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 | |
---|
65 | def 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 | |
---|
74 | def _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 |
---|
102 | def _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 |
---|
141 | def _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 | |
---|
158 | async 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) |
---|
241 | class _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) |
---|