From 918d03cc580143ced3914fa41b64fedc37a34ab4 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 21 Feb 2026 02:28:34 +0100 Subject: [PATCH] feat: skip pool hops for .onion destinations Onion addresses require Tor to resolve, so pool proxies after Tor would break connectivity. Detect .onion targets and use the static chain only (Tor), skipping pool selection and retries. Co-Authored-By: Claude Opus 4.6 --- src/s5p/server.py | 10 ++++-- tests/test_integration.py | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/s5p/server.py b/src/s5p/server.py index 5fb152e..11ce961 100644 --- a/src/s5p/server.py +++ b/src/s5p/server.py @@ -151,13 +151,17 @@ async def _handle_client( target_host, target_port = await read_socks5_address(client_reader) logger.info("[%s] connect %s:%d", tag, target_host, target_port) - # -- bypass check -- + # -- bypass / onion check -- bypass = bool(listener.bypass and _bypass_match(listener.bypass, target_host)) + onion = target_host.endswith(".onion") + skip_pool = bypass or onion if bypass: logger.debug("[%s] bypass %s:%d", tag, target_host, target_port) + elif onion: + logger.debug("[%s] onion %s:%d (chain only)", tag, target_host, target_port) # -- build chain (with retry) -- - attempts = retries if pool_seq and not bypass else 1 + attempts = retries if pool_seq and not skip_pool else 1 last_err: Exception | None = None for attempt in range(attempts): @@ -174,7 +178,7 @@ async def _handle_client( fhp = hop_pools.get((node.host, node.port)) pool_hops: list[tuple[ChainHop, ProxyPool]] = [] - if pool_seq and not bypass: + if pool_seq and not skip_pool: for candidates in pool_seq: weights = [max(pp.alive_count, 1) for pp in candidates] pp = random.choices(candidates, weights=weights)[0] diff --git a/tests/test_integration.py b/tests/test_integration.py index 23a757f..084c80c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -283,3 +283,74 @@ class TestBypassDirectConnect: await _close_server(s) asyncio.run(_run()) + + +class TestOnionChainOnly: + """Onion target uses static chain only, pool hops skipped.""" + + def test_onion_skips_pool(self): + async def _run(): + servers = [] + try: + # mock socks5 acts as the "Tor" hop + mock_host, mock_port, mock_srv = await start_mock_socks5() + servers.append(mock_srv) + + # fake pool that would add a dead hop if called + from unittest.mock import AsyncMock, MagicMock + + dead_port = free_port() + fake_pool = MagicMock() + fake_pool.alive_count = 1 + fake_pool.get = AsyncMock( + return_value=ChainHop( + proto="socks5", host="127.0.0.1", port=dead_port, + ), + ) + + listener = ListenerConfig( + listen_host="127.0.0.1", + listen_port=free_port(), + chain=[ChainHop(proto="socks5", host=mock_host, port=mock_port)], + pool_seq=[["default"]], + ) + s5p_srv = await asyncio.start_server( + lambda r, w: _handle_client( + r, w, listener, timeout=5.0, retries=1, + pool_seq=[[fake_pool]], + ), + listener.listen_host, listener.listen_port, + ) + servers.append(s5p_srv) + await s5p_srv.start_serving() + + # connect with .onion target -- mock socks5 will fail to + # resolve it, but the key assertion is pool.get NOT called + reader, writer = await asyncio.open_connection( + listener.listen_host, listener.listen_port, + ) + writer.write(b"\x05\x01\x00") + await writer.drain() + await reader.readexactly(2) + + atyp, addr_bytes = encode_address("fake.onion") + writer.write( + struct.pack("!BBB", 0x05, 0x01, 0x00) + + bytes([atyp]) + + addr_bytes + + struct.pack("!H", 80) + ) + await writer.drain() + await asyncio.wait_for(reader.read(4096), timeout=3.0) + + writer.close() + await writer.wait_closed() + + # pool.get must NOT have been called (onion skips pool) + fake_pool.get.assert_not_called() + + finally: + for s in servers: + await _close_server(s) + + asyncio.run(_run())