修复:完善 PAC 分流与启动取消

This commit is contained in:
RoyirinRosmontis
2026-09-16 19:10:12 +08:00
parent 9376e35025
commit 0603224de2
2 changed files with 846 additions and 167 deletions
+497 -167
View File
@@ -602,6 +602,52 @@ def proxy_off():
total = up + down
log(f"本次转发流量共 {_fmt_bytes(total)}")
class _StartCancelled(RuntimeError):
"""用户在启动连通性探测完成前取消了本次开启操作。"""
def _raise_if_start_cancelled(cancel_event):
if cancel_event is not None and cancel_event.is_set():
raise _StartCancelled("用户取消了开启代理")
def _verify_proxy_connectivity(timeout=10, opener=None, cancel_event=None):
"""验证本地代理的国内直连和境外隧道路径。
国内探测只作诊断;境外 google.com 探测是启动成功的必要条件,避免认证、
隧道或节点故障时仍把 UI 标记为“已开启”。返回每条路径的可达性。
"""
import urllib.request
if opener is None:
local_proxy = f"http://{PROXY_HOST}:{PROXY_PORT}"
opener = urllib.request.build_opener(
urllib.request.ProxyHandler({"http": local_proxy, "https": local_proxy}))
results = {}
test_urls = [
("baidu.com", "http://www.baidu.com", False),
("google.com", "https://www.google.com/generate_204", True),
]
for name, url, required in test_urls:
_raise_if_start_cancelled(cancel_event)
try:
with opener.open(url, timeout=timeout) as response:
response.read(1)
_raise_if_start_cancelled(cancel_event)
results[name] = True
log(f" {name} 可达")
except _StartCancelled:
raise
except Exception as e:
# 取消时,本地转发器可能已被关闭,从而让正在进行的请求报错;
# 这不是“开启失败”,也不能在稍后把界面重新切成已开启。
_raise_if_start_cancelled(cancel_event)
results[name] = False
log(f" {name} 不可达: {e}")
if required:
raise RuntimeError(f"境外隧道验证失败({name}):{e}") from e
return results
# ── 本地认证转发器 ─────────────────────────────────────
# ── 流量统计 ──────────────────────────────────────────
@@ -797,7 +843,7 @@ def _ttl_cache(seconds: int, maxsize: int = 16384):
@_ttl_cache(300, maxsize=2048)
def _is_private_ip(host: str) -> bool:
"""判断目标是否命中常用内网/回环 IPv4;域名会解析后判断。"""
"""判断目标是否命中常用内网、回环或链路本地地址(IPv4/IPv6)。"""
try:
import ipaddress
ip = ipaddress.ip_address(host)
@@ -806,7 +852,7 @@ def _is_private_ip(host: str) -> bool:
try:
import socket
addresses = []
for addr in socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM):
for addr in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM):
try:
addresses.append(ipaddress.ip_address(addr[4][0]))
except ValueError:
@@ -814,27 +860,27 @@ def _is_private_ip(host: str) -> bool:
except Exception:
return False
for ip in addresses:
if ip.version != 4:
continue
n = int(ip)
if (
(n & 0xFF000000) == 0x0A000000 or
(n & 0xFFF00000) == 0xAC100000 or
(n & 0xFFFF0000) == 0xC0A80000 or
(n & 0xFF000000) == 0x7F000000 or
(n & 0xFFFF0000) == 0xA9FE0000
):
if ip.version == 4 and _is_private_int(int(ip)):
return True
if ip.version == 6 and (ip.is_loopback or ip.is_link_local
or ip in ipaddress.ip_network("fc00::/7")):
return True
return False
# ── IP 智能分流(域名表优先,非中国 IP 走隧道,中国 IP 直连) ─────
_CN_RANGES = None # 合并排序后的 (start, end) 整数段
_CN_RANGES_V6 = None # 合并排序后的中国 IPv6 整数段(数据源提供时启用)
_CN_RANGES_LOADED = False
_FORCE_PROXY_SUFFIXES = None
_FORCE_PROXY_EXACT = None
_FORCE_PROXY_REGEXES = None
_FORCE_PROXY_LOADED = False
_DIRECT_SUFFIX_TRIE = None
_DIRECT_EXACT = None
_DIRECT_REGEXES = None
_DIRECT_LOADED = False
_PAC_DATA_LOCK = threading.RLock()
# PAC 数据由 CDN 独立下发;此处保留登录/AI 服务的关键后缀兜底。
# 即使远端表暂时未更新或不可读,这些域名也不能因解析到中国 CDN IP 而直连。
@@ -1031,10 +1077,11 @@ _TLS_LOCAL_PORT = _port_val() # 本地转发器监听端口(设置 > 内置 c
PROXY_PORT = _TLS_LOCAL_PORT
# ── PAC 数据在线更新(CDN 直连,无需认证) ─────────────
# 更新源地址取 config.json 的 pac_base_url,目录下直接放两个数据文件:
# 更新源地址取 config.json 的 pac_base_url,目录下直接放三个数据文件:
# {pac_base_url}/pac_domains.json 强制隧道域名表(version/date 字段随文件下发)
# {pac_base_url}/pac_direct_domains.json 国内/用户指定直连域名表(同上)
# {pac_base_url}/pac_ip_cn.json 中国 IP 段(同上)
_PAC_FILES = ("pac_domains", "pac_ip_cn")
_PAC_FILES = ("pac_domains", "pac_direct_domains", "pac_ip_cn")
def _pac_read_version(path):
"""读取 PAC 数据文件内的 version/date 字段。"""
@@ -1062,7 +1109,7 @@ def _pac_fetch_json(url, timeout=15, tries=2):
raise
def _pac_validate(name, data):
if name == "pac_domains":
if name in ("pac_domains", "pac_direct_domains"):
return isinstance(data.get("domains"), list)
if name == "pac_ip_cn":
return isinstance(data.get("cidrs"), list)
@@ -1076,6 +1123,31 @@ def _pac_write(name, data):
tmp.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
os.replace(tmp, _USER_DIR / f"{name}.json")
def _invalidate_pac_routing_cache(name=None):
"""清除 PAC 数据更新后的进程内缓存,使新规则无需重启立即生效。"""
global _CN_RANGES, _CN_RANGES_V6, _CN_RANGES_LOADED
global _FORCE_PROXY_SUFFIXES, _FORCE_PROXY_EXACT, _FORCE_PROXY_REGEXES, _FORCE_PROXY_LOADED
global _DIRECT_SUFFIX_TRIE, _DIRECT_EXACT, _DIRECT_REGEXES, _DIRECT_LOADED
with _PAC_DATA_LOCK:
if name in (None, "pac_ip_cn"):
_CN_RANGES = None
_CN_RANGES_V6 = None
_CN_RANGES_LOADED = False
_is_china_ip.cache_clear()
if name in (None, "pac_domains"):
_FORCE_PROXY_SUFFIXES = None
_FORCE_PROXY_EXACT = None
_FORCE_PROXY_REGEXES = None
_FORCE_PROXY_LOADED = False
_is_force_proxy.cache_clear()
if name in (None, "pac_direct_domains"):
_DIRECT_SUFFIX_TRIE = None
_DIRECT_EXACT = None
_DIRECT_REGEXES = None
_DIRECT_LOADED = False
_is_direct_domain.cache_clear()
# ── nodes.json 加密存储(Windows DPAPI,本机本用户可解) ─────────────
# 非 Windows(dev/测试)不加密,读写明文;读取对密文/明文都兼容。
@@ -1275,7 +1347,9 @@ def _user_init_remote(base_url=None):
if rver:
if rver > lver:
_pac_write(name, data)
log(f"PAC 数据已更新: {name}.json {rver} ({rdate})")
_invalidate_pac_routing_cache(name)
if name != "pac_direct_domains":
log(f"PAC 数据已更新: {name}.json {rver} ({rdate})")
updated += 1
# 否则已是最新,跳过
else:
@@ -1284,7 +1358,9 @@ def _user_init_remote(base_url=None):
if local.is_file() and local.read_bytes() == new_bytes:
continue
_pac_write(name, data)
log(f"PAC 数据已下载: {name}.json 无版本号 ({rdate})")
_invalidate_pac_routing_cache(name)
if name != "pac_direct_domains":
log(f"PAC 数据已下载: {name}.json 无版本号 ({rdate})")
updated += 1
except Exception as e:
log(f"PAC 数据 {name}.json 更新失败,使用本地副本: {e}")
@@ -1292,39 +1368,50 @@ def _user_init_remote(base_url=None):
if updated == 0 and failed == 0 and (_USER_DIR / "pac_domains.json").is_file():
log("PAC 数据已是最新")
def _load_cn_ranges():
"""加载 pac_ip_cn.json 中的中国 IPv4 段,合并为整数区间供二分查找。"""
global _CN_RANGES, _CN_RANGES_LOADED
if _CN_RANGES_LOADED:
return _CN_RANGES
_CN_RANGES_LOADED = True
try:
import json, ipaddress
path = _user_path("pac_ip_cn.json")
if path is None:
log_red("未找到 pac_ip_cn.json,智能分流不可用(全部走隧道)")
return _CN_RANGES
data = json.loads(Path(path).read_text("utf-8"))
cidrs = data.get("cidrs") or []
ranges = []
for c in cidrs:
net = ipaddress.ip_network(str(c).strip(), strict=False)
if net.version != 4:
continue
def _merge_ip_ranges(cidrs, version):
"""将指定 IP 版本的 CIDR 合并为整数闭区间,供二分查找。"""
import ipaddress
ranges = []
for c in cidrs:
net = ipaddress.ip_network(str(c).strip(), strict=False)
if net.version == version:
ranges.append((int(net.network_address), int(net.broadcast_address)))
ranges.sort()
merged = []
for s, e in ranges:
if merged and s <= merged[-1][1] + 1:
if e > merged[-1][1]:
merged[-1] = (merged[-1][0], e)
else:
merged.append((s, e))
_CN_RANGES = merged
log(f"已加载中国 IP 段: {len(cidrs)} 条 CIDR,合并为 {len(merged)} 段")
except Exception as e:
log_red(f"pac_ip_cn.json 加载失败: {e}")
return _CN_RANGES
ranges.sort()
merged = []
for start, end in ranges:
if merged and start <= merged[-1][1] + 1:
if end > merged[-1][1]:
merged[-1] = (merged[-1][0], end)
else:
merged.append((start, end))
return merged
def _load_cn_ranges():
"""加载 pac_ip_cn.json 的中国 IPv4/IPv6 CIDR,合并为整数区间。"""
global _CN_RANGES, _CN_RANGES_V6, _CN_RANGES_LOADED
with _PAC_DATA_LOCK:
if _CN_RANGES_LOADED:
return _CN_RANGES
try:
import json
path = _user_path("pac_ip_cn.json")
if path is None:
log_red("未找到 pac_ip_cn.json,智能分流不可用(全部走隧道)")
return _CN_RANGES
data = json.loads(Path(path).read_text("utf-8"))
cidrs = data.get("cidrs") or []
_CN_RANGES = _merge_ip_ranges(cidrs, 4)
_CN_RANGES_V6 = _merge_ip_ranges(cidrs, 6)
v4_count = sum(1 for c in cidrs if ":" not in str(c))
v6_count = len(cidrs) - v4_count
log(f"已加载中国 IP 段: IPv4 {v4_count} 条/合并 {len(_CN_RANGES)} 段,"
f"IPv6 {v6_count} 条/合并 {len(_CN_RANGES_V6)} 段")
except Exception as e:
log_red(f"pac_ip_cn.json 加载失败: {e}")
finally:
_CN_RANGES_LOADED = True
return _CN_RANGES
def _load_force_proxy_suffixes():
"""加载 pac_domains.json 的后缀、精确和正则规则。
@@ -1334,59 +1421,136 @@ def _load_force_proxy_suffixes():
当成带冒号的普通后缀,导致它们永远不命中。
"""
global _FORCE_PROXY_SUFFIXES, _FORCE_PROXY_EXACT, _FORCE_PROXY_REGEXES, _FORCE_PROXY_LOADED
if _FORCE_PROXY_LOADED:
return _FORCE_PROXY_SUFFIXES
_FORCE_PROXY_LOADED = True
try:
import json
path = _user_path("pac_domains.json")
if path is None:
with _PAC_DATA_LOCK:
if _FORCE_PROXY_LOADED:
return _FORCE_PROXY_SUFFIXES
try:
import json
path = _user_path("pac_domains.json")
if path is None:
_FORCE_PROXY_SUFFIXES = sorted(_CORE_FORCE_PROXY_SUFFIXES)
_FORCE_PROXY_EXACT = frozenset()
_FORCE_PROXY_REGEXES = ()
log(f"未找到 pac_domains.json,使用 {len(_FORCE_PROXY_SUFFIXES)} 条关键域名兜底")
return _FORCE_PROXY_SUFFIXES
data = json.loads(Path(path).read_text("utf-8"))
suffixes = set(_CORE_FORCE_PROXY_SUFFIXES)
exact = set()
regexes = []
invalid_regexes = 0
for raw in data.get("domains") or []:
raw_rule = str(raw).strip()
rule = raw_rule.lower()
if not raw_rule:
continue
if rule.startswith("full:"):
host = rule[len("full:"):].rstrip(".")
if host:
exact.add(host)
continue
if rule.startswith("regexp:"):
# 正则本身可能含大写字符类或大小写敏感字面量;只忽略匹配时的
# 域名大小写,不能在编译前改写 pattern。
pattern = raw_rule[len("regexp:"):]
if not pattern:
invalid_regexes += 1
continue
try:
regexes.append(re.compile(pattern, re.IGNORECASE))
except re.error:
invalid_regexes += 1
continue
suffixes.add(rule.lstrip("*.").rstrip("."))
suffixes.discard("")
_FORCE_PROXY_SUFFIXES = suffixes
_FORCE_PROXY_EXACT = frozenset(exact)
_FORCE_PROXY_REGEXES = tuple(regexes)
log(f"已加载强制代理规则: 后缀 {len(suffixes)} 条、精确 {len(exact)} 条、"
f"正则 {len(regexes)} 条 (pac_domains.json)" +
(f",跳过 {invalid_regexes} 条非法正则" if invalid_regexes else ""))
except Exception as e:
log_red(f"pac_domains.json 加载失败: {e}")
_FORCE_PROXY_SUFFIXES = sorted(_CORE_FORCE_PROXY_SUFFIXES)
_FORCE_PROXY_EXACT = frozenset()
_FORCE_PROXY_REGEXES = ()
log(f"未找到 pac_domains.json,使用 {len(_FORCE_PROXY_SUFFIXES)} 条关键域名兜底")
return _FORCE_PROXY_SUFFIXES
data = json.loads(Path(path).read_text("utf-8"))
suffixes = set(_CORE_FORCE_PROXY_SUFFIXES)
exact = set()
regexes = []
invalid_regexes = 0
for raw in data.get("domains") or []:
raw_rule = str(raw).strip()
rule = raw_rule.lower()
if not raw_rule:
continue
if rule.startswith("full:"):
host = rule[len("full:"):].rstrip(".")
if host:
exact.add(host)
continue
if rule.startswith("regexp:"):
# 正则本身可能含大写字符类或大小写敏感字面量;只忽略匹配时的
# 域名大小写,不能在编译前改写 pattern。
pattern = raw_rule[len("regexp:"):]
if not pattern:
invalid_regexes += 1
finally:
_FORCE_PROXY_LOADED = True
return _FORCE_PROXY_SUFFIXES
def _build_suffix_trie(suffixes):
"""按反向域名标签建立 Trie,适合大规模后缀规则的 O(标签数) 匹配。"""
root = {}
for suffix in suffixes:
node = root
for label in reversed(suffix.split(".")):
node = node.setdefault(label, {})
node[""] = True
return root
def _suffix_trie_matches(host: str, trie) -> bool:
node = trie
for label in reversed(host.rstrip(".").lower().split(".")):
node = node.get(label)
if node is None:
return False
if node.get(""):
return True
return bool(node and node.get(""))
def _load_direct_domains():
"""加载 pac_direct_domains.json:PAC 智能分流中明确直连的域名规则。"""
global _DIRECT_SUFFIX_TRIE, _DIRECT_EXACT, _DIRECT_REGEXES, _DIRECT_LOADED
with _PAC_DATA_LOCK:
if _DIRECT_LOADED:
return _DIRECT_SUFFIX_TRIE
try:
import json
path = _user_path("pac_direct_domains.json")
if path is None:
_DIRECT_SUFFIX_TRIE, _DIRECT_EXACT, _DIRECT_REGEXES = {}, frozenset(), ()
return _DIRECT_SUFFIX_TRIE
data = json.loads(Path(path).read_text("utf-8"))
suffixes, exact, regexes = set(), set(), []
invalid_regexes = 0
for raw in data.get("domains") or []:
raw_rule = str(raw).strip()
rule = raw_rule.lower()
if not raw_rule:
continue
try:
regexes.append(re.compile(pattern, re.IGNORECASE))
except re.error:
invalid_regexes += 1
continue
suffixes.add(rule.lstrip("*.").rstrip("."))
suffixes.discard("")
_FORCE_PROXY_SUFFIXES = suffixes
_FORCE_PROXY_EXACT = frozenset(exact)
_FORCE_PROXY_REGEXES = tuple(regexes)
log(f"已加载强制代理规则: 后缀 {len(suffixes)} 条、精确 {len(exact)} 条、"
f"正则 {len(regexes)} 条 (pac_domains.json)" +
(f",跳过 {invalid_regexes} 条非法正则" if invalid_regexes else ""))
except Exception as e:
log_red(f"pac_domains.json 加载失败: {e}")
_FORCE_PROXY_SUFFIXES = sorted(_CORE_FORCE_PROXY_SUFFIXES)
_FORCE_PROXY_EXACT = frozenset()
_FORCE_PROXY_REGEXES = ()
return _FORCE_PROXY_SUFFIXES
if rule.startswith("full:"):
host = rule[len("full:"):].rstrip(".")
if host:
exact.add(host)
continue
if rule.startswith("regexp:"):
pattern = raw_rule[len("regexp:"):]
if not pattern:
invalid_regexes += 1
continue
try:
regexes.append(re.compile(pattern, re.IGNORECASE))
except re.error:
invalid_regexes += 1
continue
suffix = rule.lstrip("*.").rstrip(".")
if suffix:
suffixes.add(suffix)
_DIRECT_SUFFIX_TRIE = _build_suffix_trie(suffixes)
_DIRECT_EXACT = frozenset(exact)
_DIRECT_REGEXES = tuple(regexes)
log(f"已加载明确直连规则: 后缀 {len(suffixes)} 条、精确 {len(exact)} 条、"
f"正则 {len(regexes)} 条 (pac_direct_domains.json)" +
(f",跳过 {invalid_regexes} 条非法正则" if invalid_regexes else ""))
except Exception as e:
log_red(f"pac_direct_domains.json 加载失败: {e}")
_DIRECT_SUFFIX_TRIE, _DIRECT_EXACT, _DIRECT_REGEXES = {}, frozenset(), ()
finally:
_DIRECT_LOADED = True
return _DIRECT_SUFFIX_TRIE
def _is_private_int(n: int) -> bool:
"""整数形式的 IPv4 是否属于常用内网/回环段。"""
@@ -1397,11 +1561,13 @@ def _is_private_int(n: int) -> bool:
(n & 0xFFFF0000) == 0xA9FE0000)
@_ttl_cache(300, maxsize=16384)
def _is_china_ip(host: str):
"""判断目标是否命中中国 IP 段;域名会先解析。
def _resolve_china_direct_ip(host: str):
"""返回可安全直连的中国 IPv4/IPv6,或 None。
任一解析结果命中中国段 -> True(直连);全部不命中 -> False(走隧道);
解析失败或数据未加载 -> None(无法判定,安全起见走隧道)。
同一地址族的所有解析结果都必须在对应中国 IP 段内,才允许直连;混合
CDN 记录不会因其中一个中国 IP 而误判。优先 IPv4 保持原有兼容性;
数据源提供 IPv6 CIDR 后,IPv6-only 目标同样可直连。返回本次解析得到
的地址,让转发器直接连接它,避免判定后再次 DNS 解析到不同的地址。
"""
import ipaddress
try:
@@ -1411,7 +1577,7 @@ def _is_china_ip(host: str):
try:
import socket
addresses = []
for addr in socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM):
for addr in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM):
try:
addresses.append(ipaddress.ip_address(addr[4][0]))
except ValueError:
@@ -1420,20 +1586,37 @@ def _is_china_ip(host: str):
return None
if not addresses:
return None
ranges = _load_cn_ranges()
if not ranges:
return None
for ip in addresses:
if ip.version != 4:
continue
n = int(ip)
# 0.0.0.0 / 内网 / 回环解析结果疑似污染或局域网,不参与判定
if n == 0 or _is_private_int(n):
continue
i = bisect.bisect_right(ranges, (n, 0xFFFFFFFF))
if i > 0 and ranges[i - 1][0] <= n <= ranges[i - 1][1]:
return True
return False
_load_cn_ranges()
def family_target(version, ranges):
if not ranges:
return None
family = [ip for ip in addresses if ip.version == version]
if not family or any(not ip.is_global for ip in family):
return None
limit = (1 << (32 if version == 4 else 128)) - 1
for ip in family:
n = int(ip)
i = bisect.bisect_right(ranges, (n, limit))
if not (i > 0 and ranges[i - 1][0] <= n <= ranges[i - 1][1]):
return None
return str(family[0])
# IPv4 仍优先;IPv6-only 目标会在 PAC 数据加入 IPv6 CIDR 后走下一分支。
return family_target(4, _CN_RANGES) or family_target(6, _CN_RANGES_V6)
@_ttl_cache(300, maxsize=16384)
def _is_china_ip(host: str):
"""目标是否可按中国 IPv4/IPv6 直连;无法安全判定时返回 False。"""
return _resolve_china_direct_ip(host) is not None
_is_china_ip_cache_clear = _is_china_ip.cache_clear
def _clear_china_ip_cache():
_is_china_ip_cache_clear()
_resolve_china_direct_ip.cache_clear()
_is_china_ip.cache_clear = _clear_china_ip_cache
@functools.lru_cache(maxsize=8192)
def _is_force_proxy(host: str) -> bool:
@@ -1448,6 +1631,19 @@ def _is_force_proxy(host: str) -> bool:
h in exact or
any(pattern.search(h) is not None for pattern in regexes))
@functools.lru_cache(maxsize=32768)
def _is_direct_domain(host: str) -> bool:
"""PAC 明确直连规则命中的域名直连(后缀 / full / regexp)。"""
trie = _load_direct_domains()
exact = _DIRECT_EXACT or ()
regexes = _DIRECT_REGEXES or ()
if not trie and not exact and not regexes:
return False
h = host.rstrip(".").lower()
return (_suffix_trie_matches(h, trie) or h in exact or
any(pattern.search(h) is not None for pattern in regexes))
def _resolve_host_ips(host: str):
"""解析域名的 IPv4/IPv6 地址集合(字面量 IP 返回自身);解析失败返回空集合。"""
import ipaddress
@@ -1491,20 +1687,46 @@ def _is_whitelisted(host: str, version: int) -> bool:
def _decide_direct(host: str, bypass_lan: bool, smart: bool) -> bool:
"""判断连接是否直连。判定顺序(白名单最优先):
白名单命中 -> 直连;域名表命中 -> 隧道;内网 + bypass_lan -> 直连;
中国 IP -> 直连,其余 -> 隧道。
白名单命中 -> 直连;强制代理域名 -> 隧道;明确直连域名 -> 直连;
内网 + bypass_lan -> 直连;中国 IP -> 直连;其余 -> 隧道。
白名单在 PAC 与固定代理模式下均生效(固定模式除白名单外全部走隧道)。
"""
if _is_whitelisted(host, _WHITELIST_VERSION):
return True
if smart and _is_force_proxy(host):
return False
if smart and _is_direct_domain(host):
return True
if bypass_lan and _is_private_ip(host):
return True
if smart:
return _is_china_ip(host) is True
return False
def _direct_connect_target(host: str, port: int, bypass_lan: bool, smart: bool):
"""返回直连 socket 目标,或 None 表示交给上游隧道。
仅智能分流判定出的中国公网地址固定为本次 DNS 解析的 IP;白名单和
内网直连保持原始主机名,保留用户显式规则与 IPv6 的原有语义。
"""
if not _decide_direct(host, bypass_lan, smart):
return None
if _is_smart_china_direct(host, bypass_lan, smart):
ip = _resolve_china_direct_ip(host)
if ip is None: # 缓存过期或 DNS 状态变化时,安全起见改走隧道。
return None
return ip, port
return host, port
def _is_smart_china_direct(host: str, bypass_lan: bool, smart: bool) -> bool:
"""当前直连是否来自智能分流的中国公网判定,而非用户显式直连规则。"""
return (smart and not _is_whitelisted(host, _WHITELIST_VERSION)
and not _is_direct_domain(host)
and not (bypass_lan and _is_private_ip(host))
and _resolve_china_direct_ip(host) is not None)
def _parse_connect_authority(first_line: str):
"""从 CONNECT 首行提取 (host, port),失败返回 None。
@@ -1565,6 +1787,24 @@ def _force_http_close(buf: bytes) -> bytes:
out.append(b"Connection: close")
return b"\r\n".join(out) + tail
def _connect_route(direct_target, allow_direct_fallback, remote_host, remote_port, ctx, use_tls,
create_connection=None):
"""连接直连目标或上游隧道;智能中国直连失败时安全回退一次隧道。"""
import socket
create = create_connection or socket.create_connection
if direct_target:
try:
return create(direct_target, 10), False
except OSError as e:
if not allow_direct_fallback:
raise
log(f"[转发器] 国内直连 {direct_target[0]} 失败,回退上游隧道: {e}")
remote = create((remote_host, remote_port), 10)
if use_tls:
remote = ctx.wrap_socket(remote, server_hostname=remote_host)
return remote, True
def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, smart=False):
"""处理一条客户端连接:内网(bypass_lan)/中国IP(智能分流)目标直接连接;其余走远端隧道。"""
import socket, select
@@ -1574,6 +1814,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
is_connect = False
injector = None
direct_target = None
allow_direct_fallback = False
max_headers = 65536
try:
while len(buf) < max_headers:
@@ -1600,34 +1841,35 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
target = _parse_connect_authority(first_line)
else:
target = _parse_http_absolute(first_line)
if target and _decide_direct(target[0], bypass_lan, smart):
direct_target = target
if target:
direct_target = _direct_connect_target(
target[0], target[1], bypass_lan, smart)
allow_direct_fallback = (direct_target is not None and
_is_smart_china_direct(target[0], bypass_lan, smart))
except Exception as e:
log(f"[转发器] 读取首包失败: {e}")
client.close()
return
count_traffic = direct_target is None
remote = None
try:
if direct_target:
remote = socket.create_connection(direct_target, 10)
remote, via_tunnel = _connect_route(
direct_target, allow_direct_fallback, remote_host, remote_port, ctx, use_tls)
count_traffic = via_tunnel
if not via_tunnel:
if is_connect:
client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
buf = b""
else:
buf = _force_http_close(buf)
else:
remote = socket.create_connection((remote_host, remote_port), 10)
if use_tls:
remote = ctx.wrap_socket(remote, server_hostname=remote_host)
if is_http and b"\r\n\r\n" in buf:
if not is_connect:
injector = _make_request_injector()
buf = _inject_proxy_auth(buf)
if buf:
remote.sendall(buf)
if count_traffic:
if via_tunnel:
_traffic_add(up=len(buf))
except Exception as e:
log(f"[转发器] 连接目标失败: {e}")
@@ -2369,6 +2611,11 @@ class MainWindow(QWidget):
def __init__(self):
super().__init__()
self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的)
# 启动完成前仍允许用户取消。事件由 GUI 线程置位,后台启动线程只读取;
# 锁保护系统代理写入和取消清理之间的交接,避免遗留代理。
self._start_cancel_event = None
self._start_in_progress = False
self._proxy_state_lock = threading.RLock()
self._status_busy = False # 防止状态轮询线程堆叠
# 把全局 log() 函数接到 GUI 日志区
global _log_func
@@ -2595,8 +2842,20 @@ class MainWindow(QWidget):
event.accept() # 即使清理异常窗口也必须关闭
# ── 开关操作 ──
def _set_toggle_button_style(self, is_start=True):
if is_start:
def _set_toggle_button_style(self, is_start=True, is_cancelling=False):
if is_cancelling:
self.btn_toggle.setText("取消开启")
self.btn_toggle.setStyleSheet("""
QPushButton {
background-color: #cc8a2e; color: #fff; border: none;
border-radius: 8px; padding: 10px 24px;
font-size: 14px; font-weight: bold;
}
QPushButton:hover { background-color: #e1a246; }
QPushButton:pressed { background-color: #ad701d; }
QPushButton:disabled { background-color: #3a3020; color: #666; }
""")
elif is_start:
self.btn_toggle.setText("开启代理")
self.btn_toggle.setStyleSheet(f"""
QPushButton {{
@@ -2625,32 +2884,63 @@ class MainWindow(QWidget):
"""根据按钮当前文字判断是开启还是关闭。"""
if self.btn_toggle.text() == "开启代理":
self.on_start()
elif self.btn_toggle.text() == "取消开启":
self.on_cancel_start()
else:
self.on_stop()
def on_start(self):
if self._start_in_progress:
return
# 节点未配置:先弹出登录框,成功后继续;取消/失败则中止开启
if _endpoint_domain() == "???":
self._show_login_dialog()
if _endpoint_domain() == "???":
log_red("未登录,无法开启代理")
return
self.btn_toggle.setEnabled(False)
self.cb_pac.setEnabled(False)
self.cb_bypass_lan.setEnabled(False)
# 在 GUI 线程读取控件状态传参,避免子线程直接访问 Qt 控件(非线程安全)
use_pac = self.cb_pac.isChecked()
use_bypass_lan = self.cb_bypass_lan.isChecked()
threading.Thread(target=self._start_thread, args=(use_pac, use_bypass_lan), daemon=True).start()
def _start_thread(self, use_pac, use_bypass_lan):
self._start_cancel_event = threading.Event()
self._start_in_progress = True
# 网络探测可能需要数秒;此时保留按钮给用户取消,而不是把界面锁死。
self._set_toggle_button_style(is_cancelling=True)
self.btn_toggle.setEnabled(True)
threading.Thread(
target=self._start_thread,
args=(use_pac, use_bypass_lan, self._start_cancel_event),
daemon=True,
).start()
def _start_thread(self, use_pac, use_bypass_lan, cancel_event):
try:
self._start_proxy(use_pac, use_bypass_lan)
self._start_proxy(use_pac, use_bypass_lan, cancel_event)
_raise_if_start_cancelled(cancel_event)
self._op_done.emit("start", True)
except _StartCancelled:
# on_cancel_start 的清理线程负责把界面切回关闭状态。
log("开启代理已取消")
except Exception as e:
if cancel_event.is_set():
log("开启代理已取消")
return
log(f"[错误] 开启失败: {e}")
self._op_done.emit("start", False)
def _start_proxy(self, use_pac, use_bypass_lan):
def _cleanup_start_resources(self):
"""回收本次启动已建立的资源;仅恢复本程序实际写入过的系统代理。"""
with self._proxy_state_lock:
proxy_owned = self._proxy_set_by_us
self._proxy_set_by_us = False
if proxy_owned:
proxy_off()
else:
# 取消可能恰好发生在“转发器已开、系统代理尚未写入”之间。
_tls_stop()
_stop_pac_server()
def _start_proxy(self, use_pac, use_bypass_lan, cancel_event=None):
"""启动本地转发器并设置系统代理(PAC/固定两种模式),可在后台线程调用。"""
if _endpoint_domain() == "???":
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
@@ -2666,39 +2956,61 @@ class MainWindow(QWidget):
if use_pac:
_load_cn_ranges()
_load_force_proxy_suffixes()
_load_direct_domains()
_raise_if_start_cancelled(cancel_event)
_tls_start(ep, _endpoint_port(), use_tls=True, bypass_lan=use_bypass_lan, smart=use_pac)
if use_pac:
# 本地生成全量转发 PAC(不再从服务器拉取),全部流量交给转发器做智能分流
pac_content = _build_pac(f"{PROXY_HOST}:{PROXY_PORT}")
try:
_raise_if_start_cancelled(cancel_event)
if use_pac:
# 本地生成全量转发 PAC(不再从服务器拉取),全部流量交给转发器做智能分流
pac_content = _build_pac(f"{PROXY_HOST}:{PROXY_PORT}")
if use_bypass_lan:
pac_content = _apply_lan_bypass(pac_content)
# 在内存中启动本地 PAC HTTP 服务,不往 EXE 目录写任何文件
pac_port = _start_pac_server(pac_content)
pac_url = f"http://127.0.0.1:{pac_port}/proxy.pac"
log(f"使用 PAC: {pac_url}")
with self._proxy_state_lock:
_raise_if_start_cancelled(cancel_event)
proxy_on(pac_url=pac_url)
self._proxy_set_by_us = True
else:
with self._proxy_state_lock:
_raise_if_start_cancelled(cancel_event)
proxy_on(bypass_lan=use_bypass_lan)
self._proxy_set_by_us = True
_raise_if_start_cancelled(cancel_event)
if use_bypass_lan:
pac_content = _apply_lan_bypass(pac_content)
# 在内存中启动本地 PAC HTTP 服务,不往 EXE 目录写任何文件
pac_port = _start_pac_server(pac_content)
pac_url = f"http://127.0.0.1:{pac_port}/proxy.pac"
log(f"使用 PAC: {pac_url}")
proxy_on(pac_url=pac_url)
else:
proxy_on(bypass_lan=use_bypass_lan)
if use_bypass_lan:
log("不代理内网 IP:内网地址将直连")
self._proxy_set_by_us = True # 标记系统代理由本程序设置,退出时据此清理
log("验证代理连通性...")
import urllib.request
# 显式 ProxyHandler 强制经本地转发器;默认 opener 会套用系统/环境代理
# 设置(PAC 模式下注册表 ProxyEnable=0,urllib 认为无代理而绕开),造成假"可达"
opener = urllib.request.build_opener(
urllib.request.ProxyHandler({"http": f"{PROXY_HOST}:{PROXY_PORT}",
"https": f"{PROXY_HOST}:{PROXY_PORT}"}))
# baidu 验证国内直连路径,google 验证境外隧道路径
test_urls = [("baidu.com", "http://www.baidu.com"), ("google.com", "http://www.google.com")]
for name, url in test_urls:
log("不代理内网 IP:内网地址将直连")
log("验证代理连通性...")
_verify_proxy_connectivity(cancel_event=cancel_event)
except Exception:
try:
opener.open(url, timeout=10)
log(f" {name} 可达")
except Exception as e:
log(f" {name} 不可达: {e}")
# 连通性未通过(或用户取消)绝不能保留指向本地转发器的系统代理。
self._cleanup_start_resources()
except Exception as cleanup_error:
log(f"[错误] 开启失败后的代理清理失败: {cleanup_error}")
raise
log("开启成功")
def on_cancel_start(self):
"""在 Baidu/Google 启动探测期间立即取消并回收代理。"""
if not self._start_in_progress or self._start_cancel_event is None:
return
self._start_cancel_event.set()
self.btn_toggle.setEnabled(False)
threading.Thread(target=self._cancel_start_thread, daemon=True).start()
def _cancel_start_thread(self):
try:
log("正在取消开启代理...")
self._cleanup_start_resources()
log("已取消开启代理")
self._op_done.emit("cancel", True)
except Exception as e:
log(f"[错误] 取消开启代理失败: {e}")
self._op_done.emit("cancel", False)
def on_stop(self):
"""禁用按钮并在后台线程执行关闭流程。"""
self.btn_toggle.setEnabled(False)
@@ -2826,13 +3138,23 @@ class MainWindow(QWidget):
def _after_op(self, op, ok):
"""开启/关闭操作完成后刷新界面状态并恢复按钮/控件可用性。"""
if op in ("start", "cancel"):
self._start_in_progress = False
self._start_cancel_event = None
self.refresh_status()
self.btn_toggle.setEnabled(True)
# 恢复模式/PAC 控件的时机:关闭完成后、或开启失败时。
# 不能靠按钮文字判断——refresh_status 是异步的,此刻按钮样式还没更新
is_off = (op == "stop") or (op == "start" and not ok)
is_off = op == "stop" or (op == "cancel" and ok) or (op == "start" and not ok)
self.cb_pac.setEnabled(is_off)
self.cb_bypass_lan.setEnabled(is_off)
if op == "start" and ok:
self._set_toggle_button_style(is_start=False)
elif is_off:
self._set_toggle_button_style(is_start=True)
elif op == "cancel":
# 取消清理失败时保留“关闭”入口,供用户再次显式清理,而非误显示为未开启。
self._set_toggle_button_style(is_start=False)
self._update_node_label()
# ── 日志 ──
_early_logs = [] # 窗口初始化期间(log_text 创建前)到达的日志缓存
@@ -2881,12 +3203,14 @@ class MainWindow(QWidget):
self.px_dot.set_on()
self.px_status.setText("已开启")
self.px_status.setStyleSheet(f"color: {C_GREEN}; background: transparent;")
self._set_toggle_button_style(is_start=False)
if not self._start_in_progress:
self._set_toggle_button_style(is_start=False)
else:
self.px_dot.set_off()
self.px_status.setText("已关闭")
self.px_status.setStyleSheet(f"color: {C_RED}; background: transparent;")
self._set_toggle_button_style(is_start=True)
if not self._start_in_progress:
self._set_toggle_button_style(is_start=True)
# 流量卡片(始终更新,未连接时显示 0)
px_on = is_proxy_on()
@@ -3046,7 +3370,13 @@ class MainWindow(QWidget):
本程序异常退出残留的代理设置由下次启动时 _cleanup_stale_proxy() 处理。
"""
if self._proxy_set_by_us:
if self._start_in_progress and self._start_cancel_event is not None:
self._start_cancel_event.set()
try:
self._cleanup_start_resources()
except Exception:
pass # 清理失败也不能阻止退出,残留交给下次启动清理
elif self._proxy_set_by_us:
try:
proxy_off()
except Exception: