修复:- 系统代理设置保存/恢复:覆盖前备份原 ProxyEnable/ProxyServer/ProxyOverride/AutoConfigURL(内存+settings.json 防崩溃丢失),关闭/退出时恢复用户原有代理而不是清空;启动残留清理只认本程序精确地址(127.0.0.1:1080 精确匹配 + PAC URL 正则),不再误删其他代理软件(如 Clash)在 127.0.0.1 上的 PAC

- CONNECT 目标解析支持 IPv6 字面量:剥掉方括号再拆 host:port,[::1]:443 不再解析成 "[::1]"(直连/白名单/内网绕过判定失效的根因)
- 更新包校验加固:release 附件按 RRPP_Lite_<tag>.exe 名字精确匹配(不再固定取第一个);下载后与 API 记录的 size 比对;发布流程附带 .sha256 边车时校验 SHA-256
- 上游代理认证注入按完整头名精确匹配:Proxy-Authorization 不再误判为已有 Authorization;注入位置紧跟请求行保证上游读到我们的认证
- 远程同步后节点按 settings 记录的域名重匹配:服务端调整节点顺序时不再静默连到别的节点;启动校验等待订阅同步完成(最多 15 秒)后再执行
- DNS 分流判定缓存加 300 秒 TTL:域名换 IP 后不再长期沿用旧的直连/隧道判断(_is_private_ip/_is_china_ip/_is_whitelisted 换 _ttl_cache,保留 cache_clear 兼容)
- is_proxy_on 状态判定同步收紧为本程序 PAC 地址(其他软件的 PAC 不再让状态灯误亮)
This commit is contained in:
RoyirinRosmontis
2026-09-11 01:56:04 +08:00
parent cb3552f595
commit 23ba368737
3 changed files with 681 additions and 80 deletions
+263 -80
View File
@@ -102,6 +102,7 @@ def _load_nodes():
_SELECTED_NODE = 0 # 当前选中节点下标(默认第一条)
_SELECTED_RESTORED = False # 启动时是否已按设置恢复过上次选中的节点
_NODES_SYNC_DONE = threading.Event() # 订阅同步线程完成信号(启动校验等它)
def _current_node():
"""当前使用的节点:默认取列表第一个;无节点时返回 None。"""
@@ -270,20 +271,42 @@ def _first_reachable_index(nodes):
return i
return 0
def _verify_selected_node():
"""启动时校验上次选中的节点在本机是否还可用。
def _rematch_saved_domain():
"""按 settings 的 current_node(域名)在最新节点列表里重匹配下标。
典型场景:上次选了 IPv6 专用节点,这次本机没有 IPv6 出口(或该节点已不可达),
继续选中它只会在开启代理时才报错。这里自动回退到第一个可达节点并记录到设置。
返回 True 表示发生了切换(调用方据此刷新标题栏)。"""
远程同步后服务端可能调整节点顺序:只按下标会静默连到别的节点。
返回 True 表示发生了切换。"""
global _SELECTED_NODE
nodes = _load_nodes()
if not nodes:
return False
saved = str(_settings_load().get("current_node") or "")
cur = nodes[_SELECTED_NODE] if 0 <= _SELECTED_NODE < len(nodes) else None
if saved and (not cur or str(cur.get("domain") or "") != saved):
match = next((i for i, n in enumerate(nodes)
if str(n.get("domain") or "") == saved), None)
if match is not None:
_SELECTED_NODE = match
log(f"节点列表已更新,继续使用上次选择的节点 {saved}")
return True
return False
def _verify_selected_node():
"""启动校验,返回 True 表示发生了切换(调用方据此刷新标题栏)。
两步:① 按设置记录的域名在最新节点列表里重匹配(服务端调过顺序时
下标会连到别的节点);② 选中节点在本机不可达(典型:上次选了 IPv6
专用节点,这次本机没有 IPv6 出口)时自动回退到第一个可达节点。"""
global _SELECTED_NODE
nodes = _load_nodes()
switched = _rematch_saved_domain()
if not nodes or not (0 <= _SELECTED_NODE < len(nodes)):
return False
if _node_reachable(nodes[_SELECTED_NODE]):
return False
return switched
cur = nodes[_SELECTED_NODE]
if _node_reachable(cur):
return switched
idx = _first_reachable_index(nodes)
old = str(nodes[_SELECTED_NODE].get("domain") or "")
old = str(cur.get("domain") or "")
new = str(nodes[idx].get("domain") or "")
_SELECTED_NODE = idx
_save_current_node(new)
@@ -479,8 +502,76 @@ def _notify_proxy_change():
pass
def _reg_query_value(name):
"""查询 HKCU Internet Settings 下指定值,返回 (类型, 数据);值不存在返回 None。"""
r = _run(["reg", "query", PROXY_REG, "/v", name], warn=False)
if r.returncode != 0:
return None
for line in (r.stdout or "").splitlines():
if name in line:
parts = line.strip().split(None, 2) # 形如 "ProxyServer REG_SZ 127.0.0.1:7897"
if len(parts) >= 3:
return parts[1], parts[2]
return None
def _is_our_pac_url(value: str) -> bool:
"""value 是否指向本程序的本地 PAC 服务(http://127.0.0.1:<端口>/proxy.pac)。"""
import re
return bool(re.fullmatch(r"http://127\.0\.0\.1:\d+/proxy\.pac", value.strip()))
_PROXY_BACKUP_KEY = "prev_proxy_state" # settings.json 里的系统代理原值备份
_proxy_prev = None # 运行期备份:None = 尚未备份
def _backup_proxy_state():
"""覆盖系统代理前把原值备份下来(内存 + settings.json,防崩溃丢失)。
只在尚未备份时执行:本次运行里第一次开启代理时的注册表状态才是用户
原有的(可能是其他代理软件的值),之后反复开/关不应覆盖首次备份。"""
global _proxy_prev
if _proxy_prev is not None:
return
state = {}
for name in ("ProxyEnable", "ProxyServer", "ProxyOverride", "AutoConfigURL"):
v = _reg_query_value(name)
state[name] = list(v) if v else None
_proxy_prev = state
try:
_settings_save({**_settings_load(), _PROXY_BACKUP_KEY: state})
except Exception:
pass # 持久化失败只影响崩溃后的恢复,不影响本次运行
def _restore_proxy_state():
"""恢复开启代理前的系统代理原值;没有备份时退化为清空(历史行为)。"""
global _proxy_prev
state = _proxy_prev
if state is None:
state = _settings_load().get(_PROXY_BACKUP_KEY)
if isinstance(state, dict):
for name in ("ProxyEnable", "ProxyServer", "ProxyOverride", "AutoConfigURL"):
v = state.get(name)
if v:
_run(["reg", "add", PROXY_REG, "/v", name, "/t", v[0], "/d", v[1], "/f"], warn=False)
else:
_run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
log("已恢复开启代理前的系统代理设置")
else:
# 无备份(旧版本升级后首次运行):清空,与历史行为一致
for name in ("ProxyServer", "ProxyOverride", "AutoConfigURL"):
_run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
_run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"])
_proxy_prev = None
# 备份已消费:清除持久化副本,避免下次启动误恢复
try:
data = _settings_load()
if isinstance(data, dict) and _PROXY_BACKUP_KEY in data:
del data[_PROXY_BACKUP_KEY]
_settings_save(data)
except Exception:
pass
def proxy_on(pac_url=None, bypass_lan=False):
"""设置 Windows 系统代理。pac_url 不为空时启用 PAC 模式,否则使用固定代理。"""
_backup_proxy_state() # 覆盖前保存用户原有代理设置,关闭时恢复而不是清空
if pac_url:
# PAC 模式:仅设置 AutoConfigURL,ProxyEnable 置 0,
# 避免程序读到"固定代理已启用但服务器为空"的混乱状态
@@ -501,11 +592,7 @@ def proxy_on(pac_url=None, bypass_lan=False):
def proxy_off():
"""关闭系统代理并停止 TLS 转发器 / PAC 本地服务。"""
log("正在关闭系统代理...")
_run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"])
# 清除 ProxyServer / ProxyOverride / AutoConfigURL,确保两种模式都清理干净
_run(["reg", "delete", PROXY_REG, "/v", "ProxyServer", "/f"], warn=False)
_run(["reg", "delete", PROXY_REG, "/v", "ProxyOverride", "/f"], warn=False)
_run(["reg", "delete", PROXY_REG, "/v", "AutoConfigURL", "/f"], warn=False)
_restore_proxy_state() # 恢复用户原有代理设置,而不是简单清空
log("系统代理已关闭")
_notify_proxy_change()
_tls_stop()
@@ -628,17 +715,28 @@ def _inject_proxy_auth(data: bytes) -> bytes:
nginx 的 auth_basic 校验 Authorization 而非 Proxy-Authorization,
因此转发器向上游发送 Basic 认证时使用 Authorization 头。
只按完整头名精确匹配(行首到冒号):Proxy-Authorization 等含
"authorization" 字样的行不算已有认证,不能阻止注入;目标站自己的
Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行,
保证上游(取首个 Authorization)读到的是我们的认证。
"""
if not _starts_with_method(data):
return data
end = data.find(b"\r\n\r\n")
if end == -1:
return data
headers_lower = data[:end].lower()
if b"authorization:" in headers_lower:
return data
head = data[:end]
first_end = head.find(b"\r\n")
request_line = head if first_end == -1 else head[:first_end]
headers = b"" if first_end == -1 else head[first_end + 2:]
for line in headers.split(b"\r\n"):
if b":" not in line:
continue
name = line[:line.index(b":")].strip().lower()
if name == b"authorization":
return data # 已有目标站自己的 Authorization,不再注入
auth_line = f"Authorization: Basic {_proxy_auth_basic()}\r\n".encode()
return data[:end + 2] + auth_line + data[end + 2:]
return request_line + b"\r\n" + auth_line + headers + data[end:]
def _make_request_injector():
"""为明文 HTTP 代理连接生成逐条注入认证的流式处理器。
@@ -661,7 +759,43 @@ def _make_request_injector():
return b""
return feed
@functools.lru_cache(maxsize=256)
def _ttl_cache(seconds: int, maxsize: int = 16384):
"""带过期时间的缓存装饰器(替代无 TTL 的 lru_cache)。
域名分流判定结果长期缓存会让域名换 IP 后继续沿用旧的直连/隧道判断;
到期自动失效重查。保留 cache_clear() 兼容(测试脚本在用)。"""
def deco(func):
store = {}
lock = threading.Lock()
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
now = time.time()
with lock:
hit = store.get(key)
if hit and now - hit[0] < seconds:
return hit[1]
val = func(*args, **kwargs)
with lock:
store[key] = (now, val)
if len(store) > maxsize:
cutoff = now - seconds
for k, (t, _) in list(store.items()):
if t < cutoff:
del store[k]
if len(store) > maxsize:
store.clear() # 极端情况下全清:最坏只是多几次解析
return val
def cache_clear():
with lock:
store.clear()
wrapper.cache_clear = cache_clear
return wrapper
return deco
@_ttl_cache(300, maxsize=2048)
def _is_private_ip(host: str) -> bool:
"""判断目标是否命中常用内网/回环 IPv4;域名会解析后判断。"""
try:
@@ -1029,31 +1163,34 @@ def _auth_load():
def _nodes_sync_remote():
"""启动时用已保存凭据拉取最新节点配置(内容比对,变化才覆盖)。"""
cred = _auth_load()
if not cred:
return # 从未登录过,等登录框处理
url = str(_config_val("nodes_url", "") or "").strip()
if not url:
return
try:
import json
data = _fetch_nodes_auth(url, cred["username"], cred["password"])
if not (isinstance(data, list) and data
and all(isinstance(n, dict) for n in data)
and str(data[0].get("domain") or "").strip()):
raise ValueError("返回的节点数据无效")
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
local = _USER_DIR / "nodes.json"
if local.is_file() and _nodes_decrypt(local.read_bytes()) == text:
log("节点配置已是最新")
cred = _auth_load()
if not cred:
return # 从未登录过,等登录框处理
url = str(_config_val("nodes_url", "") or "").strip()
if not url:
return
_USER_DIR.mkdir(parents=True, exist_ok=True)
local.write_bytes(_nodes_encrypt(text))
global _NODES
_NODES = None # 使后续读取(节点菜单/开启代理)拿到新配置
log(f"节点配置已更新: {len(data)} 个节点 (nodes.json)")
except Exception as e:
log(f"节点配置在线更新失败,使用本地配置: {e}")
try:
import json
data = _fetch_nodes_auth(url, cred["username"], cred["password"])
if not (isinstance(data, list) and data
and all(isinstance(n, dict) for n in data)
and str(data[0].get("domain") or "").strip()):
raise ValueError("返回的节点数据无效")
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
local = _USER_DIR / "nodes.json"
if local.is_file() and _nodes_decrypt(local.read_bytes()) == text:
log("节点配置已是最新")
return
_USER_DIR.mkdir(parents=True, exist_ok=True)
local.write_bytes(_nodes_encrypt(text))
global _NODES
_NODES = None # 使后续读取(节点菜单/开启代理)拿到新配置
log(f"节点配置已更新: {len(data)} 个节点 (nodes.json)")
except Exception as e:
log(f"节点配置在线更新失败,使用本地配置: {e}")
finally:
_NODES_SYNC_DONE.set() # 无论成败都要放行启动校验
class _AuthError(Exception):
"""订阅接口返回的错误:code 为 HTTP 状态码,str 为服务端返回的中文提示。"""
@@ -1208,7 +1345,7 @@ def _is_private_int(n: int) -> bool:
(n & 0xFF000000) == 0x7F000000 or
(n & 0xFFFF0000) == 0xA9FE0000)
@functools.lru_cache(maxsize=16384)
@_ttl_cache(300, maxsize=16384)
def _is_china_ip(host: str):
"""判断目标是否命中中国 IP 段;域名会先解析。
@@ -1289,7 +1426,7 @@ def _whitelist_entry_match(host: str, entry: str) -> bool:
# 域名条目:精确匹配或子域后缀匹配(与 pac_domains 同一规则)
return h == entry or h.endswith("." + entry)
@functools.lru_cache(maxsize=8192)
@_ttl_cache(300, maxsize=8192)
def _is_whitelisted(host: str, version: int) -> bool:
"""host 是否命中已启用的白名单规则(按版本键控缓存,规则变更即失效)。"""
entries = [r["value"] for r in _whitelist_rules() if r.get("enabled")]
@@ -1314,11 +1451,24 @@ def _decide_direct(host: str, bypass_lan: bool, smart: bool) -> bool:
return False
def _parse_connect_authority(first_line: str):
"""从 CONNECT 首行提取 (host, port),失败返回 None。"""
"""从 CONNECT 首行提取 (host, port),失败返回 None。
IPv6 字面量带方括号(CONNECT [::1]:443):剥掉括号再按最后一个冒号拆分,
否则会把 "[::1]" 当成主机名,直连/白名单判定全部失效。"""
parts = first_line.split(" ")
if len(parts) < 2:
return None
authority = parts[1]
if authority.startswith("[") and "]" in authority:
# 括号必须紧跟在主机名部分之后([::1]:443);"[::1" 无右括号等畸形输入
# 会因 "]:" 检查失败而走下面的普通拆分,最终被 port_s.isdigit() 拦下
try:
host = authority[1:authority.index("]")]
tail = authority[authority.index("]") + 1:]
if tail.startswith(":") and tail[1:].isdigit():
return host, int(tail[1:])
except ValueError:
pass
if ":" not in authority:
return None
host, _, port_s = authority.rpartition(":")
@@ -1501,27 +1651,28 @@ def _release_forwarder(started_here):
def is_proxy_on():
"""系统代理是否生效:固定代理已开启,或 AutoConfigURL 指向本地 PAC 服务。"""
r = _run(["reg", "query", PROXY_REG, "/v", "ProxyEnable"], warn=False)
if "0x1" in (r.stdout or ""):
v = _reg_query_value("ProxyEnable")
if v and "0x1" in (v[1] or ""):
return True
r = _run(["reg", "query", PROXY_REG, "/v", "AutoConfigURL"], warn=False)
return "127.0.0.1" in (r.stdout or "")
v = _reg_query_value("AutoConfigURL")
return bool(v) and _is_our_pac_url(v[1] or "")
def _cleanup_stale_proxy():
"""清理上次异常退出残留的系统代理设置。
"""清理上次异常退出残留的系统代理设置,并恢复用户原有设置。
TLS 转发器 / PAC 服务只活在进程内,程序启动时若注册表仍指向
本地转发端口或本地 PAC 地址,说明是残留——不清理会导致全网不通。
只认本程序的精确地址:其他软件(如 Clash)的 PAC 也在 127.0.0.1 上,
不能因为地址里带 127.0.0.1 就误删。
"""
try:
if not is_proxy_on():
server = _reg_query_value("ProxyServer")
pac = _reg_query_value("AutoConfigURL")
ours = (bool(server) and (server[1] or "").strip() == f"{PROXY_HOST}:{PROXY_PORT}") or \
(bool(pac) and _is_our_pac_url(pac[1] or ""))
if not ours:
return
r = _run(["reg", "query", PROXY_REG, "/v", "ProxyServer"], warn=False)
r2 = _run(["reg", "query", PROXY_REG, "/v", "AutoConfigURL"], warn=False)
stale = (f"{PROXY_HOST}:{PROXY_PORT}" in (r.stdout or "")
or "127.0.0.1" in (r2.stdout or ""))
if stale:
proxy_off()
proxy_off() # 内部从备份恢复用户原有设置(无备份则清空)
except Exception:
pass
@@ -2198,9 +2349,10 @@ class MainWindow(QWidget):
def _verify_node_thread(self):
"""等节点配置在线同步完成后,校验当前选中节点是否可用,必要时自动回退。
探测要解析 DNS 并尝试 TCP 连接,放在后台线程,避免卡住启动。"""
import time
time.sleep(1.5)
探测要解析 DNS 并尝试 TCP 连接,放在后台线程,避免卡住启动。
订阅同步可能较慢:等同步完成(最多 15 秒),同步后才校验,
否则服务端换过节点顺序时校验的是旧列表。"""
_NODES_SYNC_DONE.wait(15)
try:
if _verify_selected_node():
self._node_switched.emit()
@@ -2695,6 +2847,8 @@ class MainWindow(QWidget):
# 先初始化结果,任何路径(含跳过)完成后 _on_update_done 都能安全读取
self._latest_version = ""
self._download_url = ""
self._asset_size = 0 # release API 记录的附件大小,下载后比对
self._checksum_url = "" # SHA-256 边车附件链接(无则跳过校验)
try:
if _skip_update_check() and not manual:
return # 设置跳过(仅自动检查):不检查也不打日志
@@ -2721,11 +2875,23 @@ class MainWindow(QWidget):
req = urllib.request.Request(url)
data = opener.open(req, timeout=20).read().decode("utf-8-sig")
info = json.loads(data)
# 基于 Gitea 仓库 releases API:版本取 tag_name,下载取 exe 附件链接
# 基于 Gitea 仓库 releases API:版本取 tag_name,下载取 exe 附件链接。
# 附件可能有多个(发布流程附带 .sha256 边车),按名字精确匹配而不是
# 固定取第一个,避免附件顺序变化时下载到错文件。
self._latest_version = str(info.get("tag_name") or "")
assets = info.get("assets") or []
self._download_url = (assets[0].get("browser_download_url") or ""
if assets else "")
wanted = f"RRPP_Lite_{self._latest_version}.exe" if self._latest_version else ""
exe_asset = next((a for a in assets if a.get("name") == wanted), None)
if exe_asset is None:
# 老 release 命名不齐时回退到任意 exe 附件
exe_asset = next((a for a in assets
if str(a.get("name") or "").lower().endswith(".exe")), None)
if exe_asset is None:
raise RuntimeError("release 中没有可用的 EXE 附件")
self._download_url = exe_asset.get("browser_download_url") or ""
self._asset_size = int(exe_asset.get("size") or 0)
sha_asset = next((a for a in assets if a.get("name") == wanted + ".sha256"), None)
self._checksum_url = (sha_asset.get("browser_download_url") or "") if sha_asset else ""
log(f"当前版本: {VERSION},最新版本: {self._latest_version or '未知'}")
except Exception as e:
log(f"更新检查失败: {e}")
@@ -2748,9 +2914,15 @@ class MainWindow(QWidget):
threading.Thread(target=self._download_update, daemon=True).start()
def _download_update(self):
"""后台下载新版本 EXE 到 EXE 所在目录。"""
"""后台下载新版本 EXE 到 EXE 所在目录。
落盘前校验:① 大小与 release API 记录一致(传输完整性);
② 发布流程附带 .sha256 边车时校验 SHA-256(防链路被攻破/文件被替换)。
边车缺失(老 release)只做大小校验。
"""
try:
import urllib.request, urllib.parse, base64
import hashlib
import urllib.request, urllib.parse
url = self._download_url
if not url:
self._update_download_done.emit(False, "下载链接为空")
@@ -2762,29 +2934,40 @@ class MainWindow(QWidget):
log(f"正在下载新版本: {url}")
log(f"保存到: {save_path}")
# 同更新检查:按「使用代理更新」设置选择隧道或直连
if _update_via_proxy():
started_here = _ensure_forwarder()
try:
def fetch(target):
# 同更新检查:按「使用代理更新」设置选择隧道或直连
if _update_via_proxy():
opener = urllib.request.build_opener(urllib.request.ProxyHandler(
{"http": f"{PROXY_HOST}:{PROXY_PORT}",
"https": f"{PROXY_HOST}:{PROXY_PORT}"}))
req = urllib.request.Request(url)
else:
opener = _pac_no_proxy_opener()
with opener.open(urllib.request.Request(target), timeout=120) as resp:
return resp.read()
with opener.open(req, timeout=120) as resp:
data = resp.read()
finally:
_release_forwarder(started_here)
else:
opener = _pac_no_proxy_opener()
req = urllib.request.Request(url)
with opener.open(req, timeout=120) as resp:
data = resp.read()
started_here = _ensure_forwarder() if _update_via_proxy() else False
try:
data = fetch(url)
checksum_text = ""
if self._checksum_url:
checksum_text = fetch(self._checksum_url).decode("ascii", "replace")
finally:
_release_forwarder(started_here)
if len(data) < 1024:
self._update_download_done.emit(False, "下载文件过小,可能不是有效的 EXE")
return
if self._asset_size and len(data) != self._asset_size:
self._update_download_done.emit(
False, f"下载不完整:实际 {len(data)} 字节,应为 {self._asset_size} 字节")
return
if checksum_text:
want = checksum_text.strip().split()[0].lower()
got = hashlib.sha256(data).hexdigest()
if len(want) != 64 or want != got:
self._update_download_done.emit(False, "更新包校验失败(SHA-256 不匹配),已放弃")
return
log("更新包 SHA-256 校验通过")
save_path.write_bytes(data)
log(f"新版本已下载: {save_path}")