修复:完善 PAC 分流与启动取消
This commit is contained in:
@@ -14,6 +14,7 @@ pac_domains.json;PyQt5 用桩替代(决策逻辑不依赖 Qt)。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
TEST_DIR = Path(__file__).resolve().parent
|
||||
@@ -360,6 +361,351 @@ if pac_failed:
|
||||
failed += pac_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── 强制代理规则格式:后缀 / full / regexp + 关键域名兜底 ───────────
|
||||
print("\n" + "=" * 70)
|
||||
print("强制代理规则格式(后缀 / full / regexp / 关键域名兜底)")
|
||||
print("=" * 70)
|
||||
|
||||
rule_failed = 0
|
||||
rule_dir = Path(tempfile.mkdtemp()) / "RRPP"
|
||||
rule_dir.mkdir(parents=True)
|
||||
(rule_dir / "pac_domains.json").write_text(json.dumps({"domains": [
|
||||
"suffix.example", "full:only.example", "regexp:^edge-[0-9]+\\.example$",
|
||||
]}), "utf-8")
|
||||
|
||||
# 此段隔离真实 CDN 表,确保三种语义分别受测。
|
||||
old_user_dir = pp._USER_DIR
|
||||
old_suffixes = pp._FORCE_PROXY_SUFFIXES
|
||||
old_exact = pp._FORCE_PROXY_EXACT
|
||||
old_regexes = pp._FORCE_PROXY_REGEXES
|
||||
old_loaded = pp._FORCE_PROXY_LOADED
|
||||
pp._USER_DIR = rule_dir
|
||||
pp._FORCE_PROXY_SUFFIXES = None
|
||||
pp._FORCE_PROXY_EXACT = None
|
||||
pp._FORCE_PROXY_REGEXES = None
|
||||
pp._FORCE_PROXY_LOADED = False
|
||||
pp._is_force_proxy.cache_clear()
|
||||
pp._load_force_proxy_suffixes()
|
||||
|
||||
rule_cases = [
|
||||
("普通后缀匹配子域", "cdn.suffix.example", True),
|
||||
("full 精确匹配", "only.example", True),
|
||||
("full 不匹配子域", "cdn.only.example", False),
|
||||
("regexp 匹配", "edge-42.example", True),
|
||||
("regexp 不匹配", "edge-x.example", False),
|
||||
("关键 Google CDN 兜底", "assets.googlecdn.com", True),
|
||||
("无关域名", "unmatched.example", False),
|
||||
]
|
||||
for desc, host, expect in rule_cases:
|
||||
got = pp._is_force_proxy(host)
|
||||
ok = got == expect
|
||||
rule_failed += 0 if ok else 1
|
||||
print(f" {desc:<22} {host:<30} 命中={got} (期望 {expect}) {'PASS' if ok else 'FAIL'}")
|
||||
|
||||
pp._USER_DIR = old_user_dir
|
||||
pp._FORCE_PROXY_SUFFIXES = old_suffixes
|
||||
pp._FORCE_PROXY_EXACT = old_exact
|
||||
pp._FORCE_PROXY_REGEXES = old_regexes
|
||||
pp._FORCE_PROXY_LOADED = old_loaded
|
||||
pp._is_force_proxy.cache_clear()
|
||||
if rule_failed:
|
||||
failed += rule_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── 明确直连域名:优先级 / Trie / 固定代理隔离 ───────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("明确直连域名(后缀 / full / regexp / 强制代理优先 / 固定代理隔离)")
|
||||
print("=" * 70)
|
||||
|
||||
direct_failed = 0
|
||||
direct_dir = Path(tempfile.mkdtemp()) / "RRPP"
|
||||
direct_dir.mkdir(parents=True)
|
||||
(direct_dir / "pac_direct_domains.json").write_text(json.dumps({"domains": [
|
||||
"direct.example", "full:only-direct.example", "regexp:^edge-direct-[0-9]+\\.example$",
|
||||
"claude.ai",
|
||||
]}), "utf-8")
|
||||
old_user_dir = pp._USER_DIR
|
||||
old_direct_trie = pp._DIRECT_SUFFIX_TRIE
|
||||
old_direct_exact = pp._DIRECT_EXACT
|
||||
old_direct_regexes = pp._DIRECT_REGEXES
|
||||
old_direct_loaded = pp._DIRECT_LOADED
|
||||
old_data_path = pp._data_path
|
||||
try:
|
||||
pp._USER_DIR = direct_dir
|
||||
pp._data_path = lambda name: None # 隔离仓库内置种子,覆盖到临时用户数据。
|
||||
pp._DIRECT_SUFFIX_TRIE = None
|
||||
pp._DIRECT_EXACT = None
|
||||
pp._DIRECT_REGEXES = None
|
||||
pp._DIRECT_LOADED = False
|
||||
pp._is_direct_domain.cache_clear()
|
||||
pp._load_direct_domains()
|
||||
direct_cases = [
|
||||
("后缀匹配子域", "cdn.direct.example", True),
|
||||
("full 精确匹配", "only-direct.example", True),
|
||||
("full 不匹配子域", "cdn.only-direct.example", False),
|
||||
("regexp 匹配", "edge-direct-42.example", True),
|
||||
("regexp 不匹配", "edge-direct-x.example", False),
|
||||
]
|
||||
for desc, host, expect in direct_cases:
|
||||
got = pp._is_direct_domain(host)
|
||||
ok = got == expect
|
||||
direct_failed += 0 if ok else 1
|
||||
print(f" {desc:<22} {host:<30} 命中={got} (期望 {expect}) {'PASS' if ok else 'FAIL'}")
|
||||
smart_direct = pp._decide_direct("cdn.direct.example", True, True)
|
||||
fixed_proxy = not pp._decide_direct("cdn.direct.example", True, False)
|
||||
force_wins = not pp._decide_direct("claude.ai", True, True)
|
||||
direct_target = pp._direct_connect_target("cdn.direct.example", 443, True, True)
|
||||
target_keeps_host = direct_target == ("cdn.direct.example", 443)
|
||||
print(f" PAC 模式命中明确直连: {smart_direct} {'PASS' if smart_direct else 'FAIL'}")
|
||||
print(f" 固定代理模式不使用明确直连表: {fixed_proxy} {'PASS' if fixed_proxy else 'FAIL'}")
|
||||
print(f" 强制代理优先于明确直连: {force_wins} {'PASS' if force_wins else 'FAIL'}")
|
||||
print(f" 明确直连保持原始主机名: {target_keeps_host} {'PASS' if target_keeps_host else 'FAIL'}")
|
||||
direct_failed += 0 if smart_direct else 1
|
||||
direct_failed += 0 if fixed_proxy else 1
|
||||
direct_failed += 0 if force_wins else 1
|
||||
direct_failed += 0 if target_keeps_host else 1
|
||||
pp._pac_write("pac_direct_domains", {"domains": ["updated-direct.example"]})
|
||||
pp._invalidate_pac_routing_cache("pac_direct_domains")
|
||||
direct_reloaded = (not pp._is_direct_domain("direct.example")
|
||||
and pp._is_direct_domain("updated-direct.example"))
|
||||
print(f" 明确直连表热更新立即生效: {direct_reloaded} "
|
||||
f"{'PASS' if direct_reloaded else 'FAIL'}")
|
||||
direct_failed += 0 if direct_reloaded else 1
|
||||
finally:
|
||||
pp._USER_DIR = old_user_dir
|
||||
pp._data_path = old_data_path
|
||||
pp._DIRECT_SUFFIX_TRIE = old_direct_trie
|
||||
pp._DIRECT_EXACT = old_direct_exact
|
||||
pp._DIRECT_REGEXES = old_direct_regexes
|
||||
pp._DIRECT_LOADED = old_direct_loaded
|
||||
pp._is_direct_domain.cache_clear()
|
||||
if direct_failed:
|
||||
failed += direct_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── PAC 更新立即生效 + DNS 解析一致性 ──────────────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("PAC 热更新缓存失效 + 国内直连使用同一次 DNS 解析结果")
|
||||
print("=" * 70)
|
||||
|
||||
hot_failed = 0
|
||||
hot_dir = Path(tempfile.mkdtemp()) / "RRPP"
|
||||
hot_dir.mkdir(parents=True)
|
||||
old_user_dir = pp._USER_DIR
|
||||
old_cn_ranges = pp._CN_RANGES
|
||||
old_cn_ranges_v6 = pp._CN_RANGES_V6
|
||||
old_cn_loaded = pp._CN_RANGES_LOADED
|
||||
old_suffixes = pp._FORCE_PROXY_SUFFIXES
|
||||
old_exact = pp._FORCE_PROXY_EXACT
|
||||
old_regexes = pp._FORCE_PROXY_REGEXES
|
||||
old_force_loaded = pp._FORCE_PROXY_LOADED
|
||||
old_getaddrinfo = socket.getaddrinfo
|
||||
try:
|
||||
pp._USER_DIR = hot_dir
|
||||
pp._CN_RANGES = None
|
||||
pp._CN_RANGES_V6 = None
|
||||
pp._CN_RANGES_LOADED = False
|
||||
pp._FORCE_PROXY_SUFFIXES = None
|
||||
pp._FORCE_PROXY_EXACT = None
|
||||
pp._FORCE_PROXY_REGEXES = None
|
||||
pp._FORCE_PROXY_LOADED = False
|
||||
pp._is_china_ip.cache_clear()
|
||||
pp._is_force_proxy.cache_clear()
|
||||
|
||||
pp._pac_write("pac_domains", {"domains": ["old-rule.example"]})
|
||||
pp._pac_write("pac_ip_cn", {"cidrs": ["114.114.114.0/24"]})
|
||||
pp._load_force_proxy_suffixes()
|
||||
pp._load_cn_ranges()
|
||||
before = pp._is_force_proxy("old-rule.example")
|
||||
pp._pac_write("pac_domains", {"domains": ["new-rule.example"]})
|
||||
pp._invalidate_pac_routing_cache("pac_domains")
|
||||
domains_reloaded = (not pp._is_force_proxy("old-rule.example")
|
||||
and pp._is_force_proxy("new-rule.example"))
|
||||
print(f" 域名表热更新立即生效: 初始={before}, 更新后={domains_reloaded} "
|
||||
f"{'PASS' if before and domains_reloaded else 'FAIL'}")
|
||||
hot_failed += 0 if before and domains_reloaded else 1
|
||||
|
||||
initial_cn = pp._is_china_ip("114.114.114.114")
|
||||
pp._pac_write("pac_ip_cn", {"cidrs": ["8.8.8.0/24"]})
|
||||
pp._invalidate_pac_routing_cache("pac_ip_cn")
|
||||
ip_reloaded = (not pp._is_china_ip("114.114.114.114")
|
||||
and pp._is_china_ip("8.8.8.8"))
|
||||
print(f" IP 段热更新立即生效: 初始={initial_cn}, 更新后={ip_reloaded} "
|
||||
f"{'PASS' if initial_cn and ip_reloaded else 'FAIL'}")
|
||||
hot_failed += 0 if initial_cn and ip_reloaded else 1
|
||||
|
||||
# 固定本次 DNS 返回值;_direct_connect_target 必须只解析一次并直连该 IP。
|
||||
pp._pac_write("pac_ip_cn", {"cidrs": ["114.114.114.0/24"]})
|
||||
pp._invalidate_pac_routing_cache("pac_ip_cn")
|
||||
dns_calls = []
|
||||
def fake_getaddrinfo(host, *args, **kwargs):
|
||||
dns_calls.append(host)
|
||||
if host == "mixed.example":
|
||||
ips = ["114.114.114.114", "8.8.8.8"]
|
||||
else:
|
||||
ips = ["114.114.114.114"]
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0)) for ip in ips]
|
||||
socket.getaddrinfo = fake_getaddrinfo
|
||||
target = pp._direct_connect_target("cn-cdn.example", 443, False, True)
|
||||
stable_dns = target == ("114.114.114.114", 443) and dns_calls == ["cn-cdn.example"]
|
||||
mixed = pp._direct_connect_target("mixed.example", 443, False, True)
|
||||
mixed_safe = mixed is None
|
||||
print(f" 国内 CDN 固定解析 IP 且不二次解析: {stable_dns} {'PASS' if stable_dns else 'FAIL'}")
|
||||
print(f" 混合中国/境外 CDN 记录改走隧道: {mixed_safe} {'PASS' if mixed_safe else 'FAIL'}")
|
||||
hot_failed += 0 if stable_dns else 1
|
||||
hot_failed += 0 if mixed_safe else 1
|
||||
|
||||
# IPv6 中国 CIDR 与 IPv6 私网/回环分流。240e::/16 是测试用的公网 IPv6 段。
|
||||
pp._pac_write("pac_ip_cn", {"cidrs": ["240e::/16"]})
|
||||
pp._invalidate_pac_routing_cache("pac_ip_cn")
|
||||
ipv6_cn = (pp._is_china_ip("240e:1234::1")
|
||||
and pp._direct_connect_target("240e:1234::1", 443, False, True)
|
||||
== ("240e:1234::1", 443))
|
||||
ipv6_private = all(pp._is_private_ip(ip) for ip in ("::1", "fc00::1", "fe80::1"))
|
||||
ipv6_public = not pp._is_private_ip("2001:4860:4860::8888")
|
||||
print(f" IPv6 中国 CIDR 智能直连: {ipv6_cn} {'PASS' if ipv6_cn else 'FAIL'}")
|
||||
print(f" IPv6 回环/ULA/链路本地直连: {ipv6_private and ipv6_public} "
|
||||
f"{'PASS' if ipv6_private and ipv6_public else 'FAIL'}")
|
||||
hot_failed += 0 if ipv6_cn else 1
|
||||
hot_failed += 0 if ipv6_private and ipv6_public else 1
|
||||
finally:
|
||||
socket.getaddrinfo = old_getaddrinfo
|
||||
pp._USER_DIR = old_user_dir
|
||||
pp._CN_RANGES = old_cn_ranges
|
||||
pp._CN_RANGES_V6 = old_cn_ranges_v6
|
||||
pp._CN_RANGES_LOADED = old_cn_loaded
|
||||
pp._FORCE_PROXY_SUFFIXES = old_suffixes
|
||||
pp._FORCE_PROXY_EXACT = old_exact
|
||||
pp._FORCE_PROXY_REGEXES = old_regexes
|
||||
pp._FORCE_PROXY_LOADED = old_force_loaded
|
||||
pp._is_china_ip.cache_clear()
|
||||
pp._is_force_proxy.cache_clear()
|
||||
if hot_failed:
|
||||
failed += hot_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── 智能国内直连失败回退上游隧道 ───────────────────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("智能国内直连失败:仅该路径回退一次上游隧道")
|
||||
print("=" * 70)
|
||||
|
||||
fallback_failed = 0
|
||||
direct = ("114.114.114.114", 443)
|
||||
upstream = ("node.example", 1205)
|
||||
calls = []
|
||||
remote_obj = object()
|
||||
def fake_connect(target, timeout):
|
||||
calls.append((target, timeout))
|
||||
if target == direct:
|
||||
raise OSError("mock direct connect failure")
|
||||
if target == upstream:
|
||||
return remote_obj
|
||||
raise AssertionError(f"unexpected target: {target}")
|
||||
|
||||
remote, via_tunnel = pp._connect_route(
|
||||
direct, True, upstream[0], upstream[1], None, False, fake_connect)
|
||||
fallback_ok = (remote is remote_obj and via_tunnel is True
|
||||
and calls == [(direct, 10), (upstream, 10)])
|
||||
calls.clear()
|
||||
try:
|
||||
pp._connect_route(direct, False, upstream[0], upstream[1], None, False, fake_connect)
|
||||
explicit_not_fallback = False
|
||||
except OSError:
|
||||
explicit_not_fallback = calls == [(direct, 10)]
|
||||
print(f" 智能中国公网直连失败回退隧道: {fallback_ok} {'PASS' if fallback_ok else 'FAIL'}")
|
||||
print(f" 显式直连失败不回退: {explicit_not_fallback} "
|
||||
f"{'PASS' if explicit_not_fallback else 'FAIL'}")
|
||||
fallback_failed += 0 if fallback_ok else 1
|
||||
fallback_failed += 0 if explicit_not_fallback else 1
|
||||
if fallback_failed:
|
||||
failed += fallback_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── 启动连通性必须验证境外隧道 ─────────────────────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("启动连通性:google.com 隧道失败必须阻止“开启成功”")
|
||||
print("=" * 70)
|
||||
|
||||
class _FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
def read(self, size=-1):
|
||||
return b""
|
||||
|
||||
class _FakeOpener:
|
||||
def __init__(self, fail_google=False):
|
||||
self.fail_google = fail_google
|
||||
def open(self, url, timeout):
|
||||
if self.fail_google and "www.google.com" in url:
|
||||
raise OSError("mock tunnel failure")
|
||||
return _FakeResponse()
|
||||
|
||||
|
||||
class _CancellingResponse(_FakeResponse):
|
||||
def __init__(self, cancel_event):
|
||||
self.cancel_event = cancel_event
|
||||
def read(self, size=-1):
|
||||
self.cancel_event.set()
|
||||
return b""
|
||||
|
||||
|
||||
class _CancellingOpener:
|
||||
def __init__(self, cancel_event):
|
||||
self.cancel_event = cancel_event
|
||||
self.urls = []
|
||||
def open(self, url, timeout):
|
||||
self.urls.append(url)
|
||||
return _CancellingResponse(self.cancel_event)
|
||||
|
||||
|
||||
class _GoogleCancellingOpener(_CancellingOpener):
|
||||
def open(self, url, timeout):
|
||||
self.urls.append(url)
|
||||
if "www.google.com" in url:
|
||||
return _CancellingResponse(self.cancel_event)
|
||||
return _FakeResponse()
|
||||
|
||||
verify_failed = 0
|
||||
ok_results = pp._verify_proxy_connectivity(opener=_FakeOpener())
|
||||
ok = ok_results.get("google.com") is True
|
||||
try:
|
||||
pp._verify_proxy_connectivity(opener=_FakeOpener(fail_google=True))
|
||||
blocked = False
|
||||
except RuntimeError:
|
||||
blocked = True
|
||||
cancel_event = threading.Event()
|
||||
cancelling_opener = _CancellingOpener(cancel_event)
|
||||
try:
|
||||
pp._verify_proxy_connectivity(opener=cancelling_opener, cancel_event=cancel_event)
|
||||
cancel_blocked = False
|
||||
except pp._StartCancelled:
|
||||
cancel_blocked = True
|
||||
google_not_started = all("www.google.com" not in url for url in cancelling_opener.urls)
|
||||
google_cancel_event = threading.Event()
|
||||
google_cancelling_opener = _GoogleCancellingOpener(google_cancel_event)
|
||||
try:
|
||||
pp._verify_proxy_connectivity(opener=google_cancelling_opener,
|
||||
cancel_event=google_cancel_event)
|
||||
google_cancelled = False
|
||||
except pp._StartCancelled:
|
||||
google_cancelled = True
|
||||
google_started = any("www.google.com" in url for url in google_cancelling_opener.urls)
|
||||
print(f" 境外隧道成功可通过: {ok} {'PASS' if ok else 'FAIL'}")
|
||||
print(f" 境外隧道失败会阻止开启: {blocked} {'PASS' if blocked else 'FAIL'}")
|
||||
print(f" Baidu 探测中取消会中断后续 Google 探测: {cancel_blocked and google_not_started} "
|
||||
f"{'PASS' if cancel_blocked and google_not_started else 'FAIL'}")
|
||||
print(f" Google 探测中取消会终止本次开启: {google_cancelled and google_started} "
|
||||
f"{'PASS' if google_cancelled and google_started else 'FAIL'}")
|
||||
verify_failed += 0 if ok else 1
|
||||
verify_failed += 0 if blocked else 1
|
||||
verify_failed += 0 if cancel_blocked and google_not_started else 1
|
||||
verify_failed += 0 if google_cancelled and google_started else 1
|
||||
if verify_failed:
|
||||
failed += verify_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── DNS 分流 TTL 缓存 ────────────────────────────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("TTL 缓存:cache_clear 兼容 + 缓存命中")
|
||||
@@ -387,3 +733,6 @@ print(f" cache_clear 后重查: {pp._is_china_ip('114.114.114.114')} "
|
||||
if ttl_failed:
|
||||
failed += ttl_failed
|
||||
print("=" * 70)
|
||||
|
||||
print(f"\n最终结论: {'全部 PASS' if not failed else f'{failed} 个 FAIL'}")
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
Reference in New Issue
Block a user