| 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(':', r'\:') |
|---|
| 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) |
|---|