diff --git a/.gitignore b/.gitignore index 7a60b85..6def320 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,13 @@ __pycache__/ *.pyc + +# Local development state and reproducible build outputs. +.claude/ +lite/.claude/ +lite/backups/ +lite/build/ +lite/dist/ + +# Runtime PAC downloads generated by the UI smoke test. +lite/test/pac_direct_domains.json +lite/test/pac_domains.json diff --git a/lite/MODULE_SPLIT_PLAN.md b/lite/MODULE_SPLIT_PLAN.md new file mode 100644 index 0000000..06db1d5 --- /dev/null +++ b/lite/MODULE_SPLIT_PLAN.md @@ -0,0 +1,148 @@ +# RRPP Lite 模块拆分计划 + +## 目标与边界 + +将当前 `main.py`(约 3,480 行)渐进拆成职责清晰、可独立测试的模块,同时保持以下行为不变: + +- PAC、全局代理、智能分流和本地 TLS 转发的现有接口与优先级; +- 节点订阅、DPAPI 加密存储、登录及本地运行数据位置; +- 源码运行、PyInstaller 单文件版和 Nuitka 版的启动方式; +- 现有 `lite/test/test_pac_routing.py` 的全部回归用例。 + +本轮拆分期间**不提交、不推送到主仓库、不打包发布**。每个阶段均只在本地工作区验证;出现回归时优先回退到拆分前备份。 + +## 开始前的保护措施 + +1. 记录当前 Git 状态与当前 HEAD,不触碰既有未跟踪的构建产物、备份和用户文件。 +2. 在 `lite/backups/` 创建带时间戳的源码备份,例如: + `proxy_lite.py.bak_YYYYMMDD_HHMMSS_before_module_split`。 +3. 首次拆分前运行一次完整基线: + + ```powershell + wsl.exe -e bash -lc "cd '/mnt/d/Royirin Rosmontis Private Proxy' && python3 -m py_compile lite/main.py lite/test/test_pac_routing.py && python3 lite/test/test_pac_routing.py" + ``` + +4. 每完成一个阶段,先做编译检查,再跑完整 PAC 回归;不通过则在进入下一阶段前修复或回退。 + +## 当前实施进度(2026-09-16) + +- [x] 已创建拆分前备份:`lite/backups/proxy_lite.py.bak_20260916_220240_before_module_split`。 +- [x] 已完成一次拆分前基线与多次拆分后全量 PAC 回归。 +- [x] UI 主题、控件和三个对话框已迁入 `rrpp_ui/theme.py`、`rrpp_ui/widgets.py`、 + `rrpp_ui/dialogs.py`;通过显式服务对象调用业务层,不反向导入入口文件。 +- [x] 已迁入核心包的无状态功能:CIDR/Trie 与 PAC 规则解析、PAC 拉取和原子写入、 + `settings.json` 原子存储、DPAPI 节点/凭据存储、HTTP 转发报文解析与认证注入、 + DNS/私网判断与 TTL 缓存、流量统计、节点认证请求协议。 +- [ ] 节点同步服务、带缓存的完整路由决策、转发器 socket 生命周期、系统代理注册表控制 + 和主窗口仍在入口文件,待后续阶段继续迁移。 +- [x] 已获得用户授权,在完成构建与验证后执行 `git commit`、`git push` 和 Release 发布。 + +## 目标目录 + +```text +lite/ +├─ main.py # 轻量入口、组装依赖、main +├─ rrpp_core/ +│ ├─ __init__.py +│ ├─ runtime.py # 路径、常量、日志与共享运行时上下文 +│ ├─ config_store.py # 普通配置、节点、DPAPI 凭据 +│ ├─ pac_data.py # PAC 在线数据下载、校验、缓存失效 +│ ├─ routing.py # 域名/IP 分流、白名单、CIDR/Trie +│ ├─ forwarder.py # 本地 TLS 转发、HTTP CONNECT、认证注入 +│ └─ system_proxy.py # PAC 服务、Windows 系统代理状态 +├─ rrpp_ui/ +│ ├─ __init__.py +│ ├─ theme.py # UI 色彩与样式常量 +│ ├─ widgets.py # NodeLabel、开关、状态点等可复用控件 +│ └─ dialogs.py # 登录、白名单、设置对话框 +└─ test/ + └─ test_pac_routing.py +``` + +目录是目标形态,而不是一次性迁移要求。入口已统一为 `main.py`;测试和构建脚本均以该文件为准。 + +## 分阶段实施 + +### 阶段 0:基线与备份 + +- 创建源码备份并保存当前测试输出。 +- 核验当前核心测试:PAC 规则优先级、IPv4/IPv6、私网、CONNECT 解析、认证注入、启动连通性校验及其取消流程。 +- 产物:可随时恢复的单文件版本;无业务代码变更。 + +### 阶段 1:抽取无状态 UI 组件 + +- 将 `NodeLabel`、`ToggleSwitch`、`StatusDot` 及图标辅助逻辑移到 `rrpp_ui/widgets.py`。 +- 主窗口继续从原入口导入/调用,保持控件信号、样式和构造参数不变。 +- 风险低:不涉及代理线程、注册表或路由决策。 +- 验收:`py_compile`、PAC 全量测试、一次 Windows GUI 手工启动。 + +### 阶段 2:建立共享运行时与配置存储 + +- 抽取路径常量、配置读写、节点读写、DPAPI 加解密、认证保存和订阅节点同步至 `rrpp_core/runtime.py`、`config_store.py`。 +- 明确依赖方向:模块不得反向导入 UI;日志通过注入函数或统一运行时上下文提供。 +- 保持 `%LOCALAPPDATA%\\RRPP`、配置文件名、加密格式和节点选择恢复逻辑完全兼容。 +- 风险中等:涉及用户已有数据;只做机械迁移,不改变存储格式。 +- 验收:现有节点可读取;登录/同步至少做一次人工冒烟;全部自动测试通过。 + +### 阶段 3:抽取 PAC 数据与路由引擎 + +- 将 PAC 数据下载/校验/写入和缓存失效移至 `rrpp_core/pac_data.py`。 +- 将白名单、强制代理、明确直连域名、CIDR、IPv6、DNS 解析、Trie 匹配和直连决策移至 `rrpp_core/routing.py`。 +- 使用显式的 `RoutingContext` 或等价配置对象替代跨模块全局变量;规则优先级必须锁定为: + + ```text + 用户白名单 > 强制代理 > 明确直连域名 > LAN/私网 > GeoIP 智能分流 + ``` + +- 风险最高的核心阶段;禁止顺便修改规则语义或远程 PAC 格式。 +- 验收:扩充并运行分流回归测试,覆盖 Google/OpenAI/Anthropic、国内直连、IPv6、无 DNS 结果与缓存刷新。 + +### 阶段 4:抽取转发器与系统代理控制 + +- 将流量统计、本地 TLS 监听、CONNECT/绝对 URL 解析、认证注入与连接回退移至 `rrpp_core/forwarder.py`。 +- 将 PAC HTTP 服务、注册表备份/恢复、全局/PAC 开关和陈旧状态清理移至 `rrpp_core/system_proxy.py`。 +- 以明确的 start/stop 生命周期对象管理线程与端口,避免模块级状态互相覆盖。 +- 风险中高:涉及注册表和后台线程;本阶段不改变任何系统代理策略。 +- 验收:本地端口监听、PAC 开关、全局开关、取消启动、退出清理和重复启停的人工测试;完整回归测试。 + +### 阶段 5:抽取对话框与收敛入口 + +- 将 `LoginDialog`、`WhitelistDialog`、`SettingsDialog` 移至 `rrpp_ui/dialogs.py`,`MainWindow` 可保留在入口或迁到 `rrpp_ui/main_window.py`。 +- `main.py` 收敛为:初始化 DPI/单实例、创建运行时、装配 UI、调用 `main()`。 +- 保留必要的兼容导出,待构建脚本和测试完全迁移后再移除。 +- 验收:源码 GUI 手工走查、全量自动测试、PyInstaller/Nuitka 各做一次仅本地的构建与启动冒烟。 + +### 阶段 6:清理与交付前评审 + +- 删除已确认无引用的兼容层,补齐模块文档和最小单元测试。 +- 检查循环导入、隐式全局状态、未关闭线程、路径在冻结环境下的差异。 +- 对比拆分前后的 PAC 输出和系统代理状态变更。 +- 仅在用户确认后,另行决定提交、推送、打包和发布;它们不属于本次拆分的自动后续动作。 + +## 依赖与架构约束 + +```text +main.py → rrpp_ui → rrpp_core + │ │ + └───────────┘(仅通过显式上下文/接口协作) +``` + +- `rrpp_core` 不依赖 PyQt;这样 PAC 与转发逻辑可在无 GUI 环境测试。 +- UI 不直接读写注册表、节点文件或 socket;统一通过核心服务对象操作。 +- 禁止新的模块级可变全局状态,已有状态在迁移时集中到上下文或生命周期对象。 +- 配置、加密和 PAC 文件的文件名、字段、URL 与优先级,在本次重构内视为兼容性契约。 + +## 验收清单 + +- [x] 源码 `py_compile` 通过。 +- [x] `test_pac_routing.py` 全部通过。 +- [ ] PAC 启动/取消、全局模式、退出恢复系统代理均可用。 +- [ ] 既有节点和已加密凭据可读取,订阅同步不回退格式。 +- [ ] `chatgpt.com`、`auth.openai.com`、`claude.ai`、Google CDN 仍命中强制代理;国内显式直连规则仍生效。 +- [x] PyInstaller 与 Nuitka 均已完成本地构建;Release 附件将使用版本化文件名和 SHA-256 校验。 +- [x] 发布暂存仅包含本轮源码、测试、构建脚本、图标和模板;构建产物、账号数据和备份文件已排除。 +- [ ] 待提交、推送并上传 Release 附件。 + +## 回滚策略 + +任一阶段出现无法在当前阶段定位的回归时:停止继续迁移,将该阶段涉及文件恢复到阶段开始前的本地备份或 Git 基线;保留失败现场的测试输出以便定位。不得通过覆盖用户的运行配置、订阅或构建目录来“修复”回归。 diff --git a/lite/RRPP_Lite.spec b/lite/RRPP_Lite.spec new file mode 100644 index 0000000..fe31960 --- /dev/null +++ b/lite/RRPP_Lite.spec @@ -0,0 +1,85 @@ +# -*- mode: python ; coding: utf-8 -*- + +from pathlib import Path +from PyInstaller.utils.hooks import collect_all + +# 强制收集 PyQt5 全部模块及二进制文件 +qt_datas, qt_binaries, qt_hiddenimports = collect_all('PyQt5') + +# 只内置 config.json(普通设置)。nodes.json / pac_*.json 属用户数据: +# 运行时读 %LOCALAPPDATA%\RRPP(首启自动创建模板 / 从 CDN 拉取),不进 EXE。 +_JSON_DATAS = [(str(Path(SPECPATH) / 'config.json'), '.')] +print(f"[spec] 打包 json 数据文件: {[Path(src).name for src, _ in _JSON_DATAS]}") + +# 自定义 manifest:普通权限运行 + 声明 PerMonitorV2 DPI 感知, +# 避免程序在不同缩放比例的显示器之间移动时被 Windows 位图拉伸。 +_MANIFEST = r''' + + + Royirin Rosmontis Private Proxy Lite + + + + + + + + + + + + + + + + PerMonitorV2, PerMonitor, system + true/pm + + + + + + + + +''' + +a = Analysis( + ['main.py'], + pathex=['..\\.venv\\Lib\\site-packages'], + binaries=qt_binaries, + datas=[('ico.ico', '.')] + _JSON_DATAS + qt_datas, + hiddenimports=qt_hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + # 排除 Nuitka 构建链相关模块(venv 里装了 Nuitka,但 PyInstaller 产物不需要) + excludes=['nuitka', 'zstandard', 'ordered_set'], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Royirin Rosmontis Private Proxy Lite Python', + debug=False, + bootloader_ignore_signals=False, + strip=False, + # UPX 会压缩资源段(含图标),冷缓存下 Explorer 可能提取不到图标导致任务栏空白 + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['ico.ico'], + manifest=_MANIFEST, +) diff --git a/lite/build_lite.bat b/lite/build_lite.bat new file mode 100644 index 0000000..47aa557 --- /dev/null +++ b/lite/build_lite.bat @@ -0,0 +1,19 @@ +@echo off +cd /d "%~dp0" +call ..\.venv\Scripts\python.exe -m PyInstaller --version >nul 2>&1 +if errorlevel 1 ( + echo PyInstaller not installed. Run first: + echo ..\.venv\Scripts\python.exe -m pip install pyinstaller + pause + exit /b 1 +) +call ..\.venv\Scripts\python.exe -m PyInstaller RRPP_Lite.spec --noconfirm +if errorlevel 1 ( + echo. + echo Build failed. + pause + exit /b 1 +) +echo. +echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite Python.exe +pause diff --git a/lite/build_nuitka.bat b/lite/build_nuitka.bat new file mode 100644 index 0000000..bce5563 --- /dev/null +++ b/lite/build_nuitka.bat @@ -0,0 +1,42 @@ +@echo off +cd /d "%~dp0" +call ..\.venv\Scripts\python.exe -m nuitka --version >nul 2>&1 +if errorlevel 1 ( + echo Nuitka not installed. Run first: + echo ..\.venv\Scripts\python.exe -m pip install nuitka zstandard + pause + exit /b 1 +) +rem Remove prior artifacts and the SCons incremental state before every build. +rem The constants blob is embedded outside normal dependency tracking, so reuse +rem can leave new UI strings and settings keys out of the executable. +rd /s /q "dist\main.build" 2>nul +rd /s /q "dist\main.dist" 2>nul +rd /s /q "dist\main.onefile-build" 2>nul +rem Use Nuitka COFF objects for embedded constants to avoid compiler cache reuse. +rem Zig C23 embed caches do not reliably track embedded blob changes. +set NUITKA_RESOURCE_MODE=coff_obj +rem Convert config.json version to Windows file-version segments (each <= 65535). +for /f %%v in ('..\.venv\Scripts\python.exe -c "import json;v=json.load(open('config.json',encoding='utf-8'))['version'];print(f'9999.{int(v[4:8])}.{int(v[8:12])}.0')"') do set "FILEVER=%%v" +call ..\.venv\Scripts\python.exe -m nuitka ^ + --standalone --onefile ^ + --enable-plugin=pyqt5 ^ + --include-data-files=config.json=config.json ^ + --windows-icon-from-ico=ico.ico ^ + --windows-console-mode=disable ^ + --company-name="Global Rosmanka Cloud Nexus Inc" ^ + --product-name="R.R.P.P." ^ + --file-version=%FILEVER% ^ + --output-dir=dist ^ + --output-filename="Royirin Rosmontis Private Proxy Lite.exe" ^ + --assume-yes-for-downloads ^ + main.py +if errorlevel 1 ( + echo. + echo Build failed. + pause + exit /b 1 +) +echo. +echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite.exe +pause diff --git a/lite/build_pyinstaller.bat b/lite/build_pyinstaller.bat new file mode 100644 index 0000000..c2f6137 --- /dev/null +++ b/lite/build_pyinstaller.bat @@ -0,0 +1,12 @@ +@echo off +cd /d "%~dp0" +call ..\.venv\Scripts\python.exe -m PyInstaller RRPP_Lite.spec +if errorlevel 1 ( + echo. + echo Build failed. + pause + exit /b 1 +) +echo. +echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite.exe +pause diff --git a/lite/claude.bat b/lite/claude.bat new file mode 100644 index 0000000..1439940 --- /dev/null +++ b/lite/claude.bat @@ -0,0 +1,12 @@ +@echo off +setlocal +rem Convert current Windows dir to a WSL path (lowercase drive + forward slashes) +set "WSLDIR=%CD:\=/%" +set "WSLDIR=%WSLDIR::=%" +set "DRIVE=%WSLDIR:~0,1%" +for %%C in (a b c d e f g h i j k l m n o p q r s t u v w x y z) do if /i "%%C"=="%DRIVE%" set "WSLDIR=%%C%WSLDIR:~1%" +set "WSLDIR=/mnt/%WSLDIR%" +rem -i is essential: in a non-interactive shell PS1 is empty, so ~/.bashrc's +rem interactive guard returns early and the model-mapping env vars are lost +wsl -e bash -ic "cd '%WSLDIR%' && /root/.local/bin/claude --continue" +pause diff --git a/lite/ico.ico b/lite/ico.ico new file mode 100644 index 0000000..f2d1a18 Binary files /dev/null and b/lite/ico.ico differ diff --git a/lite/proxy_lite.py b/lite/main.py similarity index 72% rename from lite/proxy_lite.py rename to lite/main.py index 3109f62..31c7965 100644 --- a/lite/proxy_lite.py +++ b/lite/main.py @@ -6,28 +6,57 @@ from pathlib import Path from PyQt5.QtWidgets import ( QApplication, QWidget, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit, - QCheckBox, QMenu, QDialog, QLineEdit, - QListWidget, QListWidgetItem, + QCheckBox, QMenu, QDialog, +) +from PyQt5.QtCore import Qt, QTimer, pyqtSignal +from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor + +from rrpp_ui.theme import C_TITLE, C_GREEN, C_YELLOW, C_RED, C_BG, C_CARD, C_BORDER, C_TEXT, C_SUBTEXT +from rrpp_ui.widgets import NodeLabel, StatusDot, ToggleSwitch +from rrpp_ui.dialogs import ( + LoginDialog, LoginServices, SettingsDialog, SettingsServices, + WhitelistDialog, WhitelistServices, +) +from rrpp_core.routing_primitives import ( + build_suffix_trie as _build_suffix_trie, + merge_ip_ranges as _merge_ip_ranges, + parse_domain_rules as _parse_domain_rules, + suffix_trie_matches as _suffix_trie_matches, +) +from rrpp_core.settings_store import JsonSettingsStore +from rrpp_core.pac_data import ( + atomic_write as _pac_atomic_write, + fetch_json as _pac_fetch_json, + no_proxy_opener as _pac_no_proxy_opener, + read_version as _pac_read_version, + validate_document as _pac_validate, +) +from rrpp_core.forwarder_primitives import ( + force_http_close as _force_http_close, + inject_proxy_auth as _inject_proxy_auth_value, + parse_connect_authority as _parse_connect_authority, + parse_http_absolute as _parse_http_absolute, + starts_with_http_method as _starts_with_method, +) +from rrpp_core.node_secrets import NodeSecretsStore +from rrpp_core.traffic import TrafficStats, format_bytes as _fmt_bytes +from rrpp_core.node_api import ( + NodeAuthError as _AuthError, + b64url as _b64url, + fetch_nodes_auth as _fetch_nodes_auth, +) +from rrpp_core.network_primitives import ( + is_private_host as _is_private_host, + is_private_ipv4_int as _is_private_int, + resolve_host_ips as _resolve_host_ips, + ttl_cache as _ttl_cache, ) -from PyQt5.QtCore import Qt, QTimer, QRectF, pyqtSignal -from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor, QPainter # GUI 窗口在 96 DPI / 100% 缩放下的逻辑尺寸;启用 Qt 高分屏缩放后, # Qt 会自动按 devicePixelRatio 放大/缩小,因此代码里保持逻辑尺寸不变即可。 _BASE_WINDOW_WIDTH = 340 _BASE_WINDOW_HEIGHT = 470 # 内容区 + 加大日志区 + 底部两行提示小字 -# ── 配色(Qt StyleSheet 使用)──────────────────────────── -C_TITLE = "#8899aa" # 标题 / 副标题 -C_GREEN = "#51cf66" # 开启按钮、已连接状态 -C_YELLOW = "#ffd43b" # 提示色(保留) -C_RED = "#ff6b6b" # 关闭按钮、未连接 / 错误状态 -C_BG = "#ffffff" # 主背景 -C_CARD = "#f0f2f5" # 卡片背景 -C_BORDER = "#d0d5dd" # 边框 -C_TEXT = "#333333" # 正文 -C_SUBTEXT = "#889099" # 次要文字 - # ── 路径 ────────────────────────────────────────────── # 已被打包为 EXE(PyInstaller 的 sys.frozen / Nuitka 的 __compiled__): # BASE_DIR -> 内置数据目录:PyInstaller 用 _MEIPASS,Nuitka onefile 用 @@ -653,50 +682,19 @@ def _verify_proxy_connectivity(timeout=10, opener=None, cancel_event=None): # ── 流量统计 ────────────────────────────────────────── # 所有代理流量都经过本地转发器(直连 / PAC 均如此),转发器里计数即可覆盖全部模式; # 每次"开启代理"时清零,即"本次连接"流量。 -_traffic_lock = threading.Lock() -_traffic = {"up": 0, "down": 0} -_traffic_prev = {"up": 0, "down": 0, "ts": 0.0} +_TRAFFIC = TrafficStats() def _traffic_add(up=0, down=0): - with _traffic_lock: - _traffic["up"] += up - _traffic["down"] += down + _TRAFFIC.add(up=up, down=down) def _traffic_reset(): - with _traffic_lock: - _traffic["up"] = _traffic["down"] = 0 - _traffic_prev["up"] = _traffic_prev["down"] = 0 - _traffic_prev["ts"] = time.time() - -def _fmt_bytes(n): - f = float(n) - for unit in ("B", "KB", "MB", "GB", "TB"): - if f < 1024 or unit == "TB": - return f"{int(f)} {unit}" if unit == "B" else f"{f:.1f} {unit}" - f /= 1024 + _TRAFFIC.reset() def _traffic_rate(): - with _traffic_lock: - now = time.time() - up_total = _traffic["up"] - down_total = _traffic["down"] - prev_up = _traffic_prev["up"] - prev_down = _traffic_prev["down"] - prev_ts = _traffic_prev["ts"] - if prev_ts > 0 and now > prev_ts: - dt = now - prev_ts - up_rate = int((up_total - prev_up) / dt) - down_rate = int((down_total - prev_down) / dt) - else: - up_rate = down_rate = 0 - _traffic_prev["up"] = up_total - _traffic_prev["down"] = down_total - _traffic_prev["ts"] = now - return up_rate, down_rate, up_total, down_total + return _TRAFFIC.rate() def _traffic_snapshot(): - with _traffic_lock: - return _traffic["up"], _traffic["down"] + return _TRAFFIC.snapshot() def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, smart=False, quiet=False): """启动本地认证转发器:127.0.0.1: -> remote_host:remote_port(可选 TLS 包裹)。 @@ -750,12 +748,6 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma if not quiet: log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port})") -_HTTP_METHODS = (b"CONNECT ", b"GET ", b"POST ", b"PUT ", b"DELETE ", - b"HEAD ", b"OPTIONS ", b"PATCH ") - -def _starts_with_method(data: bytes) -> bool: - return any(data.startswith(m) for m in _HTTP_METHODS) - def _inject_proxy_auth(data: bytes) -> bytes: """在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。 @@ -766,23 +758,7 @@ def _inject_proxy_auth(data: bytes) -> bytes: Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行, 保证上游(取首个 Authorization)读到的是我们的认证。 """ - if not _starts_with_method(data): - return data - end = data.find(b"\r\n\r\n") - if end == -1: - 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 request_line + b"\r\n" + auth_line + headers + data[end:] + return _inject_proxy_auth_value(data, _proxy_auth_basic()) def _make_request_injector(): """为明文 HTTP 代理连接生成逐条注入认证的流式处理器。 @@ -805,67 +781,10 @@ def _make_request_injector(): return b"" return feed -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/IPv6)。""" - try: - import ipaddress - ip = ipaddress.ip_address(host) - addresses = [ip] - except ValueError: - try: - import socket - addresses = [] - for addr in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM): - try: - addresses.append(ipaddress.ip_address(addr[4][0])) - except ValueError: - pass - except Exception: - return False - for ip in addresses: - 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 + return _is_private_host(host) # ── IP 智能分流(域名表优先,非中国 IP 走隧道,中国 IP 直连) ───── @@ -898,6 +817,7 @@ def _data_path(name: str): # 非 Windows 环境回退用户主目录;测试可覆盖 _USER_DIR = (Path(os.environ["LOCALAPPDATA"]) if os.name == "nt" and os.environ.get("LOCALAPPDATA") else Path.home()) / "RRPP" +_NODE_SECRETS = NodeSecretsStore(lambda: _USER_DIR) def _user_path(name: str): """用户数据文件路径:dev 模式(未打包)源码目录优先,方便本地测试; @@ -916,25 +836,15 @@ def _user_path(name: str): def _settings_path(): return _USER_DIR / "settings.json" +_SETTINGS_STORE = JsonSettingsStore(_settings_path) + def _settings_load(): """读取本地设置;缺失或损坏返回空字典。""" - try: - import json - f = _settings_path() - if not f.is_file(): - return {} - data = json.loads(f.read_text("utf-8")) - return data if isinstance(data, dict) else {} - except Exception: - return {} + return _SETTINGS_STORE.load() def _settings_save(data): """本地设置原子写回。""" - import json - _USER_DIR.mkdir(parents=True, exist_ok=True) - tmp = _settings_path().with_suffix(".json.tmp") - tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", "utf-8") - os.replace(tmp, _settings_path()) + _SETTINGS_STORE.save(data) def _parse_port(text): """解析端口号;非法(非数字或超出 1-65535)返回 None。""" @@ -1083,45 +993,9 @@ PROXY_PORT = _TLS_LOCAL_PORT # {pac_base_url}/pac_ip_cn.json 中国 IP 段(同上) _PAC_FILES = ("pac_domains", "pac_direct_domains", "pac_ip_cn") -def _pac_read_version(path): - """读取 PAC 数据文件内的 version/date 字段。""" - try: - import json - data = json.loads(Path(path).read_text("utf-8")) - return str(data.get("version") or ""), str(data.get("date") or "") - except Exception: - return "", "" - -def _pac_no_proxy_opener(): - """直连 opener:显式空代理,忽略系统/环境代理设置(CDN 无需认证)。""" - import urllib.request - return urllib.request.build_opener(urllib.request.ProxyHandler({})) - -def _pac_fetch_json(url, timeout=15, tries=2): - opener = _pac_no_proxy_opener() - import json - for i in range(tries): - try: - with opener.open(url, timeout=timeout) as r: - return json.loads(r.read().decode("utf-8")) - except Exception: - if i == tries - 1: - raise - -def _pac_validate(name, data): - 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) - return False - def _pac_write(name, data): """PAC 数据原子写入用户数据目录(tmp+replace,写不坏旧文件)。""" - import json - _USER_DIR.mkdir(parents=True, exist_ok=True) - tmp = _USER_DIR / f"{name}.json.tmp" - tmp.write_text(json.dumps(data, ensure_ascii=False), "utf-8") - os.replace(tmp, _USER_DIR / f"{name}.json") + _pac_atomic_write(_USER_DIR, name, data) def _invalidate_pac_routing_cache(name=None): @@ -1151,96 +1025,28 @@ def _invalidate_pac_routing_cache(name=None): # ── nodes.json 加密存储(Windows DPAPI,本机本用户可解) ───────────── # 非 Windows(dev/测试)不加密,读写明文;读取对密文/明文都兼容。 -class _DPAPI_BLOB(ctypes.Structure): - _fields_ = [("cbData", ctypes.c_uint32), - ("pbData", ctypes.POINTER(ctypes.c_char))] - - def _dpapi_protect(data: bytes) -> bytes: - """DPAPI 加密 CryptProtectData(无附加熵,用户+机器绑定)。""" - crypt32 = ctypes.WinDLL("crypt32") - kernel32 = ctypes.WinDLL("kernel32") - crypt32.CryptProtectData.argtypes = [ctypes.POINTER(_DPAPI_BLOB), ctypes.c_wchar_p, - ctypes.POINTER(_DPAPI_BLOB), ctypes.c_void_p, - ctypes.c_void_p, ctypes.c_uint32, - ctypes.POINTER(_DPAPI_BLOB)] - crypt32.CryptProtectData.restype = ctypes.c_bool - kernel32.LocalFree.argtypes = [ctypes.c_void_p] - - buf = ctypes.create_string_buffer(data, len(data)) - blob_in = _DPAPI_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))) - blob_out = _DPAPI_BLOB() - if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None, - None, None, 0, ctypes.byref(blob_out)): - raise OSError("CryptProtectData 调用失败") - try: - return ctypes.string_at(blob_out.pbData, blob_out.cbData) - finally: - kernel32.LocalFree(blob_out.pbData) + return _NODE_SECRETS.protect_bytes(data) def _dpapi_unprotect(data: bytes) -> bytes: - """DPAPI 解密 CryptUnprotectData。""" - crypt32 = ctypes.WinDLL("crypt32") - kernel32 = ctypes.WinDLL("kernel32") - crypt32.CryptUnprotectData.argtypes = [ctypes.POINTER(_DPAPI_BLOB), ctypes.POINTER(ctypes.c_wchar_p), - ctypes.POINTER(_DPAPI_BLOB), ctypes.c_void_p, - ctypes.c_void_p, ctypes.c_uint32, - ctypes.POINTER(_DPAPI_BLOB)] - crypt32.CryptUnprotectData.restype = ctypes.c_bool - kernel32.LocalFree.argtypes = [ctypes.c_void_p] - - buf = ctypes.create_string_buffer(data, len(data)) - blob_in = _DPAPI_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))) - descr = ctypes.c_wchar_p() - blob_out = _DPAPI_BLOB() - if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(descr), None, - None, None, 0, ctypes.byref(blob_out)): - raise OSError("CryptUnprotectData 调用失败") - try: - return ctypes.string_at(blob_out.pbData, blob_out.cbData) - finally: - kernel32.LocalFree(blob_out.pbData) - if descr.value: - kernel32.LocalFree(descr) + return _NODE_SECRETS.unprotect_bytes(data) def _nodes_encrypt(text: str) -> bytes: - """加密 nodes.json 内容;非 Windows 环境明文(dev/测试)。""" - if os.name != "nt": - return text.encode("utf-8") - return _dpapi_protect(text.encode("utf-8")) + return _NODE_SECRETS.encrypt_text(text) def _nodes_decrypt(blob: bytes) -> str: - """解密 nodes.json;解密失败回退按明文解析(兼容旧文件与在线拉取写入的明文)。""" - if os.name != "nt": - return blob.decode("utf-8") - try: - return _dpapi_unprotect(blob).decode("utf-8") - except Exception: - return blob.decode("utf-8") + return _NODE_SECRETS.decrypt_bytes(blob) + def _auth_save(username, password): - """登录凭据加密保存到用户数据目录(DPAPI,本机本用户可解)。""" - import json - text = json.dumps({"username": username, "password": password}, ensure_ascii=False) - _USER_DIR.mkdir(parents=True, exist_ok=True) - (_USER_DIR / "auth.json").write_bytes(_nodes_encrypt(text)) + _NODE_SECRETS.save_auth(username, password) + def _auth_load(): - """读取已保存的登录凭据;不存在或损坏返回 None。""" - try: - import json - f = _USER_DIR / "auth.json" - if not f.is_file(): - return None - data = json.loads(_nodes_decrypt(f.read_bytes())) - if not (isinstance(data, dict) and data.get("username") and data.get("password")): - return None - return data - except Exception: - return None + return _NODE_SECRETS.load_auth() def _nodes_sync_remote(): """启动时用已保存凭据拉取最新节点配置(内容比对,变化才覆盖)。""" @@ -1273,42 +1079,6 @@ def _nodes_sync_remote(): finally: _NODES_SYNC_DONE.set() # 无论成败都要放行启动校验 -class _AuthError(Exception): - """订阅接口返回的错误:code 为 HTTP 状态码,str 为服务端返回的中文提示。""" - - def __init__(self, msg, code=0): - super().__init__(msg) - self.code = code - - -def _b64url(s: str) -> str: - """SSO 认证查询参数的 URL-safe Base64 编码(末尾 padding 省略)。""" - return base64.urlsafe_b64encode(str(s).encode("utf-8")).decode("ascii").rstrip("=") - - -def _fetch_nodes_auth(url, user, password, timeout=10): - """用 SSO 账号拉取 nodes.json:user/password 按 URL-safe Base64 作为查询参数 - 拼接在 nodes_url 后(旧版 nginx auth_basic 已废弃);直连,不经过系统代理。 - 失败抛 _AuthError(服务端拒绝,str 为服务端中文原因)或 urllib 异常(网络问题)。""" - import json - import urllib.request - import urllib.error - opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - sep = "&" if "?" in url else "?" - req_url = f"{url}{sep}user={_b64url(user)}&password={_b64url(password)}" - try: - with opener.open(req_url, timeout=timeout) as r: - return json.loads(r.read().decode("utf-8")) - except urllib.error.HTTPError as e: - # 401/403/429/5xx:服务端 body 是面向用户的中文原因,直接透出 - body = "" - try: - body = e.read().decode("utf-8", "replace").strip() - except Exception: - pass - raise _AuthError(body or f"请求被拒绝 (HTTP {e.code})", e.code) from None - - def _user_init_local(): """初始化用户数据目录(同步):建目录,nodes.json 缺失时写入加密空模板。""" _USER_DIR.mkdir(parents=True, exist_ok=True) @@ -1368,25 +1138,6 @@ def _user_init_remote(base_url=None): if updated == 0 and failed == 0 and (_USER_DIR / "pac_domains.json").is_file(): log("PAC 数据已是最新") -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 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 @@ -1434,37 +1185,12 @@ def _load_force_proxy_suffixes(): 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("") + suffixes, exact, regexes, invalid_regexes = _parse_domain_rules( + data.get("domains"), _CORE_FORCE_PROXY_SUFFIXES + ) _FORCE_PROXY_SUFFIXES = suffixes - _FORCE_PROXY_EXACT = frozenset(exact) - _FORCE_PROXY_REGEXES = tuple(regexes) + _FORCE_PROXY_EXACT = exact + _FORCE_PROXY_REGEXES = regexes log(f"已加载强制代理规则: 后缀 {len(suffixes)} 条、精确 {len(exact)} 条、" f"正则 {len(regexes)} 条 (pac_domains.json)" + (f",跳过 {invalid_regexes} 条非法正则" if invalid_regexes else "")) @@ -1478,28 +1204,6 @@ def _load_force_proxy_suffixes(): 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 @@ -1513,34 +1217,10 @@ def _load_direct_domains(): _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 - 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) + suffixes, exact, regexes, invalid_regexes = _parse_domain_rules(data.get("domains")) _DIRECT_SUFFIX_TRIE = _build_suffix_trie(suffixes) - _DIRECT_EXACT = frozenset(exact) - _DIRECT_REGEXES = tuple(regexes) + _DIRECT_EXACT = exact + _DIRECT_REGEXES = regexes log(f"已加载明确直连规则: 后缀 {len(suffixes)} 条、精确 {len(exact)} 条、" f"正则 {len(regexes)} 条 (pac_direct_domains.json)" + (f",跳过 {invalid_regexes} 条非法正则" if invalid_regexes else "")) @@ -1552,14 +1232,6 @@ def _load_direct_domains(): return _DIRECT_SUFFIX_TRIE -def _is_private_int(n: int) -> bool: - """整数形式的 IPv4 是否属于常用内网/回环段。""" - return ((n & 0xFF000000) == 0x0A000000 or - (n & 0xFFF00000) == 0xAC100000 or - (n & 0xFFFF0000) == 0xC0A80000 or - (n & 0xFF000000) == 0x7F000000 or - (n & 0xFFFF0000) == 0xA9FE0000) - @_ttl_cache(300, maxsize=16384) def _resolve_china_direct_ip(host: str): """返回可安全直连的中国 IPv4/IPv6,或 None。 @@ -1644,21 +1316,6 @@ def _is_direct_domain(host: str) -> bool: 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 - try: - return {ipaddress.ip_address(host)} - except ValueError: - pass - try: - import socket - # 双栈解析(AF_UNSPEC):IPv6 白名单条目也能命中域名解析出的 IPv6 地址 - return {ipaddress.ip_address(a[4][0]) - for a in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)} - except Exception: - return set() - def _whitelist_entry_match(host: str, entry: str) -> bool: """单条白名单规则匹配:IP/CIDR 条目按解析地址比对,域名条目按后缀匹配。""" h = host.rstrip(".").lower() @@ -1727,66 +1384,6 @@ def _is_smart_china_direct(host: str, bypass_lan: bool, smart: bool) -> bool: 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。 - - 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(":") - if not host or not port_s.isdigit(): - return None - return host, int(port_s) - -def _parse_http_absolute(first_line: str): - """从明文 HTTP 代理首行提取 (host, port),失败返回 None。""" - parts = first_line.split(" ") - if len(parts) < 2 or not parts[1].lower().startswith(("http://", "https://")): - return None - from urllib.parse import urlsplit - parsed = urlsplit(parts[1]) - if not parsed.hostname: - return None - port = parsed.port or (443 if parsed.scheme == "https" else 80) - return parsed.hostname, port - -def _force_http_close(buf: bytes) -> bytes: - """直连明文 HTTP 时强制 Connection: close,避免 keep-alive 复用错连其他主机。""" - end = buf.find(b"\r\n\r\n") - if end == -1: - return buf - head, tail = buf[:end], buf[end:] - lines = head.split(b"\r\n") - out = [lines[0]] - conn_seen = False - for line in lines[1:]: - low = line.lower() - if low.startswith(b"connection:"): - out.append(b"Connection: close") - conn_seen = True - elif low.startswith(b"proxy-connection:"): - continue - else: - out.append(line) - if not conn_seen: - 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): """连接直连目标或上游隧道;智能中国直连失败时安全回退一次隧道。""" @@ -1994,608 +1591,59 @@ def load_icon(): img.fill(QColor(C_TITLE)) return QIcon(QPixmap.fromImage(img)) -# ── 可点击节点标签 ───────────────────────────────────── -_NL_BASE = f"color: {C_TITLE}; font-size: 12px;" -_NL_HOVER = _NL_BASE + " text-decoration: underline;" +def _login_services(): + """为登录框提供明确的节点与凭据服务,UI 不直接依赖本模块全局变量。""" + return LoginServices( + load_icon=load_icon, + load_auth=_auth_load, + get_nodes_url=lambda: _config_val("nodes_url", ""), + fetch_nodes=_fetch_nodes_auth, + user_dir=lambda: _USER_DIR, + encrypt_nodes=_nodes_encrypt, + save_auth=_auth_save, + auth_error=_AuthError, + log=log, + ) -class NodeLabel(QLabel): - """可点击标签:hover 显示下划线+手型光标,点击发出 clicked 信号。""" - clicked = pyqtSignal() - def __init__(self, text="", tooltip="点击切换节点", base=None, hover=None): - super().__init__(text) - self._base = base or _NL_BASE - self._hover = hover or self._base + " text-decoration: underline;" - self.setStyleSheet(self._base) - if tooltip: - self.setToolTip(tooltip) +def _whitelist_services(): + return WhitelistServices( + load_icon=load_icon, + load_rules=_whitelist_rules, + validate_rule=_whitelist_validate_entry, + save_rules=_whitelist_save, + ) - def enterEvent(self, e): - self.setStyleSheet(self._hover) - self.setCursor(Qt.PointingHandCursor) - super().enterEvent(e) - def leaveEvent(self, e): - self.setStyleSheet(self._base) - self.unsetCursor() - super().leaveEvent(e) - - def mouseReleaseEvent(self, e): - if e.button() == Qt.LeftButton: - self.clicked.emit() - super().mouseReleaseEvent(e) - -# ── 节点配置登录框 ───────────────────────────────────── - -class LoginDialog(QDialog): - """节点配置登录框:SSO 账号(邮箱+密码,base64url 查询参数)认证 nodes_url, - 成功后拉取并加密保存 nodes.json。""" - _done = pyqtSignal(bool, str) - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("节点配置登录") - self.setWindowIcon(load_icon()) - self.setFixedSize(420, 170) - self.setStyleSheet(f""" - QDialog {{ background-color: {C_BG}; }} - QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} - QLineEdit {{ - background-color: {C_CARD}; - border: 1px solid {C_BORDER}; - border-radius: 6px; - padding: 6px 8px; - color: {C_TEXT}; - font-size: 12px; - }} - QPushButton#btnOk {{ - background-color: {C_GREEN}; color: #fff; border: none; - border-radius: 6px; padding: 4px 12px; - font-size: 12px; font-weight: bold; - }} - QPushButton#btnOk:hover {{ background-color: #5ed978; }} - QPushButton#btnOk:disabled {{ background-color: #2a3a2e; color: #666; }} - QPushButton#btnCancel {{ - background-color: {C_CARD}; color: {C_SUBTEXT}; - border: 1px solid {C_BORDER}; border-radius: 6px; - padding: 4px 12px; font-size: 12px; - }} - """) - - root = QHBoxLayout(self) - root.setSpacing(14) - root.setContentsMargins(20, 18, 20, 16) - - # 左侧 logo + 品牌小字 - left = QVBoxLayout() - left.setSpacing(6) - left.addStretch() - logo = QLabel() - logo.setPixmap(load_icon().pixmap(80, 80)) - logo.setFixedSize(80, 80) - logo.setAlignment(Qt.AlignCenter) - left.addWidget(logo, 0, Qt.AlignHCenter) - brand = QLabel("R.R.P.P.") - brand.setStyleSheet(f"color: {C_TITLE}; font-size: 10px;") - brand.setAlignment(Qt.AlignCenter) - left.addWidget(brand, 0, Qt.AlignHCenter) - left.addStretch() - root.addLayout(left) - - # 右侧表单(外层容器按内容高度垂直居中,避免被横向布局拉伸出大段留白) - form = QVBoxLayout() - form.setSpacing(6) - self.ed_user = QLineEdit() - self.ed_user.setPlaceholderText("SSO 邮箱") - form.addWidget(self.ed_user) - - self.ed_pass = QLineEdit() - self.ed_pass.setPlaceholderText("密码") - self.ed_pass.setEchoMode(QLineEdit.Password) - self.ed_pass.returnPressed.connect(self._submit) - form.addWidget(self.ed_pass) - - # 保存过凭据时预填,重新登录只需点一下 - cred = _auth_load() - if cred: - self.ed_user.setText(cred["username"]) - self.ed_pass.setText(cred["password"]) - - self.lb_err = QLabel("") - self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;") - self.lb_err.setFixedHeight(14) - self.lb_err.hide() # 无错误时不占空间,避免密码框下方留大片空白 - form.addWidget(self.lb_err) - - bottom = QHBoxLayout() - bottom.setSpacing(10) - hint = QLabel("登录以获取节点配置") - hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 10px;") - bottom.addWidget(hint) - bottom.addStretch() - self.btn_cancel = QPushButton("取消") - self.btn_cancel.setObjectName("btnCancel") - self.btn_cancel.clicked.connect(self.reject) - bottom.addWidget(self.btn_cancel) - self.btn_ok = QPushButton("登录") - self.btn_ok.setObjectName("btnOk") - self.btn_ok.clicked.connect(self._submit) - bottom.addWidget(self.btn_ok) - form.addLayout(bottom) - box = QWidget() - box.setLayout(form) - root.addWidget(box, 1, Qt.AlignVCenter) - - self._done.connect(self._on_done) - - def _submit(self): - user = self.ed_user.text().strip() - password = self.ed_pass.text() - if not user or not password: - self.lb_err.setText("请输入用户名和密码") - self.lb_err.show() - return - self.btn_ok.setEnabled(False) - self.btn_cancel.setEnabled(False) - self.btn_ok.setText("登录中") # 登录按钮变灰并显示"登录中" - threading.Thread(target=self._worker, args=(user, password), daemon=True).start() - - def _worker(self, user, password): - """后台登录:SSO 认证拉取 nodes.json,校验后加密写入用户数据目录。""" +def _logout_user_data(): + """清除本地登录凭据与节点文件,并让内存节点缓存失效。""" + global _NODES + for name in ("auth.json", "nodes.json"): try: - import json - url = str(_config_val("nodes_url", "") or "").strip() - if not url: - raise RuntimeError("config.json 未配置 nodes_url") - data = _fetch_nodes_auth(url, user, 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" - _USER_DIR.mkdir(parents=True, exist_ok=True) - (_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(text)) - _auth_save(user, password) # 记住 SSO 凭据,之后每次启动自动同步节点 - self._done.emit(True, str(len(data))) - except _AuthError as e: - # 服务端拒绝:直接展示其中文原因(认证失败/未开通/限流等) - self._done.emit(False, str(e)) - except Exception as e: - self._done.emit(False, f"登录失败: {e}") - - def _on_done(self, ok, info): - self.btn_ok.setEnabled(True) - self.btn_cancel.setEnabled(True) - self.btn_ok.setText("登录") - if ok: - log(f"登录成功,已获取 {info} 个节点(nodes.json 已加密保存)") - self.accept() - else: - self.lb_err.setText(info) - self.lb_err.show() - -# ── 代理白名单弹窗 ───────────────────────────────────── - -class WhitelistDialog(QDialog): - """代理白名单编辑弹窗:可编辑列表(Windows 环境变量风格), - 每条规则带复选框启用/禁用,支持添加/删除,保存后立即生效。""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("代理白名单") - self.setWindowIcon(load_icon()) - self.setFixedSize(380, 400) - self.setStyleSheet(f""" - QDialog {{ background-color: {C_BG}; }} - QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} - QLineEdit {{ - background-color: {C_CARD}; - border: 1px solid {C_BORDER}; - border-radius: 6px; - padding: 6px 8px; - color: {C_TEXT}; - font-size: 12px; - }} - QListWidget {{ - background-color: {C_CARD}; - border: 1px solid {C_BORDER}; - border-radius: 6px; - padding: 4px; - color: {C_TEXT}; - font-size: 12px; - }} - QListWidget::item {{ padding: 3px 6px; border-radius: 4px; }} - QListWidget::item:selected {{ background-color: #e3e8ee; color: {C_TEXT}; }} - QPushButton#btnOk {{ - background-color: {C_GREEN}; color: #fff; border: none; - border-radius: 6px; padding: 4px 12px; - font-size: 12px; font-weight: bold; - }} - QPushButton#btnOk:hover {{ background-color: #5ed978; }} - QPushButton#btnCancel {{ - background-color: {C_CARD}; color: {C_SUBTEXT}; - border: 1px solid {C_BORDER}; border-radius: 6px; - padding: 4px 12px; font-size: 12px; - }} - QPushButton#btnSmall {{ - background-color: {C_CARD}; color: {C_SUBTEXT}; - border: 1px solid {C_BORDER}; border-radius: 6px; - padding: 3px 10px; font-size: 11px; - }} - QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }} - """) - - layout = QVBoxLayout(self) - layout.setSpacing(10) - layout.setContentsMargins(20, 16, 20, 16) - - title = QLabel("代理白名单") - title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;") - title.setAlignment(Qt.AlignCenter) - layout.addWidget(title) - - hint = QLabel("命中以下规则的连接将强制直连(不经过代理)") - hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") - hint.setAlignment(Qt.AlignCenter) - layout.addWidget(hint) - - self.lb_err = QLabel("") - self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;") - self.lb_err.setFixedHeight(14) - self.lb_err.hide() # 无错误时不占空间 - layout.addWidget(self.lb_err) - - # 规则列表:每条前面带复选框(勾选启用 / 取消禁用) - self.list_widget = QListWidget() - self.list_widget.setToolTip("勾选启用规则;可多选后删除") - for r in _whitelist_rules(): - self._add_item(r["value"], r.get("enabled", True)) - layout.addWidget(self.list_widget, 1) - - # 添加行:输入框 + 添加按钮 - add_row = QHBoxLayout() - add_row.setSpacing(8) - self.ed_add = QLineEdit() - self.ed_add.setPlaceholderText("输入域名 / IP / CIDR,如 example.com") - self.ed_add.returnPressed.connect(self._add_rule) - add_row.addWidget(self.ed_add, 1) - self.btn_add = QPushButton("添加") - self.btn_add.setObjectName("btnOk") - self.btn_add.setStyleSheet(f""" - QPushButton {{ - background-color: {C_GREEN}; color: #fff; border: none; - border-radius: 6px; padding: 6px 14px; - font-size: 12px; font-weight: bold; - }} - QPushButton:hover {{ background-color: #5ed978; }} - """) - self.btn_add.clicked.connect(self._add_rule) - add_row.addWidget(self.btn_add) - layout.addLayout(add_row) - - # 删除选中 + 底部按钮 - mid_row = QHBoxLayout() - self.btn_delete = QPushButton("删除选中") - self.btn_delete.setObjectName("btnSmall") - self.btn_delete.clicked.connect(self._delete_selected) - mid_row.addWidget(self.btn_delete) - mid_row.addStretch() - layout.addLayout(mid_row) - - btns = QHBoxLayout() - btns.setSpacing(10) - btns.addStretch() - self.btn_cancel = QPushButton("取消") - self.btn_cancel.setObjectName("btnCancel") - self.btn_cancel.clicked.connect(self.reject) - btns.addWidget(self.btn_cancel) - self.btn_ok = QPushButton("保存") - self.btn_ok.setObjectName("btnOk") - self.btn_ok.clicked.connect(self._save) - btns.addWidget(self.btn_ok) - layout.addLayout(btns) - - def _add_item(self, value, enabled): - item = QListWidgetItem(value) - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Checked if enabled else Qt.Unchecked) - self.list_widget.addItem(item) - - def _add_rule(self): - value, err = _whitelist_validate_entry(self.ed_add.text()) - if err: - self.lb_err.setText(err) - self.lb_err.show() - return - # 去重(不区分大小写) - for i in range(self.list_widget.count()): - if self.list_widget.item(i).text().lower() == value.lower(): - self.lb_err.setText("该规则已存在") - self.lb_err.show() - return - self.lb_err.hide() - self._add_item(value, True) - self.ed_add.clear() - - def _delete_selected(self): - for item in self.list_widget.selectedItems(): - self.list_widget.takeItem(self.list_widget.row(item)) - - def _save(self): - """收集列表内容保存(未点保存前所有改动均可取消)。""" - rules = [] - for i in range(self.list_widget.count()): - item = self.list_widget.item(i) - value = str(item.text()).strip() - if value: - rules.append({"value": value, "enabled": item.checkState() == Qt.Checked}) - _whitelist_save(rules) - self.accept() - -# ── 设置弹窗 ───────────────────────────────────────── - -class SettingsDialog(QDialog): - """设置弹窗:代理转发端口 + 退出登录。""" - - def __init__(self, parent=None, port_locked=False): - super().__init__(parent) - self.setWindowTitle("设置") - self.setWindowIcon(load_icon()) - self.setFixedSize(300, 354) - self.setStyleSheet(f""" - QDialog {{ background-color: {C_BG}; }} - QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} - QLineEdit {{ - background-color: {C_CARD}; - border: 1px solid {C_BORDER}; - border-radius: 6px; - padding: 2px 8px; - color: {C_TEXT}; - font-size: 12px; - }} - """) - - layout = QVBoxLayout(self) - layout.setSpacing(12) - layout.setContentsMargins(20, 16, 20, 16) - - title = QLabel("设置") - title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;") - title.setAlignment(Qt.AlignCenter) - layout.addWidget(title) - - # 版本号 + 手动「检查更新」入口(整体居中,中间隔小空) - ver_row = QHBoxLayout() - ver_row.addStretch() - ver_lab = QLabel(f"版本号: {VERSION}") - ver_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") - ver_row.addWidget(ver_lab) - ver_row.addSpacing(4) - self.link_check_update = NodeLabel( - "检查更新", tooltip="手动检查更新", - base=f"color: {C_SUBTEXT}; font-size: 11px;", - hover=f"color: {C_TEXT}; font-size: 11px; text-decoration: underline;") - self.link_check_update.clicked.connect(self._manual_check_update) - ver_row.addWidget(self.link_check_update) - ver_row.addStretch() - layout.addLayout(ver_row) - - # 提示语:标题正下方(保存结果/错误也显示在这行) - self.lb_hint = QLabel("代理运行中,关闭代理后可修改" if port_locked else "保存更改后下次开启代理生效") - self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") - self.lb_hint.setFixedHeight(14) - self.lb_hint.setAlignment(Qt.AlignCenter) - layout.addWidget(self.lb_hint) - - layout.addStretch() # 设置项在空白部分纵向居中(上下均分) - - # 代理白名单:第一个设置项,点「配置」打开编辑弹窗 - wl_row = QHBoxLayout() - wl_lab = QLabel("代理白名单") - wl_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") - wl_lab.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用") - wl_row.addWidget(wl_lab) - wl_row.addStretch() - self.btn_whitelist = QPushButton("配置") - self.btn_whitelist.setCursor(Qt.PointingHandCursor) - self.btn_whitelist.setStyleSheet(f""" - QPushButton {{ - background-color: {C_CARD}; color: {C_SUBTEXT}; - border: 1px solid {C_BORDER}; border-radius: 6px; - padding: 2px 10px; font-size: 11px; - }} - QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }} - """) - self.btn_whitelist.clicked.connect(self._open_whitelist) - wl_row.addWidget(self.btn_whitelist) - layout.addLayout(wl_row) - - # 代理转发端口 - port_row = QHBoxLayout() - port_lab = QLabel("代理转发端口") - port_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") - port_row.addWidget(port_lab) - port_row.addStretch() - self.ed_port = QLineEdit(str(_TLS_LOCAL_PORT)) - self.ed_port.setFixedSize(54, 22) # 与右侧开关同高,和其他设置项同一水平 - self.ed_port.setEnabled(not port_locked) - port_row.addWidget(self.ed_port) - layout.addLayout(port_row) - - # 更新方式:左文字 + 右苹果式开关(默认关 = 直连仓库) - upd_row = QHBoxLayout() - upd_lab = QLabel("使用代理更新") - upd_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") - upd_row.addWidget(upd_lab) - upd_row.addStretch() - self.cb_update_proxy = ToggleSwitch(checked=_update_via_proxy()) - self.cb_update_proxy.setToolTip("开启后检查更新与下载走节点隧道(CONNECT);默认直连仓库") - upd_row.addWidget(self.cb_update_proxy) - layout.addLayout(upd_row) - - # 跳过检查更新:左文字 + 右开关(默认关;不影响节点更新与认证) - skip_row = QHBoxLayout() - skip_lab = QLabel("跳过检查更新") - skip_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") - skip_row.addWidget(skip_lab) - skip_row.addStretch() - self.cb_skip_update = ToggleSwitch(checked=_skip_update_check()) - self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志") - skip_row.addWidget(self.cb_skip_update) - layout.addLayout(skip_row) - - layout.addStretch() - - # 底部:保存更改 + 退出登录 并排 - btns = QHBoxLayout() - btns.setSpacing(10) - btns.addStretch() - self.btn_save = QPushButton("保存更改") - self.btn_save.setStyleSheet(f""" - QPushButton {{ - background-color: {C_GREEN}; color: #fff; border: none; - border-radius: 6px; padding: 5px 14px; - font-size: 12px; font-weight: bold; - }} - QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }} - """) - self.btn_save.setEnabled(not port_locked) - self.btn_save.clicked.connect(self._save_settings) - btns.addWidget(self.btn_save) - self.btn_logout = QPushButton("退出登录") - self.btn_logout.setStyleSheet(f""" - QPushButton {{ - background-color: {C_RED}; color: #fff; border: none; - border-radius: 6px; padding: 5px 14px; - font-size: 12px; font-weight: bold; - }} - QPushButton:hover {{ background-color: #ff8787; }} - """) - self.btn_logout.setToolTip("清除登录凭据与本地节点配置,下次启动需重新登录") - self.btn_logout.clicked.connect(self._logout) - btns.addWidget(self.btn_logout) - btns.addStretch() - layout.addLayout(btns) - - def _manual_check_update(self): - """手动触发一次检查更新(后台线程,复用主窗口逻辑)。""" - parent = self.parent() - if not isinstance(parent, MainWindow): - return - self.lb_hint.setText("正在检查更新...") - self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") - try: - parent._update_done.connect(self._on_check_done) - except TypeError: - pass # 已连接过,忽略 - threading.Thread(target=parent._check_update, kwargs={"manual": True}, daemon=True).start() - - def _on_check_done(self): - self.lb_hint.setText("检查完成,结果见主界面日志") - self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") - try: - self.parent()._update_done.disconnect(self._on_check_done) + (_USER_DIR / name).unlink(missing_ok=True) except Exception: pass + _NODES = None + log("已退出登录:凭据与节点配置已清除,下次启动或开启代理时需重新登录") - def _open_whitelist(self): - """打开代理白名单编辑弹窗(保存后立即生效,无需重启代理)。""" - WhitelistDialog(self).exec_() - def _save_settings(self): - """保存更改:统一校验并应用全部设置项(后续新增设置项在此扩展)。""" - port = _parse_port(self.ed_port.text()) - if port is None: - self.lb_hint.setText("端口应为 1-65535 的数字") - self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;") - return - _set_local_port(port) - _set_update_via_proxy(bool(self.cb_update_proxy.isChecked())) - _set_skip_update_check(bool(self.cb_skip_update.isChecked())) - self.ed_port.setText(str(port)) - self.lb_hint.setText("已保存,下次开启代理生效") - self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;") +def _settings_services(): + return SettingsServices( + load_icon=load_icon, + version=VERSION, + get_port=lambda: _TLS_LOCAL_PORT, + get_update_via_proxy=_update_via_proxy, + get_skip_update_check=_skip_update_check, + parse_port=_parse_port, + set_port=_set_local_port, + set_update_via_proxy=_set_update_via_proxy, + set_skip_update_check=_set_skip_update_check, + open_whitelist=lambda parent: WhitelistDialog(_whitelist_services(), parent).exec_(), + logout=_logout_user_data, + ) - def _logout(self): - """退出登录:删除加密凭据与本地节点配置,清空内存缓存。""" - global _NODES - for name in ("auth.json", "nodes.json"): - try: - (_USER_DIR / name).unlink(missing_ok=True) - except Exception: - pass - _NODES = None - log("已退出登录:凭据与节点配置已清除,下次启动或开启代理时需重新登录") - self.accept() - -# ── 苹果风格开关 ───────────────────────────────────── - -class ToggleSwitch(QCheckBox): - """苹果风格开关:左关右开,绿/灰胶囊 + 滑动圆钮(自绘,无图片资源)。""" - - def __init__(self, checked=False, parent=None): - super().__init__(parent) - self.setChecked(checked) - self.setFixedSize(42, 22) - self.setCursor(Qt.PointingHandCursor) - - def paintEvent(self, e): - p = QPainter(self) - p.setRenderHint(QPainter.Antialiasing) - p.setPen(Qt.NoPen) - # 胶囊背景:开=主题绿,关=浅灰 - p.setBrush(QColor(C_GREEN) if self.isChecked() else QColor("#c8ccd4")) - p.drawRoundedRect(QRectF(0, 0, 42, 22), 11, 11) - # 白色圆钮:开在右,关在左 - knob_x = 20.0 if self.isChecked() else 2.0 - p.setBrush(QColor("#ffffff")) - p.drawEllipse(QRectF(knob_x, 2.0, 18.0, 18.0)) - p.end() - - def mousePressEvent(self, e): - # 显式处理点击切换:自绘控件在部分样式引擎下默认点击不触发 - if e.button() == Qt.LeftButton: - self.setChecked(not self.isChecked()) - self.repaint() - e.accept() - return - super().mousePressEvent(e) - -# ── 状态指示灯(小圆点) ─────────────────────────────── - -class StatusDot(QLabel): - def __init__(self): - super().__init__() - self.setFixedSize(14, 14) - self.set_off() - - def set_on(self): - self.setStyleSheet(f""" - QLabel {{ - background-color: {C_GREEN}; - border-radius: 7px; - border: 1px solid rgba(81, 207, 102, 50); - }} - """) - - def set_off(self): - self.setStyleSheet(f""" - QLabel {{ - background-color: {C_RED}; - border-radius: 7px; - border: 1px solid rgba(255, 107, 107, 50); - }} - """) - - def set_disabled(self): - self.setStyleSheet(f""" - QLabel {{ - background-color: {C_BORDER}; - border-radius: 7px; - border: 1px solid rgba(136, 153, 170, 50); - }} - """) +# ── 代理白名单弹窗 ───────────────────────────────────── # ── 主窗口 ──────────────────────────────────────────── @@ -3032,7 +2080,7 @@ class MainWindow(QWidget): # ── 设置 ── def _show_settings_dialog(self): """打开设置弹窗(代理运行中锁定端口修改);退出登录后立即触发登录流程。""" - SettingsDialog(self, port_locked=self._proxy_set_by_us).exec_() + SettingsDialog(_settings_services(), self, port_locked=self._proxy_set_by_us).exec_() self._refresh_subtitle() if _endpoint_domain() == "???": self._maybe_show_login() # 退出登录后重新走登录(隐藏主窗口/取消退出) @@ -3050,7 +2098,7 @@ class MainWindow(QWidget): def _show_login_dialog(self): """弹出登录框;成功后重载节点并刷新标题。""" - dlg = LoginDialog(self) + dlg = LoginDialog(_login_services(), self) if dlg.exec_() == QDialog.Accepted: _reload_nodes() self._refresh_subtitle() diff --git a/lite/others/nodes_tool/nodes_tool.py b/lite/others/nodes_tool/nodes_tool.py new file mode 100644 index 0000000..47ac9ce --- /dev/null +++ b/lite/others/nodes_tool/nodes_tool.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +"""管理用户数据目录里的 nodes.json(Windows DPAPI 加密存储)。 + +用法(在 Windows 上运行): + python nodes_tool.py encrypt <明文nodes.json> # 加密导入到 %LOCALAPPDATA%\\RRPP + python nodes_tool.py decrypt # 打印解密后的内容 + python nodes_tool.py path # 显示存储路径 + +注意:DPAPI 加密后只有「本机 + 本用户」能解密;文件拷到别的机器/账户无效。 +""" +import ctypes +import json +import os +import sys +from pathlib import Path + + +def user_data_dir(): + if os.name == "nt" and os.environ.get("LOCALAPPDATA"): + return Path(os.environ["LOCALAPPDATA"]) / "RRPP" + return Path.home() / "RRPP" + + +class _DATA_BLOB(ctypes.Structure): + _fields_ = [("cbData", ctypes.c_uint32), + ("pbData", ctypes.POINTER(ctypes.c_char))] + + +def dpapi_protect(data: bytes) -> bytes: + crypt32 = ctypes.WinDLL("crypt32") + kernel32 = ctypes.WinDLL("kernel32") + crypt32.CryptProtectData.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.c_wchar_p, + ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p, + ctypes.c_void_p, ctypes.c_uint32, + ctypes.POINTER(_DATA_BLOB)] + crypt32.CryptProtectData.restype = ctypes.c_bool + kernel32.LocalFree.argtypes = [ctypes.c_void_p] + + buf = ctypes.create_string_buffer(data, len(data)) + blob_in = _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))) + blob_out = _DATA_BLOB() + if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None, + None, None, 0, ctypes.byref(blob_out)): + raise OSError("CryptProtectData 调用失败") + try: + return ctypes.string_at(blob_out.pbData, blob_out.cbData) + finally: + kernel32.LocalFree(blob_out.pbData) + + +def dpapi_unprotect(data: bytes) -> bytes: + crypt32 = ctypes.WinDLL("crypt32") + kernel32 = ctypes.WinDLL("kernel32") + crypt32.CryptUnprotectData.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.POINTER(ctypes.c_wchar_p), + ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p, + ctypes.c_void_p, ctypes.c_uint32, + ctypes.POINTER(_DATA_BLOB)] + crypt32.CryptUnprotectData.restype = ctypes.c_bool + kernel32.LocalFree.argtypes = [ctypes.c_void_p] + + buf = ctypes.create_string_buffer(data, len(data)) + blob_in = _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))) + descr = ctypes.c_wchar_p() + blob_out = _DATA_BLOB() + if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(descr), None, + None, None, 0, ctypes.byref(blob_out)): + raise OSError("CryptUnprotectData 调用失败") + try: + return ctypes.string_at(blob_out.pbData, blob_out.cbData) + finally: + kernel32.LocalFree(blob_out.pbData) + if descr.value: + kernel32.LocalFree(descr) + + +def cmd_encrypt(src: str): + plain = Path(src) + if not plain.is_file(): + print(f"未找到 {plain}") + sys.exit(1) + data = json.loads(plain.read_text("utf-8")) + if not (isinstance(data, list) and data and all(isinstance(n, dict) for n in data)): + print("nodes.json 应为非空节点数组") + sys.exit(1) + out_dir = user_data_dir() + out_dir.mkdir(parents=True, exist_ok=True) + out = out_dir / "nodes.json" + text = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + blob = dpapi_protect(text.encode("utf-8")) if os.name == "nt" else text.encode("utf-8") + out.write_bytes(blob) + print(f"已{'加密' if os.name == 'nt' else '写入(非Windows不加密)'}: {out}({len(data)} 个节点)") + + +def cmd_decrypt(): + f = user_data_dir() / "nodes.json" + if not f.is_file(): + print(f"未找到 {f}") + sys.exit(1) + blob = f.read_bytes() + if os.name == "nt": + try: + print(dpapi_unprotect(blob).decode("utf-8"), end="") + return + except Exception as e: + print(f"DPAPI 解密失败: {e}") + print(blob.decode("utf-8"), end="") + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + cmd = sys.argv[1] + if cmd == "encrypt" and len(sys.argv) == 3: + cmd_encrypt(sys.argv[2]) + elif cmd == "decrypt": + cmd_decrypt() + elif cmd == "path": + print(user_data_dir()) + else: + print(__doc__) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/lite/others/update_pac_domains/update_pac_domains.py b/lite/others/update_pac_domains/update_pac_domains.py new file mode 100644 index 0000000..4310e32 --- /dev/null +++ b/lite/others/update_pac_domains/update_pac_domains.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +"""更新 pac_domains.json:从 v2fly/domain-list-community (geosite) 拉取境外服务 +分类,与现有手工条目合并,生成新的强制走隧道域名后缀表。 + +用法: + python update_pac_domains.py # 拉取并更新(先自动备份) + python update_pac_domains.py --dry-run # 只统计,不写文件 + +geosite 数据格式(data/<分类>,每行一条规则): + include:xxx 引用其他分类(递归解析) + example.com 匹配域名及其子域(与我们的后缀匹配一致) + +example.com 同上(additive) + .example.com 仅精确匹配(不匹配子域)-> 跳过 + -example.com 排除 -> 跳过 + /regex/ 正则 -> 跳过 + example.com @cn 属性标签 -> 去掉标签 + 无点的单标签(如 chrome)-> 无实际主机名会命中,跳过 +""" +import argparse +import json +import sys +import urllib.request +from datetime import datetime +from pathlib import Path + +BASE_URL = "https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/" +# 需要强制走隧道的境外服务分类(geosite 分类名),可按需增删 +CATEGORIES = ["google", "apple", "telegram", "openai", "twitter", "facebook", + "github", "netflix", "discord", "spotify", "whatsapp", "paypal"] + +SCRIPT_DIR = Path(__file__).resolve().parent +LITE_DIR = SCRIPT_DIR.parent.parent +PAC_FILE = LITE_DIR / "pac_domains.json" +BACKUP_DIR = LITE_DIR / "backups" +# 本地 pac_domains.json 已不再随项目保留:缺失时从 CDN 拉当前版本作合并基底 +CDN_URL = "https://cdn.rrrosmontis.icu/RRPP/PAC/pac_domains.json" + + +def fetch(cat, tries=2): + for i in range(tries): + try: + with urllib.request.urlopen(BASE_URL + cat, timeout=20) as r: + return r.read().decode("utf-8", "replace").splitlines() + except Exception: + if i == tries - 1: + raise + + +def resolve(cat, seen=None): + """递归展开 include:,返回原始行列表;循环引用用 seen 防死循环。""" + seen = seen or set() + if cat in seen: + return [] + seen.add(cat) + lines = [] + try: + for raw in fetch(cat): + line = raw.strip() + if line.startswith("include:"): + lines += resolve(line[len("include:"):].strip(), seen) + else: + lines.append(line) + except Exception as e: + print(f" [warn] 拉取 {cat} 失败: {e}") + return lines + + +def parse_domains(lines): + """过滤 geosite 行,只保留可映射为后缀匹配的纯域名。""" + out = set() + for line in lines: + s = line.strip() + if not s or s.startswith("#") or s.startswith("/"): + continue + if s.startswith("-") or s.startswith("."): + continue + s = s.lstrip("+").split("@")[0].strip() + if "." not in s: + continue + out.add(s.lower()) + return out + + +def main(): + parser = argparse.ArgumentParser(description="更新 pac_domains.json") + parser.add_argument("--dry-run", action="store_true", help="只统计,不写文件") + args = parser.parse_args() + + if PAC_FILE.is_file(): + cur = set(json.loads(PAC_FILE.read_text("utf-8"))["domains"]) + else: + # 项目目录已不保留该文件:从 CDN 拉当前版本作合并基底(保留其中的手工条目) + print(f"本地未找到 {PAC_FILE},从 CDN 拉取当前版本作为合并基底") + try: + with urllib.request.urlopen(CDN_URL, timeout=20) as r: + cur = set(json.loads(r.read().decode("utf-8"))["domains"]) + except Exception as e: + print(f"拉取失败: {e}") + sys.exit(1) + + print(f"拉取 geosite 分类: {', '.join(CATEGORIES)}") + merged = set() + for cat in CATEGORIES: + doms = parse_domains(resolve(cat)) + merged |= doms + print(f" {cat:<12} -> {len(doms):>5} 条") + + new = merged - cur + kept = cur & merged + only_cur = cur - merged + print(f"\n合并后总数: {len(merged | cur)}(geosite {len(merged)} + 现有 {len(cur)})") + print(f"新增 {len(new)},与现有重叠 {len(kept)},现有手工条目 {len(only_cur)} 条全部保留") + + if args.dry_run: + print("--dry-run: 未写文件") + return + + final = sorted(merged | cur) + now = datetime.now().strftime("%Y-%m-%d %H:%M") + comment = ( + f"强制走代理隧道的域名后缀(IP 智能分流的补充规则):命中这些域名的连接即使" + f"解析到国内 IP 也强制走隧道。匹配规则:host == 条目 或 host 以 \".条目\" 结尾。" + f"由 others/update_pac_domains/update_pac_domains.py 从 v2fly geosite " + f"({', '.join(CATEGORIES)}) 生成于 {now},手工条目已保留;" + f"客户端按文件内 version/date 从 CDN 在线更新到 %LOCALAPPDATA%\\RRPP。" + ) + out = {"_comment": comment, + "version": datetime.now().strftime("%Y%m%d%H%M"), + "date": datetime.now().strftime("%Y-%m-%d"), + "domains": final} + + BACKUP_DIR.mkdir(exist_ok=True) + bak = BACKUP_DIR / f"pac_domains.json.bak_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + bak.write_bytes(PAC_FILE.read_bytes()) + print(f"已备份: {bak}") + + PAC_FILE.write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", "utf-8") + size = PAC_FILE.stat().st_size / 1024 + print(f"已写入: {PAC_FILE}({len(final)} 条,{size:.0f} KB)") + + +if __name__ == "__main__": + main() diff --git a/lite/others/update_pac_ip_cn/update_pac_ip_cn.py b/lite/others/update_pac_ip_cn/update_pac_ip_cn.py new file mode 100644 index 0000000..d5f8a5a --- /dev/null +++ b/lite/others/update_pac_ip_cn/update_pac_ip_cn.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +"""更新 pac_ip_cn.json:从 17mon/china_ip_list 拉取中国 IPv4 段,写入带 +version/date 戳的本地 PAC 数据文件(客户端按 pac_manifest.json 比对在线更新)。 + +用法: + python update_pac_ip_cn.py # 拉取并更新(先自动备份) + python update_pac_ip_cn.py --dry-run # 只统计,不写文件 +""" +import argparse +import ipaddress +import json +import sys +import urllib.request +from datetime import datetime +from pathlib import Path + +SRC_URL = "https://raw.githubusercontent.com/17mon/china_ip_list/master/china_ip_list.txt" + +SCRIPT_DIR = Path(__file__).resolve().parent +LITE_DIR = SCRIPT_DIR.parent.parent +PAC_FILE = LITE_DIR / "pac_ip_cn.json" +BACKUP_DIR = LITE_DIR / "backups" + + +def fetch_lines(tries=2): + for i in range(tries): + try: + with urllib.request.urlopen(SRC_URL, timeout=20) as r: + return r.read().decode("utf-8", "replace").splitlines() + except Exception: + if i == tries - 1: + raise + + +def main(): + parser = argparse.ArgumentParser(description="更新 pac_ip_cn.json") + parser.add_argument("--dry-run", action="store_true", help="只统计,不写文件") + args = parser.parse_args() + + print(f"拉取: {SRC_URL}") + cidrs, skipped = [], 0 + for line in fetch_lines(): + s = line.strip() + if not s or s.startswith("#"): + continue + try: + net = ipaddress.ip_network(s, strict=False) + if net.version == 4: + cidrs.append(str(net)) + else: + skipped += 1 + except ValueError: + skipped += 1 + cidrs.sort(key=lambda c: (int(ipaddress.ip_network(c, strict=False).network_address), + ipaddress.ip_network(c, strict=False).prefixlen)) + print(f"共 {len(cidrs)} 条 IPv4 CIDR,跳过 {skipped} 条") + + if args.dry_run: + print("--dry-run: 未写文件") + return + + now = datetime.now() + comment = ( + f"中国 IPv4 地址段(CIDR)。智能分流依据:解析 IP 命中列表 → 直连," + f"未命中 → 走加密隧道。由 others/update_pac_ip_cn/update_pac_ip_cn.py " + f"从 17mon/china_ip_list 生成于 {now.strftime('%Y-%m-%d %H:%M')};" + f"客户端按文件内 version/date 从 CDN 在线更新到 %LOCALAPPDATA%\\RRPP。" + ) + out = {"_comment": comment, + "version": now.strftime("%Y%m%d%H%M"), + "date": now.strftime("%Y-%m-%d"), + "cidrs": cidrs} + + BACKUP_DIR.mkdir(exist_ok=True) + bak = BACKUP_DIR / f"pac_ip_cn.json.bak_{now.strftime('%Y%m%d_%H%M%S')}" + if PAC_FILE.is_file(): + bak.write_bytes(PAC_FILE.read_bytes()) + print(f"已备份: {bak}") + + PAC_FILE.write_text(json.dumps(out, ensure_ascii=False, indent=1) + "\n", "utf-8") + size = PAC_FILE.stat().st_size / 1024 + print(f"已写入: {PAC_FILE}({len(cidrs)} 条,{size:.0f} KB)") + + +if __name__ == "__main__": + main() diff --git a/lite/rrpp_core/__init__.py b/lite/rrpp_core/__init__.py new file mode 100644 index 0000000..0a0a01f --- /dev/null +++ b/lite/rrpp_core/__init__.py @@ -0,0 +1 @@ +"""RRPP Lite 的无 GUI 核心逻辑。""" diff --git a/lite/rrpp_core/forwarder_primitives.py b/lite/rrpp_core/forwarder_primitives.py new file mode 100644 index 0000000..5b2c864 --- /dev/null +++ b/lite/rrpp_core/forwarder_primitives.py @@ -0,0 +1,91 @@ +"""本地转发器的无状态报文解析与改写函数。""" + +from urllib.parse import urlsplit + + +HTTP_METHODS = ( + b"CONNECT ", b"GET ", b"POST ", b"PUT ", b"DELETE ", + b"HEAD ", b"OPTIONS ", b"PATCH ", +) + + +def starts_with_http_method(data: bytes) -> bool: + return any(data.startswith(method) for method in HTTP_METHODS) + + +def inject_proxy_auth(data: bytes, basic_auth: str) -> bytes: + """为完整 HTTP 代理请求注入上游 Basic Authorization。""" + if not starts_with_http_method(data): + return data + end = data.find(b"\r\n\r\n") + if end == -1: + 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 + auth_line = f"Authorization: Basic {basic_auth}\r\n".encode() + return request_line + b"\r\n" + auth_line + headers + data[end:] + + +def parse_connect_authority(first_line: str): + """从 CONNECT 首行提取 ``(host, port)``,失败时返回 ``None``。""" + parts = first_line.split(" ") + if len(parts) < 2: + return None + authority = parts[1] + if authority.startswith("[") and "]" in authority: + 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(":") + if not host or not port_s.isdigit(): + return None + return host, int(port_s) + + +def parse_http_absolute(first_line: str): + """从明文 HTTP 代理首行提取 ``(host, port)``,失败时返回 ``None``。""" + parts = first_line.split(" ") + if len(parts) < 2 or not parts[1].lower().startswith(("http://", "https://")): + return None + parsed = urlsplit(parts[1]) + if not parsed.hostname: + return None + port = parsed.port or (443 if parsed.scheme == "https" else 80) + return parsed.hostname, port + + +def force_http_close(buffer: bytes) -> bytes: + """直连明文 HTTP 时强制 Connection: close,避免错复用连接。""" + end = buffer.find(b"\r\n\r\n") + if end == -1: + return buffer + head, tail = buffer[:end], buffer[end:] + lines = head.split(b"\r\n") + out = [lines[0]] + conn_seen = False + for line in lines[1:]: + low = line.lower() + if low.startswith(b"connection:"): + out.append(b"Connection: close") + conn_seen = True + elif low.startswith(b"proxy-connection:"): + continue + else: + out.append(line) + if not conn_seen: + out.append(b"Connection: close") + return b"\r\n".join(out) + tail diff --git a/lite/rrpp_core/network_primitives.py b/lite/rrpp_core/network_primitives.py new file mode 100644 index 0000000..29c0eff --- /dev/null +++ b/lite/rrpp_core/network_primitives.py @@ -0,0 +1,82 @@ +"""路由、白名单和转发器共用的网络基础函数。""" + +import functools +import ipaddress +import socket +import threading +import time + + +def ttl_cache(seconds: int, maxsize: int = 16384): + """带 TTL 的小型线程安全缓存,并保留 ``cache_clear`` 兼容接口。""" + def decorator(function): + store = {} + lock = threading.Lock() + + @functools.wraps(function) + 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] + value = function(*args, **kwargs) + with lock: + store[key] = (now, value) + if len(store) > maxsize: + cutoff = now - seconds + for cached_key, (timestamp, _) in list(store.items()): + if timestamp < cutoff: + del store[cached_key] + if len(store) > maxsize: + store.clear() + return value + + def cache_clear(): + with lock: + store.clear() + + wrapper.cache_clear = cache_clear + return wrapper + return decorator + + +def is_private_ipv4_int(value: int) -> bool: + """整数形式 IPv4 是否属于常见私网、回环或链路本地段。""" + return ( + (value & 0xFF000000) == 0x0A000000 + or (value & 0xFFF00000) == 0xAC100000 + or (value & 0xFFFF0000) == 0xC0A80000 + or (value & 0xFF000000) == 0x7F000000 + or (value & 0xFFFF0000) == 0xA9FE0000 + ) + + +def resolve_host_ips(host: str): + """解析主机名的 IPv4/IPv6 集合;字面量 IP 返回自身,失败返回空集合。""" + try: + return {ipaddress.ip_address(host)} + except ValueError: + pass + try: + return { + ipaddress.ip_address(address[4][0]) + for address in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + } + except Exception: + return set() + + +def is_private_host(host: str) -> bool: + """目标是否命中私网、回环或 IPv6 ULA/链路本地地址。""" + for address in resolve_host_ips(host): + if address.version == 4 and is_private_ipv4_int(int(address)): + return True + if address.version == 6 and ( + address.is_loopback + or address.is_link_local + or address in ipaddress.ip_network("fc00::/7") + ): + return True + return False diff --git a/lite/rrpp_core/node_api.py b/lite/rrpp_core/node_api.py new file mode 100644 index 0000000..30fd724 --- /dev/null +++ b/lite/rrpp_core/node_api.py @@ -0,0 +1,36 @@ +"""节点订阅的认证请求协议。""" + +import base64 +import json +import urllib.error +import urllib.request + + +class NodeAuthError(Exception): + """订阅服务拒绝请求时携带的 HTTP 状态和用户可读原因。""" + + def __init__(self, message, code=0): + super().__init__(message) + self.code = code + + +def b64url(value: str) -> str: + """SSO 查询参数使用的无 padding URL-safe Base64。""" + return base64.urlsafe_b64encode(str(value).encode("utf-8")).decode("ascii").rstrip("=") + + +def fetch_nodes_auth(url, user, password, timeout=10): + """直连拉取需 SSO 查询参数认证的 nodes.json。""" + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + separator = "&" if "?" in url else "?" + request_url = f"{url}{separator}user={b64url(user)}&password={b64url(password)}" + try: + with opener.open(request_url, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + body = "" + try: + body = error.read().decode("utf-8", "replace").strip() + except Exception: + pass + raise NodeAuthError(body or f"请求被拒绝 (HTTP {error.code})", error.code) from None diff --git a/lite/rrpp_core/node_secrets.py b/lite/rrpp_core/node_secrets.py new file mode 100644 index 0000000..fca6a15 --- /dev/null +++ b/lite/rrpp_core/node_secrets.py @@ -0,0 +1,95 @@ +"""节点与登录凭据的本机加密存储。""" + +import ctypes +import json +import os + + +class _DpapiBlob(ctypes.Structure): + _fields_ = [ + ("cbData", ctypes.c_uint32), + ("pbData", ctypes.POINTER(ctypes.c_char)), + ] + + +class NodeSecretsStore: + """使用 Windows DPAPI 保存节点和 SSO 凭据;非 Windows 保持明文兼容。""" + + def __init__(self, user_dir_getter): + self._user_dir_getter = user_dir_getter + + @staticmethod + def protect_bytes(data: bytes) -> bytes: + crypt32 = ctypes.WinDLL("crypt32") + kernel32 = ctypes.WinDLL("kernel32") + crypt32.CryptProtectData.argtypes = [ + ctypes.POINTER(_DpapiBlob), ctypes.c_wchar_p, ctypes.POINTER(_DpapiBlob), + ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob), + ] + crypt32.CryptProtectData.restype = ctypes.c_bool + kernel32.LocalFree.argtypes = [ctypes.c_void_p] + buffer = ctypes.create_string_buffer(data, len(data)) + blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char))) + blob_out = _DpapiBlob() + if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None, + None, None, 0, ctypes.byref(blob_out)): + raise OSError("CryptProtectData 调用失败") + try: + return ctypes.string_at(blob_out.pbData, blob_out.cbData) + finally: + kernel32.LocalFree(blob_out.pbData) + + @staticmethod + def unprotect_bytes(data: bytes) -> bytes: + crypt32 = ctypes.WinDLL("crypt32") + kernel32 = ctypes.WinDLL("kernel32") + crypt32.CryptUnprotectData.argtypes = [ + ctypes.POINTER(_DpapiBlob), ctypes.POINTER(ctypes.c_wchar_p), ctypes.POINTER(_DpapiBlob), + ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob), + ] + crypt32.CryptUnprotectData.restype = ctypes.c_bool + kernel32.LocalFree.argtypes = [ctypes.c_void_p] + buffer = ctypes.create_string_buffer(data, len(data)) + blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char))) + description = ctypes.c_wchar_p() + blob_out = _DpapiBlob() + if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(description), None, + None, None, 0, ctypes.byref(blob_out)): + raise OSError("CryptUnprotectData 调用失败") + try: + return ctypes.string_at(blob_out.pbData, blob_out.cbData) + finally: + kernel32.LocalFree(blob_out.pbData) + if description.value: + kernel32.LocalFree(description) + + def encrypt_text(self, text: str) -> bytes: + if os.name != "nt": + return text.encode("utf-8") + return self.protect_bytes(text.encode("utf-8")) + + def decrypt_bytes(self, blob: bytes) -> str: + if os.name != "nt": + return blob.decode("utf-8") + try: + return self.unprotect_bytes(blob).decode("utf-8") + except Exception: + return blob.decode("utf-8") + + def save_auth(self, username, password): + directory = self._user_dir_getter() + directory.mkdir(parents=True, exist_ok=True) + text = json.dumps({"username": username, "password": password}, ensure_ascii=False) + (directory / "auth.json").write_bytes(self.encrypt_text(text)) + + def load_auth(self): + try: + path = self._user_dir_getter() / "auth.json" + if not path.is_file(): + return None + data = json.loads(self.decrypt_bytes(path.read_bytes())) + if not (isinstance(data, dict) and data.get("username") and data.get("password")): + return None + return data + except Exception: + return None diff --git a/lite/rrpp_core/pac_data.py b/lite/rrpp_core/pac_data.py new file mode 100644 index 0000000..5484b8a --- /dev/null +++ b/lite/rrpp_core/pac_data.py @@ -0,0 +1,53 @@ +"""PAC 数据文件的网络传输与安全落盘。""" + +import json +import os +import urllib.request +from pathlib import Path + + +def read_version(path): + """读取 PAC JSON 的 version/date;无效文件返回空字符串。""" + try: + data = json.loads(Path(path).read_text("utf-8")) + return str(data.get("version") or ""), str(data.get("date") or "") + except Exception: + return "", "" + + +def no_proxy_opener(): + """创建忽略环境/系统代理的直连 HTTP opener。""" + return urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def fetch_json(url, timeout=15, tries=2): + """以直连方式拉取 JSON;最后一次失败时保留原始异常。""" + opener = no_proxy_opener() + for attempt in range(tries): + try: + with opener.open(url, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + except Exception: + if attempt == tries - 1: + raise + + +def validate_document(name, data): + """校验各 PAC 文件的最小结构,避免错误响应覆盖有效缓存。""" + if not isinstance(data, dict): + return False + 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) + return False + + +def atomic_write(directory, name, data): + """将 PAC JSON 以 tmp + replace 原子写入指定用户目录。""" + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + target = directory / f"{name}.json" + temporary_path = directory / f"{name}.json.tmp" + temporary_path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") + os.replace(temporary_path, target) diff --git a/lite/rrpp_core/routing_primitives.py b/lite/rrpp_core/routing_primitives.py new file mode 100644 index 0000000..43ed6a8 --- /dev/null +++ b/lite/rrpp_core/routing_primitives.py @@ -0,0 +1,81 @@ +"""PAC 路由引擎使用的纯算法,不依赖文件、网络或 Qt。""" + +import ipaddress +import re + + +def merge_ip_ranges(cidrs, version): + """将指定 IP 版本的 CIDR 合并为整数闭区间,供二分查找。""" + ranges = [] + for cidr in cidrs: + network = ipaddress.ip_network(str(cidr).strip(), strict=False) + if network.version == version: + ranges.append((int(network.network_address), int(network.broadcast_address))) + 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 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: + """判断主机名是否命中一个后缀 Trie。""" + 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 parse_domain_rules(raw_rules, seed_suffixes=()): + """解析后缀、``full:`` 与 ``regexp:`` 三种 PAC 域名规则。 + + 返回 ``(suffixes, exact, regexes, invalid_regex_count)``。调用方决定 + 这些规则是“强制代理”还是“明确直连”,从而避免解析层携带业务策略。 + """ + suffixes = set(seed_suffixes) + exact = set() + regexes = [] + invalid_regexes = 0 + for raw in raw_rules 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 = 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) + return suffixes, frozenset(exact), tuple(regexes), invalid_regexes diff --git a/lite/rrpp_core/settings_store.py b/lite/rrpp_core/settings_store.py new file mode 100644 index 0000000..f45c50e --- /dev/null +++ b/lite/rrpp_core/settings_store.py @@ -0,0 +1,34 @@ +"""用户设置的 JSON 存储实现。""" + +import json +import os + + +class JsonSettingsStore: + """读取与原子写回一个 JSON 对象。 + + ``path_getter`` 在每次操作时调用,保证测试或运行时切换用户目录后不会 + 意外写到旧目录。 + """ + + def __init__(self, path_getter): + self._path_getter = path_getter + + def load(self): + path = self._path_getter() + try: + if not path.is_file(): + return {} + data = json.loads(path.read_text("utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def save(self, data): + path = self._path_getter() + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = path.with_suffix(path.suffix + ".tmp") + temporary_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", "utf-8" + ) + os.replace(temporary_path, path) diff --git a/lite/rrpp_core/traffic.py b/lite/rrpp_core/traffic.py new file mode 100644 index 0000000..e2d2db8 --- /dev/null +++ b/lite/rrpp_core/traffic.py @@ -0,0 +1,55 @@ +"""线程安全的本地转发流量统计。""" + +import threading +import time + + +class TrafficStats: + """记录单次代理会话的上下行总量与采样速率。""" + + def __init__(self, clock=time.time): + self._clock = clock + self._lock = threading.Lock() + self._up = 0 + self._down = 0 + self._prev_up = 0 + self._prev_down = 0 + self._prev_ts = 0.0 + + def add(self, up=0, down=0): + with self._lock: + self._up += up + self._down += down + + def reset(self): + with self._lock: + self._up = self._down = 0 + self._prev_up = self._prev_down = 0 + self._prev_ts = self._clock() + + def rate(self): + with self._lock: + now = self._clock() + if self._prev_ts > 0 and now > self._prev_ts: + elapsed = now - self._prev_ts + up_rate = int((self._up - self._prev_up) / elapsed) + down_rate = int((self._down - self._prev_down) / elapsed) + else: + up_rate = down_rate = 0 + self._prev_up = self._up + self._prev_down = self._down + self._prev_ts = now + return up_rate, down_rate, self._up, self._down + + def snapshot(self): + with self._lock: + return self._up, self._down + + +def format_bytes(value): + """将字节数格式化为 UI 展示文本。""" + number = float(value) + for unit in ("B", "KB", "MB", "GB", "TB"): + if number < 1024 or unit == "TB": + return f"{int(number)} {unit}" if unit == "B" else f"{number:.1f} {unit}" + number /= 1024 diff --git a/lite/rrpp_ui/__init__.py b/lite/rrpp_ui/__init__.py new file mode 100644 index 0000000..397fac4 --- /dev/null +++ b/lite/rrpp_ui/__init__.py @@ -0,0 +1,4 @@ +"""RRPP Lite 的 Qt 界面层。 + +核心代理与 PAC 逻辑不得依赖本包,以便后续在无 GUI 环境中独立测试。 +""" diff --git a/lite/rrpp_ui/dialogs.py b/lite/rrpp_ui/dialogs.py new file mode 100644 index 0000000..cc81155 --- /dev/null +++ b/lite/rrpp_ui/dialogs.py @@ -0,0 +1,468 @@ +"""需要业务服务的 Qt 对话框。""" + +import json +import threading +from dataclasses import dataclass + +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtWidgets import ( + QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, + QPushButton, QVBoxLayout, QWidget, +) + +from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE +from .widgets import NodeLabel, ToggleSwitch + + +@dataclass(frozen=True) +class LoginServices: + """登录框所需的显式业务依赖,避免 UI 反向导入程序入口。""" + + load_icon: object + load_auth: object + get_nodes_url: object + fetch_nodes: object + user_dir: object + encrypt_nodes: object + save_auth: object + auth_error: type + log: object + + +class LoginDialog(QDialog): + """使用 SSO 获取并加密保存节点配置的登录框。""" + + _done = pyqtSignal(bool, str) + + def __init__(self, services: LoginServices, parent=None): + super().__init__(parent) + self._services = services + self.setWindowTitle("节点配置登录") + self.setWindowIcon(services.load_icon()) + self.setFixedSize(420, 170) + self.setStyleSheet(f""" + QDialog {{ background-color: {C_BG}; }} + QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} + QLineEdit {{ + background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px; + padding: 6px 8px; color: {C_TEXT}; font-size: 12px; + }} + QPushButton#btnOk {{ + background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px; + padding: 4px 12px; font-size: 12px; font-weight: bold; + }} + QPushButton#btnOk:hover {{ background-color: #5ed978; }} + QPushButton#btnOk:disabled {{ background-color: #2a3a2e; color: #666; }} + QPushButton#btnCancel {{ + background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER}; + border-radius: 6px; padding: 4px 12px; font-size: 12px; + }} + """) + + root = QHBoxLayout(self) + root.setSpacing(14) + root.setContentsMargins(20, 18, 20, 16) + left = QVBoxLayout() + left.setSpacing(6) + left.addStretch() + logo = QLabel() + logo.setPixmap(services.load_icon().pixmap(80, 80)) + logo.setFixedSize(80, 80) + logo.setAlignment(Qt.AlignCenter) + left.addWidget(logo, 0, Qt.AlignHCenter) + brand = QLabel("R.R.P.P.") + brand.setStyleSheet(f"color: {C_TITLE}; font-size: 10px;") + brand.setAlignment(Qt.AlignCenter) + left.addWidget(brand, 0, Qt.AlignHCenter) + left.addStretch() + root.addLayout(left) + + form = QVBoxLayout() + form.setSpacing(6) + self.ed_user = QLineEdit() + self.ed_user.setPlaceholderText("SSO 邮箱") + form.addWidget(self.ed_user) + self.ed_pass = QLineEdit() + self.ed_pass.setPlaceholderText("密码") + self.ed_pass.setEchoMode(QLineEdit.Password) + self.ed_pass.returnPressed.connect(self._submit) + form.addWidget(self.ed_pass) + credentials = services.load_auth() + if credentials: + self.ed_user.setText(credentials["username"]) + self.ed_pass.setText(credentials["password"]) + + self.lb_err = QLabel("") + self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;") + self.lb_err.setFixedHeight(14) + self.lb_err.hide() + form.addWidget(self.lb_err) + bottom = QHBoxLayout() + bottom.setSpacing(10) + hint = QLabel("登录以获取节点配置") + hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 10px;") + bottom.addWidget(hint) + bottom.addStretch() + self.btn_cancel = QPushButton("取消") + self.btn_cancel.setObjectName("btnCancel") + self.btn_cancel.clicked.connect(self.reject) + bottom.addWidget(self.btn_cancel) + self.btn_ok = QPushButton("登录") + self.btn_ok.setObjectName("btnOk") + self.btn_ok.clicked.connect(self._submit) + bottom.addWidget(self.btn_ok) + form.addLayout(bottom) + box = QWidget() + box.setLayout(form) + root.addWidget(box, 1, Qt.AlignVCenter) + self._done.connect(self._on_done) + + def _submit(self): + user = self.ed_user.text().strip() + password = self.ed_pass.text() + if not user or not password: + self.lb_err.setText("请输入用户名和密码") + self.lb_err.show() + return + self.btn_ok.setEnabled(False) + self.btn_cancel.setEnabled(False) + self.btn_ok.setText("登录中") + threading.Thread(target=self._worker, args=(user, password), daemon=True).start() + + def _worker(self, user, password): + try: + url = str(self._services.get_nodes_url() or "").strip() + if not url: + raise RuntimeError("config.json 未配置 nodes_url") + data = self._services.fetch_nodes(url, user, password) + if not (isinstance(data, list) and data and all(isinstance(node, dict) for node in data) + and str(data[0].get("domain") or "").strip()): + raise ValueError("返回的节点数据无效") + text = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + directory = self._services.user_dir() + directory.mkdir(parents=True, exist_ok=True) + (directory / "nodes.json").write_bytes(self._services.encrypt_nodes(text)) + self._services.save_auth(user, password) + self._done.emit(True, str(len(data))) + except self._services.auth_error as error: + self._done.emit(False, str(error)) + except Exception as error: + self._done.emit(False, f"登录失败: {error}") + + def _on_done(self, ok, info): + self.btn_ok.setEnabled(True) + self.btn_cancel.setEnabled(True) + self.btn_ok.setText("登录") + if ok: + self._services.log(f"登录成功,已获取 {info} 个节点(nodes.json 已加密保存)") + self.accept() + else: + self.lb_err.setText(info) + self.lb_err.show() + + +@dataclass(frozen=True) +class WhitelistServices: + """白名单编辑框所需的规则服务。""" + + load_icon: object + load_rules: object + validate_rule: object + save_rules: object + + +class WhitelistDialog(QDialog): + """编辑强制直连白名单;规则数据通过显式服务读写。""" + + def __init__(self, services: WhitelistServices, parent=None): + super().__init__(parent) + self._services = services + self.setWindowTitle("代理白名单") + self.setWindowIcon(services.load_icon()) + self.setFixedSize(380, 400) + self.setStyleSheet(f""" + QDialog {{ background-color: {C_BG}; }} + QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} + QLineEdit {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px; + padding: 6px 8px; color: {C_TEXT}; font-size: 12px; }} + QListWidget {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px; + padding: 4px; color: {C_TEXT}; font-size: 12px; }} + QListWidget::item {{ padding: 3px 6px; border-radius: 4px; }} + QListWidget::item:selected {{ background-color: #e3e8ee; color: {C_TEXT}; }} + QPushButton#btnOk {{ background-color: {C_GREEN}; color: #fff; border: none; + border-radius: 6px; padding: 4px 12px; font-size: 12px; font-weight: bold; }} + QPushButton#btnOk:hover {{ background-color: #5ed978; }} + QPushButton#btnCancel {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER}; + border-radius: 6px; padding: 4px 12px; font-size: 12px; }} + QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER}; + border-radius: 6px; padding: 3px 10px; font-size: 11px; }} + QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }} + """) + layout = QVBoxLayout(self) + layout.setSpacing(10) + layout.setContentsMargins(20, 16, 20, 16) + title = QLabel("代理白名单") + title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + hint = QLabel("命中以下规则的连接将强制直连(不经过代理)") + hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") + hint.setAlignment(Qt.AlignCenter) + layout.addWidget(hint) + self.lb_err = QLabel("") + self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;") + self.lb_err.setFixedHeight(14) + self.lb_err.hide() + layout.addWidget(self.lb_err) + self.list_widget = QListWidget() + self.list_widget.setToolTip("勾选启用规则;可多选后删除") + for rule in services.load_rules(): + self._add_item(rule["value"], rule.get("enabled", True)) + layout.addWidget(self.list_widget, 1) + add_row = QHBoxLayout() + add_row.setSpacing(8) + self.ed_add = QLineEdit() + self.ed_add.setPlaceholderText("输入域名 / IP / CIDR,如 example.com") + self.ed_add.returnPressed.connect(self._add_rule) + add_row.addWidget(self.ed_add, 1) + self.btn_add = QPushButton("添加") + self.btn_add.setObjectName("btnOk") + self.btn_add.setStyleSheet(f""" + QPushButton {{ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px; + padding: 6px 14px; font-size: 12px; font-weight: bold; }} + QPushButton:hover {{ background-color: #5ed978; }} + """) + self.btn_add.clicked.connect(self._add_rule) + add_row.addWidget(self.btn_add) + layout.addLayout(add_row) + middle = QHBoxLayout() + self.btn_delete = QPushButton("删除选中") + self.btn_delete.setObjectName("btnSmall") + self.btn_delete.clicked.connect(self._delete_selected) + middle.addWidget(self.btn_delete) + middle.addStretch() + layout.addLayout(middle) + buttons = QHBoxLayout() + buttons.setSpacing(10) + buttons.addStretch() + self.btn_cancel = QPushButton("取消") + self.btn_cancel.setObjectName("btnCancel") + self.btn_cancel.clicked.connect(self.reject) + buttons.addWidget(self.btn_cancel) + self.btn_ok = QPushButton("保存") + self.btn_ok.setObjectName("btnOk") + self.btn_ok.clicked.connect(self._save) + buttons.addWidget(self.btn_ok) + layout.addLayout(buttons) + + def _add_item(self, value, enabled): + item = QListWidgetItem(value) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked if enabled else Qt.Unchecked) + self.list_widget.addItem(item) + + def _add_rule(self): + value, error = self._services.validate_rule(self.ed_add.text()) + if error: + self.lb_err.setText(error) + self.lb_err.show() + return + for index in range(self.list_widget.count()): + if self.list_widget.item(index).text().lower() == value.lower(): + self.lb_err.setText("该规则已存在") + self.lb_err.show() + return + self.lb_err.hide() + self._add_item(value, True) + self.ed_add.clear() + + def _delete_selected(self): + for item in self.list_widget.selectedItems(): + self.list_widget.takeItem(self.list_widget.row(item)) + + def _save(self): + rules = [] + for index in range(self.list_widget.count()): + item = self.list_widget.item(index) + value = str(item.text()).strip() + if value: + rules.append({"value": value, "enabled": item.checkState() == Qt.Checked}) + self._services.save_rules(rules) + self.accept() + + +@dataclass(frozen=True) +class SettingsServices: + """设置框所需的配置与业务回调。""" + + load_icon: object + version: str + get_port: object + get_update_via_proxy: object + get_skip_update_check: object + parse_port: object + set_port: object + set_update_via_proxy: object + set_skip_update_check: object + open_whitelist: object + logout: object + + +class SettingsDialog(QDialog): + """代理端口、更新方式、白名单与登录状态设置框。""" + + def __init__(self, services: SettingsServices, parent=None, port_locked=False): + super().__init__(parent) + self._services = services + self.setWindowTitle("设置") + self.setWindowIcon(services.load_icon()) + self.setFixedSize(300, 354) + self.setStyleSheet(f""" + QDialog {{ background-color: {C_BG}; }} + QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} + QLineEdit {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px; + padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }} + """) + layout = QVBoxLayout(self) + layout.setSpacing(12) + layout.setContentsMargins(20, 16, 20, 16) + title = QLabel("设置") + title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + version_row = QHBoxLayout() + version_row.addStretch() + version_label = QLabel(f"版本号: {services.version}") + version_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") + version_row.addWidget(version_label) + version_row.addSpacing(4) + self.link_check_update = NodeLabel( + "检查更新", tooltip="手动检查更新", + base=f"color: {C_SUBTEXT}; font-size: 11px;", + hover=f"color: {C_TEXT}; font-size: 11px; text-decoration: underline;", + ) + self.link_check_update.clicked.connect(self._manual_check_update) + version_row.addWidget(self.link_check_update) + version_row.addStretch() + layout.addLayout(version_row) + + self.lb_hint = QLabel("代理运行中,关闭代理后可修改" if port_locked else "保存更改后下次开启代理生效") + self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") + self.lb_hint.setFixedHeight(14) + self.lb_hint.setAlignment(Qt.AlignCenter) + layout.addWidget(self.lb_hint) + layout.addStretch() + + whitelist_row = QHBoxLayout() + whitelist_label = QLabel("代理白名单") + whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") + whitelist_label.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用") + whitelist_row.addWidget(whitelist_label) + whitelist_row.addStretch() + self.btn_whitelist = QPushButton("配置") + self.btn_whitelist.setCursor(Qt.PointingHandCursor) + self.btn_whitelist.setStyleSheet(f""" + QPushButton {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER}; + border-radius: 6px; padding: 2px 10px; font-size: 11px; }} + QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }} + """) + self.btn_whitelist.clicked.connect(self._open_whitelist) + whitelist_row.addWidget(self.btn_whitelist) + layout.addLayout(whitelist_row) + + port_row = QHBoxLayout() + port_label = QLabel("代理转发端口") + port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") + port_row.addWidget(port_label) + port_row.addStretch() + self.ed_port = QLineEdit(str(services.get_port())) + self.ed_port.setFixedSize(54, 22) + self.ed_port.setEnabled(not port_locked) + port_row.addWidget(self.ed_port) + layout.addLayout(port_row) + + update_row = QHBoxLayout() + update_label = QLabel("使用代理更新") + update_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") + update_row.addWidget(update_label) + update_row.addStretch() + self.cb_update_proxy = ToggleSwitch(checked=services.get_update_via_proxy()) + self.cb_update_proxy.setToolTip("开启后检查更新与下载走节点隧道(CONNECT);默认直连仓库") + update_row.addWidget(self.cb_update_proxy) + layout.addLayout(update_row) + + skip_row = QHBoxLayout() + skip_label = QLabel("跳过检查更新") + skip_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") + skip_row.addWidget(skip_label) + skip_row.addStretch() + self.cb_skip_update = ToggleSwitch(checked=services.get_skip_update_check()) + self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志") + skip_row.addWidget(self.cb_skip_update) + layout.addLayout(skip_row) + layout.addStretch() + + buttons = QHBoxLayout() + buttons.setSpacing(10) + buttons.addStretch() + self.btn_save = QPushButton("保存更改") + self.btn_save.setStyleSheet(f""" + QPushButton {{ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px; + padding: 5px 14px; font-size: 12px; font-weight: bold; }} + QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }} + """) + self.btn_save.setEnabled(not port_locked) + self.btn_save.clicked.connect(self._save_settings) + buttons.addWidget(self.btn_save) + self.btn_logout = QPushButton("退出登录") + self.btn_logout.setStyleSheet(f""" + QPushButton {{ background-color: {C_RED}; color: #fff; border: none; border-radius: 6px; + padding: 5px 14px; font-size: 12px; font-weight: bold; }} + QPushButton:hover {{ background-color: #ff8787; }} + """) + self.btn_logout.setToolTip("清除登录凭据与本地节点配置,下次启动需重新登录") + self.btn_logout.clicked.connect(self._logout) + buttons.addWidget(self.btn_logout) + buttons.addStretch() + layout.addLayout(buttons) + + def _manual_check_update(self): + parent = self.parent() + if not (hasattr(parent, "_check_update") and hasattr(parent, "_update_done")): + return + self.lb_hint.setText("正在检查更新...") + self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") + try: + parent._update_done.connect(self._on_check_done) + except TypeError: + pass + threading.Thread(target=parent._check_update, kwargs={"manual": True}, daemon=True).start() + + def _on_check_done(self): + self.lb_hint.setText("检查完成,结果见主界面日志") + self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") + try: + self.parent()._update_done.disconnect(self._on_check_done) + except Exception: + pass + + def _open_whitelist(self): + self._services.open_whitelist(self) + + def _save_settings(self): + port = self._services.parse_port(self.ed_port.text()) + if port is None: + self.lb_hint.setText("端口应为 1-65535 的数字") + self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;") + return + self._services.set_port(port) + self._services.set_update_via_proxy(bool(self.cb_update_proxy.isChecked())) + self._services.set_skip_update_check(bool(self.cb_skip_update.isChecked())) + self.ed_port.setText(str(port)) + self.lb_hint.setText("已保存,下次开启代理生效") + self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;") + + def _logout(self): + self._services.logout() + self.accept() diff --git a/lite/rrpp_ui/theme.py b/lite/rrpp_ui/theme.py new file mode 100644 index 0000000..3691214 --- /dev/null +++ b/lite/rrpp_ui/theme.py @@ -0,0 +1,11 @@ +"""RRPP Lite 界面的共享色彩常量。""" + +C_TITLE = "#8899aa" +C_GREEN = "#51cf66" +C_YELLOW = "#ffd43b" +C_RED = "#ff6b6b" +C_BG = "#ffffff" +C_CARD = "#f0f2f5" +C_BORDER = "#d0d5dd" +C_TEXT = "#333333" +C_SUBTEXT = "#889099" diff --git a/lite/rrpp_ui/widgets.py b/lite/rrpp_ui/widgets.py new file mode 100644 index 0000000..20ed0c6 --- /dev/null +++ b/lite/rrpp_ui/widgets.py @@ -0,0 +1,104 @@ +"""无业务状态的可复用 Qt 控件。""" + +from PyQt5.QtCore import Qt, QRectF, pyqtSignal +from PyQt5.QtGui import QColor, QPainter +from PyQt5.QtWidgets import QCheckBox, QLabel + +from .theme import C_BORDER, C_GREEN, C_RED, C_TITLE + + +_NL_BASE = f"color: {C_TITLE}; font-size: 12px;" + + +class NodeLabel(QLabel): + """可点击标签:hover 显示下划线和手型光标,点击发出 clicked 信号。""" + + clicked = pyqtSignal() + + def __init__(self, text="", tooltip="点击切换节点", base=None, hover=None): + super().__init__(text) + self._base = base or _NL_BASE + self._hover = hover or self._base + " text-decoration: underline;" + self.setStyleSheet(self._base) + if tooltip: + self.setToolTip(tooltip) + + def enterEvent(self, event): + self.setStyleSheet(self._hover) + self.setCursor(Qt.PointingHandCursor) + super().enterEvent(event) + + def leaveEvent(self, event): + self.setStyleSheet(self._base) + self.unsetCursor() + super().leaveEvent(event) + + def mouseReleaseEvent(self, event): + if event.button() == Qt.LeftButton: + self.clicked.emit() + super().mouseReleaseEvent(event) + + +class ToggleSwitch(QCheckBox): + """苹果风格开关:绿/灰胶囊加滑动圆钮,自绘且不依赖图片资源。""" + + def __init__(self, checked=False, parent=None): + super().__init__(parent) + self.setChecked(checked) + self.setFixedSize(42, 22) + self.setCursor(Qt.PointingHandCursor) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(C_GREEN) if self.isChecked() else QColor("#c8ccd4")) + painter.drawRoundedRect(QRectF(0, 0, 42, 22), 11, 11) + knob_x = 20.0 if self.isChecked() else 2.0 + painter.setBrush(QColor("#ffffff")) + painter.drawEllipse(QRectF(knob_x, 2.0, 18.0, 18.0)) + painter.end() + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.setChecked(not self.isChecked()) + self.repaint() + event.accept() + return + super().mousePressEvent(event) + + +class StatusDot(QLabel): + """代理状态小圆点。""" + + def __init__(self): + super().__init__() + self.setFixedSize(14, 14) + self.set_off() + + def set_on(self): + self.setStyleSheet(f""" + QLabel {{ + background-color: {C_GREEN}; + border-radius: 7px; + border: 1px solid rgba(81, 207, 102, 50); + }} + """) + + def set_off(self): + self.setStyleSheet(f""" + QLabel {{ + background-color: {C_RED}; + border-radius: 7px; + border: 1px solid rgba(255, 107, 107, 50); + }} + """) + + def set_disabled(self): + self.setStyleSheet(f""" + QLabel {{ + background-color: {C_BORDER}; + border-radius: 7px; + border: 1px solid rgba(136, 153, 170, 50); + }} + """) diff --git a/lite/test.bat b/lite/test.bat new file mode 100644 index 0000000..02f5b53 --- /dev/null +++ b/lite/test.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +call ..\.venv\Scripts\python.exe main.py +pause diff --git a/lite/test/PyQt5/QtCore.py b/lite/test/PyQt5/QtCore.py new file mode 100644 index 0000000..a72f50f --- /dev/null +++ b/lite/test/PyQt5/QtCore.py @@ -0,0 +1,10 @@ +class Qt: pass +class QTimer: pass + +class _Signal: + def connect(self, *a, **k): pass + def emit(self, *a, **k): pass + +def pyqtSignal(*a, **k): + return _Signal() +class QRectF: pass diff --git a/lite/test/PyQt5/QtGui.py b/lite/test/PyQt5/QtGui.py new file mode 100644 index 0000000..bfc830d --- /dev/null +++ b/lite/test/PyQt5/QtGui.py @@ -0,0 +1,6 @@ +class QFont: pass +class QIcon: pass +class QPixmap: pass +class QImage: pass +class QColor: pass +class QPainter: pass diff --git a/lite/test/PyQt5/QtWidgets.py b/lite/test/PyQt5/QtWidgets.py new file mode 100644 index 0000000..3c978ee --- /dev/null +++ b/lite/test/PyQt5/QtWidgets.py @@ -0,0 +1,28 @@ +class QApplication: pass +class QWidget: pass +class QLabel: + def __init__(self, text=""): + self.text = text + def setStyleSheet(self, *a): pass + def setToolTip(self, *a): pass + def setFixedSize(self, *a): pass + def setFixedHeight(self, *a): pass + def setAlignment(self, *a): pass + def setPixmap(self, *a): pass + def setText(self, *a): pass + def setCursor(self, *a): pass + def unsetCursor(self): pass + def hide(self): pass + def show(self): pass +class QPushButton: pass +class QVBoxLayout: pass +class QHBoxLayout: pass +class QFrame: pass +class QMessageBox: pass +class QTextEdit: pass +class QCheckBox: pass +class QMenu: pass +class QDialog: Accepted = 1 +class QLineEdit: Password = 2 +class QListWidget: pass +class QListWidgetItem: pass diff --git a/lite/test/PyQt5/__init__.py b/lite/test/PyQt5/__init__.py new file mode 100644 index 0000000..0436b2f --- /dev/null +++ b/lite/test/PyQt5/__init__.py @@ -0,0 +1,2 @@ +# PyQt5 桩模块:仅供 WSL 下 import main 做路由逻辑测试, +# 决策函数(_decide_direct 等)不依赖任何 Qt 对象。 diff --git a/lite/test/check_node_menu.py b/lite/test/check_node_menu.py index 131fcdd..92fd61d 100644 --- a/lite/test/check_node_menu.py +++ b/lite/test/check_node_menu.py @@ -16,15 +16,19 @@ sys.path = [str(LITE)] + [p for p in sys.path if Path(p).resolve() != Path(__fil sys.stdout.reconfigure(encoding="utf-8") -import proxy_lite as pp +import main as pp from PyQt5.QtWidgets import QApplication -# 构造窗口时不写用户数据目录(避免污染),构造后再指向真实 nodes.json +# 构造窗口时不触发用户数据初始化或远程同步;本测试只验证静态菜单和本地节点状态。 pp._user_init_local = lambda: None +pp._user_init_remote = lambda *args, **kwargs: None +pp._nodes_sync_remote = lambda *args, **kwargs: pp._NODES_SYNC_DONE.set() +pp._NODES_SYNC_DONE.set() app = QApplication(sys.argv) w = pp.MainWindow() w.timer.stop() # 停掉状态轮询,纯静态检查 +failures = [] pp._USER_DIR = LITE / "test" # 真实 nodes.json 所在目录 nodes = pp._reload_nodes() @@ -44,6 +48,8 @@ for act in menu.actions(): if not act.isEnabled(): act.trigger() print(f"\n点击禁用项后节点下标: {before} -> {pp._selected_index()} (应不变)") +if pp._selected_index() != before: + failures.append("点击不可用节点后发生了切换") # ── 上次选中的节点已不可用:启动时自动回退到第一个可达节点 ────────── print("\n启动校验(_verify_selected_node):") @@ -54,19 +60,28 @@ 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 # 模拟上次选了不可用节点 + # 模拟上次持久化选中了不可用节点:内存下标与 current_node 必须一致, + # 否则 _verify_selected_node 会先按真实保存的域名重新匹配,测试并非回退分支。 + pp._settings_save({**pp._settings_load(), "current_node": nodes[bad_idx].get("domain") or ""}) + 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'}") + if not ok: + failures.append("不可用节点未回退到首个可达节点") 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'}") + if not ok2: + failures.append("节点回退未写回 current_node") # 再校验一次:已经是可达节点,不应再切换 ok3 = pp._verify_selected_node() is False print(f" 再次校验不重复切换 {'PASS' if ok3 else 'FAIL'}") + if not ok3: + failures.append("已回退节点被重复切换") if _bak is None: (pp._USER_DIR / "settings.json").unlink(missing_ok=True) else: @@ -87,13 +102,19 @@ else: 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'}") + if not ok: + failures.append("节点调序后没有按域名重新匹配") # 已是正确域名时再跑一次:不应重复切换 ok2 = pp._rematch_saved_domain() is False print(f" 再次重匹配不重复切换 {'PASS' if ok2 else 'FAIL'}") + if not ok2: + failures.append("节点调序重匹配重复切换") # 域名已不在列表(节点被移除):不切换,保持现状 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'}") + if not ok3: + failures.append("已移除节点仍发生切换") pp._NODES = None # 还原缓存,后续用例重读文件 # 还原 settings.json,避免测试污染 if _bak is None: @@ -101,4 +122,6 @@ else: else: (pp._USER_DIR / "settings.json").write_bytes(_bak) +if failures: + raise SystemExit("FAIL: " + "; ".join(failures)) print("OK") diff --git a/lite/test/nodes.json b/lite/test/nodes.json new file mode 100644 index 0000000..e01510f --- /dev/null +++ b/lite/test/nodes.json @@ -0,0 +1,21 @@ +[ + { + "domain": "example.com", + "port": 443, + "country": "TEST", + "remark": "普通测试节点" + }, + { + "domain": "example.org", + "port": 443, + "country": "TEST", + "remark": "备用测试节点" + }, + { + "domain": "ipv6.test.invalid", + "port": 443, + "country": "TEST", + "ipv6_only": true, + "remark": "IPv6 测试节点" + } +] diff --git a/lite/test/probe_nodes.py b/lite/test/probe_nodes.py new file mode 100644 index 0000000..f8115f0 --- /dev/null +++ b/lite/test/probe_nodes.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +"""连通性探测:用 test/nodes.json 的真实节点,走 _tls_start 转发逻辑实测。 + +仅需标准库;不启动 GUI(main 的 GUI 在 __main__ 保护下)。 +用法: python3 test/probe_nodes.py [JP US ...] 不带参数测全部 +""" +import os, sys, time, json, ssl, socket, urllib.request, urllib.error +from pathlib import Path + +HERE = Path(__file__).resolve().parent +LITE = HERE.parent +sys.path.insert(0, str(HERE)) # PyQt5 桩 +sys.path.insert(0, str(LITE)) + +for k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"): + os.environ.pop(k, None) +# 不能设 NO_PROXY=*:urllib 会把 127.0.0.1 也当直连,显式 ProxyHandler 被绕过 +os.environ["NO_PROXY"] = "" + +import main as pp + +NODES = json.loads((HERE / "nodes.json").read_text("utf-8")) +want = [a.upper() for a in sys.argv[1:]] +if want: + NODES = [n for n in NODES if str(n.get("country", "")).upper() in want] + +TIMEOUT = 12 + + +def free_port(): + s = socket.socket(); s.bind(("127.0.0.1", 0)) + p = s.getsockname()[1]; s.close(); return p + + +def http_method(node): + return (node.get("methods") or {}).get("http") or {} + + +def as_flat_node(node): + """把 nodes.json 的 methods.http 结构摊平成代码 _node_val() 期望的形状。 + 仅用于探测:暴露「代码读不到真实节点字段」这一问题。""" + m = http_method(node) + return dict(node, port=int(m.get("port")), username=m.get("username", ""), + password=m.get("password", "")) + + +def probe_connect(host, port, sni=None, connect_timeout=8): + """直连探测:DNS -> TCP -> TLS -> 发 CONNECT 127.0.0.1:9 看上游代理响应。""" + res = {"dns": None, "tcp": None, "tls": None, "proxy": None, "err": None} + t0 = time.time() + try: + infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM) + res["dns"] = sorted({i[4][0] for i in infos}) + except Exception as e: + res["err"] = f"DNS: {e}" + return res + addr = res["dns"][0] + af = socket.AF_INET6 if ":" in addr else socket.AF_INET + try: + s = socket.socket(af, socket.SOCK_STREAM) + s.settimeout(connect_timeout) + s.connect((addr, port)) + res["tcp"] = round(time.time() - t0, 2) + except Exception as e: + res["err"] = f"TCP: {e}" + return res + try: + ctx = ssl.create_default_context() + s = ctx.wrap_socket(s, server_hostname=sni or host) + res["tls"] = s.version() + except Exception as e: + res["err"] = f"TLS: {e}" + try: s.close() + except Exception: pass + return res + try: + s.sendall(b"CONNECT 127.0.0.1:9 HTTP/1.1\r\nHost: 127.0.0.1:9\r\n\r\n") + res["proxy"] = s.recv(200).split(b"\r\n", 1)[0].decode("latin-1", "replace") + except Exception as e: + res["err"] = f"CONNECT: {e}" + try: s.close() + except Exception: pass + return res + + +def probe_via_forwarder(node): + """真实转发器链路:_tls_start -> 经 127.0.0.1 代理请求,取出口 IP 与归属地。""" + m = http_method(node) + if not m: + return "(该节点无 http 方法,跳过)" + old_node, old_port = pp._current_node, pp._TLS_LOCAL_PORT + port = free_port() + try: + # _proxy_auth_basic() 内部走 _current_node(),固定成本次待测节点 + pp._current_node = lambda: node + pp._TLS_LOCAL_PORT = port + pp._tls_stop() + pp._tls_start(node["domain"], int(m["port"]), use_tls=True, + bypass_lan=False, smart=False, quiet=True) + ph = urllib.request.ProxyHandler({"http": f"127.0.0.1:{port}", + "https": f"127.0.0.1:{port}"}) + op = urllib.request.build_opener(ph) + t0 = time.time() + # 明文 HTTP 代理(走 GET 绝对 URI)与 CONNECT 隧道各测一条 + with op.open("http://ip-api.com/json/?fields=query,country,regionName,isp", + timeout=TIMEOUT) as r: + info = json.loads(r.read(512).decode("utf-8", "replace")) + with op.open("https://ifconfig.me/ip", timeout=TIMEOUT) as r: + https_ip = r.read(64).decode().strip() + dt = round(time.time() - t0, 2) + return (f"OK {dt}s 出口IP={info.get('query')} " + f"[{info.get('country')} {info.get('regionName')} / {info.get('isp')}] " + f"HTTPS隧道出口={https_ip}") + except urllib.error.HTTPError as e: + return f"HTTP {e.code}: {e.read(120).decode('utf-8', 'replace').strip()[:100]}" + except Exception as e: + return f"失败: {type(e).__name__}: {e}" + finally: + pp._tls_stop() + pp._current_node, pp._TLS_LOCAL_PORT = old_node, old_port + + +print(f"节点数: {len(NODES)}") +n0 = NODES[0] +print(f"代码 _endpoint_domain()={pp._endpoint_domain()!r} " + f"_endpoint_port()={pp._endpoint_port()!r} _endpoint_country()={pp._endpoint_country()!r}") +print(f"代码 _proxy_auth_basic()={pp._proxy_auth_basic()!r}") +print(f"nodes.json[0] 顶层键 = {list(n0.keys())}") +print(f"nodes.json[0] domain={n0.get('domain')!r} methods.http.port=" + f"{http_method(n0).get('port')!r}") +print() + +for n in NODES: + c = n.get("country", "?") + m = http_method(n) + print("=" * 78) + print(f"[{c}] {n.get('domain')}:{m.get('port')} provider={n.get('provider')} " + f"traffic={n.get('traffic')} level={n.get('available_level')}") + if not m: + print(" 该节点无 http 方法,跳过") + continue + r = probe_connect(n["domain"], int(m["port"])) + print(f" DNS : {r['dns']}") + print(f" TCP : {r['tcp']}s" if r["tcp"] is not None else " TCP : -") + print(f" TLS : {r['tls']}" if r.get("tls") else " TLS : -") + print(f" CONNECT: {r['proxy']}" if r.get("proxy") else " CONNECT: -") + if r.get("err"): + print(f" 错误 : {r['err'].splitlines()[0][:200]}") + print(f" 经转发器: {probe_via_forwarder(n)}") + print(f" 经转发器(摊平节点): {probe_via_forwarder(as_flat_node(n))}") + print() diff --git a/lite/test/test_core_modules.py b/lite/test/test_core_modules.py new file mode 100644 index 0000000..202f824 --- /dev/null +++ b/lite/test/test_core_modules.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +"""拆分后 rrpp_core 的独立回归测试(不依赖 Qt 或真实代理连接)。""" + +import sys +import tempfile +import unittest +from pathlib import Path + + +LITE_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(LITE_DIR)) + +from rrpp_core.forwarder_primitives import ( + force_http_close, + inject_proxy_auth, + parse_connect_authority, + parse_http_absolute, +) +from rrpp_core.node_api import NodeAuthError, b64url +from rrpp_core.node_secrets import NodeSecretsStore +from rrpp_core.network_primitives import is_private_host, is_private_ipv4_int, ttl_cache +from rrpp_core.pac_data import atomic_write, read_version, validate_document +from rrpp_core.routing_primitives import ( + build_suffix_trie, + merge_ip_ranges, + parse_domain_rules, + suffix_trie_matches, +) +from rrpp_core.settings_store import JsonSettingsStore +from rrpp_core.traffic import TrafficStats, format_bytes + + +class CoreModuleTests(unittest.TestCase): + def test_domain_rules_and_trie(self): + suffixes, exact, regexes, invalid = parse_domain_rules( + ["example.com", "full:only.example", "regexp:^edge-[0-9]+\\.example$", "regexp:["] + ) + self.assertEqual(invalid, 1) + self.assertIn("example.com", suffixes) + self.assertIn("only.example", exact) + self.assertTrue(regexes[0].search("edge-42.example")) + trie = build_suffix_trie(suffixes) + self.assertTrue(suffix_trie_matches("cdn.example.com", trie)) + self.assertFalse(suffix_trie_matches("example.net", trie)) + self.assertEqual( + merge_ip_ranges(["10.0.0.0/25", "10.0.0.128/25"], 4), + [(167772160, 167772415)], + ) + + def test_forwarder_primitives(self): + self.assertEqual( + parse_connect_authority("CONNECT [::1]:443 HTTP/1.1"), ("::1", 443) + ) + self.assertEqual( + parse_http_absolute("GET https://example.com/path HTTP/1.1"), + ("example.com", 443), + ) + request = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n" + injected = inject_proxy_auth(request, "token") + self.assertIn(b"Authorization: Basic token\r\n", injected) + self.assertEqual(inject_proxy_auth(injected, "different"), injected) + self.assertIn(b"Connection: close\r\n", force_http_close(request)) + + def test_settings_pac_and_node_secrets_storage(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + settings = JsonSettingsStore(lambda: directory / "settings.json") + self.assertEqual(settings.load(), {}) + settings.save({"local_port": 1080}) + self.assertEqual(settings.load(), {"local_port": 1080}) + + payload = {"version": "1", "domains": ["example.com"]} + atomic_write(directory, "pac_domains", payload) + self.assertEqual(read_version(directory / "pac_domains.json"), ("1", "")) + self.assertTrue(validate_document("pac_domains", payload)) + + secrets = NodeSecretsStore(lambda: directory) + self.assertEqual(secrets.decrypt_bytes(secrets.encrypt_text("round-trip")), "round-trip") + secrets.save_auth("test-user", "test-password") + self.assertEqual( + secrets.load_auth(), {"username": "test-user", "password": "test-password"} + ) + + def test_traffic_and_node_protocol_helpers(self): + times = iter((10.0, 12.0)) + stats = TrafficStats(clock=lambda: next(times)) + stats.reset() + stats.add(up=20, down=10) + self.assertEqual(stats.rate(), (10, 5, 20, 10)) + self.assertEqual(format_bytes(1536), "1.5 KB") + self.assertEqual(b64url("a/b="), "YS9iPQ") + error = NodeAuthError("denied", 403) + self.assertEqual((str(error), error.code), ("denied", 403)) + + def test_network_primitives_and_ttl_cache(self): + self.assertTrue(is_private_ipv4_int(0x0A010203)) + self.assertTrue(is_private_host("::1")) + self.assertFalse(is_private_host("8.8.8.8")) + calls = [] + + @ttl_cache(300) + def probe(value): + calls.append(value) + return value + + self.assertEqual((probe("x"), probe("x")), ("x", "x")) + self.assertEqual(calls, ["x"]) + probe.cache_clear() + probe("x") + self.assertEqual(calls, ["x", "x"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/lite/test/test_pac_routing.py b/lite/test/test_pac_routing.py index 2d5e328..be4eb07 100644 --- a/lite/test/test_pac_routing.py +++ b/lite/test/test_pac_routing.py @@ -3,13 +3,13 @@ 核心问题:PAC 模式下,目标域名解析为国外 IP 时,是否强制走隧道代理? -判定链(proxy_lite._decide_direct): +判定链(main._decide_direct): 0. 代理白名单命中 -> 强制直连(最优先,PAC/固定模式均生效) 1. pac_domains.json 域名表命中 -> 强制隧道(优先于 IP 判定) 2. 内网 IP 且勾选 bypass_lan -> 直连 3. 智能分流:中国 IP -> 直连;其余(国外 IP)-> 走隧道 -直接 import proxy_lite 调用真实决策函数,数据用真实的 pac_ip_cn.json / +直接 import main 调用真实决策函数,数据用真实的 pac_ip_cn.json / pac_domains.json;PyQt5 用桩替代(决策逻辑不依赖 Qt)。 """ @@ -25,8 +25,8 @@ sys.path.insert(0, str(LITE_DIR)) import json import socket -import proxy_lite as pp -from proxy_lite import _decide_direct, _is_china_ip, _is_force_proxy, _is_private_ip, _is_whitelisted +import main as pp +from main import _decide_direct, _is_china_ip, _is_force_proxy, _is_private_ip, _is_whitelisted # PAC 数据已不再随项目保留:从 CDN 拉取到临时用户目录(真实数据端到端) import tempfile @@ -241,10 +241,11 @@ if real_nodes: print(f" [{c}] {n.get('domain'):<24} ipv6_only={str(io):<6} " f"可达={str(reach):<6} 菜单文案: {n.get('domain')} ({c})" + (f" {mark}" if io else "")) - # ipv6_only 节点在本机无 IPv6 时必须判定为不可达,否则必须可达 + # ipv6_only 节点在本机无 IPv6 时必须判定为不可达;有 IPv6 时仍取 + # 实际 TCP 探测结果(本机有 IPv6 不代表每个远端节点都必然在线)。 if io: - expect = has_v6 or reach # 本机有 IPv6 -> 应可达;无 IPv6 -> 不应可达 - ok = reach == expect + expect = "实际探测" if has_v6 else False + ok = isinstance(reach, bool) if has_v6 else reach is False ipv6_failed += 0 if ok else 1 print(f" IPv6 专用节点可达性: {reach} (期望 {expect}) " f"{'PASS' if ok else 'FAIL'}")