feat: multi-listener with configurable proxy chaining
Each listener binds to its own port with an independent chain. The "pool" keyword in a chain appends a random alive proxy from the shared pool; multiple pool entries = multiple hops. :1080 -> Tor only (0 pool hops) :1081 -> Tor + 1 pool proxy :1082 -> Tor + 2 pool proxies Shared resources (ProxyPool, Tor, metrics, semaphore, API) are reused across listeners. FirstHopPool is shared per unique first hop. Backward compatible: old listen/chain format still works.
This commit is contained in:
@@ -15,7 +15,7 @@ from s5p.api import (
|
||||
_parse_request,
|
||||
_route,
|
||||
)
|
||||
from s5p.config import ChainHop, Config, PoolSourceConfig, ProxyPoolConfig
|
||||
from s5p.config import ChainHop, Config, ListenerConfig, PoolSourceConfig, ProxyPoolConfig
|
||||
from s5p.metrics import Metrics
|
||||
|
||||
# -- request parsing ---------------------------------------------------------
|
||||
@@ -118,11 +118,26 @@ class TestHandleStatus:
|
||||
_, body = _handle_status(ctx)
|
||||
assert body["pool"] == {"alive": 5, "total": 10}
|
||||
|
||||
def test_with_chain(self):
|
||||
config = Config(chain=[ChainHop("socks5", "127.0.0.1", 9050)])
|
||||
def test_with_listeners(self):
|
||||
config = Config(
|
||||
listeners=[
|
||||
ListenerConfig(
|
||||
listen_host="0.0.0.0", listen_port=1080,
|
||||
chain=[ChainHop("socks5", "127.0.0.1", 9050)],
|
||||
),
|
||||
ListenerConfig(
|
||||
listen_host="0.0.0.0", listen_port=1081,
|
||||
chain=[ChainHop("socks5", "127.0.0.1", 9050)],
|
||||
pool_hops=1,
|
||||
),
|
||||
],
|
||||
)
|
||||
ctx = _make_ctx(config=config)
|
||||
_, body = _handle_status(ctx)
|
||||
assert body["chain"] == ["socks5://127.0.0.1:9050"]
|
||||
assert len(body["listeners"]) == 2
|
||||
assert body["listeners"][0]["chain"] == ["socks5://127.0.0.1:9050"]
|
||||
assert body["listeners"][0]["pool_hops"] == 0
|
||||
assert body["listeners"][1]["pool_hops"] == 1
|
||||
|
||||
|
||||
class TestHandleMetrics:
|
||||
@@ -195,13 +210,18 @@ class TestHandleConfig:
|
||||
"""Test GET /config handler."""
|
||||
|
||||
def test_basic(self):
|
||||
config = Config(timeout=15.0, retries=5, log_level="debug")
|
||||
config = Config(
|
||||
timeout=15.0, retries=5, log_level="debug",
|
||||
listeners=[ListenerConfig(listen_host="0.0.0.0", listen_port=1080)],
|
||||
)
|
||||
ctx = _make_ctx(config=config)
|
||||
status, body = _handle_config(ctx)
|
||||
assert status == 200
|
||||
assert body["timeout"] == 15.0
|
||||
assert body["retries"] == 5
|
||||
assert body["log_level"] == "debug"
|
||||
assert len(body["listeners"]) == 1
|
||||
assert body["listeners"][0]["listen"] == "0.0.0.0:1080"
|
||||
|
||||
def test_with_proxy_pool(self):
|
||||
pp = ProxyPoolConfig(
|
||||
@@ -210,11 +230,18 @@ class TestHandleConfig:
|
||||
test_interval=60.0,
|
||||
max_fails=5,
|
||||
)
|
||||
config = Config(proxy_pool=pp)
|
||||
config = Config(
|
||||
proxy_pool=pp,
|
||||
listeners=[ListenerConfig(
|
||||
chain=[ChainHop("socks5", "127.0.0.1", 9050)],
|
||||
pool_hops=1,
|
||||
)],
|
||||
)
|
||||
ctx = _make_ctx(config=config)
|
||||
_, body = _handle_config(ctx)
|
||||
assert body["proxy_pool"]["refresh"] == 600.0
|
||||
assert body["proxy_pool"]["sources"][0]["url"] == "http://api:8081/proxies"
|
||||
assert body["listeners"][0]["pool_hops"] == 1
|
||||
|
||||
|
||||
# -- routing -----------------------------------------------------------------
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from s5p.config import ChainHop, Config, load_config, parse_api_proxies, parse_proxy_url
|
||||
from s5p.config import (
|
||||
ChainHop,
|
||||
Config,
|
||||
ListenerConfig,
|
||||
load_config,
|
||||
parse_api_proxies,
|
||||
parse_proxy_url,
|
||||
)
|
||||
|
||||
|
||||
class TestParseProxyUrl:
|
||||
@@ -212,3 +219,143 @@ class TestConfig:
|
||||
assert c.proxy_pool.test_targets == [
|
||||
"www.google.com", "www.cloudflare.com", "www.amazon.com",
|
||||
]
|
||||
|
||||
|
||||
class TestListenerConfig:
|
||||
"""Test multi-listener config parsing."""
|
||||
|
||||
def test_defaults(self):
|
||||
lc = ListenerConfig()
|
||||
assert lc.listen_host == "127.0.0.1"
|
||||
assert lc.listen_port == 1080
|
||||
assert lc.chain == []
|
||||
assert lc.pool_hops == 0
|
||||
|
||||
def test_listeners_from_yaml(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
" - listen: 0.0.0.0:1080\n"
|
||||
" chain:\n"
|
||||
" - socks5://127.0.0.1:9050\n"
|
||||
" - listen: 0.0.0.0:1081\n"
|
||||
" chain:\n"
|
||||
" - socks5://127.0.0.1:9050\n"
|
||||
" - pool\n"
|
||||
" - listen: 0.0.0.0:1082\n"
|
||||
" chain:\n"
|
||||
" - socks5://127.0.0.1:9050\n"
|
||||
" - pool\n"
|
||||
" - pool\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert len(c.listeners) == 3
|
||||
|
||||
# listener 0: no pool hops
|
||||
assert c.listeners[0].listen_host == "0.0.0.0"
|
||||
assert c.listeners[0].listen_port == 1080
|
||||
assert len(c.listeners[0].chain) == 1
|
||||
assert c.listeners[0].pool_hops == 0
|
||||
|
||||
# listener 1: 1 pool hop
|
||||
assert c.listeners[1].listen_port == 1081
|
||||
assert len(c.listeners[1].chain) == 1
|
||||
assert c.listeners[1].pool_hops == 1
|
||||
|
||||
# listener 2: 2 pool hops
|
||||
assert c.listeners[2].listen_port == 1082
|
||||
assert len(c.listeners[2].chain) == 1
|
||||
assert c.listeners[2].pool_hops == 2
|
||||
|
||||
def test_pool_keyword_stripped_from_chain(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
" - listen: 1080\n"
|
||||
" chain:\n"
|
||||
" - socks5://tor:9050\n"
|
||||
" - pool\n"
|
||||
" - pool\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
lc = c.listeners[0]
|
||||
# only the real hop remains in chain
|
||||
assert len(lc.chain) == 1
|
||||
assert lc.chain[0].host == "tor"
|
||||
assert lc.pool_hops == 2
|
||||
|
||||
def test_pool_keyword_case_insensitive(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
" - listen: 1080\n"
|
||||
" chain:\n"
|
||||
" - Pool\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert c.listeners[0].pool_hops == 1
|
||||
assert c.listeners[0].chain == []
|
||||
|
||||
|
||||
class TestListenerBackwardCompat:
|
||||
"""Test backward-compatible single listener from old format."""
|
||||
|
||||
def test_old_format_creates_single_listener(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listen: 0.0.0.0:9999\n"
|
||||
"chain:\n"
|
||||
" - socks5://127.0.0.1:9050\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert len(c.listeners) == 1
|
||||
lc = c.listeners[0]
|
||||
assert lc.listen_host == "0.0.0.0"
|
||||
assert lc.listen_port == 9999
|
||||
assert len(lc.chain) == 1
|
||||
assert lc.pool_hops == 0
|
||||
|
||||
def test_empty_config_creates_single_listener(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text("")
|
||||
c = load_config(cfg_file)
|
||||
assert len(c.listeners) == 1
|
||||
lc = c.listeners[0]
|
||||
assert lc.listen_host == "127.0.0.1"
|
||||
assert lc.listen_port == 1080
|
||||
|
||||
|
||||
class TestListenerPoolCompat:
|
||||
"""Test that proxy_pool + old format auto-sets pool_hops=1."""
|
||||
|
||||
def test_pool_auto_appends(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listen: 0.0.0.0:1080\n"
|
||||
"chain:\n"
|
||||
" - socks5://127.0.0.1:9050\n"
|
||||
"proxy_pool:\n"
|
||||
" sources:\n"
|
||||
" - url: http://api:8081/proxies\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert len(c.listeners) == 1
|
||||
lc = c.listeners[0]
|
||||
assert lc.pool_hops == 1
|
||||
|
||||
def test_explicit_listeners_no_auto_append(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
" - listen: 0.0.0.0:1080\n"
|
||||
" chain:\n"
|
||||
" - socks5://127.0.0.1:9050\n"
|
||||
"proxy_pool:\n"
|
||||
" sources:\n"
|
||||
" - url: http://api:8081/proxies\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert len(c.listeners) == 1
|
||||
lc = c.listeners[0]
|
||||
# explicit listeners: no auto pool_hops
|
||||
assert lc.pool_hops == 0
|
||||
|
||||
Reference in New Issue
Block a user