Compare commits
2
Commits
cddd3afd18
...
02d36c72f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02d36c72f7 | ||
|
|
c37d8af821 |
+11
@@ -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
|
||||
|
||||
@@ -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 基线;保留失败现场的测试输出以便定位。不得通过覆盖用户的运行配置、订阅或构建目录来“修复”回归。
|
||||
@@ -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'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity version="1.0.0.0" name="RRPP_Lite" type="win32"/>
|
||||
<description>Royirin Rosmontis Private Proxy Lite</description>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor, system</dpiAwareness>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
'''
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
+117
-1069
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "",
|
||||
"date": "",
|
||||
"description": "PAC 智能分流的明确直连域名模板。强制代理规则和用户白名单优先级更高。",
|
||||
"domains": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""RRPP Lite 的无 GUI 核心逻辑。"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
"""RRPP Lite 的 Qt 界面层。
|
||||
|
||||
核心代理与 PAC 逻辑不得依赖本包,以便后续在无 GUI 环境中独立测试。
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}}
|
||||
""")
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
call ..\.venv\Scripts\python.exe main.py
|
||||
pause
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
class QFont: pass
|
||||
class QIcon: pass
|
||||
class QPixmap: pass
|
||||
class QImage: pass
|
||||
class QColor: pass
|
||||
class QPainter: pass
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# PyQt5 桩模块:仅供 WSL 下 import main 做路由逻辑测试,
|
||||
# 决策函数(_decide_direct 等)不依赖任何 Qt 对象。
|
||||
@@ -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")
|
||||
|
||||
@@ -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 测试节点"
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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'}")
|
||||
|
||||
Reference in New Issue
Block a user