1 | # -*- coding: utf-8 -*- |
---|
2 | |
---|
3 | from __future__ import annotations |
---|
4 | |
---|
5 | from typing import Any |
---|
6 | from typing_extensions import Literal |
---|
7 | |
---|
8 | import os |
---|
9 | |
---|
10 | from zope.interface import ( |
---|
11 | implementer, |
---|
12 | ) |
---|
13 | |
---|
14 | from twisted.internet.defer import inlineCallbacks, returnValue |
---|
15 | from twisted.internet.endpoints import clientFromString |
---|
16 | from twisted.internet.error import ConnectionRefusedError, ConnectError |
---|
17 | from twisted.application import service |
---|
18 | from twisted.python.usage import Options |
---|
19 | |
---|
20 | from ..listeners import ListenerConfig |
---|
21 | from ..interfaces import ( |
---|
22 | IAddressFamily, |
---|
23 | ) |
---|
24 | from ..node import _Config |
---|
25 | |
---|
26 | def create(reactor: Any, config: _Config) -> IAddressFamily: |
---|
27 | """ |
---|
28 | Create a new Provider service (this is an IService so must be |
---|
29 | hooked up to a parent or otherwise started). |
---|
30 | |
---|
31 | If foolscap.connections.i2p or txi2p are not installed, then |
---|
32 | Provider.get_i2p_handler() will return None. If 'tahoe.cfg' wants |
---|
33 | to start an I2P Destination too, then this `create()` method will |
---|
34 | throw a nice error (and startService will throw an ugly error). |
---|
35 | """ |
---|
36 | provider = _Provider(config, reactor) |
---|
37 | provider.check_dest_config() |
---|
38 | return provider |
---|
39 | |
---|
40 | |
---|
41 | def _import_i2p(): |
---|
42 | # this exists to be overridden by unit tests |
---|
43 | try: |
---|
44 | from foolscap.connections import i2p |
---|
45 | return i2p |
---|
46 | except ImportError: # pragma: no cover |
---|
47 | return None |
---|
48 | |
---|
49 | def _import_txi2p(): |
---|
50 | try: |
---|
51 | import txi2p |
---|
52 | return txi2p |
---|
53 | except ImportError: # pragma: no cover |
---|
54 | return None |
---|
55 | |
---|
56 | def is_available() -> bool: |
---|
57 | """ |
---|
58 | Can this type of listener actually be used in this runtime |
---|
59 | environment? |
---|
60 | |
---|
61 | If its dependencies are missing then it cannot be. |
---|
62 | """ |
---|
63 | return not (_import_i2p() is None or _import_txi2p() is None) |
---|
64 | |
---|
65 | def can_hide_ip() -> Literal[True]: |
---|
66 | """ |
---|
67 | Can the transport supported by this type of listener conceal the |
---|
68 | node's public internet address from peers? |
---|
69 | """ |
---|
70 | return True |
---|
71 | |
---|
72 | def _try_to_connect(reactor, endpoint_desc, stdout, txi2p): |
---|
73 | # yields True or None |
---|
74 | ep = clientFromString(reactor, endpoint_desc) |
---|
75 | d = txi2p.testAPI(reactor, 'SAM', ep) |
---|
76 | def _failed(f): |
---|
77 | # depending upon what's listening at that endpoint, we might get |
---|
78 | # various errors. If this list is too short, we might expose an |
---|
79 | # exception to the user (causing "tahoe create-node" to fail messily) |
---|
80 | # when we're supposed to just try the next potential port instead. |
---|
81 | # But I don't want to catch everything, because that may hide actual |
---|
82 | # coding errors. |
---|
83 | f.trap(ConnectionRefusedError, # nothing listening on TCP |
---|
84 | ConnectError, # missing unix socket, or permission denied |
---|
85 | #ValueError, |
---|
86 | # connecting to e.g. an HTTP server causes an |
---|
87 | # UnhandledException (around a ValueError) when the handshake |
---|
88 | # fails to parse, but that's not something we can catch. The |
---|
89 | # attempt hangs, so don't do that. |
---|
90 | RuntimeError, # authentication failure |
---|
91 | ) |
---|
92 | if stdout: |
---|
93 | stdout.write("Unable to reach I2P SAM API at '%s': %s\n" % |
---|
94 | (endpoint_desc, f.value)) |
---|
95 | return None |
---|
96 | d.addErrback(_failed) |
---|
97 | return d |
---|
98 | |
---|
99 | @inlineCallbacks |
---|
100 | def _connect_to_i2p(reactor, cli_config, txi2p): |
---|
101 | # we assume i2p is already running |
---|
102 | ports_to_try = ["tcp:127.0.0.1:7656"] |
---|
103 | if cli_config["i2p-sam-port"]: |
---|
104 | ports_to_try = [cli_config["i2p-sam-port"]] |
---|
105 | for port in ports_to_try: |
---|
106 | accessible = yield _try_to_connect(reactor, port, cli_config.stdout, |
---|
107 | txi2p) |
---|
108 | if accessible: |
---|
109 | returnValue(port) ; break # helps editor |
---|
110 | else: |
---|
111 | raise ValueError("unable to reach any default I2P SAM port") |
---|
112 | |
---|
113 | async def create_config(reactor: Any, cli_config: Options) -> ListenerConfig: |
---|
114 | """ |
---|
115 | For a given set of command-line options, construct an I2P listener. |
---|
116 | |
---|
117 | This includes allocating a new I2P address. |
---|
118 | """ |
---|
119 | txi2p = _import_txi2p() |
---|
120 | if not txi2p: |
---|
121 | raise ValueError("Cannot create I2P Destination without txi2p. " |
---|
122 | "Please 'pip install tahoe-lafs[i2p]' to fix this.") |
---|
123 | tahoe_config_i2p = [] # written into tahoe.cfg:[i2p] |
---|
124 | private_dir = os.path.abspath(os.path.join(cli_config["basedir"], "private")) |
---|
125 | # XXX We shouldn't carry stdout around by jamming it into the Options |
---|
126 | # value. See https://tahoe-lafs.org/trac/tahoe-lafs/ticket/4048 |
---|
127 | stdout = cli_config.stdout # type: ignore[attr-defined] |
---|
128 | if cli_config["i2p-launch"]: |
---|
129 | raise NotImplementedError("--i2p-launch is under development.") |
---|
130 | else: |
---|
131 | print("connecting to I2P (to allocate .i2p address)..", file=stdout) |
---|
132 | sam_port = await _connect_to_i2p(reactor, cli_config, txi2p) |
---|
133 | print("I2P connection established", file=stdout) |
---|
134 | tahoe_config_i2p.append(("sam.port", sam_port)) |
---|
135 | |
---|
136 | external_port = 3457 # TODO: pick this randomly? there's no contention. |
---|
137 | |
---|
138 | privkeyfile = os.path.join(private_dir, "i2p_dest.privkey") |
---|
139 | sam_endpoint = clientFromString(reactor, sam_port) |
---|
140 | print("allocating .i2p address...", file=stdout) |
---|
141 | dest = await txi2p.generateDestination(reactor, privkeyfile, 'SAM', sam_endpoint) |
---|
142 | print(".i2p address allocated", file=stdout) |
---|
143 | i2p_port = "listen:i2p" # means "see [i2p]", calls Provider.get_listener() |
---|
144 | i2p_location = "i2p:%s:%d" % (dest.host, external_port) |
---|
145 | |
---|
146 | # in addition to the "how to launch/connect-to i2p" keys above, we also |
---|
147 | # record information about the I2P service into tahoe.cfg. |
---|
148 | # * "port" is the random "public Destination port" (integer), which |
---|
149 | # (when combined with the .i2p address) should match "i2p_location" |
---|
150 | # (which will be added to tub.location) |
---|
151 | # * "private_key_file" points to the on-disk copy of the private key |
---|
152 | # material (although we always write it to the same place) |
---|
153 | |
---|
154 | tahoe_config_i2p.extend([ |
---|
155 | ("dest", "true"), |
---|
156 | ("dest.port", str(external_port)), |
---|
157 | ("dest.private_key_file", os.path.join("private", "i2p_dest.privkey")), |
---|
158 | ]) |
---|
159 | |
---|
160 | # tahoe_config_i2p: this is a dictionary of keys/values to add to the |
---|
161 | # "[i2p]" section of tahoe.cfg, which tells the new node how to launch |
---|
162 | # I2P in the right way. |
---|
163 | |
---|
164 | # i2p_port: a server endpoint string, it will be added to tub.port= |
---|
165 | |
---|
166 | # i2p_location: a foolscap connection hint, "i2p:B32_ADDR:PORT" |
---|
167 | |
---|
168 | # We assume/require that the Node gives us the same data_directory= |
---|
169 | # at both create-node and startup time. The data directory is not |
---|
170 | # recorded in tahoe.cfg |
---|
171 | |
---|
172 | return ListenerConfig([i2p_port], [i2p_location], {"i2p": tahoe_config_i2p}) |
---|
173 | |
---|
174 | |
---|
175 | @implementer(IAddressFamily) |
---|
176 | class _Provider(service.MultiService): |
---|
177 | def __init__(self, config, reactor): |
---|
178 | service.MultiService.__init__(self) |
---|
179 | self._config = config |
---|
180 | self._i2p = _import_i2p() |
---|
181 | self._txi2p = _import_txi2p() |
---|
182 | self._reactor = reactor |
---|
183 | |
---|
184 | def _get_i2p_config(self, *args, **kwargs): |
---|
185 | return self._config.get_config("i2p", *args, **kwargs) |
---|
186 | |
---|
187 | def get_listener(self): |
---|
188 | # this is relative to BASEDIR, and our cwd should be BASEDIR |
---|
189 | privkeyfile = self._get_i2p_config("dest.private_key_file") |
---|
190 | external_port = self._get_i2p_config("dest.port") |
---|
191 | sam_port = self._get_i2p_config("sam.port") |
---|
192 | escaped_sam_port = sam_port.replace(':', '\:') |
---|
193 | # for now, this returns a string, which then gets passed to |
---|
194 | # endpoints.serverFromString . But it can also return an Endpoint |
---|
195 | # directly, which means we don't need to encode all these options |
---|
196 | # into a string |
---|
197 | i2p_port = "i2p:%s:%s:api=SAM:apiEndpoint=%s" % \ |
---|
198 | (privkeyfile, external_port, escaped_sam_port) |
---|
199 | return i2p_port |
---|
200 | |
---|
201 | def get_client_endpoint(self): |
---|
202 | """ |
---|
203 | Get an ``IStreamClientEndpoint`` which will set up a connection to an I2P |
---|
204 | address. |
---|
205 | |
---|
206 | If I2P is not enabled or the dependencies are not available, return |
---|
207 | ``None`` instead. |
---|
208 | """ |
---|
209 | enabled = self._get_i2p_config("enabled", True, boolean=True) |
---|
210 | if not enabled: |
---|
211 | return None |
---|
212 | if not self._i2p: |
---|
213 | return None |
---|
214 | |
---|
215 | sam_port = self._get_i2p_config("sam.port", None) |
---|
216 | launch = self._get_i2p_config("launch", False, boolean=True) |
---|
217 | configdir = self._get_i2p_config("i2p.configdir", None) |
---|
218 | keyfile = self._get_i2p_config("dest.private_key_file", None) |
---|
219 | |
---|
220 | if sam_port: |
---|
221 | if launch: |
---|
222 | raise ValueError("tahoe.cfg [i2p] must not set both " |
---|
223 | "sam.port and launch") |
---|
224 | ep = clientFromString(self._reactor, sam_port) |
---|
225 | return self._i2p.sam_endpoint(ep, keyfile=keyfile) |
---|
226 | |
---|
227 | if launch: |
---|
228 | executable = self._get_i2p_config("i2p.executable", None) |
---|
229 | return self._i2p.launch(i2p_configdir=configdir, i2p_binary=executable) |
---|
230 | |
---|
231 | if configdir: |
---|
232 | return self._i2p.local_i2p(configdir) |
---|
233 | |
---|
234 | return self._i2p.default(self._reactor, keyfile=keyfile) |
---|
235 | |
---|
236 | # Backwards compatibility alias |
---|
237 | get_i2p_handler = get_client_endpoint |
---|
238 | |
---|
239 | def check_dest_config(self): |
---|
240 | if self._get_i2p_config("dest", False, boolean=True): |
---|
241 | if not self._txi2p: |
---|
242 | raise ValueError("Cannot create I2P Destination without txi2p. " |
---|
243 | "Please 'pip install tahoe-lafs[i2p]' to fix.") |
---|
244 | |
---|
245 | # to start an I2P server, we either need an I2P SAM port, or |
---|
246 | # we need to launch I2P |
---|
247 | sam_port = self._get_i2p_config("sam.port", None) |
---|
248 | launch = self._get_i2p_config("launch", False, boolean=True) |
---|
249 | configdir = self._get_i2p_config("i2p.configdir", None) |
---|
250 | if not sam_port and not launch and not configdir: |
---|
251 | raise ValueError("[i2p] dest = true, but we have neither " |
---|
252 | "sam.port= nor launch=true nor configdir=") |
---|
253 | if sam_port and launch: |
---|
254 | raise ValueError("tahoe.cfg [i2p] must not set both " |
---|
255 | "sam.port and launch") |
---|
256 | if launch: |
---|
257 | raise NotImplementedError("[i2p] launch is under development.") |
---|
258 | # check that all the expected Destination-specific keys are present |
---|
259 | def require(name): |
---|
260 | if not self._get_i2p_config("dest.%s" % name, None): |
---|
261 | raise ValueError("[i2p] dest = true," |
---|
262 | " but dest.%s= is missing" % name) |
---|
263 | require("port") |
---|
264 | require("private_key_file") |
---|
265 | |
---|
266 | def startService(self): |
---|
267 | service.MultiService.startService(self) |
---|
268 | # if we need to start I2P, now is the time |
---|
269 | # TODO: implement i2p launching |
---|
270 | |
---|
271 | @inlineCallbacks |
---|
272 | def stopService(self): |
---|
273 | # TODO: can we also stop i2p? |
---|
274 | yield service.MultiService.stopService(self) |
---|