feat: multi-Tor round-robin via tor_nodes config
New top-level tor_nodes list distributes traffic across multiple Tor SOCKS proxies. First hop is replaced at connection time by round-robin selection; health tests also rotate across all nodes. FirstHopPools are created for each node when pool_size > 0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -255,6 +255,51 @@ class TestHandleConfig:
|
||||
assert body["proxy_pool"]["sources"][0]["url"] == "http://api:8081/proxies"
|
||||
assert body["listeners"][0]["pool_hops"] == 1
|
||||
|
||||
def test_with_tor_nodes(self):
|
||||
config = Config(
|
||||
tor_nodes=[
|
||||
ChainHop("socks5", "10.200.1.1", 9050),
|
||||
ChainHop("socks5", "10.200.1.13", 9050),
|
||||
],
|
||||
listeners=[ListenerConfig()],
|
||||
)
|
||||
ctx = _make_ctx(config=config)
|
||||
_, body = _handle_config(ctx)
|
||||
assert body["tor_nodes"] == [
|
||||
"socks5://10.200.1.1:9050",
|
||||
"socks5://10.200.1.13:9050",
|
||||
]
|
||||
|
||||
def test_no_tor_nodes(self):
|
||||
config = Config(listeners=[ListenerConfig()])
|
||||
ctx = _make_ctx(config=config)
|
||||
_, body = _handle_config(ctx)
|
||||
assert "tor_nodes" not in body
|
||||
|
||||
|
||||
class TestHandleStatusTorNodes:
|
||||
"""Test tor_nodes in GET /status response."""
|
||||
|
||||
def test_tor_nodes_in_status(self):
|
||||
config = Config(
|
||||
tor_nodes=[
|
||||
ChainHop("socks5", "10.200.1.1", 9050),
|
||||
ChainHop("socks5", "10.200.1.13", 9050),
|
||||
],
|
||||
listeners=[ListenerConfig()],
|
||||
)
|
||||
ctx = _make_ctx(config=config)
|
||||
_, body = _handle_status(ctx)
|
||||
assert body["tor_nodes"] == [
|
||||
"socks5://10.200.1.1:9050",
|
||||
"socks5://10.200.1.13:9050",
|
||||
]
|
||||
|
||||
def test_no_tor_nodes_in_status(self):
|
||||
ctx = _make_ctx()
|
||||
_, body = _handle_status(ctx)
|
||||
assert "tor_nodes" not in body
|
||||
|
||||
|
||||
# -- routing -----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ class TestConfig:
|
||||
assert c.max_connections == 256
|
||||
assert c.pool_size == 0
|
||||
assert c.pool_max_idle == 30.0
|
||||
assert c.tor_nodes == []
|
||||
|
||||
def test_max_connections_from_yaml(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
@@ -221,6 +222,31 @@ class TestConfig:
|
||||
]
|
||||
|
||||
|
||||
class TestTorNodes:
|
||||
"""Test tor_nodes config parsing."""
|
||||
|
||||
def test_tor_nodes_from_yaml(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"tor_nodes:\n"
|
||||
" - socks5://10.200.1.1:9050\n"
|
||||
" - socks5://10.200.1.254:9050\n"
|
||||
" - socks5://10.200.1.13:9050\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert len(c.tor_nodes) == 3
|
||||
assert c.tor_nodes[0].host == "10.200.1.1"
|
||||
assert c.tor_nodes[0].port == 9050
|
||||
assert c.tor_nodes[1].host == "10.200.1.254"
|
||||
assert c.tor_nodes[2].host == "10.200.1.13"
|
||||
|
||||
def test_no_tor_nodes(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text("listen: 1080\n")
|
||||
c = load_config(cfg_file)
|
||||
assert c.tor_nodes == []
|
||||
|
||||
|
||||
class TestListenerConfig:
|
||||
"""Test multi-listener config parsing."""
|
||||
|
||||
|
||||
@@ -22,6 +22,38 @@ class TestProxyEntry:
|
||||
assert entry.tests == 0
|
||||
|
||||
|
||||
class TestEffectiveChain:
|
||||
"""Test chain_nodes round-robin in pool health tests."""
|
||||
|
||||
def test_no_nodes_returns_original(self):
|
||||
cfg = ProxyPoolConfig(sources=[])
|
||||
chain = [ChainHop(proto="socks5", host="10.0.0.1", port=9050)]
|
||||
pool = ProxyPool(cfg, chain, timeout=10.0)
|
||||
assert pool._effective_chain() == chain
|
||||
|
||||
def test_round_robin_across_nodes(self):
|
||||
cfg = ProxyPoolConfig(sources=[])
|
||||
chain = [ChainHop(proto="socks5", host="original", port=9050)]
|
||||
nodes = [
|
||||
ChainHop(proto="socks5", host="node-a", port=9050),
|
||||
ChainHop(proto="socks5", host="node-b", port=9050),
|
||||
ChainHop(proto="socks5", host="node-c", port=9050),
|
||||
]
|
||||
pool = ProxyPool(cfg, chain, timeout=10.0, chain_nodes=nodes)
|
||||
|
||||
hosts = [pool._effective_chain()[0].host for _ in range(6)]
|
||||
assert hosts == [
|
||||
"node-a", "node-b", "node-c",
|
||||
"node-a", "node-b", "node-c",
|
||||
]
|
||||
|
||||
def test_empty_chain_no_replacement(self):
|
||||
cfg = ProxyPoolConfig(sources=[])
|
||||
nodes = [ChainHop(proto="socks5", host="node-a", port=9050)]
|
||||
pool = ProxyPool(cfg, [], timeout=10.0, chain_nodes=nodes)
|
||||
assert pool._effective_chain() == []
|
||||
|
||||
|
||||
class TestProxyPoolMerge:
|
||||
"""Test proxy deduplication and merge."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user