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 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 02:28:34 +01:00
parent c191942712
commit 918d03cc58
2 changed files with 78 additions and 3 deletions

View File

@@ -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]

View File

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