- 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 不再让状态灯误亮)
105 lines
4.8 KiB
Python
105 lines
4.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""离屏验证节点菜单:IPv6-only 节点的标注与置灰(真实 PyQt5,不出窗口)。
|
||
|
||
需要 Windows venv 的真实 PyQt5:
|
||
..\\.venv\\Scripts\\python.exe test\\check_node_menu.py
|
||
读取 test/nodes.json 作为节点数据,只构建菜单不进事件循环。
|
||
"""
|
||
import os, sys
|
||
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||
from pathlib import Path
|
||
|
||
LITE = Path(__file__).resolve().parent.parent
|
||
# 脚本所在目录(test/)会被解释器自动放到 sys.path 首位,那里有 PyQt5 桩,
|
||
# 会顶掉真实 PyQt5 —— 必须移除,改用本程序自己的 venv
|
||
sys.path = [str(LITE)] + [p for p in sys.path if Path(p).resolve() != Path(__file__).resolve().parent]
|
||
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
|
||
import proxy_lite as pp
|
||
from PyQt5.QtWidgets import QApplication
|
||
|
||
# 构造窗口时不写用户数据目录(避免污染),构造后再指向真实 nodes.json
|
||
pp._user_init_local = lambda: None
|
||
|
||
app = QApplication(sys.argv)
|
||
w = pp.MainWindow()
|
||
w.timer.stop() # 停掉状态轮询,纯静态检查
|
||
|
||
pp._USER_DIR = LITE / "test" # 真实 nodes.json 所在目录
|
||
nodes = pp._reload_nodes()
|
||
print(f"本机有全局 IPv6: {pp._local_has_ipv6()}")
|
||
print(f"节点数: {len(nodes)}")
|
||
|
||
menu = w._build_node_menu(nodes)
|
||
print("\n菜单项(enabled / 文案 / tooltip):")
|
||
for act in menu.actions():
|
||
print(f" {str(act.isEnabled()):5} | {act.text()}")
|
||
if act.toolTip():
|
||
print(f" └ {act.toolTip()}")
|
||
|
||
# 点击被禁用的项不应切换节点
|
||
before = pp._selected_index()
|
||
for act in menu.actions():
|
||
if not act.isEnabled():
|
||
act.trigger()
|
||
print(f"\n点击禁用项后节点下标: {before} -> {pp._selected_index()} (应不变)")
|
||
|
||
# ── 上次选中的节点已不可用:启动时自动回退到第一个可达节点 ──────────
|
||
print("\n启动校验(_verify_selected_node):")
|
||
bad = [i for i, n in enumerate(nodes) if not pp._node_reachable(n)]
|
||
if not bad:
|
||
print(" 本机所有节点均可达,无法验证回退(本机有 IPv6?)")
|
||
else:
|
||
import json
|
||
_bak = (pp._USER_DIR / "settings.json").read_bytes() if (pp._USER_DIR / "settings.json").is_file() else None
|
||
bad_idx = bad[0]
|
||
pp._SELECTED_NODE = bad_idx # 模拟上次选了不可用节点
|
||
switched = pp._verify_selected_node()
|
||
expect = pp._first_reachable_index(nodes)
|
||
ok = switched and pp._selected_index() == expect
|
||
print(f" 上次选中 {nodes[bad_idx].get('domain')}(下标 {bad_idx}) 不可用 -> "
|
||
f"回退到下标 {pp._selected_index()} ({nodes[pp._selected_index()].get('domain')}) "
|
||
f"{'PASS' if ok else 'FAIL'}")
|
||
saved = json.loads((pp._USER_DIR / "settings.json").read_text("utf-8")).get("current_node") if (pp._USER_DIR / "settings.json").is_file() else ""
|
||
ok2 = saved == str(nodes[expect].get("domain") or "")
|
||
print(f" 回退结果已写入设置 current_node={saved!r} {'PASS' if ok2 else 'FAIL'}")
|
||
# 再校验一次:已经是可达节点,不应再切换
|
||
ok3 = pp._verify_selected_node() is False
|
||
print(f" 再次校验不重复切换 {'PASS' if ok3 else 'FAIL'}")
|
||
if _bak is None:
|
||
(pp._USER_DIR / "settings.json").unlink(missing_ok=True)
|
||
else:
|
||
(pp._USER_DIR / "settings.json").write_bytes(_bak)
|
||
|
||
# ── 服务端调整节点顺序:按域名重匹配而不是死守下标 ──────────────────
|
||
print("\n服务端调序(_rematch_saved_domain):")
|
||
if len(nodes) < 2:
|
||
print(" 节点不足 2 个,跳过")
|
||
else:
|
||
import json
|
||
dom_old = nodes[0].get("domain")
|
||
ordered = [nodes[1], nodes[0]] + nodes[2:] # 模拟服务端交换了前两个节点
|
||
pp._NODES = ordered
|
||
# 设置里记录的是原下标 0 的域名,重匹配后下标应跟随域名(新列表里是 1)
|
||
pp._settings_save({**pp._settings_load(), "current_node": dom_old})
|
||
switched = pp._rematch_saved_domain()
|
||
ok = switched and ordered[pp._selected_index()].get("domain") == dom_old
|
||
print(f" current_node={dom_old} 调序后跟随域名 -> 下标 {pp._selected_index()} "
|
||
f"{'PASS' if ok else 'FAIL'}")
|
||
# 已是正确域名时再跑一次:不应重复切换
|
||
ok2 = pp._rematch_saved_domain() is False
|
||
print(f" 再次重匹配不重复切换 {'PASS' if ok2 else 'FAIL'}")
|
||
# 域名已不在列表(节点被移除):不切换,保持现状
|
||
pp._settings_save({**pp._settings_load(), "current_node": "gone.example.com"})
|
||
ok3 = pp._rematch_saved_domain() is False
|
||
print(f" 节点被移除时不动 {'PASS' if ok3 else 'FAIL'}")
|
||
pp._NODES = None # 还原缓存,后续用例重读文件
|
||
# 还原 settings.json,避免测试污染
|
||
if _bak is None:
|
||
(pp._USER_DIR / "settings.json").unlink(missing_ok=True)
|
||
else:
|
||
(pp._USER_DIR / "settings.json").write_bytes(_bak)
|
||
|
||
print("OK")
|