feat: add bypass rules, weighted pool selection, integration tests
Per-listener bypass rules skip the chain for local/private destinations (CIDR, exact IP/hostname, domain suffix). Weighted multi-candidate pool selection biases toward pools with more alive proxies. End-to-end integration tests validate the full client->s5p->hop->target path using mock SOCKS5 proxies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ from s5p.config import (
|
||||
parse_api_proxies,
|
||||
parse_proxy_url,
|
||||
)
|
||||
from s5p.server import _bypass_match
|
||||
|
||||
|
||||
class TestParseProxyUrl:
|
||||
@@ -411,7 +412,7 @@ class TestPoolSeq:
|
||||
"""Test per-hop pool references (pool:name syntax)."""
|
||||
|
||||
def test_bare_pool_uses_default_name(self, tmp_path):
|
||||
"""Bare `pool` + `pool: clean` -> pool_seq=["clean"]."""
|
||||
"""Bare `pool` + `pool: clean` -> pool_seq=[["clean"]]."""
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
@@ -421,10 +422,10 @@ class TestPoolSeq:
|
||||
" - pool\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert c.listeners[0].pool_seq == ["clean"]
|
||||
assert c.listeners[0].pool_seq == [["clean"]]
|
||||
|
||||
def test_bare_pool_no_pool_name(self, tmp_path):
|
||||
"""Bare `pool` with no `pool:` key -> pool_seq=["default"]."""
|
||||
"""Bare `pool` with no `pool:` key -> pool_seq=[["default"]]."""
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
@@ -433,10 +434,10 @@ class TestPoolSeq:
|
||||
" - pool\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert c.listeners[0].pool_seq == ["default"]
|
||||
assert c.listeners[0].pool_seq == [["default"]]
|
||||
|
||||
def test_pool_colon_name(self, tmp_path):
|
||||
"""`pool:clean, pool:mitm` -> pool_seq=["clean", "mitm"]."""
|
||||
"""`pool:clean, pool:mitm` -> pool_seq=[["clean"], ["mitm"]]."""
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
@@ -446,10 +447,10 @@ class TestPoolSeq:
|
||||
" - pool:mitm\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert c.listeners[0].pool_seq == ["clean", "mitm"]
|
||||
assert c.listeners[0].pool_seq == [["clean"], ["mitm"]]
|
||||
|
||||
def test_mixed_bare_and_named(self, tmp_path):
|
||||
"""Bare `pool` + `pool:mitm` with `pool: clean` -> ["clean", "mitm"]."""
|
||||
"""Bare `pool` + `pool:mitm` with `pool: clean` -> [["clean"], ["mitm"]]."""
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
@@ -460,10 +461,10 @@ class TestPoolSeq:
|
||||
" - pool:mitm\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert c.listeners[0].pool_seq == ["clean", "mitm"]
|
||||
assert c.listeners[0].pool_seq == [["clean"], ["mitm"]]
|
||||
|
||||
def test_pool_colon_case_insensitive_prefix(self, tmp_path):
|
||||
"""`Pool:MyPool` -> pool_seq=["MyPool"] (prefix case-insensitive)."""
|
||||
"""`Pool:MyPool` -> pool_seq=[["MyPool"]] (prefix case-insensitive)."""
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
@@ -472,7 +473,7 @@ class TestPoolSeq:
|
||||
" - Pool:MyPool\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert c.listeners[0].pool_seq == ["MyPool"]
|
||||
assert c.listeners[0].pool_seq == [["MyPool"]]
|
||||
|
||||
def test_pool_colon_empty_is_bare(self, tmp_path):
|
||||
"""`pool:` (empty name) -> treated as bare pool."""
|
||||
@@ -485,17 +486,17 @@ class TestPoolSeq:
|
||||
" - pool:\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert c.listeners[0].pool_seq == ["clean"]
|
||||
assert c.listeners[0].pool_seq == [["clean"]]
|
||||
|
||||
def test_backward_compat_pool_hops_property(self):
|
||||
"""pool_hops property returns len(pool_seq)."""
|
||||
lc = ListenerConfig(pool_seq=["clean", "mitm"])
|
||||
lc = ListenerConfig(pool_seq=[["clean"], ["mitm"]])
|
||||
assert lc.pool_hops == 2
|
||||
lc2 = ListenerConfig()
|
||||
assert lc2.pool_hops == 0
|
||||
|
||||
def test_legacy_auto_append(self, tmp_path):
|
||||
"""Singular `proxy_pool:` -> pool_seq=["default"]."""
|
||||
"""Singular `proxy_pool:` -> pool_seq=[["default"]]."""
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listen: 0.0.0.0:1080\n"
|
||||
@@ -507,9 +508,26 @@ class TestPoolSeq:
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
lc = c.listeners[0]
|
||||
assert lc.pool_seq == ["default"]
|
||||
assert lc.pool_seq == [["default"]]
|
||||
assert lc.pool_hops == 1
|
||||
|
||||
def test_list_candidates(self, tmp_path):
|
||||
"""List in chain -> multi-candidate hop."""
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
" - listen: 1080\n"
|
||||
" chain:\n"
|
||||
" - socks5://tor:9050\n"
|
||||
" - [pool:clean, pool:mitm]\n"
|
||||
" - [pool:clean, pool:mitm]\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
lc = c.listeners[0]
|
||||
assert len(lc.chain) == 1
|
||||
assert lc.pool_hops == 2
|
||||
assert lc.pool_seq == [["clean", "mitm"], ["clean", "mitm"]]
|
||||
|
||||
|
||||
class TestListenerBackwardCompat:
|
||||
"""Test backward-compatible single listener from old format."""
|
||||
@@ -573,3 +591,79 @@ class TestListenerPoolCompat:
|
||||
lc = c.listeners[0]
|
||||
# explicit listeners: no auto pool_hops
|
||||
assert lc.pool_hops == 0
|
||||
|
||||
|
||||
class TestBypassConfig:
|
||||
"""Test bypass rules in listener config."""
|
||||
|
||||
def test_bypass_from_yaml(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
" - listen: 1080\n"
|
||||
" bypass:\n"
|
||||
" - 127.0.0.0/8\n"
|
||||
" - 192.168.0.0/16\n"
|
||||
" - localhost\n"
|
||||
" - .local\n"
|
||||
" chain:\n"
|
||||
" - socks5://127.0.0.1:9050\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
lc = c.listeners[0]
|
||||
assert lc.bypass == ["127.0.0.0/8", "192.168.0.0/16", "localhost", ".local"]
|
||||
|
||||
def test_bypass_empty_default(self):
|
||||
lc = ListenerConfig()
|
||||
assert lc.bypass == []
|
||||
|
||||
def test_bypass_absent_from_yaml(self, tmp_path):
|
||||
cfg_file = tmp_path / "test.yaml"
|
||||
cfg_file.write_text(
|
||||
"listeners:\n"
|
||||
" - listen: 1080\n"
|
||||
" chain:\n"
|
||||
" - socks5://127.0.0.1:9050\n"
|
||||
)
|
||||
c = load_config(cfg_file)
|
||||
assert c.listeners[0].bypass == []
|
||||
|
||||
|
||||
class TestBypassMatch:
|
||||
"""Test _bypass_match function."""
|
||||
|
||||
def test_cidr_ipv4(self):
|
||||
assert _bypass_match(["10.0.0.0/8"], "10.1.2.3") is True
|
||||
assert _bypass_match(["10.0.0.0/8"], "11.0.0.1") is False
|
||||
|
||||
def test_cidr_ipv6(self):
|
||||
assert _bypass_match(["fc00::/7"], "fd00::1") is True
|
||||
assert _bypass_match(["fc00::/7"], "2001:db8::1") is False
|
||||
|
||||
def test_exact_ip(self):
|
||||
assert _bypass_match(["127.0.0.1"], "127.0.0.1") is True
|
||||
assert _bypass_match(["127.0.0.1"], "127.0.0.2") is False
|
||||
|
||||
def test_exact_hostname(self):
|
||||
assert _bypass_match(["localhost"], "localhost") is True
|
||||
assert _bypass_match(["localhost"], "otherhost") is False
|
||||
|
||||
def test_domain_suffix(self):
|
||||
assert _bypass_match([".local"], "myhost.local") is True
|
||||
assert _bypass_match([".local"], "local") is True
|
||||
assert _bypass_match([".local"], "notlocal") is False
|
||||
assert _bypass_match([".example.com"], "api.example.com") is True
|
||||
assert _bypass_match([".example.com"], "example.com") is True
|
||||
|
||||
def test_multiple_rules(self):
|
||||
rules = ["10.0.0.0/8", "192.168.0.0/16", ".local"]
|
||||
assert _bypass_match(rules, "10.1.2.3") is True
|
||||
assert _bypass_match(rules, "192.168.1.1") is True
|
||||
assert _bypass_match(rules, "host.local") is True
|
||||
assert _bypass_match(rules, "8.8.8.8") is False
|
||||
|
||||
def test_empty_rules(self):
|
||||
assert _bypass_match([], "anything") is False
|
||||
|
||||
def test_hostname_not_matched_by_cidr(self):
|
||||
assert _bypass_match(["10.0.0.0/8"], "example.com") is False
|
||||
|
||||
Reference in New Issue
Block a user