From 5dc2ae243e21021f981c343eafb9ff51d9f39ce4 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Wed, 5 Mar 2025 14:31:53 +0000 Subject: [PATCH] util: refactor Tor-detection to be async - on my PC, with Tor Browser running (socks proxy on port 9150), detect_tor_socks_proxy took ~4.01 seconds - this was because we probed port 9050, 2 sec timeout, 9051, 2 sec timeout, 9150, ~few ms to succeed - instead we now probe all ports concurrently --- electrum/network.py | 21 +++++++++--------- electrum/util.py | 52 ++++++++++++++++++++++++++++++--------------- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/electrum/network.py b/electrum/network.py index 2c3d91a1b..22880bac3 100644 --- a/electrum/network.py +++ b/electrum/network.py @@ -177,7 +177,7 @@ def is_valid_host(ph: str): class ProxySettings: MODES = ['socks4', 'socks5'] - probe_thread = None + probe_fut = None def __init__(self): self.enabled = False @@ -255,20 +255,19 @@ class ProxySettings: @classmethod def probe_tor(cls, on_finished: Callable[[str | None, int | None], None]): - def detect_task(finished: Callable[[str | None, int | None], None]): - net_addr = detect_tor_socks_proxy() + async def detect_task(finished: Callable[[str | None, int | None], None]): + net_addr = await detect_tor_socks_proxy() if net_addr is None: finished('', -1) else: host = net_addr[0] port = net_addr[1] finished(host, port) - cls.probe_thread = None + cls.probe_fut = None - if cls.probe_thread: # don't spam threads + if cls.probe_fut: # one probe at a time return - cls.probe_thread = threading.Thread(target=detect_task, args=[on_finished], daemon=True) - cls.probe_thread.start() + cls.probe_fut = asyncio.run_coroutine_threadsafe(detect_task(on_finished), util.get_asyncio_loop()) def __eq__(self, other): return self.enabled == other.enabled \ @@ -721,9 +720,9 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): util.trigger_callback('proxy_set', self.proxy) def _detect_if_proxy_is_tor(self) -> None: - def tor_probe_task(p): + async def tor_probe_task(p): assert p is not None - is_tor = util.is_tor_socks_port(p.host, int(p.port)) + is_tor = await util.is_tor_socks_port(p.host, int(p.port)) if self.proxy == p: # is this the proxy we probed? if self.is_proxy_tor != is_tor: self.logger.info(f'Proxy is {"" if is_tor else "not "}TOR') @@ -732,8 +731,8 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): proxy = self.proxy if proxy and proxy.enabled and proxy.mode == 'socks5': - t = threading.Thread(target=tor_probe_task, args=(proxy,), daemon=True) - t.start() + # FIXME GC issues? do we need to store the Future? + asyncio.run_coroutine_threadsafe(tor_probe_task(proxy), self.asyncio_loop) @log_exceptions async def set_parameters(self, net_params: NetworkParameters): diff --git a/electrum/util.py b/electrum/util.py index 267f65e40..66188fae6 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -1547,33 +1547,51 @@ class NetworkJobOnDefaultServer(Logger, ABC): return s -def detect_tor_socks_proxy() -> Optional[Tuple[str, int]]: +async def detect_tor_socks_proxy() -> Optional[Tuple[str, int]]: # Probable ports for Tor to listen at candidates = [ ("127.0.0.1", 9050), ("127.0.0.1", 9051), ("127.0.0.1", 9150), ] - for net_addr in candidates: - if is_tor_socks_port(*net_addr): - return net_addr - return None + + proxy_addr = None + async def test_net_addr(net_addr): + is_tor = await is_tor_socks_port(*net_addr) + # set result, and cancel remaining probes + if is_tor: + nonlocal proxy_addr + proxy_addr = net_addr + await group.cancel_remaining() + + async with OldTaskGroup() as group: + for net_addr in candidates: + await group.spawn(test_net_addr(net_addr)) + return proxy_addr -def is_tor_socks_port(host: str, port: int) -> bool: +@log_exceptions +async def is_tor_socks_port(host: str, port: int) -> bool: + # mimic "tor-resolve 0.0.0.0". + # see https://github.com/spesmilo/electrum/issues/7317#issuecomment-1369281075 + # > this is a socks5 handshake, followed by a socks RESOLVE request as defined in + # > [tor's socks extension spec](https://github.com/torproject/torspec/blob/7116c9cdaba248aae07a3f1d0e15d9dd102f62c5/socks-extensions.txt#L63), + # > resolving 0.0.0.0, which being an IP, tor resolves itself without needing to ask a relay. + writer = None try: - with socket.create_connection((host, port), timeout=10) as s: - # mimic "tor-resolve 0.0.0.0". - # see https://github.com/spesmilo/electrum/issues/7317#issuecomment-1369281075 - # > this is a socks5 handshake, followed by a socks RESOLVE request as defined in - # > [tor's socks extension spec](https://github.com/torproject/torspec/blob/7116c9cdaba248aae07a3f1d0e15d9dd102f62c5/socks-extensions.txt#L63), - # > resolving 0.0.0.0, which being an IP, tor resolves itself without needing to ask a relay. - s.send(b'\x05\x01\x00\x05\xf0\x00\x03\x070.0.0.0\x00\x00') - if s.recv(1024) == b'\x05\x00\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00': + async with async_timeout(10): + reader, writer = await asyncio.open_connection(host, port) + writer.write(b'\x05\x01\x00\x05\xf0\x00\x03\x070.0.0.0\x00\x00') + await writer.drain() + data = await reader.read(1024) + if data == b'\x05\x00\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00': return True - except socket.error: - pass - return False + return False + except (OSError, asyncio.TimeoutError): + return False + finally: + if writer: + writer.close() AS_LIB_USER_I_WANT_TO_MANAGE_MY_OWN_ASYNCIO_LOOP = False # used by unit tests