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:
user
2026-02-17 22:03:37 +01:00
parent ba60d087c0
commit 7dc3926f48
11 changed files with 495 additions and 62 deletions

View File

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