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())