修复:完善 PAC 分流与启动取消
This commit is contained in:
+497
-167
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user