Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02d36c72f7 | ||
|
|
c37d8af821 | ||
|
|
cddd3afd18 | ||
|
|
0603224de2 | ||
|
|
3ce5e57dae | ||
|
|
9376e35025 | ||
|
|
110a526fc6 | ||
|
|
9b7b817cb7 | ||
|
|
a39647d46f | ||
|
|
be02d1b17f | ||
|
|
53c36e143f | ||
|
|
23ba368737 | ||
|
|
cb3552f595 | ||
|
|
2a20215d9e | ||
|
|
c3286593a6 | ||
|
|
9e766d8132 | ||
|
|
259a1ae41f | ||
|
|
bcb113e8c9 | ||
|
|
6d204c77fb | ||
|
|
63837eb9d8 | ||
|
|
efa7e25f66 | ||
|
|
20d2532e8f | ||
|
|
17cdb276ea | ||
|
|
f8eba465a9 | ||
|
|
53e9d1e31a | ||
|
|
59393b9ae9 | ||
|
|
89b5c09e95 | ||
|
|
b15beb97b4 | ||
|
|
567d395d6f | ||
|
|
c2c3bd1426 | ||
|
|
2aeb327638 | ||
|
|
717b7569de | ||
|
|
2120009c8c | ||
|
|
5620f0051e | ||
|
|
f8e8fd1acd | ||
|
|
d1369d02d4 | ||
|
|
ebccfb4165 | ||
|
|
b9a35684ac | ||
|
|
7e6c95f78a | ||
|
|
54385d72ce | ||
|
|
2652213a10 | ||
|
|
4afb810078 | ||
|
|
3519fbe9e3 | ||
|
|
a08ca154c9 | ||
|
|
f92c3f52e0 | ||
|
|
22498cb884 | ||
|
|
8e550cba6b |
+13
@@ -0,0 +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
|
||||
@@ -1,11 +1,17 @@
|
||||
# Royirin Rosmontis Private Proxy
|
||||
Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu
|
||||
## 警告
|
||||
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本
|
||||
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
|
||||
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows .exe
|
||||
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows 应用 (.exe)
|
||||
|
||||
|
||||
## 如何区分 common 版本与 Lite 版本
|
||||
### 如何区分 common 版本与 Lite 版本
|
||||
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳
|
||||
|
||||
## 服务器侧配置手册 (部分内容过时)
|
||||
https://rrrosmontis.icu/articles/index.html?id=00005
|
||||
- https://rrrosmontis.icu/articles/index.html?id=00005
|
||||
- 更推荐参考本仓库 server_config 文件夹中的 md 文件手册
|
||||
|
||||
## 若要直接使用仓库客户端对应的服务
|
||||
请前往 https://sso.rrrosmontis.icu 注册 Rosmanka ID,然后与管理员商定使用细则
|
||||
royirin@rrrosmontis.icu
|
||||
@@ -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 |
+1108
-820
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 对象。
|
||||
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""离屏验证节点菜单:IPv6-only 节点的标注与置灰(真实 PyQt5,不出窗口)。
|
||||
|
||||
需要 Windows venv 的真实 PyQt5:
|
||||
..\\.venv\\Scripts\\python.exe test\\check_node_menu.py
|
||||
读取 test/nodes.json 作为节点数据,只构建菜单不进事件循环。
|
||||
"""
|
||||
import os, sys
|
||||
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||||
from pathlib import Path
|
||||
|
||||
LITE = Path(__file__).resolve().parent.parent
|
||||
# 脚本所在目录(test/)会被解释器自动放到 sys.path 首位,那里有 PyQt5 桩,
|
||||
# 会顶掉真实 PyQt5 —— 必须移除,改用本程序自己的 venv
|
||||
sys.path = [str(LITE)] + [p for p in sys.path if Path(p).resolve() != Path(__file__).resolve().parent]
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
import main as pp
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
# 构造窗口时不触发用户数据初始化或远程同步;本测试只验证静态菜单和本地节点状态。
|
||||
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()
|
||||
print(f"本机有全局 IPv6: {pp._local_has_ipv6()}")
|
||||
print(f"节点数: {len(nodes)}")
|
||||
|
||||
menu = w._build_node_menu(nodes)
|
||||
print("\n菜单项(enabled / 文案 / tooltip):")
|
||||
for act in menu.actions():
|
||||
print(f" {str(act.isEnabled()):5} | {act.text()}")
|
||||
if act.toolTip():
|
||||
print(f" └ {act.toolTip()}")
|
||||
|
||||
# 点击被禁用的项不应切换节点
|
||||
before = pp._selected_index()
|
||||
for act in menu.actions():
|
||||
if not act.isEnabled():
|
||||
act.trigger()
|
||||
print(f"\n点击禁用项后节点下标: {before} -> {pp._selected_index()} (应不变)")
|
||||
if pp._selected_index() != before:
|
||||
failures.append("点击不可用节点后发生了切换")
|
||||
|
||||
# ── 上次选中的节点已不可用:启动时自动回退到第一个可达节点 ──────────
|
||||
print("\n启动校验(_verify_selected_node):")
|
||||
bad = [i for i, n in enumerate(nodes) if not pp._node_reachable(n)]
|
||||
if not bad:
|
||||
print(" 本机所有节点均可达,无法验证回退(本机有 IPv6?)")
|
||||
else:
|
||||
import json
|
||||
_bak = (pp._USER_DIR / "settings.json").read_bytes() if (pp._USER_DIR / "settings.json").is_file() else None
|
||||
bad_idx = bad[0]
|
||||
# 模拟上次持久化选中了不可用节点:内存下标与 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:
|
||||
(pp._USER_DIR / "settings.json").write_bytes(_bak)
|
||||
|
||||
# ── 服务端调整节点顺序:按域名重匹配而不是死守下标 ──────────────────
|
||||
print("\n服务端调序(_rematch_saved_domain):")
|
||||
if len(nodes) < 2:
|
||||
print(" 节点不足 2 个,跳过")
|
||||
else:
|
||||
import json
|
||||
dom_old = nodes[0].get("domain")
|
||||
ordered = [nodes[1], nodes[0]] + nodes[2:] # 模拟服务端交换了前两个节点
|
||||
pp._NODES = ordered
|
||||
# 设置里记录的是原下标 0 的域名,重匹配后下标应跟随域名(新列表里是 1)
|
||||
pp._settings_save({**pp._settings_load(), "current_node": dom_old})
|
||||
switched = pp._rematch_saved_domain()
|
||||
ok = switched and ordered[pp._selected_index()].get("domain") == dom_old
|
||||
print(f" current_node={dom_old} 调序后跟随域名 -> 下标 {pp._selected_index()} "
|
||||
f"{'PASS' if ok else 'FAIL'}")
|
||||
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:
|
||||
(pp._USER_DIR / "settings.json").unlink(missing_ok=True)
|
||||
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)
|
||||
@@ -0,0 +1,739 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PAC(智能分流)模式路由决策测试。
|
||||
|
||||
核心问题:PAC 模式下,目标域名解析为国外 IP 时,是否强制走隧道代理?
|
||||
|
||||
判定链(main._decide_direct):
|
||||
0. 代理白名单命中 -> 强制直连(最优先,PAC/固定模式均生效)
|
||||
1. pac_domains.json 域名表命中 -> 强制隧道(优先于 IP 判定)
|
||||
2. 内网 IP 且勾选 bypass_lan -> 直连
|
||||
3. 智能分流:中国 IP -> 直连;其余(国外 IP)-> 走隧道
|
||||
|
||||
直接 import main 调用真实决策函数,数据用真实的 pac_ip_cn.json /
|
||||
pac_domains.json;PyQt5 用桩替代(决策逻辑不依赖 Qt)。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
TEST_DIR = Path(__file__).resolve().parent
|
||||
LITE_DIR = TEST_DIR.parent
|
||||
|
||||
sys.path.insert(0, str(TEST_DIR)) # 假 PyQt5 桩
|
||||
sys.path.insert(0, str(LITE_DIR))
|
||||
|
||||
import json
|
||||
import socket
|
||||
import main as pp
|
||||
from main import _decide_direct, _is_china_ip, _is_force_proxy, _is_private_ip, _is_whitelisted
|
||||
|
||||
# PAC 数据已不再随项目保留:从 CDN 拉取到临时用户目录(真实数据端到端)
|
||||
import tempfile
|
||||
pp._USER_DIR = Path(tempfile.mkdtemp()) / "RRPP"
|
||||
pp._user_init_remote(base_url="https://cdn.rrrosmontis.icu/RRPP/PAC/")
|
||||
|
||||
pp._load_cn_ranges()
|
||||
pp._load_force_proxy_suffixes()
|
||||
if not pp._CN_RANGES:
|
||||
print("警告: 未能获取 pac_ip_cn.json(CDN 不可达?),中国 IP 判定将失效")
|
||||
print(f"中国IP段: {len(pp._CN_RANGES or [])} 段, 强制域名表: {len(pp._FORCE_PROXY_SUFFIXES or [])} 条\n")
|
||||
|
||||
|
||||
def resolved_ips(host):
|
||||
try:
|
||||
return sorted({a[4][0] for a in socket.getaddrinfo(host, 443, socket.AF_INET)})
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def mark(ip):
|
||||
if _is_private_ip(ip):
|
||||
return "内网"
|
||||
if _is_china_ip(ip):
|
||||
return "中国IP"
|
||||
return "国外IP"
|
||||
|
||||
|
||||
PATH = {True: "直连", False: "隧道"}
|
||||
MODES = [
|
||||
("PAC+bypass_lan", dict(bypass_lan=True, smart=True)), # 默认勾选状态
|
||||
("PAC无bypass_lan", dict(bypass_lan=False, smart=True)),
|
||||
("固定代理模式", dict(bypass_lan=False, smart=False)), # 全部走隧道
|
||||
]
|
||||
|
||||
# (名称, host, 期望 PAC+bypass_lan 决策: True=直连 / False=隧道)
|
||||
cases = [
|
||||
("境外域名 google.com", "google.com", False),
|
||||
("境外域名 github.com", "github.com", False),
|
||||
("境外域名 youtube.com", "youtube.com", False),
|
||||
("域名表强制 claude.ai", "claude.ai", False),
|
||||
("域名表强制 *.google 后缀", "antigravity.google", False),
|
||||
("国内域名 www.baidu.com", "www.baidu.com", True),
|
||||
("国内域名 www.qq.com", "www.qq.com", True),
|
||||
("内网 localhost", "localhost", True),
|
||||
("内网 IP 192.168.1.1", "192.168.1.1", True),
|
||||
("境外 IP 8.8.8.8", "8.8.8.8", False),
|
||||
("国内 IP 114.114.114.114", "114.114.114.114", True),
|
||||
]
|
||||
|
||||
failed = 0
|
||||
for name, host, expect in cases:
|
||||
ips = resolved_ips(host)
|
||||
force = _is_force_proxy(host)
|
||||
marks = f"[{','.join(mark(ip) for ip in ips)}]" if ips else "[解析失败]"
|
||||
row = f"{name:<22} {host:<18} {str(ips) if ips else '无IP':<45} {marks}"
|
||||
results = []
|
||||
for mode_name, kw in MODES:
|
||||
got = _decide_direct(host, **kw)
|
||||
results.append(f"{mode_name}={PATH[got]}")
|
||||
pac = _decide_direct(host, **MODES[0][1])
|
||||
ok = pac == expect
|
||||
failed += 0 if ok else 1
|
||||
print(f"{row}\n {'域名表命中' if force else '域名表未命中':<10} | {' '.join(results)} | 期望 {PATH[expect]} -> {'PASS' if ok else 'FAIL'}")
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f"结论: {'全部 PASS' if not failed else f'{failed} 个 FAIL'}")
|
||||
print("=" * 70)
|
||||
print("PAC 模式(智能分流)下: 域名解析结果全部为国外 IP -> 走隧道代理 ✓"
|
||||
if not failed else "存在 FAIL,请检查上方明细")
|
||||
|
||||
# ── 附加测试:不在域名表的被墙境外站 ─────────────────────
|
||||
# 这类站点不能靠域名表兜底,必须仅凭「解析结果为国外 IP」强制走隧道,
|
||||
# 否则会尝试直连 -> 被墙。
|
||||
print("\n" + "=" * 70)
|
||||
print("附加: 不在 pac_domains.json 的被墙境外站(只能靠 IP 判定走隧道)")
|
||||
print("=" * 70)
|
||||
|
||||
extra_candidates = [
|
||||
"dailymotion.com", "vimeo.com", "notion.so", "linear.app",
|
||||
"quora.com", "feedly.com", "dropbox.com", "tumblr.com",
|
||||
"flickr.com", "pastebin.com", "fandom.com", "archive.org",
|
||||
"godaddy.com", "imgur.com", "kickstarter.com", "shopify.com",
|
||||
]
|
||||
|
||||
extra_failed = 0
|
||||
skipped = 0
|
||||
for host in extra_candidates:
|
||||
if _is_force_proxy(host):
|
||||
print(f"{host:<20} 已在域名表中,跳过")
|
||||
skipped += 1
|
||||
continue
|
||||
ips = resolved_ips(host)
|
||||
if not ips:
|
||||
print(f"{host:<20} 解析失败,跳过")
|
||||
skipped += 1
|
||||
continue
|
||||
foreign = all(not _is_private_ip(ip) and not _is_china_ip(ip) for ip in ips)
|
||||
if not foreign:
|
||||
print(f"{host:<20} 解析到非国外 IP {ips},跳过(不是有效的境外用例)")
|
||||
skipped += 1
|
||||
continue
|
||||
got = _decide_direct(host, bypass_lan=True, smart=True) # PAC 模式
|
||||
if got is not False:
|
||||
# 决策函数内部会重新解析域名,CDN 轮询 DNS 可能与其上方展示的解析结果
|
||||
# 不一致:清缓存重试一次,仍不一致则按 DNS 不稳定跳过(非代码问题)
|
||||
_is_china_ip.cache_clear()
|
||||
_is_private_ip.cache_clear()
|
||||
ips = resolved_ips(host)
|
||||
got = _decide_direct(host, bypass_lan=True, smart=True)
|
||||
if got is not False:
|
||||
print(f"{host:<20} 解析/判定随 DNS 轮询不一致 {ips},跳过(非代码问题)")
|
||||
skipped += 1
|
||||
continue
|
||||
print(f"{host:<20} 重试后走隧道(DNS 瞬时差异) PASS")
|
||||
continue
|
||||
extra_failed += 0
|
||||
print(f"{host:<20} {str(ips):<50} [{','.join(mark(ip) for ip in ips)}] -> "
|
||||
f"{PATH[got]} (期望隧道) PASS")
|
||||
|
||||
print(f"\n附加测试: {len(extra_candidates)} 个候选, 有效用例 {len(extra_candidates) - skipped} 个, "
|
||||
f"{'全部 PASS' if not extra_failed else f'{extra_failed} 个 FAIL'}")
|
||||
if extra_failed:
|
||||
failed += extra_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── 代理白名单测试(用户可配置:命中即直连,最优先,含 IPv4/IPv6/CIDR) ─────
|
||||
print("\n" + "=" * 70)
|
||||
print("代理白名单: 命中规则强制直连(优先于域名表/固定代理,支持 IPv4/IPv6/CIDR)")
|
||||
print("=" * 70)
|
||||
|
||||
wl_cases = [
|
||||
# (规则, 测试host, 期望命中)
|
||||
("example.com", "example.com", True),
|
||||
("example.com", "www.example.com", True),
|
||||
("example.com", "sub.www.example.com", True),
|
||||
("example.com", "example.org", False),
|
||||
("claude.ai", "claude.ai", True), # 域名表强制隧道,白名单优先
|
||||
("localhost", "localhost", True),
|
||||
("8.8.8.8", "8.8.8.8", True), # IPv4 字面量
|
||||
("8.8.8.8", "8.8.4.4", False),
|
||||
("10.0.0.0/8", "10.1.2.3", True), # IPv4 CIDR
|
||||
("192.0.2.0/24", "192.0.2.55", True), # IPv4 CIDR
|
||||
("2001:db8::/32", "2001:db8::1", True), # IPv6 网段
|
||||
("fe80::1", "fe80::1", True), # IPv6 单地址
|
||||
]
|
||||
|
||||
# 注入白名单规则(绕过 settings.json 直接改内存缓存;版本 +1 使匹配缓存失效),
|
||||
# 另加一条"禁用"的规则验证不参与匹配
|
||||
pp._WHITELIST = [{"value": "203.0.113.0/24", "enabled": False}] + \
|
||||
[{"value": r, "enabled": True} for r, _, _ in wl_cases]
|
||||
wl_ver = pp._WHITELIST_VERSION + 1
|
||||
pp._WHITELIST_VERSION = wl_ver
|
||||
|
||||
wl_failed = 0
|
||||
for rule, host, expect in wl_cases:
|
||||
got = _is_whitelisted(host, wl_ver)
|
||||
ok = got == expect
|
||||
wl_failed += 0 if ok else 1
|
||||
print(f"规则 {rule:<16} host {host:<22} 命中={got} 期望={expect} -> {'PASS' if ok else 'FAIL'}")
|
||||
got = _is_whitelisted("203.0.113.9", wl_ver) # 禁用的规则不应命中
|
||||
print(f"禁用规则 203.0.113.0/24 host 203.0.113.9 -> 命中={got} 期望=False -> {'PASS' if not got else 'FAIL'}")
|
||||
wl_failed += 0 if not got else 1
|
||||
|
||||
# 白名单在三种模式下都强制直连(固定代理模式原本全部走隧道)
|
||||
print("\n白名单在三种模式下的决策(命中白名单应为直连):")
|
||||
for mode_name, kw in MODES:
|
||||
got = _decide_direct("www.example.com", **kw)
|
||||
ok = got is True
|
||||
wl_failed += 0 if ok else 1
|
||||
print(f" {mode_name:<16} www.example.com -> {PATH[got]} (期望 直连) {'PASS' if ok else 'FAIL'}")
|
||||
# 白名单优先于域名表:claude.ai 在 pac_domains.json 中强制隧道,白名单命中应直连
|
||||
got = _decide_direct("claude.ai", bypass_lan=True, smart=True)
|
||||
ok = got is True
|
||||
wl_failed += 0 if ok else 1
|
||||
print(f" {'域名表冲突':<14} claude.ai -> {PATH[got]} (期望 直连,白名单优先) {'PASS' if ok else 'FAIL'}")
|
||||
# 未命中白名单的境外域名仍走隧道(白名单不影响其他路由)
|
||||
got = _decide_direct("google.com", bypass_lan=True, smart=True)
|
||||
ok = got is False
|
||||
wl_failed += 0 if ok else 1
|
||||
print(f" {'未命中白名单':<14} google.com -> {PATH[got]} (期望 隧道) {'PASS' if ok else 'FAIL'}")
|
||||
|
||||
# 恢复空白名单,避免影响后续用例
|
||||
pp._WHITELIST = None
|
||||
pp._WHITELIST_VERSION += 1
|
||||
if wl_failed:
|
||||
failed += wl_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── IPv6-only 节点标注 ─────────────────────────────────────
|
||||
# IPv6 专用节点只提供 AAAA 记录,本机没有 IPv6 出口时连不上:
|
||||
# 客户端据此在节点菜单标注并置灰。判定三级:ipv6_only 字段 →
|
||||
# remark 里的 "(IPv6)"(服务端 rrpp_client 现网唯一的线索)→ 域名解析兜底。
|
||||
print("\n" + "=" * 70)
|
||||
print("IPv6 专用节点:识别(三级判定)与可达性")
|
||||
print("=" * 70)
|
||||
|
||||
ipv6_failed = 0
|
||||
has_v6 = pp._local_has_ipv6()
|
||||
print(f"本机是否有全局 IPv6 地址: {has_v6}")
|
||||
|
||||
# 用真实 nodes.json(test 目录);没有则跳过
|
||||
_nodes_file = TEST_DIR / "nodes.json"
|
||||
real_nodes = json.loads(_nodes_file.read_text("utf-8")) if _nodes_file.is_file() else []
|
||||
if real_nodes:
|
||||
pp._IPV6_OK.clear() # 清缓存,做真实探测
|
||||
for n in real_nodes:
|
||||
c = n.get("country") or "?"
|
||||
io = pp._node_ipv6_only(n)
|
||||
reach = pp._node_reachable(n)
|
||||
mark = "[IPv6]" if reach else "[不可用:本机无 IPv6]"
|
||||
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 时仍取
|
||||
# 实际 TCP 探测结果(本机有 IPv6 不代表每个远端节点都必然在线)。
|
||||
if io:
|
||||
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'}")
|
||||
else:
|
||||
ok = reach is True
|
||||
ipv6_failed += 0 if ok else 1
|
||||
print(f" 普通节点恒可达: {reach} {'PASS' if ok else 'FAIL'}")
|
||||
# 缓存生效:再次判定不发新探测(结果一致)
|
||||
cached = list(pp._IPV6_OK.items())
|
||||
if cached:
|
||||
print(f" 可达性缓存: {cached}")
|
||||
|
||||
# 三级判定:字段 → remark 里的 "(IPv6)" 标记 → 域名解析兜底。
|
||||
# 服务端 subscription?method=rrpp_client 只吐旧扁平格式,ipv6_only 字段被丢掉,
|
||||
# 只在 remark 里留 "(IPv6)"(后端 countries_to_rrpp_client),故第 2 级是现网主路径。
|
||||
synthetic = [
|
||||
({"domain": "example.com"}, False, "无标记(example.com 有 A 记录)"),
|
||||
({"domain": "example.com", "ipv6_only": False}, False, "ipv6_only=false"),
|
||||
({"domain": "example.com", "ipv6_only": True}, True, "ipv6_only=true"),
|
||||
({"domain": "example.com", "remark": "GlobalVM 350G (IPv6)"}, True, "remark 含 (IPv6)"),
|
||||
({"domain": "example.com", "remark": "Azure 15G"}, False, "remark 无 IPv6 标记"),
|
||||
({"domain": "example.com", "remark": "IPv6 专用机"}, True, "remark 含 IPv6(无括号)"),
|
||||
({"domain": "example.com", "remark": "some_ipv6_thing"}, True, "remark 小写 ipv6"),
|
||||
]
|
||||
for node, expect, desc in synthetic:
|
||||
got = pp._node_ipv6_only(node)
|
||||
ok = got == expect
|
||||
ipv6_failed += 0 if ok else 1
|
||||
print(f" {desc:<24} _node_ipv6_only={got} (期望 {expect}) {'PASS' if ok else 'FAIL'}")
|
||||
pp._IPV6_OK.clear() # 合成用例可能污染缓存
|
||||
|
||||
if ipv6_failed:
|
||||
failed += ipv6_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── CONNECT authority 解析:IPv6 字面量带方括号 ──────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("CONNECT authority 解析(IPv6 字面量剥方括号)")
|
||||
print("=" * 70)
|
||||
|
||||
conn_cases = [
|
||||
("CONNECT [::1]:443 HTTP/1.1", ("::1", 443)),
|
||||
("CONNECT example.com:443 HTTP/1.1", ("example.com", 443)),
|
||||
("CONNECT iam.aeolianchi.me:443 HTTP/1.1", ("iam.aeolianchi.me", 443)),
|
||||
("CONNECT [::1] HTTP/1.1", None), # 无端口
|
||||
("CONNECT example.com HTTP/1.1", None), # 无端口
|
||||
("CONNECT [fe80::1%25eth0]:8443 HTTP/1.1", ("fe80::1%25eth0", 8443)),
|
||||
("CONNECT 127.0.0.1:1080 HTTP/1.1", ("127.0.0.1", 1080)),
|
||||
]
|
||||
conn_failed = 0
|
||||
for line, expect in conn_cases:
|
||||
got = pp._parse_connect_authority(line)
|
||||
ok = got == expect
|
||||
conn_failed += 0 if ok else 1
|
||||
print(f" {line:<45} -> {got} (期望 {expect}) {'PASS' if ok else 'FAIL'}")
|
||||
if conn_failed:
|
||||
failed += conn_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── 上游代理认证注入:按完整头名精确匹配 ────────────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("认证注入:仅精确的 authorization 头名阻止注入")
|
||||
print("=" * 70)
|
||||
|
||||
def _auth_lines(data: bytes):
|
||||
head = data.split(b"\r\n\r\n", 1)[0]
|
||||
return [l for l in head.split(b"\r\n")[1:] if l.lower().startswith(b"authorization:")]
|
||||
|
||||
auth_cases = [
|
||||
# (描述, 请求, 期望):数字=注入后 authorization 行数;"unchanged"=原样跳过
|
||||
("无任何认证头", b"CONNECT iam.aeolianchi.me:443 HTTP/1.1\r\nHost: iam.aeolianchi.me\r\n\r\n", 1),
|
||||
("Proxy-Authorization", b"CONNECT iam.aeolianchi.me:443 HTTP/1.1\r\nProxy-Authorization: Basic xyz\r\n\r\n", 1),
|
||||
("已有 Authorization", b"GET http://example.com/ HTTP/1.1\r\nAuthorization: Bearer tok\r\n\r\n", "unchanged"),
|
||||
("小写 authorization", b"GET http://example.com/ HTTP/1.1\r\nauthorization: basic abc\r\n\r\n", "unchanged"),
|
||||
("X-Authorization 前缀", b"CONNECT iam.aeolianchi.me:443 HTTP/1.1\r\nX-Authorization-Go: 1\r\n\r\n", 1),
|
||||
("无头空块", b"GET http://example.com/ HTTP/1.1\r\n\r\n", 1),
|
||||
]
|
||||
auth_failed = 0
|
||||
for desc, req, expect in auth_cases:
|
||||
out = pp._inject_proxy_auth(req)
|
||||
if expect == "unchanged":
|
||||
ok = out == req
|
||||
print(f" {desc:<22} 原样跳过 {'PASS' if ok else 'FAIL'}")
|
||||
else:
|
||||
lines = _auth_lines(out)
|
||||
ok = len(lines) == expect and out.split(b"\r\n", 2)[1] == lines[0]
|
||||
# 注入位置必须紧跟请求行(上游取首个 Authorization 时读到我们的认证)
|
||||
print(f" {desc:<22} authorization 行数={len(lines)} (期望 {expect}) "
|
||||
f"{'PASS' if ok else 'FAIL'}")
|
||||
auth_failed += 0 if ok else 1
|
||||
if auth_failed:
|
||||
failed += auth_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── 本程序 PAC 地址判定(启动清理只认自己的残留) ────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("PAC 地址归属判定(_is_our_pac_url)")
|
||||
print("=" * 70)
|
||||
|
||||
pac_cases = [
|
||||
("http://127.0.0.1:39453/proxy.pac", True), # 本程序 PAC 服务
|
||||
("http://127.0.0.1:7897/pac?auth=xyz", False), # 其他代理软件(如 Clash)
|
||||
("http://127.0.0.1:1080/other.pac", False), # 路径不符
|
||||
("http://192.168.1.1:8080/proxy.pac", False), # 非本机
|
||||
("", False),
|
||||
]
|
||||
pac_failed = 0
|
||||
for url, expect in pac_cases:
|
||||
got = pp._is_our_pac_url(url)
|
||||
ok = got == expect
|
||||
pac_failed += 0 if ok else 1
|
||||
print(f" {url:<42} 命中={got} (期望 {expect}) {'PASS' if ok else 'FAIL'}")
|
||||
if pac_failed:
|
||||
failed += pac_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── 强制代理规则格式:后缀 / full / regexp + 关键域名兜底 ───────────
|
||||
print("\n" + "=" * 70)
|
||||
print("强制代理规则格式(后缀 / full / regexp / 关键域名兜底)")
|
||||
print("=" * 70)
|
||||
|
||||
rule_failed = 0
|
||||
rule_dir = Path(tempfile.mkdtemp()) / "RRPP"
|
||||
rule_dir.mkdir(parents=True)
|
||||
(rule_dir / "pac_domains.json").write_text(json.dumps({"domains": [
|
||||
"suffix.example", "full:only.example", "regexp:^edge-[0-9]+\\.example$",
|
||||
]}), "utf-8")
|
||||
|
||||
# 此段隔离真实 CDN 表,确保三种语义分别受测。
|
||||
old_user_dir = pp._USER_DIR
|
||||
old_suffixes = pp._FORCE_PROXY_SUFFIXES
|
||||
old_exact = pp._FORCE_PROXY_EXACT
|
||||
old_regexes = pp._FORCE_PROXY_REGEXES
|
||||
old_loaded = pp._FORCE_PROXY_LOADED
|
||||
pp._USER_DIR = rule_dir
|
||||
pp._FORCE_PROXY_SUFFIXES = None
|
||||
pp._FORCE_PROXY_EXACT = None
|
||||
pp._FORCE_PROXY_REGEXES = None
|
||||
pp._FORCE_PROXY_LOADED = False
|
||||
pp._is_force_proxy.cache_clear()
|
||||
pp._load_force_proxy_suffixes()
|
||||
|
||||
rule_cases = [
|
||||
("普通后缀匹配子域", "cdn.suffix.example", True),
|
||||
("full 精确匹配", "only.example", True),
|
||||
("full 不匹配子域", "cdn.only.example", False),
|
||||
("regexp 匹配", "edge-42.example", True),
|
||||
("regexp 不匹配", "edge-x.example", False),
|
||||
("关键 Google CDN 兜底", "assets.googlecdn.com", True),
|
||||
("无关域名", "unmatched.example", False),
|
||||
]
|
||||
for desc, host, expect in rule_cases:
|
||||
got = pp._is_force_proxy(host)
|
||||
ok = got == expect
|
||||
rule_failed += 0 if ok else 1
|
||||
print(f" {desc:<22} {host:<30} 命中={got} (期望 {expect}) {'PASS' if ok else 'FAIL'}")
|
||||
|
||||
pp._USER_DIR = old_user_dir
|
||||
pp._FORCE_PROXY_SUFFIXES = old_suffixes
|
||||
pp._FORCE_PROXY_EXACT = old_exact
|
||||
pp._FORCE_PROXY_REGEXES = old_regexes
|
||||
pp._FORCE_PROXY_LOADED = old_loaded
|
||||
pp._is_force_proxy.cache_clear()
|
||||
if rule_failed:
|
||||
failed += rule_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── 明确直连域名:优先级 / Trie / 固定代理隔离 ───────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("明确直连域名(后缀 / full / regexp / 强制代理优先 / 固定代理隔离)")
|
||||
print("=" * 70)
|
||||
|
||||
direct_failed = 0
|
||||
direct_dir = Path(tempfile.mkdtemp()) / "RRPP"
|
||||
direct_dir.mkdir(parents=True)
|
||||
(direct_dir / "pac_direct_domains.json").write_text(json.dumps({"domains": [
|
||||
"direct.example", "full:only-direct.example", "regexp:^edge-direct-[0-9]+\\.example$",
|
||||
"claude.ai",
|
||||
]}), "utf-8")
|
||||
old_user_dir = pp._USER_DIR
|
||||
old_direct_trie = pp._DIRECT_SUFFIX_TRIE
|
||||
old_direct_exact = pp._DIRECT_EXACT
|
||||
old_direct_regexes = pp._DIRECT_REGEXES
|
||||
old_direct_loaded = pp._DIRECT_LOADED
|
||||
old_data_path = pp._data_path
|
||||
try:
|
||||
pp._USER_DIR = direct_dir
|
||||
pp._data_path = lambda name: None # 隔离仓库内置种子,覆盖到临时用户数据。
|
||||
pp._DIRECT_SUFFIX_TRIE = None
|
||||
pp._DIRECT_EXACT = None
|
||||
pp._DIRECT_REGEXES = None
|
||||
pp._DIRECT_LOADED = False
|
||||
pp._is_direct_domain.cache_clear()
|
||||
pp._load_direct_domains()
|
||||
direct_cases = [
|
||||
("后缀匹配子域", "cdn.direct.example", True),
|
||||
("full 精确匹配", "only-direct.example", True),
|
||||
("full 不匹配子域", "cdn.only-direct.example", False),
|
||||
("regexp 匹配", "edge-direct-42.example", True),
|
||||
("regexp 不匹配", "edge-direct-x.example", False),
|
||||
]
|
||||
for desc, host, expect in direct_cases:
|
||||
got = pp._is_direct_domain(host)
|
||||
ok = got == expect
|
||||
direct_failed += 0 if ok else 1
|
||||
print(f" {desc:<22} {host:<30} 命中={got} (期望 {expect}) {'PASS' if ok else 'FAIL'}")
|
||||
smart_direct = pp._decide_direct("cdn.direct.example", True, True)
|
||||
fixed_proxy = not pp._decide_direct("cdn.direct.example", True, False)
|
||||
force_wins = not pp._decide_direct("claude.ai", True, True)
|
||||
direct_target = pp._direct_connect_target("cdn.direct.example", 443, True, True)
|
||||
target_keeps_host = direct_target == ("cdn.direct.example", 443)
|
||||
print(f" PAC 模式命中明确直连: {smart_direct} {'PASS' if smart_direct else 'FAIL'}")
|
||||
print(f" 固定代理模式不使用明确直连表: {fixed_proxy} {'PASS' if fixed_proxy else 'FAIL'}")
|
||||
print(f" 强制代理优先于明确直连: {force_wins} {'PASS' if force_wins else 'FAIL'}")
|
||||
print(f" 明确直连保持原始主机名: {target_keeps_host} {'PASS' if target_keeps_host else 'FAIL'}")
|
||||
direct_failed += 0 if smart_direct else 1
|
||||
direct_failed += 0 if fixed_proxy else 1
|
||||
direct_failed += 0 if force_wins else 1
|
||||
direct_failed += 0 if target_keeps_host else 1
|
||||
pp._pac_write("pac_direct_domains", {"domains": ["updated-direct.example"]})
|
||||
pp._invalidate_pac_routing_cache("pac_direct_domains")
|
||||
direct_reloaded = (not pp._is_direct_domain("direct.example")
|
||||
and pp._is_direct_domain("updated-direct.example"))
|
||||
print(f" 明确直连表热更新立即生效: {direct_reloaded} "
|
||||
f"{'PASS' if direct_reloaded else 'FAIL'}")
|
||||
direct_failed += 0 if direct_reloaded else 1
|
||||
finally:
|
||||
pp._USER_DIR = old_user_dir
|
||||
pp._data_path = old_data_path
|
||||
pp._DIRECT_SUFFIX_TRIE = old_direct_trie
|
||||
pp._DIRECT_EXACT = old_direct_exact
|
||||
pp._DIRECT_REGEXES = old_direct_regexes
|
||||
pp._DIRECT_LOADED = old_direct_loaded
|
||||
pp._is_direct_domain.cache_clear()
|
||||
if direct_failed:
|
||||
failed += direct_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── PAC 更新立即生效 + DNS 解析一致性 ──────────────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("PAC 热更新缓存失效 + 国内直连使用同一次 DNS 解析结果")
|
||||
print("=" * 70)
|
||||
|
||||
hot_failed = 0
|
||||
hot_dir = Path(tempfile.mkdtemp()) / "RRPP"
|
||||
hot_dir.mkdir(parents=True)
|
||||
old_user_dir = pp._USER_DIR
|
||||
old_cn_ranges = pp._CN_RANGES
|
||||
old_cn_ranges_v6 = pp._CN_RANGES_V6
|
||||
old_cn_loaded = pp._CN_RANGES_LOADED
|
||||
old_suffixes = pp._FORCE_PROXY_SUFFIXES
|
||||
old_exact = pp._FORCE_PROXY_EXACT
|
||||
old_regexes = pp._FORCE_PROXY_REGEXES
|
||||
old_force_loaded = pp._FORCE_PROXY_LOADED
|
||||
old_getaddrinfo = socket.getaddrinfo
|
||||
try:
|
||||
pp._USER_DIR = hot_dir
|
||||
pp._CN_RANGES = None
|
||||
pp._CN_RANGES_V6 = None
|
||||
pp._CN_RANGES_LOADED = False
|
||||
pp._FORCE_PROXY_SUFFIXES = None
|
||||
pp._FORCE_PROXY_EXACT = None
|
||||
pp._FORCE_PROXY_REGEXES = None
|
||||
pp._FORCE_PROXY_LOADED = False
|
||||
pp._is_china_ip.cache_clear()
|
||||
pp._is_force_proxy.cache_clear()
|
||||
|
||||
pp._pac_write("pac_domains", {"domains": ["old-rule.example"]})
|
||||
pp._pac_write("pac_ip_cn", {"cidrs": ["114.114.114.0/24"]})
|
||||
pp._load_force_proxy_suffixes()
|
||||
pp._load_cn_ranges()
|
||||
before = pp._is_force_proxy("old-rule.example")
|
||||
pp._pac_write("pac_domains", {"domains": ["new-rule.example"]})
|
||||
pp._invalidate_pac_routing_cache("pac_domains")
|
||||
domains_reloaded = (not pp._is_force_proxy("old-rule.example")
|
||||
and pp._is_force_proxy("new-rule.example"))
|
||||
print(f" 域名表热更新立即生效: 初始={before}, 更新后={domains_reloaded} "
|
||||
f"{'PASS' if before and domains_reloaded else 'FAIL'}")
|
||||
hot_failed += 0 if before and domains_reloaded else 1
|
||||
|
||||
initial_cn = pp._is_china_ip("114.114.114.114")
|
||||
pp._pac_write("pac_ip_cn", {"cidrs": ["8.8.8.0/24"]})
|
||||
pp._invalidate_pac_routing_cache("pac_ip_cn")
|
||||
ip_reloaded = (not pp._is_china_ip("114.114.114.114")
|
||||
and pp._is_china_ip("8.8.8.8"))
|
||||
print(f" IP 段热更新立即生效: 初始={initial_cn}, 更新后={ip_reloaded} "
|
||||
f"{'PASS' if initial_cn and ip_reloaded else 'FAIL'}")
|
||||
hot_failed += 0 if initial_cn and ip_reloaded else 1
|
||||
|
||||
# 固定本次 DNS 返回值;_direct_connect_target 必须只解析一次并直连该 IP。
|
||||
pp._pac_write("pac_ip_cn", {"cidrs": ["114.114.114.0/24"]})
|
||||
pp._invalidate_pac_routing_cache("pac_ip_cn")
|
||||
dns_calls = []
|
||||
def fake_getaddrinfo(host, *args, **kwargs):
|
||||
dns_calls.append(host)
|
||||
if host == "mixed.example":
|
||||
ips = ["114.114.114.114", "8.8.8.8"]
|
||||
else:
|
||||
ips = ["114.114.114.114"]
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0)) for ip in ips]
|
||||
socket.getaddrinfo = fake_getaddrinfo
|
||||
target = pp._direct_connect_target("cn-cdn.example", 443, False, True)
|
||||
stable_dns = target == ("114.114.114.114", 443) and dns_calls == ["cn-cdn.example"]
|
||||
mixed = pp._direct_connect_target("mixed.example", 443, False, True)
|
||||
mixed_safe = mixed is None
|
||||
print(f" 国内 CDN 固定解析 IP 且不二次解析: {stable_dns} {'PASS' if stable_dns else 'FAIL'}")
|
||||
print(f" 混合中国/境外 CDN 记录改走隧道: {mixed_safe} {'PASS' if mixed_safe else 'FAIL'}")
|
||||
hot_failed += 0 if stable_dns else 1
|
||||
hot_failed += 0 if mixed_safe else 1
|
||||
|
||||
# IPv6 中国 CIDR 与 IPv6 私网/回环分流。240e::/16 是测试用的公网 IPv6 段。
|
||||
pp._pac_write("pac_ip_cn", {"cidrs": ["240e::/16"]})
|
||||
pp._invalidate_pac_routing_cache("pac_ip_cn")
|
||||
ipv6_cn = (pp._is_china_ip("240e:1234::1")
|
||||
and pp._direct_connect_target("240e:1234::1", 443, False, True)
|
||||
== ("240e:1234::1", 443))
|
||||
ipv6_private = all(pp._is_private_ip(ip) for ip in ("::1", "fc00::1", "fe80::1"))
|
||||
ipv6_public = not pp._is_private_ip("2001:4860:4860::8888")
|
||||
print(f" IPv6 中国 CIDR 智能直连: {ipv6_cn} {'PASS' if ipv6_cn else 'FAIL'}")
|
||||
print(f" IPv6 回环/ULA/链路本地直连: {ipv6_private and ipv6_public} "
|
||||
f"{'PASS' if ipv6_private and ipv6_public else 'FAIL'}")
|
||||
hot_failed += 0 if ipv6_cn else 1
|
||||
hot_failed += 0 if ipv6_private and ipv6_public else 1
|
||||
finally:
|
||||
socket.getaddrinfo = old_getaddrinfo
|
||||
pp._USER_DIR = old_user_dir
|
||||
pp._CN_RANGES = old_cn_ranges
|
||||
pp._CN_RANGES_V6 = old_cn_ranges_v6
|
||||
pp._CN_RANGES_LOADED = old_cn_loaded
|
||||
pp._FORCE_PROXY_SUFFIXES = old_suffixes
|
||||
pp._FORCE_PROXY_EXACT = old_exact
|
||||
pp._FORCE_PROXY_REGEXES = old_regexes
|
||||
pp._FORCE_PROXY_LOADED = old_force_loaded
|
||||
pp._is_china_ip.cache_clear()
|
||||
pp._is_force_proxy.cache_clear()
|
||||
if hot_failed:
|
||||
failed += hot_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── 智能国内直连失败回退上游隧道 ───────────────────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("智能国内直连失败:仅该路径回退一次上游隧道")
|
||||
print("=" * 70)
|
||||
|
||||
fallback_failed = 0
|
||||
direct = ("114.114.114.114", 443)
|
||||
upstream = ("node.example", 1205)
|
||||
calls = []
|
||||
remote_obj = object()
|
||||
def fake_connect(target, timeout):
|
||||
calls.append((target, timeout))
|
||||
if target == direct:
|
||||
raise OSError("mock direct connect failure")
|
||||
if target == upstream:
|
||||
return remote_obj
|
||||
raise AssertionError(f"unexpected target: {target}")
|
||||
|
||||
remote, via_tunnel = pp._connect_route(
|
||||
direct, True, upstream[0], upstream[1], None, False, fake_connect)
|
||||
fallback_ok = (remote is remote_obj and via_tunnel is True
|
||||
and calls == [(direct, 10), (upstream, 10)])
|
||||
calls.clear()
|
||||
try:
|
||||
pp._connect_route(direct, False, upstream[0], upstream[1], None, False, fake_connect)
|
||||
explicit_not_fallback = False
|
||||
except OSError:
|
||||
explicit_not_fallback = calls == [(direct, 10)]
|
||||
print(f" 智能中国公网直连失败回退隧道: {fallback_ok} {'PASS' if fallback_ok else 'FAIL'}")
|
||||
print(f" 显式直连失败不回退: {explicit_not_fallback} "
|
||||
f"{'PASS' if explicit_not_fallback else 'FAIL'}")
|
||||
fallback_failed += 0 if fallback_ok else 1
|
||||
fallback_failed += 0 if explicit_not_fallback else 1
|
||||
if fallback_failed:
|
||||
failed += fallback_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── 启动连通性必须验证境外隧道 ─────────────────────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("启动连通性:google.com 隧道失败必须阻止“开启成功”")
|
||||
print("=" * 70)
|
||||
|
||||
class _FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
def read(self, size=-1):
|
||||
return b""
|
||||
|
||||
class _FakeOpener:
|
||||
def __init__(self, fail_google=False):
|
||||
self.fail_google = fail_google
|
||||
def open(self, url, timeout):
|
||||
if self.fail_google and "www.google.com" in url:
|
||||
raise OSError("mock tunnel failure")
|
||||
return _FakeResponse()
|
||||
|
||||
|
||||
class _CancellingResponse(_FakeResponse):
|
||||
def __init__(self, cancel_event):
|
||||
self.cancel_event = cancel_event
|
||||
def read(self, size=-1):
|
||||
self.cancel_event.set()
|
||||
return b""
|
||||
|
||||
|
||||
class _CancellingOpener:
|
||||
def __init__(self, cancel_event):
|
||||
self.cancel_event = cancel_event
|
||||
self.urls = []
|
||||
def open(self, url, timeout):
|
||||
self.urls.append(url)
|
||||
return _CancellingResponse(self.cancel_event)
|
||||
|
||||
|
||||
class _GoogleCancellingOpener(_CancellingOpener):
|
||||
def open(self, url, timeout):
|
||||
self.urls.append(url)
|
||||
if "www.google.com" in url:
|
||||
return _CancellingResponse(self.cancel_event)
|
||||
return _FakeResponse()
|
||||
|
||||
verify_failed = 0
|
||||
ok_results = pp._verify_proxy_connectivity(opener=_FakeOpener())
|
||||
ok = ok_results.get("google.com") is True
|
||||
try:
|
||||
pp._verify_proxy_connectivity(opener=_FakeOpener(fail_google=True))
|
||||
blocked = False
|
||||
except RuntimeError:
|
||||
blocked = True
|
||||
cancel_event = threading.Event()
|
||||
cancelling_opener = _CancellingOpener(cancel_event)
|
||||
try:
|
||||
pp._verify_proxy_connectivity(opener=cancelling_opener, cancel_event=cancel_event)
|
||||
cancel_blocked = False
|
||||
except pp._StartCancelled:
|
||||
cancel_blocked = True
|
||||
google_not_started = all("www.google.com" not in url for url in cancelling_opener.urls)
|
||||
google_cancel_event = threading.Event()
|
||||
google_cancelling_opener = _GoogleCancellingOpener(google_cancel_event)
|
||||
try:
|
||||
pp._verify_proxy_connectivity(opener=google_cancelling_opener,
|
||||
cancel_event=google_cancel_event)
|
||||
google_cancelled = False
|
||||
except pp._StartCancelled:
|
||||
google_cancelled = True
|
||||
google_started = any("www.google.com" in url for url in google_cancelling_opener.urls)
|
||||
print(f" 境外隧道成功可通过: {ok} {'PASS' if ok else 'FAIL'}")
|
||||
print(f" 境外隧道失败会阻止开启: {blocked} {'PASS' if blocked else 'FAIL'}")
|
||||
print(f" Baidu 探测中取消会中断后续 Google 探测: {cancel_blocked and google_not_started} "
|
||||
f"{'PASS' if cancel_blocked and google_not_started else 'FAIL'}")
|
||||
print(f" Google 探测中取消会终止本次开启: {google_cancelled and google_started} "
|
||||
f"{'PASS' if google_cancelled and google_started else 'FAIL'}")
|
||||
verify_failed += 0 if ok else 1
|
||||
verify_failed += 0 if blocked else 1
|
||||
verify_failed += 0 if cancel_blocked and google_not_started else 1
|
||||
verify_failed += 0 if google_cancelled and google_started else 1
|
||||
if verify_failed:
|
||||
failed += verify_failed
|
||||
print("=" * 70)
|
||||
|
||||
# ── DNS 分流 TTL 缓存 ────────────────────────────────────────
|
||||
print("\n" + "=" * 70)
|
||||
print("TTL 缓存:cache_clear 兼容 + 缓存命中")
|
||||
print("=" * 70)
|
||||
|
||||
ttl_failed = 0
|
||||
assert hasattr(pp._is_china_ip, "cache_clear"), "cache_clear 缺失"
|
||||
assert hasattr(pp._is_private_ip, "cache_clear"), "cache_clear 缺失"
|
||||
assert hasattr(pp._is_whitelisted, "cache_clear"), "cache_clear 缺失"
|
||||
pp._is_china_ip.cache_clear()
|
||||
pp._is_private_ip.cache_clear()
|
||||
pp._is_whitelisted.cache_clear()
|
||||
a1, a2 = pp._is_private_ip("10.1.2.3"), pp._is_private_ip("10.1.2.3")
|
||||
ok = a1 is True and a2 is True
|
||||
ttl_failed += 0 if ok else 1
|
||||
print(f" 内网 IP 判定缓存命中: {a1}/{a2} {'PASS' if ok else 'FAIL'}")
|
||||
b1, b2 = pp._is_china_ip("114.114.114.114"), pp._is_china_ip("114.114.114.114")
|
||||
ok = b1 is True and b2 is True
|
||||
ttl_failed += 0 if ok else 1
|
||||
print(f" 中国 IP 判定缓存命中: {b1}/{b2} {'PASS' if ok else 'FAIL'}")
|
||||
pp._is_china_ip.cache_clear()
|
||||
pp._is_private_ip.cache_clear()
|
||||
print(f" cache_clear 后重查: {pp._is_china_ip('114.114.114.114')} "
|
||||
f"{'PASS' if pp._is_china_ip('114.114.114.114') is True else 'FAIL'}")
|
||||
if ttl_failed:
|
||||
failed += ttl_failed
|
||||
print("=" * 70)
|
||||
|
||||
print(f"\n最终结论: {'全部 PASS' if not failed else f'{failed} 个 FAIL'}")
|
||||
sys.exit(1 if failed else 0)
|
||||
+450
-19
@@ -1,26 +1,457 @@
|
||||
# server_config
|
||||
# RRPP 服务端部署规范
|
||||
|
||||
> 一套统一的跨境代理节点部署流程,适用于 Debian/Ubuntu 系 Linux。
|
||||
> 每台节点运行相同的服务栈:**BBR 拥塞控制 + nginx CONNECT 隧道 + sing-box(VLESS+REALITY / Shadowsocks) + mtg(MTProto)**。
|
||||
>
|
||||
> 本文档为**通用规范**,所有 IP / 域名 / 端口 / 密钥均以**占位符**表示,部署时替换为实际值。
|
||||
> 凡标注 `<...>` 的均为占位符,不要原样照抄。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [0. 架构总览](#0-架构总览)
|
||||
- [1. 端口与服务规划](#1-端口与服务规划)
|
||||
- [2. 系统基础优化(BBR 跨境丢包优化)](#2-系统基础优化bbr-跨境丢包优化)
|
||||
- [3. 伪装站点页面](#3-伪装站点页面)
|
||||
- [4. HTTPS 证书(acme.sh DNS-01)](#4-https-证书acmesh-dns-01)
|
||||
- [5. nginx 源码编译 + CONNECT 隧道](#5-nginx-源码编译--connect-隧道)
|
||||
- [6. sing-box(VLESS+REALITY / Shadowsocks)](#6-sing-boxvlessreality--shadowsocks)
|
||||
- [7. mtg(MTProto 代理)](#7-mtgmtproto-代理)
|
||||
- [8. 本机公网 IP 自动识别(防自代理回环)](#8-本机公网-ip-自动识别防自代理回环)
|
||||
- [9. systemd 服务文件](#9-systemd-服务文件)
|
||||
- [10. 启动顺序与验证清单](#10-启动顺序与验证清单)
|
||||
- [11. 目录结构与文件索引](#11-目录结构与文件索引)
|
||||
|
||||
---
|
||||
|
||||
## 0. 架构总览
|
||||
|
||||
```
|
||||
客户端 ──► nginx(443/<nginx端口>) ──┬─► CONNECT 隧道(任意端口, 全端口放行)
|
||||
│ └─► 伪装页面(直接访问/IP访问)
|
||||
│
|
||||
├─► sing-box(<vless端口>) ──► VLESS+REALITY(vision)
|
||||
├─► sing-box(<ss端口>) ──► Shadowsocks(2022-blake3-aes-256-gcm)
|
||||
└─► mtg(<mtproto端口>) ──► MTProto(Fake TLS, Telegram 内置代理)
|
||||
```
|
||||
|
||||
**四层代理各司其职:**
|
||||
|
||||
| 层 | 组件 | 协议 | 适用场景 |
|
||||
|---|---|---|---|
|
||||
| HTTP 隧道 | nginx | CONNECT + Basic Auth | 浏览器/curl/系统代理,全端口 WebSocket 透传 |
|
||||
| 通用代理 | sing-box | VLESS+REALITY | Shadowrocket/Clash/v2rayN 客户端 |
|
||||
| 兼容旧客户端 | sing-box | Shadowsocks | Shadowrocket 2.x 等不支持 REALITY 的客户端 || Telegram 专用 | mtg | MTProto Fake TLS | Telegram 内置代理,抗封锁 |
|
||||
|
||||
**核心密钥体系(2026-09 起全部每节点独立):**
|
||||
- VLESS+REALITY:每节点**独立** UUID、REALITY 密钥对、short_id;SNI 仍全节点统一(用 `<伪装SNI域名>`,选一个高流量、TLS 指纹稳定的知名站点域名)
|
||||
- Shadowsocks:每节点**独立** PSK,方法 `2022-blake3-aes-256-gcm`(SIP022,抗重放探测)
|
||||
- nginx CONNECT:每节点**独立** Basic Auth 密码(客户端按节点携带凭据,无兼容性问题)
|
||||
- MTProto:每台**独立 secret**(Fake TLS 类型,fronting 到 `<伪装SNI域名>`)
|
||||
|
||||
> 独立化的目的:单节点密钥泄漏/被污染时不波及其余节点,且换钥粒度更小。
|
||||
> 代价是订阅/nodes.json 必须逐节点维护,改任一项后必须做端到端验证(见 6.3、第 10 节)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 端口与服务规划
|
||||
|
||||
端口全部落在**同一区段**便于防火墙管理(示例用 1200–1220,可按需调整):
|
||||
|
||||
| 端口 | 服务 | 说明 |
|
||||
|---|---|---|
|
||||
| `<nginx端口>` | nginx | HTTPS 站点 + CONNECT 隧道(示例 `1205`) |
|
||||
| `<vless端口>` | sing-box | VLESS+REALITY(示例 `1206`) |
|
||||
| `<ss端口>` | sing-box | Shadowsocks(示例 `1207`) |
|
||||
| `<mtproto端口>` | mtg | MTProto(示例 `1210`) |
|
||||
|
||||
> 节点 A 用 `80xx` 区段、节点 B 用 `120xx` 区段皆可,**只要同节点内不冲突、且运营商/防火墙放行**。
|
||||
> 防火墙放行:`iptables -A INPUT -p tcp -m multiport --dports <nginx端口>,<vless端口>,<ss端口>,<mtproto端口> -j ACCEPT`
|
||||
|
||||
**域名规划:** 每个节点一个独立域名 `<节点域名>`(如 `node1.example.com`),A 记录指向节点公网 IP。acme.sh 用 DNS-01 签证书,无需 80 端口可达。
|
||||
|
||||
---
|
||||
|
||||
## 2. 系统基础优化(BBR 跨境丢包优化)
|
||||
|
||||
BBR(Bottleneck Bandwidth and RTT)+ fq 队列,显著改善跨境 TCP 丢包/高延迟下的吞吐。
|
||||
|
||||
### 2.1 启用 BBR
|
||||
|
||||
写入 `/etc/sysctl.d/99-bbr.conf`:
|
||||
|
||||
```conf
|
||||
net.core.default_qdisc=fq
|
||||
net.ipv4.tcp_congestion_control=bbr
|
||||
```
|
||||
|
||||
应用并验证:
|
||||
|
||||
```bash
|
||||
modprobe tcp_bbr 2>/dev/null || true # 部分内核需手动加载模块
|
||||
sysctl --system
|
||||
sysctl net.ipv4.tcp_congestion_control net.core.default_qdisc
|
||||
# 期望输出: bbr / fq
|
||||
```
|
||||
|
||||
> **内核要求**:BBR 需 Linux 4.9+。`default_qdisc=fq` 在部分新内核(6.12+ cloud 内核)可能无此参数,仅保留 `tcp_congestion_control=bbr` 即可,不影响 BBR 生效。
|
||||
|
||||
### 2.2 文件描述符
|
||||
|
||||
nginx worker 高并发需要提高 limit,在 `/etc/security/limits.conf` 追加:
|
||||
|
||||
```
|
||||
* soft nofile 65535
|
||||
* hard nofile 65535
|
||||
```
|
||||
|
||||
### 2.3 时区与 NTP
|
||||
|
||||
代理证书校验、日志排障都依赖准确时间:
|
||||
|
||||
```bash
|
||||
timedatectl set-timezone Asia/Shanghai
|
||||
apt install -y chrony && systemctl enable --now chrony
|
||||
```
|
||||
|
||||
### 2.4 PMTUD 黑洞缓解(tcp_mtu_probing)
|
||||
|
||||
**症状**:部分客户端网络上出现"TCP 能连上、但 TLS 握手稳定超时"
|
||||
(如 `_ssl.c:1011 handshake operation timed out`),而服务器从其他网络探测一切正常。
|
||||
原因是客户端↔服务器路径上某跳 MTU 变小且 ICMP Fragmentation Needed 被过滤
|
||||
(PMTUD 黑洞),服务器发出的满尺寸大包永远到不了客户端。
|
||||
|
||||
开启 RFC 4821 PLPMTUD,让服务器在探测失败后自动降低 MSS 恢复通信。
|
||||
写入 `/etc/sysctl.d/99-mtu-probing.conf`:
|
||||
|
||||
```conf
|
||||
net.ipv4.tcp_mtu_probing = 1
|
||||
```
|
||||
|
||||
```bash
|
||||
sysctl --system
|
||||
sysctl net.ipv4.tcp_mtu_probing # 期望输出: 1
|
||||
```
|
||||
|
||||
> 只影响命中黑洞的连接,正常连接无感知,可安全全节点开启。
|
||||
|
||||
### 2.5 fail2ban(sshd 爆破防护)
|
||||
|
||||
节点 SSH 暴露在公网,爆破尝试是常态(实测单节点累计 3 万+ 次)。装 fail2ban 护 sshd:
|
||||
|
||||
```bash
|
||||
apt install -y fail2ban
|
||||
cat > /etc/fail2ban/jail.d/sshd.local << 'EOF'
|
||||
[sshd]
|
||||
enabled = true
|
||||
backend = systemd
|
||||
EOF
|
||||
systemctl enable --now fail2ban
|
||||
fail2ban-client status sshd # 确认 jail 在跑
|
||||
```
|
||||
|
||||
> - `backend = systemd`:无 `/var/log/auth.log` 的最小化系统(无 rsyslog)必须加这行,
|
||||
> 否则 fail2ban 启动报 "Have not found any log file for sshd jail"。
|
||||
> - LXC 容器也能正常封禁(实测 ban/unban 链路可用)。
|
||||
> - 只加 fail2ban、**不动 sshd 认证方式**(root 登录等现状保留)。
|
||||
> - 如需同时护 nginx CONNECT 的 401 爆破,可再加 nginx jail(凭据够强时可不做)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 伪装站点页面
|
||||
|
||||
直接访问节点/IP 时返回一个普通网页(而非代理错误),降低被探测的特征。
|
||||
|
||||
放置一个静态 `index.html` 到 `/var/www/html/index.html`:
|
||||
- 任意抓取的合法 HTML 页面(博客/门户首页快照均可)
|
||||
- **所有节点用同一份**(md5 一致),避免逐节点特征差异
|
||||
|
||||
```bash
|
||||
mkdir -p /var/www/html
|
||||
# index.html 为本仓库 fake-site/index.html 的拷贝
|
||||
cp fake-site/index.html /var/www/html/index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTPS 证书(acme.sh DNS-01)
|
||||
|
||||
nginx 站点与 CONNECT 隧道共用一张证书。用 [acme.sh](https://github.com/acmesh-official/acme.sh) + DNS-01(以阿里云 DNS 为例,其他 DNS 商同理):
|
||||
|
||||
```bash
|
||||
curl https://get.acme.sh | sh -s email=<你的邮箱>
|
||||
export Ali_Key="<阿里云AccessKey>"
|
||||
export Ali_Secret="<阿里云Secret>"
|
||||
|
||||
~/.acme.sh/acme.sh --issue --dns dns_ali -d <节点域名> --keylength ec-256
|
||||
|
||||
# 安装到 nginx 读取路径
|
||||
mkdir -p /etc/nginx/ssl/<节点域名>
|
||||
~/.acme.sh/acme.sh --install-cert -d <节点域名> --ecc \
|
||||
--fullchain-file /etc/nginx/ssl/<节点域名>/fullchain.pem \
|
||||
--key-file /etc/nginx/ssl/<节点域名>/privkey.pem
|
||||
```
|
||||
|
||||
acme.sh 自带 cron 自动续期。证书路径固定为 `/etc/nginx/ssl/<节点域名>/{fullchain,privkey}.pem`。
|
||||
|
||||
---
|
||||
|
||||
## 5. nginx 源码编译 + CONNECT 隧道
|
||||
|
||||
标准 nginx 不自带 CONNECT 方法支持,需打 [ngx_http_proxy_connect_module](https://github.com/chobits/ngx_http_proxy_connect_module) 补丁后**源码编译**。
|
||||
|
||||
### 5.1 编译
|
||||
|
||||
```bash
|
||||
apt install -y build-essential libssl-dev libpcre3-dev zlib1g-dev curl wget
|
||||
|
||||
NGINX_VER=1.26.3
|
||||
cd /usr/local/src
|
||||
curl -LO http://nginx.org/download/nginx-$NGINX_VER.tar.gz
|
||||
tar xzf nginx-$NGINX_VER.tar.gz
|
||||
|
||||
git clone https://github.com/chobits/ngx_http_proxy_connect_module.git
|
||||
cd nginx-$NGINX_VER
|
||||
patch -p1 < ../ngx_http_proxy_connect_module/patch/proxy_connect_rewrite_102101.patch
|
||||
|
||||
./configure \
|
||||
--prefix=/usr/local/nginx \
|
||||
--conf-path=/etc/nginx/nginx.conf \
|
||||
--pid-path=/var/run/nginx.pid \
|
||||
--error-log-path=/var/log/nginx/error.log \
|
||||
--http-log-path=/var/log/nginx/access.log \
|
||||
--with-http_ssl_module \
|
||||
--with-http_v2_module \
|
||||
--with-http_realip_module \
|
||||
--with-http_stub_status_module \
|
||||
--with-http_gzip_static_module \
|
||||
--add-module=../ngx_http_proxy_connect_module
|
||||
make -j$(nproc) && make install
|
||||
```
|
||||
|
||||
> 若发行版 nginx 已自带 proxy_connect 补丁(极少见),可用 `apt install nginx` 跳过编译,但源码编译是通用做法。
|
||||
|
||||
### 5.2 Basic Auth 凭据
|
||||
|
||||
CONNECT 代理需要 Basic Auth(用户名/密码),**每节点独立密码**(客户端配置按节点携带凭据,无兼容性问题)。生成 htpasswd:
|
||||
|
||||
```bash
|
||||
apt install -y apache2-utils
|
||||
htpasswd -bc /etc/nginx/.htpasswd <代理用户名> '<本节点独立密码>'
|
||||
chmod 644 /etc/nginx/.htpasswd # worker 以 nobody 运行,必须可读
|
||||
```
|
||||
|
||||
> 无 apache2-utils 的机器可用 openssl 生成(sha512-crypt,nginx 同样支持):
|
||||
> `echo "<用户名>:$(openssl passwd -6 '<密码>')" > /etc/nginx/.htpasswd`
|
||||
> htpasswd 是**每次请求实时读取**的,改密码无需 reload nginx。
|
||||
|
||||
### 5.3 nginx.conf
|
||||
|
||||
完整配置见 [nginx/nginx.conf](nginx/nginx.conf)。**关键设计**:
|
||||
|
||||
- **两个 server 块共用 `<nginx端口>`**:
|
||||
- 主块 `server_name <节点域名>`:证书站点 + `location /` 返回伪装页;非本域名一律 rewrite 到 `/__proxy` 转发
|
||||
- fallback 块 `default_server`:其他 SNI 也接受 CONNECT 代理;本机/私网/公网 IP 直连返回伪装页,其余走 `/__proxy`
|
||||
- `proxy_connect_allow all;`:**全端口** CONNECT 放行(codex/WebSocket 走任意端口均通)。若需限制,改回 `proxy_connect_allow 443 563;`
|
||||
- `auth_basic`:仅 CONNECT/正向代理需要认证,`location /` 伪装页 `auth_basic off`
|
||||
- **自代理回环保护**:`/__proxy` 转发时带 `X-Proxy-Loop-Guard` 头,二次进入返回 508 防死循环
|
||||
- `/__local`:本机 IP 直连时剥前缀返回伪装页
|
||||
- `map $host $is_local`:私网/回环段判定;公网 IP 由 cron 脚本动态写入 `local_ip_rules.conf`
|
||||
|
||||
### 5.4 自代理回环保护说明
|
||||
|
||||
正向代理转发目标若指向**本机自己**(如域名公网 IP 变更、或代理目标就是本节点),会无限循环。两层防护:
|
||||
1. `map` 判定私网/回环段 + cron 维护的本机公网 IP 规则 → 直连伪装页
|
||||
2. `/__proxy` 的 Loop-Guard 头兜底 → 二次进入返回 508
|
||||
|
||||
---
|
||||
|
||||
## 6. sing-box(VLESS+REALITY / Shadowsocks)
|
||||
|
||||
### 6.1 安装
|
||||
|
||||
```bash
|
||||
SINGBOX_VER=1.13.19
|
||||
curl -LO https://github.com/SagerNet/sing-box/releases/download/v$SINGBOX_VER/sing-box-$SINGBOX_VER-linux-amd64.tar.gz
|
||||
tar xzf sing-box-*.tar.gz
|
||||
install -m 755 sing-box-$SINGBOX_VER-linux-amd64/sing-box /usr/local/bin/sing-box
|
||||
```
|
||||
|
||||
### 6.2 配置
|
||||
|
||||
完整配置见 [sing-box/config.json](sing-box/config.json)。要点:
|
||||
- **VLESS+REALITY**:每节点独立 UUID/密钥对/short_id,SNI 全节点统一,flow `xtls-rprx-vision`;REALITY handshake server 用 `<伪装SNI域名>:443`
|
||||
- **Shadowsocks**:`2022-blake3-aes-256-gcm`(SIP022:会话子密钥 + 抗重放探测,显著优于老 AEAD `aes-256-gcm`)+ 每节点独立 PSK,独立端口(兼容不支持 REALITY 的老客户端)
|
||||
- **PSK 格式要求**:密码必须是 base64 编码的 32 字节密钥(`openssl rand -base64 32`),不是任意字符串;改错格式 sing-box 启动即报错
|
||||
- sing-box / Shadowrocket / mihomo 均支持 2022-blake3 系列
|
||||
- 无 SOCKS5 inbound(已按需移除;如需可在 inbounds 追加 `type: socks`)
|
||||
|
||||
### 6.3 REALITY 密钥生成
|
||||
|
||||
**每台节点独立生成一套**(互不共用):
|
||||
|
||||
```bash
|
||||
# 生成私钥/公钥(sing-box 1.13+ 内置 reality 密钥生成)
|
||||
sing-box generate reality-keypair
|
||||
# 输出 private_key / public_key,记录备用
|
||||
# short_id 用 8 字节随机 hex:
|
||||
openssl rand -hex 8
|
||||
# UUID:
|
||||
sing-box generate uuid
|
||||
```
|
||||
|
||||
> 私钥只放服务端配置;公钥给客户端订阅。UUID/密钥对/short_id 每节点独立。
|
||||
|
||||
**公钥必须推导验证,禁止手抄。** 订阅/节点配置里的 `public_key` 与服务端
|
||||
`private_key` 必须严格配对(X25519 推导),曾发生手抄错一个字符导致全节点
|
||||
REALITY 握手失败(客户端报 `reality verification failed`)的事故。改配置后验证:
|
||||
|
||||
```bash
|
||||
python3 - << 'EOF'
|
||||
import base64
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
priv = base64.urlsafe_b64decode('<REALITY_PRIVATE_KEY>' + '==')
|
||||
pub = X25519PrivateKey.from_private_bytes(priv).public_key().public_bytes(
|
||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
print(base64.urlsafe_b64encode(pub).decode().rstrip('=')) # 应与下发的 public_key 逐字符一致
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. mtg(MTProto 代理)
|
||||
|
||||
Telegram 内置代理,Fake TLS 模式抗封锁。用 [mtg](https://github.com/9seconds/mtg)(Go 单二进制):
|
||||
|
||||
### 7.1 安装
|
||||
|
||||
```bash
|
||||
MTG_VER=2.2.8
|
||||
curl -LO https://github.com/9seconds/mtg/releases/download/v$MTG_VER/mtg-$MTG_VER-linux-amd64.tar.gz
|
||||
tar xzf mtg-*.tar.gz
|
||||
install -m 755 mtg-$MTG_VER-linux-amd64/mtg /usr/local/bin/mtg
|
||||
```
|
||||
|
||||
### 7.2 生成 secret(每台独立)
|
||||
|
||||
```bash
|
||||
mtg generate-secret --hex <伪装SNI域名>
|
||||
# 输出 ee<32hex><域名hex>,以 ee 开头 = Fake TLS 类型
|
||||
```
|
||||
|
||||
### 7.3 运行
|
||||
|
||||
```bash
|
||||
mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <上面生成的secret>
|
||||
```
|
||||
|
||||
> `--prefer-ip=prefer-ipv4`:强制 IPv4 出站,避免容器/双栈环境下 IPv6 不可达导致回退失败。
|
||||
|
||||
### 7.4 Telegram 客户端参数
|
||||
|
||||
- 服务器:`<节点域名>`
|
||||
- 端口:`<mtproto端口>`
|
||||
- Secret:上面生成的 hex(Telegram 自动识别 Fake TLS)
|
||||
|
||||
---
|
||||
|
||||
## 8. 本机公网 IP 自动识别(防自代理回环)
|
||||
|
||||
正向代理转发时,若目标 Host 是本机公网 IP,会被识别为"本机直连"返回伪装页(而非经代理转发本机,造成回环)。公网 IP 可能变动,用 cron 脚本每 5 分钟自动维护。
|
||||
|
||||
脚本见 [scripts/sync-nginx-local-ips.sh](scripts/sync-nginx-local-ips.sh),cron 见 [cron/sync-nginx-local-ips](cron/sync-nginx-local-ips):
|
||||
|
||||
```cron
|
||||
*/5 * * * * root /usr/local/sbin/sync-nginx-local-ips.sh >/dev/null 2>&1
|
||||
```
|
||||
|
||||
脚本逻辑:取 `api.ipify.org` 出站 IP + 节点域名 A 记录,比对重写 `/etc/nginx/local_ip_rules.conf`,**仅变更时** `nginx -t && systemctl reload nginx`。
|
||||
|
||||
---
|
||||
|
||||
## 9. systemd 服务文件
|
||||
|
||||
| 服务 | 文件 |
|
||||
|---|---|
|
||||
| nginx | [systemd/nginx.service](systemd/nginx.service) |
|
||||
| sing-box | [systemd/sing-box.service](systemd/sing-box.service) |
|
||||
| mtg | [systemd/mtg.service](systemd/mtg.service) |
|
||||
|
||||
```bash
|
||||
cp systemd/*.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now nginx sing-box mtg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 启动顺序与验证清单
|
||||
|
||||
### 启动顺序
|
||||
|
||||
```bash
|
||||
sysctl --system # 1. BBR
|
||||
systemctl start sing-box # 2. VLESS/SS
|
||||
systemctl start mtg # 3. MTProto
|
||||
systemctl start nginx # 4. nginx(最后,依赖证书/伪装页/规则文件)
|
||||
```
|
||||
|
||||
### 验证清单
|
||||
|
||||
```bash
|
||||
# BBR
|
||||
sysctl net.ipv4.tcp_congestion_control # => bbr
|
||||
sysctl net.ipv4.tcp_mtu_probing # => 1(PMTUD 黑洞缓解,见 2.4)
|
||||
|
||||
# fail2ban
|
||||
fail2ban-client status sshd # => jail 存在(见 2.5)
|
||||
|
||||
# sing-box
|
||||
sing-box check -c /etc/sing-box/config.json
|
||||
systemctl is-active sing-box
|
||||
|
||||
# mtg(Fake TLS:非法 ClientHello 应返回 TLS alert 0x15)
|
||||
timeout 8 python3 -c "
|
||||
import socket,os,struct
|
||||
s=socket.connect(('<节点域名>',<mtproto端口>)) if False else socket.create_connection(('<节点域名>',<mtproto端口>),timeout=6)
|
||||
s.sendall(b'\x16\x03\x01'+struct.pack('>H',100)+os.urandom(100))
|
||||
print('resp:',s.recv(8).hex())" # 期望首字节 15 (TLS alert)
|
||||
|
||||
# nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>'))
|
||||
B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0)
|
||||
curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \
|
||||
--proxy-header "Authorization: Basic $B64" \
|
||||
-o /dev/null -w '%{http_code}\n' http://example.com/ # => 200
|
||||
|
||||
# 伪装页(直连 IP/域名,无认证)
|
||||
curl -sk https://<节点域名>:<nginx端口>/ -o /dev/null -w '%{http_code}\n' # => 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 目录结构与文件索引
|
||||
|
||||
```
|
||||
server_config/
|
||||
├── README.md ← 本文档
|
||||
├── nginx/
|
||||
│ ├── nginx.conf
|
||||
│ ├── conf.d/
|
||||
│ │ ├── api.aeolianchi.me.conf
|
||||
│ │ └── autodiscover.aeolianchi.me.conf
|
||||
│ └── local_ip_rules.conf.example
|
||||
│ ├── nginx.conf ← 完整 nginx 配置(占位符版)
|
||||
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
|
||||
│ └── conf.d/ ← 预留:额外站点配置
|
||||
├── sing-box/
|
||||
│ └── config.json
|
||||
├── traffic-monitor/
|
||||
│ ├── config.json
|
||||
│ ├── traffic_monitor.py
|
||||
│ └── traffic-monitor.service
|
||||
│ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
|
||||
├── systemd/
|
||||
│ ├── nginx.service
|
||||
│ ├── sing-box.service
|
||||
│ └── mtg.service
|
||||
├── scripts/
|
||||
│ ├── sync-nginx-local-ips.sh
|
||||
│ └── rrpp-stats
|
||||
├── pac/
|
||||
│ ├── proxy.pac
|
||||
│ └── proxy_wg.pac
|
||||
└── cron/
|
||||
├── crontab.txt
|
||||
└── sync-nginx-local-ips
|
||||
│ └── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
|
||||
├── cron/
|
||||
│ └── sync-nginx-local-ips ← cron 定义(每 5 分钟)
|
||||
└── fake-site/
|
||||
└── index.html ← 伪装站点页面(所有节点共用同一份)
|
||||
```
|
||||
|
||||
> 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
* * * * * /usr/local/bin/rrpp-stats | sudo tee /root/backups/stats.json > /dev/null
|
||||
27 0,6,12,18 * * * "/home/RRRosmontis/.acme.sh"/acme.sh --cron --home "/home/RRRosmontis/.acme.sh" > /dev/null
|
||||
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name api.aeolianchi.me;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/api.aeolianchi.me/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/api.aeolianchi.me/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
location /vpn/status/traffic {
|
||||
proxy_pass http://127.0.0.1:18080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# autodiscover.aeolianchi.me -> jp 反代 -> bj autodiscover.rrrosmontis.icu
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name autodiscover.aeolianchi.me;
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/acme;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# autodiscover.aeolianchi.me 443 -> 反代 -> bj autodiscover.rrrosmontis.icu
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name autodiscover.aeolianchi.me;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/aeolianchi.me/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/aeolianchi.me/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
|
||||
location / {
|
||||
proxy_pass https://autodiscover.rrrosmontis.icu;
|
||||
proxy_set_header Host autodiscover.rrrosmontis.icu;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name autodiscover.rrrosmontis.icu;
|
||||
}
|
||||
|
||||
access_log /var/log/nginx/autodiscover.aeolianchi.me.log;
|
||||
error_log /var/log/nginx/autodiscover.aeolianchi.me.error.log;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
# nginx 本机公网 IP 直连规则(自动生成,勿手改;由 sync-nginx-local-ips.sh 维护)
|
||||
# 公网 IP 变更后本文件会被重写并 reload nginx
|
||||
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
|
||||
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
|
||||
# 每条规则对应一个本机公网 IP(出站 IP + 节点域名 A 记录)
|
||||
if ($host = "<PUBLIC_IP_1>") { rewrite ^(.*)$ /__local$1 last; }
|
||||
if ($host = "<PUBLIC_IP_2>") { rewrite ^(.*)$ /__local$1 last; }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
load_module /usr/lib/nginx/modules/ngx_http_proxy_connect_module.so;
|
||||
worker_processes auto;
|
||||
worker_rlimit_nofile 65535;
|
||||
events {
|
||||
@@ -25,46 +24,15 @@ http {
|
||||
keepalive_timeout 65;
|
||||
keepalive_requests 100;
|
||||
|
||||
# 80 端口:iam.aeolianchi.me 强制跳转到 HTTPS(保留 ACME 验证路径)
|
||||
# <nginx端口>:主域名 HTTPS 站点 + 认证 CONNECT 代理
|
||||
server {
|
||||
listen 80;
|
||||
server_name iam.aeolianchi.me;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/acme;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# 80 端口:默认站点只做静态伪装,不提供代理、不弹认证
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /var/www/html;
|
||||
index index.html;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/acme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
|
||||
# 443 端口:iam.aeolianchi.me HTTPS 站点 + 认证 CONNECT 代理
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen <nginx端口> ssl;
|
||||
http2 on;
|
||||
server_name iam.aeolianchi.me;
|
||||
server_name <节点域名>;
|
||||
root /var/www/html;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/iam.aeolianchi.me/privkey.pem;
|
||||
ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
@@ -75,14 +43,14 @@ http {
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
|
||||
proxy_connect;
|
||||
proxy_connect_allow 443 563;
|
||||
proxy_connect_allow all;
|
||||
proxy_connect_connect_timeout 60s;
|
||||
proxy_connect_read_timeout 120s;
|
||||
proxy_connect_send_timeout 120s;
|
||||
|
||||
location / {
|
||||
auth_basic off;
|
||||
if ($host != "iam.aeolianchi.me") {
|
||||
if ($host != "<节点域名>") {
|
||||
rewrite ^ /__proxy last;
|
||||
}
|
||||
index index.html;
|
||||
@@ -105,14 +73,14 @@ http {
|
||||
}
|
||||
}
|
||||
|
||||
# 443 端口 fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
|
||||
# <nginx端口> fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen <nginx端口> ssl default_server;
|
||||
http2 on;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/iam.aeolianchi.me/privkey.pem;
|
||||
ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
@@ -123,7 +91,7 @@ http {
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
|
||||
proxy_connect;
|
||||
proxy_connect_allow 443 563;
|
||||
proxy_connect_allow all;
|
||||
proxy_connect_connect_timeout 60s;
|
||||
proxy_connect_read_timeout 120s;
|
||||
proxy_connect_send_timeout 120s;
|
||||
|
||||
@@ -1,647 +0,0 @@
|
||||
function FindProxyForURL(url, host) {
|
||||
// RRPP 规则代理列表 — 仅以下域名走加密隧道
|
||||
var proxy = "PROXY 127.0.0.1:1080";
|
||||
// Google 全系 + Gemini + CDN
|
||||
if (shExpMatch(host, "google.com") ||
|
||||
shExpMatch(host, "*.google.com") ||
|
||||
shExpMatch(host, "google.com.hk") ||
|
||||
shExpMatch(host, "*.google.com.hk") ||
|
||||
shExpMatch(host, "google.co.jp") ||
|
||||
shExpMatch(host, "*.google.co.jp") ||
|
||||
shExpMatch(host, "googleapis.com") ||
|
||||
shExpMatch(host, "*.googleapis.com") ||
|
||||
shExpMatch(host, "googleusercontent.com") ||
|
||||
shExpMatch(host, "*.googleusercontent.com") ||
|
||||
shExpMatch(host, "googletagmanager.com") ||
|
||||
shExpMatch(host, "*.googletagmanager.com") ||
|
||||
shExpMatch(host, "google-analytics.com") ||
|
||||
shExpMatch(host, "*.google-analytics.com") ||
|
||||
shExpMatch(host, "gstatic.com") ||
|
||||
shExpMatch(host, "*.gstatic.com") ||
|
||||
shExpMatch(host, "ggpht.com") ||
|
||||
shExpMatch(host, "*.ggpht.com") ||
|
||||
shExpMatch(host, "googlehosted.com") ||
|
||||
shExpMatch(host, "*.googlehosted.com") ||
|
||||
shExpMatch(host, "1e100.net") ||
|
||||
shExpMatch(host, "*.1e100.net") ||
|
||||
shExpMatch(host, "youtube.com") ||
|
||||
shExpMatch(host, "*.youtube.com") ||
|
||||
shExpMatch(host, "ytimg.com") ||
|
||||
shExpMatch(host, "*.ytimg.com") ||
|
||||
shExpMatch(host, "googlevideo.com") ||
|
||||
shExpMatch(host, "*.googlevideo.com") ||
|
||||
shExpMatch(host, "gemini.google.com") ||
|
||||
shExpMatch(host, "*.gemini.google.com") ||
|
||||
shExpMatch(host, "generativeai.googleapis.com") ||
|
||||
shExpMatch(host, "*.generativeai.googleapis.com") ||
|
||||
shExpMatch(host, "aistudio.google.com") ||
|
||||
shExpMatch(host, "*.aistudio.google.com") ||
|
||||
shExpMatch(host, "deepmind.google.com") ||
|
||||
shExpMatch(host, "*.deepmind.google.com") ||
|
||||
shExpMatch(host, "deepmind.com") ||
|
||||
shExpMatch(host, "*.deepmind.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// OpenAI / ChatGPT 全系 + CDN
|
||||
if (shExpMatch(host, "openai.com") ||
|
||||
shExpMatch(host, "*.openai.com") ||
|
||||
shExpMatch(host, "chatgpt.com") ||
|
||||
shExpMatch(host, "*.chatgpt.com") ||
|
||||
shExpMatch(host, "oaistatic.com") ||
|
||||
shExpMatch(host, "*.oaistatic.com") ||
|
||||
shExpMatch(host, "oaiusercontent.com") ||
|
||||
shExpMatch(host, "*.oaiusercontent.com") ||
|
||||
shExpMatch(host, "openaiusercontent.com") ||
|
||||
shExpMatch(host, "*.openaiusercontent.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Grok / xAI 全系 + CDN
|
||||
if (shExpMatch(host, "grok.com") ||
|
||||
shExpMatch(host, "*.grok.com") ||
|
||||
shExpMatch(host, "x.com") ||
|
||||
shExpMatch(host, "*.x.com") ||
|
||||
shExpMatch(host, "twitter.com") ||
|
||||
shExpMatch(host, "*.twitter.com") ||
|
||||
shExpMatch(host, "twimg.com") ||
|
||||
shExpMatch(host, "*.twimg.com") ||
|
||||
shExpMatch(host, "x.ai") ||
|
||||
shExpMatch(host, "*.x.ai") ||
|
||||
shExpMatch(host, "xai.com") ||
|
||||
shExpMatch(host, "*.xai.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Meta 全系 + CDN
|
||||
if (shExpMatch(host, "meta.com") ||
|
||||
shExpMatch(host, "*.meta.com") ||
|
||||
shExpMatch(host, "meta.ai") ||
|
||||
shExpMatch(host, "*.meta.ai") ||
|
||||
shExpMatch(host, "facebook.com") ||
|
||||
shExpMatch(host, "*.facebook.com") ||
|
||||
shExpMatch(host, "fb.com") ||
|
||||
shExpMatch(host, "*.fb.com") ||
|
||||
shExpMatch(host, "fbcdn.net") ||
|
||||
shExpMatch(host, "*.fbcdn.net") ||
|
||||
shExpMatch(host, "fbsbx.com") ||
|
||||
shExpMatch(host, "*.fbsbx.com") ||
|
||||
shExpMatch(host, "instagram.com") ||
|
||||
shExpMatch(host, "*.instagram.com") ||
|
||||
shExpMatch(host, "cdninstagram.com") ||
|
||||
shExpMatch(host, "*.cdninstagram.com") ||
|
||||
shExpMatch(host, "threads.net") ||
|
||||
shExpMatch(host, "*.threads.net") ||
|
||||
shExpMatch(host, "whatsapp.com") ||
|
||||
shExpMatch(host, "*.whatsapp.com") ||
|
||||
shExpMatch(host, "whatsapp.net") ||
|
||||
shExpMatch(host, "*.whatsapp.net") ||
|
||||
shExpMatch(host, "messenger.com") ||
|
||||
shExpMatch(host, "*.messenger.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Claude / Anthropic 全系 + CDN
|
||||
if (shExpMatch(host, "anthropic.com") ||
|
||||
shExpMatch(host, "*.anthropic.com") ||
|
||||
shExpMatch(host, "claude.ai") ||
|
||||
shExpMatch(host, "*.claude.ai") ||
|
||||
shExpMatch(host, "claudeusercontent.com") ||
|
||||
shExpMatch(host, "*.claudeusercontent.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Dify 全系
|
||||
if (shExpMatch(host, "dify.ai") ||
|
||||
shExpMatch(host, "*.dify.ai") ||
|
||||
shExpMatch(host, "udify.app") ||
|
||||
shExpMatch(host, "*.udify.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Cloudflare 全系
|
||||
if (shExpMatch(host, "cloudflare.com") ||
|
||||
shExpMatch(host, "*.cloudflare.com") ||
|
||||
shExpMatch(host, "cloudflare.net") ||
|
||||
shExpMatch(host, "*.cloudflare.net") ||
|
||||
shExpMatch(host, "cloudflareinsights.com") ||
|
||||
shExpMatch(host, "*.cloudflareinsights.com") ||
|
||||
shExpMatch(host, "cloudflarestatus.com") ||
|
||||
shExpMatch(host, "*.cloudflarestatus.com") ||
|
||||
shExpMatch(host, "cloudflarecdn.com") ||
|
||||
shExpMatch(host, "*.cloudflarecdn.com") ||
|
||||
shExpMatch(host, "cf-cdn.com") ||
|
||||
shExpMatch(host, "*.cf-cdn.com") ||
|
||||
shExpMatch(host, "workers.dev") ||
|
||||
shExpMatch(host, "*.workers.dev") ||
|
||||
shExpMatch(host, "pages.dev") ||
|
||||
shExpMatch(host, "*.pages.dev")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GitHub 全系
|
||||
if (shExpMatch(host, "github.com") ||
|
||||
shExpMatch(host, "*.github.com") ||
|
||||
shExpMatch(host, "githubusercontent.com") ||
|
||||
shExpMatch(host, "*.githubusercontent.com") ||
|
||||
shExpMatch(host, "githubassets.com") ||
|
||||
shExpMatch(host, "*.githubassets.com") ||
|
||||
shExpMatch(host, "github.io") ||
|
||||
shExpMatch(host, "*.github.io") ||
|
||||
shExpMatch(host, "githubcopilot.com") ||
|
||||
shExpMatch(host, "*.githubcopilot.com") ||
|
||||
shExpMatch(host, "ghcr.io") ||
|
||||
shExpMatch(host, "*.ghcr.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Hugging Face 全系 + CDN
|
||||
if (shExpMatch(host, "huggingface.co") ||
|
||||
shExpMatch(host, "*.huggingface.co") ||
|
||||
shExpMatch(host, "hf.co") ||
|
||||
shExpMatch(host, "*.hf.co") ||
|
||||
shExpMatch(host, "huggingfacecdn.com") ||
|
||||
shExpMatch(host, "*.huggingfacecdn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Microsoft Copilot 全系 + Bing + Azure CDN
|
||||
if (shExpMatch(host, "microsoft.com") ||
|
||||
shExpMatch(host, "*.microsoft.com") ||
|
||||
shExpMatch(host, "bing.com") ||
|
||||
shExpMatch(host, "*.bing.com") ||
|
||||
shExpMatch(host, "bingapis.com") ||
|
||||
shExpMatch(host, "*.bingapis.com") ||
|
||||
shExpMatch(host, "bing.net") ||
|
||||
shExpMatch(host, "*.bing.net") ||
|
||||
shExpMatch(host, "live.com") ||
|
||||
shExpMatch(host, "*.live.com") ||
|
||||
shExpMatch(host, "microsoftonline.com") ||
|
||||
shExpMatch(host, "*.microsoftonline.com") ||
|
||||
shExpMatch(host, "microsoft365.com") ||
|
||||
shExpMatch(host, "*.microsoft365.com") ||
|
||||
shExpMatch(host, "office.com") ||
|
||||
shExpMatch(host, "*.office.com") ||
|
||||
shExpMatch(host, "office.net") ||
|
||||
shExpMatch(host, "*.office.net") ||
|
||||
shExpMatch(host, "azure.com") ||
|
||||
shExpMatch(host, "*.azure.com") ||
|
||||
shExpMatch(host, "azureedge.net") ||
|
||||
shExpMatch(host, "*.azureedge.net") ||
|
||||
shExpMatch(host, "azurefd.net") ||
|
||||
shExpMatch(host, "*.azurefd.net") ||
|
||||
shExpMatch(host, "msn.com") ||
|
||||
shExpMatch(host, "*.msn.com") ||
|
||||
shExpMatch(host, "msecnd.net") ||
|
||||
shExpMatch(host, "*.msecnd.net") ||
|
||||
shExpMatch(host, "msftncsi.com") ||
|
||||
shExpMatch(host, "*.msftncsi.com") ||
|
||||
shExpMatch(host, "s-microsoft.com") ||
|
||||
shExpMatch(host, "*.s-microsoft.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Steam 全系 + CDN / 社区 / 下载
|
||||
if (shExpMatch(host, "steampowered.com") ||
|
||||
shExpMatch(host, "*.steampowered.com") ||
|
||||
shExpMatch(host, "steamcommunity.com") ||
|
||||
shExpMatch(host, "*.steamcommunity.com") ||
|
||||
shExpMatch(host, "steam.com") ||
|
||||
shExpMatch(host, "*.steam.com") ||
|
||||
shExpMatch(host, "steamgames.com") ||
|
||||
shExpMatch(host, "*.steamgames.com") ||
|
||||
shExpMatch(host, "steamusercontent.com") ||
|
||||
shExpMatch(host, "*.steamusercontent.com") ||
|
||||
shExpMatch(host, "steamcontent.com") ||
|
||||
shExpMatch(host, "*.steamcontent.com") ||
|
||||
shExpMatch(host, "steamstatic.com") ||
|
||||
shExpMatch(host, "*.steamstatic.com") ||
|
||||
shExpMatch(host, "steam-chat.com") ||
|
||||
shExpMatch(host, "*.steam-chat.com") ||
|
||||
shExpMatch(host, "steam-api.com") ||
|
||||
shExpMatch(host, "*.steam-api.com") ||
|
||||
shExpMatch(host, "steamserver.net") ||
|
||||
shExpMatch(host, "*.steamserver.net") ||
|
||||
shExpMatch(host, "steamcdn-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamcdn-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamcommunity-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamcommunity-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamstore-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamstore-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamuserimages-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamuserimages-a.akamaihd.net") ||
|
||||
shExpMatch(host, "akamaihd.net") ||
|
||||
shExpMatch(host, "*.akamaihd.net") ||
|
||||
shExpMatch(host, "valvesoftware.com") ||
|
||||
shExpMatch(host, "*.valvesoftware.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Discord 全系
|
||||
if (shExpMatch(host, "discord.com") ||
|
||||
shExpMatch(host, "*.discord.com") ||
|
||||
shExpMatch(host, "discordapp.com") ||
|
||||
shExpMatch(host, "*.discordapp.com") ||
|
||||
shExpMatch(host, "discord.gg") ||
|
||||
shExpMatch(host, "*.discord.gg") ||
|
||||
shExpMatch(host, "discord.media") ||
|
||||
shExpMatch(host, "*.discord.media") ||
|
||||
shExpMatch(host, "discordapp.net") ||
|
||||
shExpMatch(host, "*.discordapp.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Telegram 全系
|
||||
if (shExpMatch(host, "telegram.org") ||
|
||||
shExpMatch(host, "*.telegram.org") ||
|
||||
shExpMatch(host, "t.me") ||
|
||||
shExpMatch(host, "*.t.me") ||
|
||||
shExpMatch(host, "telega.one") ||
|
||||
shExpMatch(host, "*.telega.one") ||
|
||||
shExpMatch(host, "telegram-cdn.org") ||
|
||||
shExpMatch(host, "*.telegram-cdn.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Reddit 全系
|
||||
if (shExpMatch(host, "reddit.com") ||
|
||||
shExpMatch(host, "*.reddit.com") ||
|
||||
shExpMatch(host, "redd.it") ||
|
||||
shExpMatch(host, "*.redd.it") ||
|
||||
shExpMatch(host, "redditmedia.com") ||
|
||||
shExpMatch(host, "*.redditmedia.com") ||
|
||||
shExpMatch(host, "redditstatic.com") ||
|
||||
shExpMatch(host, "*.redditstatic.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// LINE 全系
|
||||
if (shExpMatch(host, "line.me") ||
|
||||
shExpMatch(host, "*.line.me") ||
|
||||
shExpMatch(host, "line-apps.com") ||
|
||||
shExpMatch(host, "*.line-apps.com") ||
|
||||
shExpMatch(host, "line-scdn.net") ||
|
||||
shExpMatch(host, "*.line-scdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Spotify 全系
|
||||
if (shExpMatch(host, "spotify.com") ||
|
||||
shExpMatch(host, "*.spotify.com") ||
|
||||
shExpMatch(host, "spotifycdn.com") ||
|
||||
shExpMatch(host, "*.spotifycdn.com") ||
|
||||
shExpMatch(host, "scdn.co") ||
|
||||
shExpMatch(host, "*.scdn.co")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Netflix 全系
|
||||
if (shExpMatch(host, "netflix.com") ||
|
||||
shExpMatch(host, "*.netflix.com") ||
|
||||
shExpMatch(host, "nflxvideo.net") ||
|
||||
shExpMatch(host, "*.nflxvideo.net") ||
|
||||
shExpMatch(host, "nflximg.net") ||
|
||||
shExpMatch(host, "*.nflximg.net") ||
|
||||
shExpMatch(host, "nflxext.com") ||
|
||||
shExpMatch(host, "*.nflxext.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Disney+ 全系
|
||||
if (shExpMatch(host, "disneyplus.com") ||
|
||||
shExpMatch(host, "*.disneyplus.com") ||
|
||||
shExpMatch(host, "disney-plus.net") ||
|
||||
shExpMatch(host, "*.disney-plus.net") ||
|
||||
shExpMatch(host, "dssott.com") ||
|
||||
shExpMatch(host, "*.dssott.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// TikTok 全系
|
||||
if (shExpMatch(host, "tiktok.com") ||
|
||||
shExpMatch(host, "*.tiktok.com") ||
|
||||
shExpMatch(host, "tiktokcdn.com") ||
|
||||
shExpMatch(host, "*.tiktokcdn.com") ||
|
||||
shExpMatch(host, "tiktokv.com") ||
|
||||
shExpMatch(host, "*.tiktokv.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// PlayStation 全系
|
||||
if (shExpMatch(host, "playstation.com") ||
|
||||
shExpMatch(host, "*.playstation.com") ||
|
||||
shExpMatch(host, "sonyentertainmentnetwork.com") ||
|
||||
shExpMatch(host, "*.sonyentertainmentnetwork.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Nintendo 全系
|
||||
if (shExpMatch(host, "nintendo.com") ||
|
||||
shExpMatch(host, "*.nintendo.com") ||
|
||||
shExpMatch(host, "nintendowifi.net") ||
|
||||
shExpMatch(host, "*.nintendowifi.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Epic Games 全系
|
||||
if (shExpMatch(host, "epicgames.com") ||
|
||||
shExpMatch(host, "*.epicgames.com") ||
|
||||
shExpMatch(host, "unrealengine.com") ||
|
||||
shExpMatch(host, "*.unrealengine.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// DuckDuckGo / Brave Search
|
||||
if (shExpMatch(host, "duckduckgo.com") ||
|
||||
shExpMatch(host, "*.duckduckgo.com") ||
|
||||
shExpMatch(host, "search.brave.com") ||
|
||||
shExpMatch(host, "*.search.brave.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Proton 全系
|
||||
if (shExpMatch(host, "proton.me") ||
|
||||
shExpMatch(host, "*.proton.me") ||
|
||||
shExpMatch(host, "protonmail.com") ||
|
||||
shExpMatch(host, "*.protonmail.com") ||
|
||||
shExpMatch(host, "protonvpn.com") ||
|
||||
shExpMatch(host, "*.protonvpn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Stack Overflow / Stack Exchange 全系
|
||||
if (shExpMatch(host, "stackoverflow.com") ||
|
||||
shExpMatch(host, "*.stackoverflow.com") ||
|
||||
shExpMatch(host, "stackexchange.com") ||
|
||||
shExpMatch(host, "*.stackexchange.com") ||
|
||||
shExpMatch(host, "superuser.com") ||
|
||||
shExpMatch(host, "*.superuser.com") ||
|
||||
shExpMatch(host, "serverfault.com") ||
|
||||
shExpMatch(host, "*.serverfault.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Quora / Medium
|
||||
if (shExpMatch(host, "quora.com") ||
|
||||
shExpMatch(host, "*.quora.com") ||
|
||||
shExpMatch(host, "medium.com") ||
|
||||
shExpMatch(host, "*.medium.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Pixiv 全系
|
||||
if (shExpMatch(host, "pixiv.net") ||
|
||||
shExpMatch(host, "*.pixiv.net") ||
|
||||
shExpMatch(host, "pximg.net") ||
|
||||
shExpMatch(host, "*.pximg.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// ニコニコ 全系
|
||||
if (shExpMatch(host, "nicovideo.jp") ||
|
||||
shExpMatch(host, "*.nicovideo.jp") ||
|
||||
shExpMatch(host, "nico.ms") ||
|
||||
shExpMatch(host, "*.nico.ms")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// EA / Origin / Apex 全系
|
||||
if (shExpMatch(host, "ea.com") ||
|
||||
shExpMatch(host, "*.ea.com") ||
|
||||
shExpMatch(host, "origin.com") ||
|
||||
shExpMatch(host, "*.origin.com") ||
|
||||
shExpMatch(host, "eaassets.com") ||
|
||||
shExpMatch(host, "*.eaassets.com") ||
|
||||
shExpMatch(host, "apexlegends.com") ||
|
||||
shExpMatch(host, "*.apexlegends.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Battle.net / Blizzard 全系
|
||||
if (shExpMatch(host, "battle.net") ||
|
||||
shExpMatch(host, "*.battle.net") ||
|
||||
shExpMatch(host, "blizzard.com") ||
|
||||
shExpMatch(host, "*.blizzard.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Ubisoft 全系
|
||||
if (shExpMatch(host, "ubisoft.com") ||
|
||||
shExpMatch(host, "*.ubisoft.com") ||
|
||||
shExpMatch(host, "ubi.com") ||
|
||||
shExpMatch(host, "*.ubi.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Riot Games / 英雄联盟 / Valorant 全系
|
||||
if (shExpMatch(host, "riotgames.com") ||
|
||||
shExpMatch(host, "*.riotgames.com") ||
|
||||
shExpMatch(host, "leagueoflegends.com") ||
|
||||
shExpMatch(host, "*.leagueoflegends.com") ||
|
||||
shExpMatch(host, "valorant.com") ||
|
||||
shExpMatch(host, "*.valorant.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GOG / itch.io 全系
|
||||
if (shExpMatch(host, "gog.com") ||
|
||||
shExpMatch(host, "*.gog.com") ||
|
||||
shExpMatch(host, "itch.io") ||
|
||||
shExpMatch(host, "*.itch.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Twitch 全系
|
||||
if (shExpMatch(host, "twitch.tv") ||
|
||||
shExpMatch(host, "*.twitch.tv") ||
|
||||
shExpMatch(host, "ttvnw.net") ||
|
||||
shExpMatch(host, "*.ttvnw.net") ||
|
||||
shExpMatch(host, "twitchcdn.net") ||
|
||||
shExpMatch(host, "*.twitchcdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Crunchyroll 全系
|
||||
if (shExpMatch(host, "crunchyroll.com") ||
|
||||
shExpMatch(host, "*.crunchyroll.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Amazon Prime Video 全系
|
||||
if (shExpMatch(host, "primevideo.com") ||
|
||||
shExpMatch(host, "*.primevideo.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Apple TV+ 全系
|
||||
if (shExpMatch(host, "tv.apple.com") ||
|
||||
shExpMatch(host, "tvplus.apple.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// HBO Max / Hulu / Paramount+ 全系
|
||||
if (shExpMatch(host, "max.com") ||
|
||||
shExpMatch(host, "*.max.com") ||
|
||||
shExpMatch(host, "hulu.com") ||
|
||||
shExpMatch(host, "*.hulu.com") ||
|
||||
shExpMatch(host, "paramountplus.com") ||
|
||||
shExpMatch(host, "*.paramountplus.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Slack 全系
|
||||
if (shExpMatch(host, "slack.com") ||
|
||||
shExpMatch(host, "*.slack.com") ||
|
||||
shExpMatch(host, "slack-edge.com") ||
|
||||
shExpMatch(host, "*.slack-edge.com") ||
|
||||
shExpMatch(host, "slack-imgs.com") ||
|
||||
shExpMatch(host, "*.slack-imgs.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Zoom 全系
|
||||
if (shExpMatch(host, "zoom.us") ||
|
||||
shExpMatch(host, "*.zoom.us") ||
|
||||
shExpMatch(host, "zoom.com") ||
|
||||
shExpMatch(host, "*.zoom.com") ||
|
||||
shExpMatch(host, "zoomgov.com") ||
|
||||
shExpMatch(host, "*.zoomgov.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Signal 全系
|
||||
if (shExpMatch(host, "signal.org") ||
|
||||
shExpMatch(host, "*.signal.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Docker 全系
|
||||
if (shExpMatch(host, "docker.com") ||
|
||||
shExpMatch(host, "*.docker.com") ||
|
||||
shExpMatch(host, "docker.io") ||
|
||||
shExpMatch(host, "*.docker.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GitLab 全系
|
||||
if (shExpMatch(host, "gitlab.com") ||
|
||||
shExpMatch(host, "*.gitlab.com") ||
|
||||
shExpMatch(host, "gitlab.io") ||
|
||||
shExpMatch(host, "*.gitlab.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Vercel 全系
|
||||
if (shExpMatch(host, "vercel.com") ||
|
||||
shExpMatch(host, "*.vercel.com") ||
|
||||
shExpMatch(host, "vercel.app") ||
|
||||
shExpMatch(host, "*.vercel.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Netlify 全系
|
||||
if (shExpMatch(host, "netlify.com") ||
|
||||
shExpMatch(host, "*.netlify.com") ||
|
||||
shExpMatch(host, "netlify.app") ||
|
||||
shExpMatch(host, "*.netlify.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Perplexity / Poe / Midjourney 全系
|
||||
if (shExpMatch(host, "perplexity.ai") ||
|
||||
shExpMatch(host, "*.perplexity.ai") ||
|
||||
shExpMatch(host, "poe.com") ||
|
||||
shExpMatch(host, "*.poe.com") ||
|
||||
shExpMatch(host, "midjourney.com") ||
|
||||
shExpMatch(host, "*.midjourney.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Figma / Canva 全系
|
||||
if (shExpMatch(host, "figma.com") ||
|
||||
shExpMatch(host, "*.figma.com") ||
|
||||
shExpMatch(host, "canva.com") ||
|
||||
shExpMatch(host, "*.canva.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// LinkedIn 全系
|
||||
if (shExpMatch(host, "linkedin.com") ||
|
||||
shExpMatch(host, "*.linkedin.com") ||
|
||||
shExpMatch(host, "licdn.com") ||
|
||||
shExpMatch(host, "*.licdn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Pinterest 全系
|
||||
if (shExpMatch(host, "pinterest.com") ||
|
||||
shExpMatch(host, "*.pinterest.com") ||
|
||||
shExpMatch(host, "pinimg.com") ||
|
||||
shExpMatch(host, "*.pinimg.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Tumblr 全系
|
||||
if (shExpMatch(host, "tumblr.com") ||
|
||||
shExpMatch(host, "*.tumblr.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Snapchat 全系
|
||||
if (shExpMatch(host, "snapchat.com") ||
|
||||
shExpMatch(host, "*.snapchat.com") ||
|
||||
shExpMatch(host, "sc-cdn.net") ||
|
||||
shExpMatch(host, "*.sc-cdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Wikipedia / Wikimedia 全系
|
||||
if (shExpMatch(host, "wikipedia.org") ||
|
||||
shExpMatch(host, "*.wikipedia.org") ||
|
||||
shExpMatch(host, "wikimedia.org") ||
|
||||
shExpMatch(host, "*.wikimedia.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// PayPal 全系
|
||||
if (shExpMatch(host, "paypal.com") ||
|
||||
shExpMatch(host, "*.paypal.com") ||
|
||||
shExpMatch(host, "paypalobjects.com") ||
|
||||
shExpMatch(host, "*.paypalobjects.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Patreon 全系
|
||||
if (shExpMatch(host, "patreon.com") ||
|
||||
shExpMatch(host, "*.patreon.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// 通用 CDN
|
||||
if (shExpMatch(host, "cloudfront.net") ||
|
||||
shExpMatch(host, "*.cloudfront.net") ||
|
||||
shExpMatch(host, "fastly.net") ||
|
||||
shExpMatch(host, "*.fastly.net") ||
|
||||
shExpMatch(host, "fastlylb.net") ||
|
||||
shExpMatch(host, "*.fastlylb.net") ||
|
||||
shExpMatch(host, "jsdelivr.net") ||
|
||||
shExpMatch(host, "*.jsdelivr.net") ||
|
||||
shExpMatch(host, "unpkg.com") ||
|
||||
shExpMatch(host, "*.unpkg.com") ||
|
||||
shExpMatch(host, "statically.io") ||
|
||||
shExpMatch(host, "*.statically.io") ||
|
||||
shExpMatch(host, "cloudinary.com") ||
|
||||
shExpMatch(host, "*.cloudinary.com") ||
|
||||
shExpMatch(host, "imgur.com") ||
|
||||
shExpMatch(host, "*.imgur.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// linux.do
|
||||
if (shExpMatch(host, "linux.do") ||
|
||||
shExpMatch(host, "*.linux.do")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
return "DIRECT";
|
||||
}
|
||||
@@ -1,648 +0,0 @@
|
||||
function FindProxyForURL(url, host) {
|
||||
// RRPP 规则代理列表 — 仅以下域名走加密隧道
|
||||
var proxy = "PROXY 127.0.0.1:1080";
|
||||
// Google 全系 + Gemini + CDN
|
||||
if (shExpMatch(host, "google.com") ||
|
||||
shExpMatch(host, "*.google.com") ||
|
||||
shExpMatch(host, "google.com.hk") ||
|
||||
shExpMatch(host, "*.google.com.hk") ||
|
||||
shExpMatch(host, "google.co.jp") ||
|
||||
shExpMatch(host, "*.google.co.jp") ||
|
||||
shExpMatch(host, "googleapis.com") ||
|
||||
shExpMatch(host, "*.googleapis.com") ||
|
||||
shExpMatch(host, "googleusercontent.com") ||
|
||||
shExpMatch(host, "*.googleusercontent.com") ||
|
||||
shExpMatch(host, "googletagmanager.com") ||
|
||||
shExpMatch(host, "*.googletagmanager.com") ||
|
||||
shExpMatch(host, "google-analytics.com") ||
|
||||
shExpMatch(host, "*.google-analytics.com") ||
|
||||
shExpMatch(host, "gstatic.com") ||
|
||||
shExpMatch(host, "*.gstatic.com") ||
|
||||
shExpMatch(host, "ggpht.com") ||
|
||||
shExpMatch(host, "*.ggpht.com") ||
|
||||
shExpMatch(host, "googlehosted.com") ||
|
||||
shExpMatch(host, "*.googlehosted.com") ||
|
||||
shExpMatch(host, "1e100.net") ||
|
||||
shExpMatch(host, "*.1e100.net") ||
|
||||
shExpMatch(host, "youtube.com") ||
|
||||
shExpMatch(host, "*.youtube.com") ||
|
||||
shExpMatch(host, "ytimg.com") ||
|
||||
shExpMatch(host, "*.ytimg.com") ||
|
||||
shExpMatch(host, "googlevideo.com") ||
|
||||
shExpMatch(host, "*.googlevideo.com") ||
|
||||
shExpMatch(host, "gemini.google.com") ||
|
||||
shExpMatch(host, "*.gemini.google.com") ||
|
||||
shExpMatch(host, "generativeai.googleapis.com") ||
|
||||
shExpMatch(host, "*.generativeai.googleapis.com") ||
|
||||
shExpMatch(host, "aistudio.google.com") ||
|
||||
shExpMatch(host, "*.aistudio.google.com") ||
|
||||
shExpMatch(host, "deepmind.google.com") ||
|
||||
shExpMatch(host, "*.deepmind.google.com") ||
|
||||
shExpMatch(host, "deepmind.com") ||
|
||||
shExpMatch(host, "*.deepmind.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// OpenAI / ChatGPT 全系 + CDN
|
||||
if (shExpMatch(host, "openai.com") ||
|
||||
shExpMatch(host, "*.openai.com") ||
|
||||
shExpMatch(host, "chatgpt.com") ||
|
||||
shExpMatch(host, "*.chatgpt.com") ||
|
||||
shExpMatch(host, "oaistatic.com") ||
|
||||
shExpMatch(host, "*.oaistatic.com") ||
|
||||
shExpMatch(host, "oaiusercontent.com") ||
|
||||
shExpMatch(host, "*.oaiusercontent.com") ||
|
||||
shExpMatch(host, "openaiusercontent.com") ||
|
||||
shExpMatch(host, "*.openaiusercontent.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Grok / xAI 全系 + CDN
|
||||
if (shExpMatch(host, "grok.com") ||
|
||||
shExpMatch(host, "*.grok.com") ||
|
||||
shExpMatch(host, "x.com") ||
|
||||
shExpMatch(host, "*.x.com") ||
|
||||
shExpMatch(host, "twitter.com") ||
|
||||
shExpMatch(host, "*.twitter.com") ||
|
||||
shExpMatch(host, "twimg.com") ||
|
||||
shExpMatch(host, "*.twimg.com") ||
|
||||
shExpMatch(host, "x.ai") ||
|
||||
shExpMatch(host, "*.x.ai") ||
|
||||
shExpMatch(host, "xai.com") ||
|
||||
shExpMatch(host, "*.xai.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Meta 全系 + CDN
|
||||
if (shExpMatch(host, "meta.com") ||
|
||||
shExpMatch(host, "*.meta.com") ||
|
||||
shExpMatch(host, "meta.ai") ||
|
||||
shExpMatch(host, "*.meta.ai") ||
|
||||
shExpMatch(host, "facebook.com") ||
|
||||
shExpMatch(host, "*.facebook.com") ||
|
||||
shExpMatch(host, "fb.com") ||
|
||||
shExpMatch(host, "*.fb.com") ||
|
||||
shExpMatch(host, "fbcdn.net") ||
|
||||
shExpMatch(host, "*.fbcdn.net") ||
|
||||
shExpMatch(host, "fbsbx.com") ||
|
||||
shExpMatch(host, "*.fbsbx.com") ||
|
||||
shExpMatch(host, "instagram.com") ||
|
||||
shExpMatch(host, "*.instagram.com") ||
|
||||
shExpMatch(host, "cdninstagram.com") ||
|
||||
shExpMatch(host, "*.cdninstagram.com") ||
|
||||
shExpMatch(host, "threads.net") ||
|
||||
shExpMatch(host, "*.threads.net") ||
|
||||
shExpMatch(host, "whatsapp.com") ||
|
||||
shExpMatch(host, "*.whatsapp.com") ||
|
||||
shExpMatch(host, "whatsapp.net") ||
|
||||
shExpMatch(host, "*.whatsapp.net") ||
|
||||
shExpMatch(host, "messenger.com") ||
|
||||
shExpMatch(host, "*.messenger.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Claude / Anthropic 全系 + CDN
|
||||
if (shExpMatch(host, "anthropic.com") ||
|
||||
shExpMatch(host, "*.anthropic.com") ||
|
||||
shExpMatch(host, "claude.ai") ||
|
||||
shExpMatch(host, "*.claude.ai") ||
|
||||
shExpMatch(host, "claudeusercontent.com") ||
|
||||
shExpMatch(host, "*.claudeusercontent.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Dify 全系
|
||||
if (shExpMatch(host, "dify.ai") ||
|
||||
shExpMatch(host, "*.dify.ai") ||
|
||||
shExpMatch(host, "udify.app") ||
|
||||
shExpMatch(host, "*.udify.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Cloudflare 全系
|
||||
if (shExpMatch(host, "cloudflare.com") ||
|
||||
shExpMatch(host, "*.cloudflare.com") ||
|
||||
shExpMatch(host, "cloudflare.net") ||
|
||||
shExpMatch(host, "*.cloudflare.net") ||
|
||||
shExpMatch(host, "cloudflareinsights.com") ||
|
||||
shExpMatch(host, "*.cloudflareinsights.com") ||
|
||||
shExpMatch(host, "cloudflarestatus.com") ||
|
||||
shExpMatch(host, "*.cloudflarestatus.com") ||
|
||||
shExpMatch(host, "cloudflarecdn.com") ||
|
||||
shExpMatch(host, "*.cloudflarecdn.com") ||
|
||||
shExpMatch(host, "cf-cdn.com") ||
|
||||
shExpMatch(host, "*.cf-cdn.com") ||
|
||||
shExpMatch(host, "workers.dev") ||
|
||||
shExpMatch(host, "*.workers.dev") ||
|
||||
shExpMatch(host, "pages.dev") ||
|
||||
shExpMatch(host, "*.pages.dev")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GitHub 全系
|
||||
if (shExpMatch(host, "github.com") ||
|
||||
shExpMatch(host, "*.github.com") ||
|
||||
shExpMatch(host, "githubusercontent.com") ||
|
||||
shExpMatch(host, "*.githubusercontent.com") ||
|
||||
shExpMatch(host, "githubassets.com") ||
|
||||
shExpMatch(host, "*.githubassets.com") ||
|
||||
shExpMatch(host, "github.io") ||
|
||||
shExpMatch(host, "*.github.io") ||
|
||||
shExpMatch(host, "githubcopilot.com") ||
|
||||
shExpMatch(host, "*.githubcopilot.com") ||
|
||||
shExpMatch(host, "ghcr.io") ||
|
||||
shExpMatch(host, "*.ghcr.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Hugging Face 全系 + CDN
|
||||
if (shExpMatch(host, "huggingface.co") ||
|
||||
shExpMatch(host, "*.huggingface.co") ||
|
||||
shExpMatch(host, "hf.co") ||
|
||||
shExpMatch(host, "*.hf.co") ||
|
||||
shExpMatch(host, "huggingfacecdn.com") ||
|
||||
shExpMatch(host, "*.huggingfacecdn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Microsoft Copilot 全系 + Bing + Azure CDN
|
||||
if (shExpMatch(host, "microsoft.com") ||
|
||||
shExpMatch(host, "*.microsoft.com") ||
|
||||
shExpMatch(host, "bing.com") ||
|
||||
shExpMatch(host, "*.bing.com") ||
|
||||
shExpMatch(host, "bingapis.com") ||
|
||||
shExpMatch(host, "*.bingapis.com") ||
|
||||
shExpMatch(host, "bing.net") ||
|
||||
shExpMatch(host, "*.bing.net") ||
|
||||
shExpMatch(host, "live.com") ||
|
||||
shExpMatch(host, "*.live.com") ||
|
||||
shExpMatch(host, "microsoftonline.com") ||
|
||||
shExpMatch(host, "*.microsoftonline.com") ||
|
||||
shExpMatch(host, "microsoft365.com") ||
|
||||
shExpMatch(host, "*.microsoft365.com") ||
|
||||
shExpMatch(host, "office.com") ||
|
||||
shExpMatch(host, "*.office.com") ||
|
||||
shExpMatch(host, "office.net") ||
|
||||
shExpMatch(host, "*.office.net") ||
|
||||
shExpMatch(host, "azure.com") ||
|
||||
shExpMatch(host, "*.azure.com") ||
|
||||
shExpMatch(host, "azureedge.net") ||
|
||||
shExpMatch(host, "*.azureedge.net") ||
|
||||
shExpMatch(host, "azurefd.net") ||
|
||||
shExpMatch(host, "*.azurefd.net") ||
|
||||
shExpMatch(host, "msn.com") ||
|
||||
shExpMatch(host, "*.msn.com") ||
|
||||
shExpMatch(host, "msecnd.net") ||
|
||||
shExpMatch(host, "*.msecnd.net") ||
|
||||
shExpMatch(host, "msftncsi.com") ||
|
||||
shExpMatch(host, "*.msftncsi.com") ||
|
||||
shExpMatch(host, "s-microsoft.com") ||
|
||||
shExpMatch(host, "*.s-microsoft.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Steam 全系 + CDN / 社区 / 下载
|
||||
if (shExpMatch(host, "steampowered.com") ||
|
||||
shExpMatch(host, "*.steampowered.com") ||
|
||||
shExpMatch(host, "steamcommunity.com") ||
|
||||
shExpMatch(host, "*.steamcommunity.com") ||
|
||||
shExpMatch(host, "steam.com") ||
|
||||
shExpMatch(host, "*.steam.com") ||
|
||||
shExpMatch(host, "steamgames.com") ||
|
||||
shExpMatch(host, "*.steamgames.com") ||
|
||||
shExpMatch(host, "steamusercontent.com") ||
|
||||
shExpMatch(host, "*.steamusercontent.com") ||
|
||||
shExpMatch(host, "steamcontent.com") ||
|
||||
shExpMatch(host, "*.steamcontent.com") ||
|
||||
shExpMatch(host, "steamstatic.com") ||
|
||||
shExpMatch(host, "*.steamstatic.com") ||
|
||||
shExpMatch(host, "steam-chat.com") ||
|
||||
shExpMatch(host, "*.steam-chat.com") ||
|
||||
shExpMatch(host, "steam-api.com") ||
|
||||
shExpMatch(host, "*.steam-api.com") ||
|
||||
shExpMatch(host, "steamserver.net") ||
|
||||
shExpMatch(host, "*.steamserver.net") ||
|
||||
shExpMatch(host, "steamcdn-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamcdn-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamcommunity-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamcommunity-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamstore-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamstore-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamuserimages-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamuserimages-a.akamaihd.net") ||
|
||||
shExpMatch(host, "akamaihd.net") ||
|
||||
shExpMatch(host, "*.akamaihd.net") ||
|
||||
shExpMatch(host, "valvesoftware.com") ||
|
||||
shExpMatch(host, "*.valvesoftware.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Discord 全系
|
||||
if (shExpMatch(host, "discord.com") ||
|
||||
shExpMatch(host, "*.discord.com") ||
|
||||
shExpMatch(host, "discordapp.com") ||
|
||||
shExpMatch(host, "*.discordapp.com") ||
|
||||
shExpMatch(host, "discord.gg") ||
|
||||
shExpMatch(host, "*.discord.gg") ||
|
||||
shExpMatch(host, "discord.media") ||
|
||||
shExpMatch(host, "*.discord.media") ||
|
||||
shExpMatch(host, "discordapp.net") ||
|
||||
shExpMatch(host, "*.discordapp.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Telegram 全系
|
||||
if (shExpMatch(host, "telegram.org") ||
|
||||
shExpMatch(host, "*.telegram.org") ||
|
||||
shExpMatch(host, "t.me") ||
|
||||
shExpMatch(host, "*.t.me") ||
|
||||
shExpMatch(host, "telega.one") ||
|
||||
shExpMatch(host, "*.telega.one") ||
|
||||
shExpMatch(host, "telegram-cdn.org") ||
|
||||
shExpMatch(host, "*.telegram-cdn.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Reddit 全系
|
||||
if (shExpMatch(host, "reddit.com") ||
|
||||
shExpMatch(host, "*.reddit.com") ||
|
||||
shExpMatch(host, "redd.it") ||
|
||||
shExpMatch(host, "*.redd.it") ||
|
||||
shExpMatch(host, "redditmedia.com") ||
|
||||
shExpMatch(host, "*.redditmedia.com") ||
|
||||
shExpMatch(host, "redditstatic.com") ||
|
||||
shExpMatch(host, "*.redditstatic.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// LINE 全系
|
||||
if (shExpMatch(host, "line.me") ||
|
||||
shExpMatch(host, "*.line.me") ||
|
||||
shExpMatch(host, "line-apps.com") ||
|
||||
shExpMatch(host, "*.line-apps.com") ||
|
||||
shExpMatch(host, "line-scdn.net") ||
|
||||
shExpMatch(host, "*.line-scdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Spotify 全系
|
||||
if (shExpMatch(host, "spotify.com") ||
|
||||
shExpMatch(host, "*.spotify.com") ||
|
||||
shExpMatch(host, "spotifycdn.com") ||
|
||||
shExpMatch(host, "*.spotifycdn.com") ||
|
||||
shExpMatch(host, "scdn.co") ||
|
||||
shExpMatch(host, "*.scdn.co")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Netflix 全系
|
||||
if (shExpMatch(host, "netflix.com") ||
|
||||
shExpMatch(host, "*.netflix.com") ||
|
||||
shExpMatch(host, "nflxvideo.net") ||
|
||||
shExpMatch(host, "*.nflxvideo.net") ||
|
||||
shExpMatch(host, "nflximg.net") ||
|
||||
shExpMatch(host, "*.nflximg.net") ||
|
||||
shExpMatch(host, "nflxext.com") ||
|
||||
shExpMatch(host, "*.nflxext.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Disney+ 全系
|
||||
if (shExpMatch(host, "disneyplus.com") ||
|
||||
shExpMatch(host, "*.disneyplus.com") ||
|
||||
shExpMatch(host, "disney-plus.net") ||
|
||||
shExpMatch(host, "*.disney-plus.net") ||
|
||||
shExpMatch(host, "dssott.com") ||
|
||||
shExpMatch(host, "*.dssott.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// TikTok 全系
|
||||
if (shExpMatch(host, "tiktok.com") ||
|
||||
shExpMatch(host, "*.tiktok.com") ||
|
||||
shExpMatch(host, "tiktokcdn.com") ||
|
||||
shExpMatch(host, "*.tiktokcdn.com") ||
|
||||
shExpMatch(host, "tiktokv.com") ||
|
||||
shExpMatch(host, "*.tiktokv.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// PlayStation 全系
|
||||
if (shExpMatch(host, "playstation.com") ||
|
||||
shExpMatch(host, "*.playstation.com") ||
|
||||
shExpMatch(host, "sonyentertainmentnetwork.com") ||
|
||||
shExpMatch(host, "*.sonyentertainmentnetwork.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Nintendo 全系
|
||||
if (shExpMatch(host, "nintendo.com") ||
|
||||
shExpMatch(host, "*.nintendo.com") ||
|
||||
shExpMatch(host, "nintendowifi.net") ||
|
||||
shExpMatch(host, "*.nintendowifi.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Epic Games 全系
|
||||
if (shExpMatch(host, "epicgames.com") ||
|
||||
shExpMatch(host, "*.epicgames.com") ||
|
||||
shExpMatch(host, "unrealengine.com") ||
|
||||
shExpMatch(host, "*.unrealengine.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// DuckDuckGo / Brave Search
|
||||
if (shExpMatch(host, "duckduckgo.com") ||
|
||||
shExpMatch(host, "*.duckduckgo.com") ||
|
||||
shExpMatch(host, "search.brave.com") ||
|
||||
shExpMatch(host, "*.search.brave.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Proton 全系
|
||||
if (shExpMatch(host, "proton.me") ||
|
||||
shExpMatch(host, "*.proton.me") ||
|
||||
shExpMatch(host, "protonmail.com") ||
|
||||
shExpMatch(host, "*.protonmail.com") ||
|
||||
shExpMatch(host, "protonvpn.com") ||
|
||||
shExpMatch(host, "*.protonvpn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Stack Overflow / Stack Exchange 全系
|
||||
if (shExpMatch(host, "stackoverflow.com") ||
|
||||
shExpMatch(host, "*.stackoverflow.com") ||
|
||||
shExpMatch(host, "stackexchange.com") ||
|
||||
shExpMatch(host, "*.stackexchange.com") ||
|
||||
shExpMatch(host, "superuser.com") ||
|
||||
shExpMatch(host, "*.superuser.com") ||
|
||||
shExpMatch(host, "serverfault.com") ||
|
||||
shExpMatch(host, "*.serverfault.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Quora / Medium
|
||||
if (shExpMatch(host, "quora.com") ||
|
||||
shExpMatch(host, "*.quora.com") ||
|
||||
shExpMatch(host, "medium.com") ||
|
||||
shExpMatch(host, "*.medium.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Pixiv 全系
|
||||
if (shExpMatch(host, "pixiv.net") ||
|
||||
shExpMatch(host, "*.pixiv.net") ||
|
||||
shExpMatch(host, "pximg.net") ||
|
||||
shExpMatch(host, "*.pximg.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// ニコニコ 全系
|
||||
if (shExpMatch(host, "nicovideo.jp") ||
|
||||
shExpMatch(host, "*.nicovideo.jp") ||
|
||||
shExpMatch(host, "nico.ms") ||
|
||||
shExpMatch(host, "*.nico.ms")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// EA / Origin / Apex 全系
|
||||
if (shExpMatch(host, "ea.com") ||
|
||||
shExpMatch(host, "*.ea.com") ||
|
||||
shExpMatch(host, "origin.com") ||
|
||||
shExpMatch(host, "*.origin.com") ||
|
||||
shExpMatch(host, "eaassets.com") ||
|
||||
shExpMatch(host, "*.eaassets.com") ||
|
||||
shExpMatch(host, "apexlegends.com") ||
|
||||
shExpMatch(host, "*.apexlegends.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Battle.net / Blizzard 全系
|
||||
if (shExpMatch(host, "battle.net") ||
|
||||
shExpMatch(host, "*.battle.net") ||
|
||||
shExpMatch(host, "blizzard.com") ||
|
||||
shExpMatch(host, "*.blizzard.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Ubisoft 全系
|
||||
if (shExpMatch(host, "ubisoft.com") ||
|
||||
shExpMatch(host, "*.ubisoft.com") ||
|
||||
shExpMatch(host, "ubi.com") ||
|
||||
shExpMatch(host, "*.ubi.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Riot Games / 英雄联盟 / Valorant 全系
|
||||
if (shExpMatch(host, "riotgames.com") ||
|
||||
shExpMatch(host, "*.riotgames.com") ||
|
||||
shExpMatch(host, "leagueoflegends.com") ||
|
||||
shExpMatch(host, "*.leagueoflegends.com") ||
|
||||
shExpMatch(host, "valorant.com") ||
|
||||
shExpMatch(host, "*.valorant.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GOG / itch.io 全系
|
||||
if (shExpMatch(host, "gog.com") ||
|
||||
shExpMatch(host, "*.gog.com") ||
|
||||
shExpMatch(host, "itch.io") ||
|
||||
shExpMatch(host, "*.itch.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Twitch 全系
|
||||
if (shExpMatch(host, "twitch.tv") ||
|
||||
shExpMatch(host, "*.twitch.tv") ||
|
||||
shExpMatch(host, "ttvnw.net") ||
|
||||
shExpMatch(host, "*.ttvnw.net") ||
|
||||
shExpMatch(host, "twitchcdn.net") ||
|
||||
shExpMatch(host, "*.twitchcdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Crunchyroll 全系
|
||||
if (shExpMatch(host, "crunchyroll.com") ||
|
||||
shExpMatch(host, "*.crunchyroll.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Amazon Prime Video 全系
|
||||
if (shExpMatch(host, "primevideo.com") ||
|
||||
shExpMatch(host, "*.primevideo.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Apple TV+ 全系
|
||||
if (shExpMatch(host, "tv.apple.com") ||
|
||||
shExpMatch(host, "tvplus.apple.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// HBO Max / Hulu / Paramount+ 全系
|
||||
if (shExpMatch(host, "max.com") ||
|
||||
shExpMatch(host, "*.max.com") ||
|
||||
shExpMatch(host, "hulu.com") ||
|
||||
shExpMatch(host, "*.hulu.com") ||
|
||||
shExpMatch(host, "paramountplus.com") ||
|
||||
shExpMatch(host, "*.paramountplus.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Slack 全系
|
||||
if (shExpMatch(host, "slack.com") ||
|
||||
shExpMatch(host, "*.slack.com") ||
|
||||
shExpMatch(host, "slack-edge.com") ||
|
||||
shExpMatch(host, "*.slack-edge.com") ||
|
||||
shExpMatch(host, "slack-imgs.com") ||
|
||||
shExpMatch(host, "*.slack-imgs.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Zoom 全系
|
||||
if (shExpMatch(host, "zoom.us") ||
|
||||
shExpMatch(host, "*.zoom.us") ||
|
||||
shExpMatch(host, "zoom.com") ||
|
||||
shExpMatch(host, "*.zoom.com") ||
|
||||
shExpMatch(host, "zoomgov.com") ||
|
||||
shExpMatch(host, "*.zoomgov.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Signal 全系
|
||||
if (shExpMatch(host, "signal.org") ||
|
||||
shExpMatch(host, "*.signal.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Docker 全系
|
||||
if (shExpMatch(host, "docker.com") ||
|
||||
shExpMatch(host, "*.docker.com") ||
|
||||
shExpMatch(host, "docker.io") ||
|
||||
shExpMatch(host, "*.docker.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GitLab 全系
|
||||
if (shExpMatch(host, "gitlab.com") ||
|
||||
shExpMatch(host, "*.gitlab.com") ||
|
||||
shExpMatch(host, "gitlab.io") ||
|
||||
shExpMatch(host, "*.gitlab.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Vercel 全系
|
||||
if (shExpMatch(host, "vercel.com") ||
|
||||
shExpMatch(host, "*.vercel.com") ||
|
||||
shExpMatch(host, "vercel.app") ||
|
||||
shExpMatch(host, "*.vercel.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Netlify 全系
|
||||
if (shExpMatch(host, "netlify.com") ||
|
||||
shExpMatch(host, "*.netlify.com") ||
|
||||
shExpMatch(host, "netlify.app") ||
|
||||
shExpMatch(host, "*.netlify.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Perplexity / Poe / Midjourney 全系
|
||||
if (shExpMatch(host, "perplexity.ai") ||
|
||||
shExpMatch(host, "*.perplexity.ai") ||
|
||||
shExpMatch(host, "poe.com") ||
|
||||
shExpMatch(host, "*.poe.com") ||
|
||||
shExpMatch(host, "midjourney.com") ||
|
||||
shExpMatch(host, "*.midjourney.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Figma / Canva 全系
|
||||
if (shExpMatch(host, "figma.com") ||
|
||||
shExpMatch(host, "*.figma.com") ||
|
||||
shExpMatch(host, "canva.com") ||
|
||||
shExpMatch(host, "*.canva.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// LinkedIn 全系
|
||||
if (shExpMatch(host, "linkedin.com") ||
|
||||
shExpMatch(host, "*.linkedin.com") ||
|
||||
shExpMatch(host, "licdn.com") ||
|
||||
shExpMatch(host, "*.licdn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Pinterest 全系
|
||||
if (shExpMatch(host, "pinterest.com") ||
|
||||
shExpMatch(host, "*.pinterest.com") ||
|
||||
shExpMatch(host, "pinimg.com") ||
|
||||
shExpMatch(host, "*.pinimg.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Tumblr 全系
|
||||
if (shExpMatch(host, "tumblr.com") ||
|
||||
shExpMatch(host, "*.tumblr.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Snapchat 全系
|
||||
if (shExpMatch(host, "snapchat.com") ||
|
||||
shExpMatch(host, "*.snapchat.com") ||
|
||||
shExpMatch(host, "sc-cdn.net") ||
|
||||
shExpMatch(host, "*.sc-cdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Wikipedia / Wikimedia 全系
|
||||
if (shExpMatch(host, "wikipedia.org") ||
|
||||
shExpMatch(host, "*.wikipedia.org") ||
|
||||
shExpMatch(host, "wikimedia.org") ||
|
||||
shExpMatch(host, "*.wikimedia.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// PayPal 全系
|
||||
if (shExpMatch(host, "paypal.com") ||
|
||||
shExpMatch(host, "*.paypal.com") ||
|
||||
shExpMatch(host, "paypalobjects.com") ||
|
||||
shExpMatch(host, "*.paypalobjects.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Patreon 全系
|
||||
if (shExpMatch(host, "patreon.com") ||
|
||||
shExpMatch(host, "*.patreon.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// 通用 CDN
|
||||
if (shExpMatch(host, "cloudfront.net") ||
|
||||
shExpMatch(host, "*.cloudfront.net") ||
|
||||
shExpMatch(host, "fastly.net") ||
|
||||
shExpMatch(host, "*.fastly.net") ||
|
||||
shExpMatch(host, "fastlylb.net") ||
|
||||
shExpMatch(host, "*.fastlylb.net") ||
|
||||
shExpMatch(host, "jsdelivr.net") ||
|
||||
shExpMatch(host, "*.jsdelivr.net") ||
|
||||
shExpMatch(host, "unpkg.com") ||
|
||||
shExpMatch(host, "*.unpkg.com") ||
|
||||
shExpMatch(host, "statically.io") ||
|
||||
shExpMatch(host, "*.statically.io") ||
|
||||
shExpMatch(host, "cloudinary.com") ||
|
||||
shExpMatch(host, "*.cloudinary.com") ||
|
||||
shExpMatch(host, "imgur.com") ||
|
||||
shExpMatch(host, "*.imgur.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// linux.do
|
||||
if (shExpMatch(host, "linux.do") ||
|
||||
shExpMatch(host, "*.linux.do")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
return "DIRECT";
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# /usr/local/bin/rrpp-stats — returns traffic stats as JSON
|
||||
|
||||
vnstat --json 2>/dev/null || echo '{"error":"vnstat not available"}'
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/bin/bash
|
||||
# sync-nginx-local-ips.sh
|
||||
# 自动维护 nginx 本机公网 IP 直连规则(/etc/nginx/local_ip_rules.conf):
|
||||
# 出站公网 IP(ipify)+ iam.aeolianchi.me 的 A 记录 = 本机当前公网 IP 集合。
|
||||
# 出站公网 IP(ipify)+ <节点域名> 的 A 记录 = 本机当前公网 IP 集合。
|
||||
# 公网 IP 变更时重写规则文件并 reload nginx(仅变更时 reload)。
|
||||
# 由 /etc/cron.d/sync-nginx-local-ips 每 5 分钟调用一次。
|
||||
#
|
||||
# 部署时把 DOMAIN 改为本节点域名。
|
||||
set -u
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
RULES=/etc/nginx/local_ip_rules.conf
|
||||
DOMAIN=iam.aeolianchi.me
|
||||
DOMAIN=<节点域名>
|
||||
|
||||
ips=""
|
||||
# 出站公网 IP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"log": {
|
||||
"level": "warn",
|
||||
"level": "info",
|
||||
"timestamp": true
|
||||
},
|
||||
"inbounds": [
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "vless",
|
||||
"tag": "vless-reality-in",
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": 8443,
|
||||
"listen_port": "<vless端口>",
|
||||
"users": [
|
||||
{
|
||||
"uuid": "<VLESS_UUID>",
|
||||
@@ -17,11 +17,11 @@
|
||||
],
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "www.apple.com",
|
||||
"server_name": "<伪装SNI域名>",
|
||||
"reality": {
|
||||
"enabled": true,
|
||||
"handshake": {
|
||||
"server": "www.apple.com",
|
||||
"server": "<伪装SNI域名>",
|
||||
"server_port": 443
|
||||
},
|
||||
"private_key": "<REALITY_PRIVATE_KEY>",
|
||||
@@ -30,6 +30,14 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shadowsocks",
|
||||
"tag": "ss-in",
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": "<ss端口>",
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<SS_PSK_BASE64_32字节>"
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=MTProto proxy (mtg) for Telegram
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <MTPROTO_SECRET>
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=nginx (RRPP proxy, compiled with ngx_http_proxy_connect_module)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
PIDFile=/var/run/nginx.pid
|
||||
ExecStartPre=/usr/local/nginx/sbin/nginx -t
|
||||
ExecStart=/usr/local/nginx/sbin/nginx
|
||||
ExecReload=/usr/local/nginx/sbin/nginx -s reload
|
||||
ExecStop=/usr/local/nginx/sbin/nginx -s quit
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=sing-box (VLESS+REALITY / Shadowsocks)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/sing-box -D /var/lib/sing-box -C /etc/sing-box run
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"vnstat_bin": "vnstat",
|
||||
"interface": "eth0",
|
||||
"data_dir": "/home/agent/data",
|
||||
"alert_state_path": "/home/agent/alert_state.json",
|
||||
"poll_interval_sec": 60,
|
||||
"schedule_interval_sec": 30,
|
||||
"http_host": "127.0.0.1",
|
||||
"http_port": 18080,
|
||||
"quota_bytes": 15000000000,
|
||||
"alert_thresholds_pct": [
|
||||
50,
|
||||
80,
|
||||
90,
|
||||
95,
|
||||
99,
|
||||
100
|
||||
],
|
||||
"effective_domain": "iam.aeolianchi.me",
|
||||
"log_level": "INFO",
|
||||
"smtp": {
|
||||
"host": "mail.rrrosmontis.icu",
|
||||
"port": 465,
|
||||
"user": "<SMTP_USER>",
|
||||
"password": "<SMTP_PASSWORD>",
|
||||
"from_name": "Royirin Rosmontis Private Proxy JP 流量监控",
|
||||
"from_addr": "<SMTP_FROM>",
|
||||
"to_addr": "<SMTP_TO>",
|
||||
"starttls": false,
|
||||
"skip_cert_verify": false,
|
||||
"timeout_sec": 30,
|
||||
"ssl": true
|
||||
},
|
||||
"current_ip": "<PUBLIC_IP>",
|
||||
"billing_cycle_start_day": 1,
|
||||
"azure_usage_enabled": true,
|
||||
"azure_usage_fetch_interval_sec": 21600,
|
||||
"azure_usage_cache_path": "/home/agent/data/azure_usage.json",
|
||||
"azure_meter_name_match": "Data Transfer Out",
|
||||
"azure_free_limit_gb": 15
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=Royirin Rosmontis Private Proxy JP Traffic Monitor
|
||||
After=network-online.target nginx.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/agent
|
||||
ExecStart=/usr/bin/python3 /home/agent/traffic_monitor.py --config /home/agent/config.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,666 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Royirin Rosmontis Private Proxy JP 流量监控(Azure 门户口径版)
|
||||
- 主计量口径:Azure 门户免费服务页 "Data Transfer Out (GB)"(Standard Data Transfer Out - Free,MGN 路由)
|
||||
—— 通过 VM 托管身份调用 Consumption Usage Details API 拉取,按自然月聚合,缓存至 data/azure_usage.json
|
||||
- 参考口径:vnstat -i eth0(实时原始出站,仅作参考展示)
|
||||
- 数据源(参考):vnstat -i <interface> --json m/d(eth0 为 bond 主接口,覆盖 nginx 443 与 sing-box 8443 全部流量)
|
||||
- 按 年/月 聚合成 JSON 存入 data/YYYY/YYYY-MM.json(弃用 SQLite)
|
||||
- HTTP 服务: GET /vpn/status/traffic 返回当月用量 JSON(Azure 口径为主)
|
||||
- 阈值告警: 达到配额 50/80/90/95/99/100% 时发邮件(每档每月只发一次,跳档补发;基于 Azure 口径)
|
||||
- 月报: 每月 1 日 00:05 起发送上个月报告(失败重试;基于 Azure 口径)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import smtplib
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
log = logging.getLogger('traffic_monitor')
|
||||
|
||||
GB = 1_000_000_000 # Azure 数据转移按十进制 GB 计费
|
||||
|
||||
|
||||
def utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def load_json(path, default):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
log.warning('read %s failed: %s; using default', path, e)
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path, obj):
|
||||
tmp = path + '.tmp'
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump(obj, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def gb_str(n_bytes):
|
||||
return f'{n_bytes / GB:.2f} GB'
|
||||
|
||||
|
||||
def month_struct(year, month):
|
||||
return {
|
||||
'year': int(year),
|
||||
'month': int(month),
|
||||
'rx_bytes': 0,
|
||||
'tx_bytes': 0,
|
||||
'days': [],
|
||||
'updated_at': utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class Monitor:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.lock = threading.RLock()
|
||||
self.months = {}
|
||||
self.alert_state = None
|
||||
|
||||
os.makedirs(self.config['data_dir'], exist_ok=True)
|
||||
self._load_all_months()
|
||||
self._load_alert_state()
|
||||
# Azure 计费用量(Usage Details API,VM 托管身份鉴权)
|
||||
self.azure_usage = None
|
||||
self._azure_last_fetch = 0.0
|
||||
cache_path = self.config.get('azure_usage_cache_path')
|
||||
if cache_path and os.path.exists(cache_path):
|
||||
self.azure_usage = load_json(cache_path, None)
|
||||
log.info('azure usage cache loaded: %s', self.azure_usage)
|
||||
|
||||
# ---------- 基础 ----------
|
||||
@property
|
||||
def _config_path(self):
|
||||
return os.environ.get('TRAFFIC_CONFIG', '/home/agent/config.json')
|
||||
|
||||
def _load_all_months(self):
|
||||
root = self.config['data_dir']
|
||||
if not os.path.isdir(root):
|
||||
return
|
||||
for year_dir in os.listdir(root):
|
||||
ypath = os.path.join(root, year_dir)
|
||||
if not os.path.isdir(ypath):
|
||||
continue
|
||||
for fn in os.listdir(ypath):
|
||||
if not fn.endswith('.json'):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(ypath, fn), 'r', encoding='utf-8') as f:
|
||||
m = json.load(f)
|
||||
mk = f"{m['year']:04d}-{m['month']:02d}"
|
||||
self.months[mk] = m
|
||||
except Exception as e:
|
||||
log.warning('load %s/%s failed: %s', ypath, fn, e)
|
||||
|
||||
def _load_alert_state(self):
|
||||
self.alert_state = load_json(
|
||||
self.config['alert_state_path'],
|
||||
{'alert_thresholds_sent': {}, 'monthly_reports_sent': []})
|
||||
self.alert_state.setdefault('alert_thresholds_sent', {})
|
||||
self.alert_state.setdefault('monthly_reports_sent', [])
|
||||
|
||||
def _save_alert_state(self):
|
||||
save_json(self.config['alert_state_path'], self.alert_state)
|
||||
|
||||
def _month_path(self, year, month):
|
||||
return os.path.join(self.config['data_dir'], str(year), f'{year:04d}-{month:02d}.json')
|
||||
|
||||
def _persist_month(self, mk):
|
||||
m = self.months.get(mk)
|
||||
if m is None:
|
||||
return
|
||||
path = self._month_path(m['year'], m['month'])
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
save_json(path, m)
|
||||
|
||||
# ---------- vnstat 读取 ----------
|
||||
def _vnstat(self, what):
|
||||
bin_path = self.config.get('vnstat_bin', 'vnstat')
|
||||
iface = self.config.get('interface', 'eth0')
|
||||
cmd = [bin_path, '-i', iface, '--json', what]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, timeout=20, check=True)
|
||||
return json.loads(out.stdout.decode('utf-8'))
|
||||
except FileNotFoundError:
|
||||
log.error('vnstat binary not found: %s', bin_path)
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error('vnstat --json %s timed out', what)
|
||||
return None
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError, OSError) as e:
|
||||
log.error('vnstat --json %s failed: %s', what, e)
|
||||
return None
|
||||
|
||||
def poll_vnstat(self):
|
||||
"""拉取 vnstat 月/日数据,更新并落盘。返回更新的月数。"""
|
||||
monthly = self._vnstat('m')
|
||||
daily = self._vnstat('d')
|
||||
touched = set()
|
||||
|
||||
with self.lock:
|
||||
if monthly:
|
||||
try:
|
||||
for itf in monthly.get('interfaces', []):
|
||||
for item in itf.get('traffic', {}).get('month', []):
|
||||
d = item.get('date', {})
|
||||
mk = f"{int(d['year']):04d}-{int(d['month']):02d}"
|
||||
m = self.months.get(mk)
|
||||
if m is None:
|
||||
m = month_struct(d['year'], d['month'])
|
||||
self.months[mk] = m
|
||||
m['rx_bytes'] = int(item.get('rx', 0))
|
||||
m['tx_bytes'] = int(item.get('tx', 0))
|
||||
m['updated_at'] = utcnow().isoformat()
|
||||
touched.add(mk)
|
||||
except Exception as e:
|
||||
log.exception('parse vnstat monthly failed: %s', e)
|
||||
|
||||
if daily:
|
||||
try:
|
||||
by_month = {}
|
||||
for itf in daily.get('interfaces', []):
|
||||
for item in itf.get('traffic', {}).get('day', []):
|
||||
d = item.get('date', {})
|
||||
mk = f"{int(d['year']):04d}-{int(d['month']):02d}"
|
||||
by_month.setdefault(mk, []).append({
|
||||
'day': int(d['day']),
|
||||
'rx_bytes': int(item.get('rx', 0)),
|
||||
'tx_bytes': int(item.get('tx', 0)),
|
||||
})
|
||||
for mk, days in by_month.items():
|
||||
m = self.months.get(mk)
|
||||
if m is None:
|
||||
y, mon = mk.split('-')
|
||||
m = month_struct(int(y), int(mon))
|
||||
self.months[mk] = m
|
||||
days.sort(key=lambda x: x['day'])
|
||||
m['days'] = days
|
||||
m['updated_at'] = utcnow().isoformat()
|
||||
touched.add(mk)
|
||||
except Exception as e:
|
||||
log.exception('parse vnstat daily failed: %s', e)
|
||||
|
||||
for mk in touched:
|
||||
self._persist_month(mk)
|
||||
return len(touched)
|
||||
|
||||
# ---------- 用量(按 Azure 计费周期) ----------
|
||||
def current_month_key(self):
|
||||
return utcnow().strftime('%Y-%m')
|
||||
|
||||
def cycle_start(self, now):
|
||||
"""返回当前计费周期的起始 datetime(每月 billing_cycle_start_day 00:00 UTC)。"""
|
||||
day = int(self.config.get('billing_cycle_start_day', 1))
|
||||
day = max(1, min(28, day))
|
||||
if now.day >= day:
|
||||
return now.replace(day=day, hour=0, minute=0, second=0, microsecond=0)
|
||||
prev = (now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
- timedelta(days=1))
|
||||
return prev.replace(day=day, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
def cycle_end(self, start):
|
||||
"""周期结束 = 下个月同一天的 00:00(不含)。"""
|
||||
nxt = (start.replace(day=1) + timedelta(days=32)).replace(day=1)
|
||||
return nxt.replace(day=start.day, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
def _day_totals(self):
|
||||
"""返回 [{date, rx, tx}],date 为 UTC date。"""
|
||||
out = []
|
||||
for m in self.months.values():
|
||||
for d in m.get('days', []):
|
||||
out.append((date(m['year'], m['month'], d['day']),
|
||||
int(d.get('rx_bytes', 0)), int(d.get('tx_bytes', 0))))
|
||||
return out
|
||||
|
||||
def cycle_usage(self):
|
||||
"""返回当前计费周期 {rx_bytes, tx_bytes, start, end}。"""
|
||||
now = utcnow()
|
||||
start = self.cycle_start(now)
|
||||
end = self.cycle_end(start)
|
||||
rx = tx = 0
|
||||
with self.lock:
|
||||
for dt, r, t in self._day_totals():
|
||||
if start.date() <= dt <= now.date():
|
||||
rx += r
|
||||
tx += t
|
||||
return {'rx_bytes': rx, 'tx_bytes': tx, 'start': start, 'end': end}
|
||||
|
||||
def tx_bytes_for(self, mk):
|
||||
with self.lock:
|
||||
m = self.months.get(mk, {})
|
||||
return int(m.get('tx_bytes', 0))
|
||||
|
||||
def current_status(self):
|
||||
"""当前状态:以 Azure 门户口径(Standard Data Transfer Out - Free)为主计量。"""
|
||||
now = utcnow()
|
||||
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
|
||||
cu = self.cycle_usage()
|
||||
az = self.azure_usage or {}
|
||||
used_gb = az.get('portal_data_transfer_out_gb')
|
||||
used_pct = round(used_gb / quota_gb * 100, 4) if (used_gb is not None and quota_gb) else None
|
||||
with self.lock:
|
||||
cal_mk = now.strftime('%Y-%m')
|
||||
m = self.months.get(cal_mk)
|
||||
cal_tx = int(m.get('tx_bytes', 0)) if m else 0
|
||||
cal_rx = int(m.get('rx_bytes', 0)) if m else 0
|
||||
vnstat_updated = (m or {}).get('updated_at')
|
||||
return {
|
||||
'year': now.year,
|
||||
'month': now.month,
|
||||
'meter': 'azure_portal',
|
||||
'used_gb': used_gb,
|
||||
'quota_gb': quota_gb,
|
||||
'quota_used_pct': used_pct,
|
||||
'billing_cycle_start_day': int(self.config.get('billing_cycle_start_day', 1)),
|
||||
'cycle_start': cu['start'].strftime('%Y-%m-%d'),
|
||||
'cycle_end': cu['end'].strftime('%Y-%m-%d'),
|
||||
'azure_portal_data_transfer_out_gb': az.get('portal_data_transfer_out_gb'),
|
||||
'azure_bandwidth_out_gb': az.get('bandwidth_out_gb'),
|
||||
'azure_usage_cost_cny': az.get('cost_cny'),
|
||||
'azure_usage_month': az.get('month'),
|
||||
'azure_usage_updated_at': az.get('updated_at'),
|
||||
'vnstat_calendar_month_tx_bytes': cal_tx,
|
||||
'vnstat_calendar_month_rx_bytes': cal_rx,
|
||||
'vnstat_updated_at': vnstat_updated,
|
||||
'updated_at': now.isoformat(),
|
||||
}
|
||||
|
||||
def _vnstat_calendar_tx_gb(self):
|
||||
"""参考值:vnstat 自然月出站(GB,十进制)。"""
|
||||
now = utcnow()
|
||||
with self.lock:
|
||||
m = self.months.get(now.strftime('%Y-%m'), {})
|
||||
return int(m.get('tx_bytes', 0)) / GB
|
||||
|
||||
# ---------- Azure 计费用量(Usage Details API) ----------
|
||||
def _azure_token(self):
|
||||
"""通过 VM 托管身份从 IMDS 获取 management.azure.com 的访问令牌。"""
|
||||
url = ('http://169.254.169.254/metadata/identity/oauth2/token'
|
||||
'?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F')
|
||||
req = urllib.request.Request(url, headers={'Metadata': 'true'})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode('utf-8'))['access_token']
|
||||
|
||||
def _azure_subscription_id(self):
|
||||
url = 'http://169.254.169.254/metadata/instance/compute?api-version=2021-02-01'
|
||||
req = urllib.request.Request(url, headers={'Metadata': 'true'})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode('utf-8')).get('subscriptionId')
|
||||
|
||||
def fetch_azure_usage(self, month_offset=0):
|
||||
"""拉取指定月份 usageDetails,聚合与门户免费服务页同源的 Data Transfer Out 用量。
|
||||
month_offset: 0=当月(写入缓存),-1=上月(用于月报,不覆盖缓存)。"""
|
||||
sub = self._azure_subscription_id()
|
||||
if not sub:
|
||||
raise RuntimeError('cannot read subscriptionId from IMDS')
|
||||
token = self._azure_token()
|
||||
now = utcnow()
|
||||
month_start = (now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
+ timedelta(days=month_offset * 32)).replace(day=1)
|
||||
nxt = (month_start.replace(day=1) + timedelta(days=32)).replace(day=1)
|
||||
flt = ("properties/usageStart ge '{0}T00:00:00Z' and "
|
||||
"properties/usageStart lt '{1}T00:00:00Z'").format(
|
||||
month_start.strftime('%Y-%m-%d'), nxt.strftime('%Y-%m-%d'))
|
||||
base = ('https://management.azure.com/subscriptions/{sub}/providers/'
|
||||
'Microsoft.Consumption/usageDetails?api-version=2023-11-01'
|
||||
'&metric=actualcost&$filter={f}')
|
||||
url = base.format(sub=sub, f=urllib.parse.quote(flt, safe=''))
|
||||
portal_gb = 0.0
|
||||
bw_out_gb = 0.0
|
||||
cost_cny = 0.0
|
||||
rows = 0
|
||||
pages = 0
|
||||
while url:
|
||||
req = urllib.request.Request(url, headers={'Authorization': 'Bearer ' + token})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
d = json.loads(resp.read().decode('utf-8'))
|
||||
for item in d.get('value', []):
|
||||
p = item.get('properties', {}) or {}
|
||||
qty = float(p.get('quantity') or 0)
|
||||
rows += 1
|
||||
name = str(p.get('meterName') or '')
|
||||
subcat = str(p.get('meterSubCategory') or '')
|
||||
if str(p.get('meterCategory') or '') == 'Bandwidth' and 'Data Transfer Out' in name:
|
||||
bw_out_gb += qty
|
||||
# 门户免费服务页 "Data Transfer Out (GB)" = MGN 路由的出站免费计费表
|
||||
if 'MGN' in subcat or '- Free' in name:
|
||||
portal_gb += qty
|
||||
cost_cny += float(p.get('costInBillingCurrency') or 0)
|
||||
pages += 1
|
||||
url = d.get('nextLink')
|
||||
if pages > 30:
|
||||
log.warning('azure usage pagination limit reached')
|
||||
break
|
||||
result = {
|
||||
'month': month_start.strftime('%Y-%m'),
|
||||
'portal_data_transfer_out_gb': round(portal_gb, 4),
|
||||
'bandwidth_out_gb': round(bw_out_gb, 4),
|
||||
'cost_cny': round(cost_cny, 4),
|
||||
'rows': rows,
|
||||
'updated_at': utcnow().isoformat(),
|
||||
}
|
||||
self.azure_usage = result
|
||||
if month_offset == 0:
|
||||
path = self.config.get('azure_usage_cache_path')
|
||||
if path:
|
||||
save_json(path, result)
|
||||
log.info('azure usage fetched (offset=%d): %s', month_offset, result)
|
||||
return result
|
||||
|
||||
def check_azure_usage(self):
|
||||
if not self.config.get('azure_usage_enabled', False):
|
||||
return
|
||||
interval = float(self.config.get('azure_usage_fetch_interval_sec', 21600))
|
||||
now = time.time()
|
||||
if now - self._azure_last_fetch < interval:
|
||||
return
|
||||
self._azure_last_fetch = now
|
||||
try:
|
||||
self.fetch_azure_usage()
|
||||
except Exception as e:
|
||||
log.warning('azure usage fetch failed: %s', e)
|
||||
|
||||
# ---------- IP / 邮件 ----------
|
||||
def get_current_ip(self):
|
||||
configured = self.config.get('current_ip')
|
||||
if configured:
|
||||
return str(configured)
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(('8.8.8.8', 80))
|
||||
return s.getsockname()[0]
|
||||
finally:
|
||||
s.close()
|
||||
except OSError:
|
||||
return '0.0.0.0'
|
||||
|
||||
def send_email(self, subject, body):
|
||||
cfg = self.config['smtp']
|
||||
msg = EmailMessage()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = formataddr((cfg.get('from_name', ''), cfg.get('from_addr')))
|
||||
msg['To'] = cfg.get('to_addr')
|
||||
msg.set_content(body)
|
||||
|
||||
context = None
|
||||
if cfg.get('skip_cert_verify'):
|
||||
context = ssl._create_unverified_context()
|
||||
host = cfg['host']
|
||||
port = int(cfg.get('port', 465))
|
||||
timeout = int(cfg.get('timeout_sec', 30))
|
||||
if cfg.get('ssl', False):
|
||||
smtp_cls = smtplib.SMTP_SSL
|
||||
kwargs = {'context': context}
|
||||
else:
|
||||
smtp_cls = smtplib.SMTP
|
||||
kwargs = {}
|
||||
with smtp_cls(host, port, timeout=timeout, **kwargs) as smtp:
|
||||
if not cfg.get('ssl', False):
|
||||
smtp.ehlo()
|
||||
if cfg.get('starttls', True):
|
||||
smtp.starttls(context=context)
|
||||
smtp.ehlo()
|
||||
smtp.login(cfg['user'], cfg['password'])
|
||||
smtp.send_message(msg)
|
||||
log.info('email sent: %s', subject)
|
||||
|
||||
def build_alert_mail(self, mk, used_gb, quota_gb, pct, az):
|
||||
ip = self.get_current_ip()
|
||||
month = (az or {}).get('month', mk.replace('azure:', ''))
|
||||
az_updated = (az or {}).get('updated_at', '')
|
||||
vnstat_tx = self._vnstat_calendar_tx_gb()
|
||||
body = (
|
||||
'Royirin Rosmontis Private Proxy JP 流量监控\n'
|
||||
'==========\n'
|
||||
'计量口径:Azure 门户(Standard Data Transfer Out - Free)\n'
|
||||
f'统计月份:{month}(自然月)\n'
|
||||
f'当前用量:{used_gb:.2f} GB / {quota_gb:.0f} GB({pct:.2f}%)\n'
|
||||
f'AZURE 数据更新:{az_updated}\n'
|
||||
f'参考(vnstat 自然月出站):{vnstat_tx:.2f} GB\n'
|
||||
f'当前 IP:{ip}\n'
|
||||
f'生效域名:{self.config.get("effective_domain", "")}\n'
|
||||
'==========\n'
|
||||
f'已达阈值 {pct:.2f}% (Azure 门户口径),请注意使用情况'
|
||||
)
|
||||
subject = f'[流量告警] {month} 已用 {pct:.2f}% ({used_gb:.2f}/{quota_gb:.0f} GB)'
|
||||
return subject, body
|
||||
|
||||
def build_monthly_mail(self, month, used_gb, quota_gb, pct, az):
|
||||
ip = self.get_current_ip()
|
||||
over = '是' if used_gb > quota_gb else '否'
|
||||
az_updated = (az or {}).get('updated_at', '')
|
||||
vnstat_tx = self._vnstat_calendar_tx_gb()
|
||||
body = (
|
||||
'Royirin Rosmontis Private Proxy JP 流量监控\n'
|
||||
'月度报告\n'
|
||||
'==========\n'
|
||||
'计量口径:Azure 门户(Standard Data Transfer Out - Free)\n'
|
||||
f'报告月份:{month}(自然月)\n'
|
||||
f'当月用量:{used_gb:.2f} GB / {quota_gb:.0f} GB({pct:.2f}%)\n'
|
||||
f'AZURE 数据更新:{az_updated}\n'
|
||||
f'参考(vnstat 自然月出站):{vnstat_tx:.2f} GB\n'
|
||||
f'当前 IP:{ip}\n'
|
||||
f'生效域名:{self.config.get("effective_domain", "")}\n'
|
||||
'==========\n'
|
||||
f'上月使用 {over} 超限, 使用量为限额的 {pct:.2f}%'
|
||||
)
|
||||
subject = f'[流量月报] {month} 用量 {used_gb:.2f} GB ({pct:.2f}%)'
|
||||
return subject, body
|
||||
|
||||
# ---------- 定时任务 ----------
|
||||
def check_alerts(self):
|
||||
"""阈值告警:以 Azure 门户口径为准(无 Azure 数据时跳过,避免误报)。"""
|
||||
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
|
||||
if quota_gb <= 0:
|
||||
return
|
||||
az = self.azure_usage or {}
|
||||
used_gb = az.get('portal_data_transfer_out_gb')
|
||||
if used_gb is None:
|
||||
log.warning('alerts skipped: no azure usage data yet')
|
||||
return
|
||||
mk = 'azure:' + str(az.get('month') or utcnow().strftime('%Y-%m'))
|
||||
pct = used_gb / quota_gb * 100
|
||||
sent = self.alert_state['alert_thresholds_sent'].setdefault(mk, [])
|
||||
if not sent:
|
||||
# 新周期/口径切换:已越过的档位直接标记,避免切换瞬间补发一堆邮件
|
||||
for t in sorted(self.config.get('alert_thresholds_pct', [])):
|
||||
if pct >= t:
|
||||
sent.append(t)
|
||||
self._save_alert_state()
|
||||
log.info('seeded thresholds for %s at %.2f%%: %s', mk, pct, sent)
|
||||
for t in sorted(self.config.get('alert_thresholds_pct', [])):
|
||||
if pct >= t and t not in sent:
|
||||
subject, body = self.build_alert_mail(mk, used_gb, quota_gb, pct, az)
|
||||
try:
|
||||
self.send_email(subject, body)
|
||||
sent.append(t)
|
||||
self._save_alert_state()
|
||||
log.info('threshold %s%% alert sent for %s (used %.2f%%)', t, mk, pct)
|
||||
except Exception as e:
|
||||
log.error('send threshold %s%% alert failed for %s: %s; will retry',
|
||||
t, mk, e)
|
||||
break
|
||||
|
||||
def check_monthly_report(self):
|
||||
"""月度报告:以 Azure 门户口径为准(上月数据,当月 1 号后发送)。"""
|
||||
now = utcnow()
|
||||
cur_start = self.cycle_start(now)
|
||||
prev_start = (cur_start.replace(day=1) - timedelta(days=1)).replace(day=1)
|
||||
prev_mk = 'azure:' + prev_start.strftime('%Y-%m')
|
||||
if prev_mk in self.alert_state['monthly_reports_sent']:
|
||||
return
|
||||
# 口径切换迁移:旧键(cycle:)已发过则直接标记,避免重复发信
|
||||
old_mk = 'cycle:' + prev_start.strftime('%Y-%m')
|
||||
if old_mk in self.alert_state['monthly_reports_sent']:
|
||||
self.alert_state['monthly_reports_sent'].append(prev_mk)
|
||||
self._save_alert_state()
|
||||
log.info('monthly report key migrated: %s already sent as %s', prev_mk, old_mk)
|
||||
return
|
||||
try:
|
||||
az_prev = self.fetch_azure_usage(month_offset=-1)
|
||||
except Exception as e:
|
||||
log.error('fetch prev month azure usage failed: %s; will retry', e)
|
||||
return
|
||||
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
|
||||
used_gb = az_prev.get('portal_data_transfer_out_gb') or 0.0
|
||||
pct = used_gb / quota_gb * 100 if quota_gb else 0.0
|
||||
subject, body = self.build_monthly_mail(
|
||||
az_prev.get('month', prev_start.strftime('%Y-%m')), used_gb, quota_gb, pct, az_prev)
|
||||
try:
|
||||
self.send_email(subject, body)
|
||||
self.alert_state['monthly_reports_sent'].append(prev_mk)
|
||||
self._save_alert_state()
|
||||
log.info('monthly report sent for %s (used %.2f GB, %.2f%%)',
|
||||
prev_mk, used_gb, pct)
|
||||
except Exception as e:
|
||||
log.error('send monthly report for %s failed: %s; will retry', prev_mk, e)
|
||||
|
||||
|
||||
def build_http_handler(monitor):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == '/vpn/status/traffic':
|
||||
try:
|
||||
payload = json.dumps(monitor.current_status(), ensure_ascii=False)
|
||||
body = payload.encode('utf-8')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except Exception as e:
|
||||
log.exception('status endpoint error')
|
||||
body = b'{"error":"internal"}'
|
||||
self.send_response(500)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
elif self.path in ('/', '/healthz'):
|
||||
body = b'ok\n'
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/plain')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
body = b'{"error":"not found"}\n'
|
||||
self.send_response(404)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
log.debug('http: ' + fmt, *args)
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def run_service(config):
|
||||
monitor = Monitor(config)
|
||||
|
||||
def poll_loop():
|
||||
while True:
|
||||
try:
|
||||
n = monitor.poll_vnstat()
|
||||
log.debug('vnstat poll updated %d month(s)', n)
|
||||
except Exception:
|
||||
log.exception('poll loop error')
|
||||
time.sleep(int(config.get('poll_interval_sec', 60)))
|
||||
|
||||
def schedule_loop():
|
||||
while True:
|
||||
try:
|
||||
monitor.check_azure_usage() # 先刷新 Azure 口径数据,再评估告警
|
||||
monitor.check_alerts()
|
||||
monitor.check_monthly_report()
|
||||
except Exception:
|
||||
log.exception('schedule loop error')
|
||||
time.sleep(int(config.get('schedule_interval_sec', 30)))
|
||||
|
||||
threading.Thread(target=poll_loop, daemon=True, name='vnstat-poll').start()
|
||||
threading.Thread(target=schedule_loop, daemon=True, name='scheduler').start()
|
||||
|
||||
host = config.get('http_host', '127.0.0.1')
|
||||
port = int(config.get('http_port', 18080))
|
||||
server = ThreadingHTTPServer((host, port), build_http_handler(monitor))
|
||||
log.info('HTTP server listening on http://%s:%d/vpn/status/traffic', host, port)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='vnstat traffic monitor')
|
||||
ap.add_argument('--config', default=os.environ.get('TRAFFIC_CONFIG', '/home/agent/config.json'))
|
||||
ap.add_argument('--once', action='store_true', help='拉取一次 vnstat 后退出')
|
||||
ap.add_argument('--status', action='store_true', help='打印当前状态 JSON 后退出')
|
||||
ap.add_argument('--test-email', action='store_true', help='发送一封测试邮件后退出')
|
||||
args = ap.parse_args()
|
||||
|
||||
os.environ['TRAFFIC_CONFIG'] = args.config
|
||||
with open(args.config, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, config.get('log_level', 'INFO').upper(), logging.INFO),
|
||||
format='%(asctime)s %(levelname)s %(name)s: %(message)s')
|
||||
|
||||
if args.once:
|
||||
m = Monitor(config)
|
||||
n = m.poll_vnstat()
|
||||
print(f'updated {n} month(s)')
|
||||
return 0
|
||||
if args.status:
|
||||
m = Monitor(config)
|
||||
m.poll_vnstat()
|
||||
print(json.dumps(m.current_status(), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.test_email:
|
||||
m = Monitor(config)
|
||||
m.poll_vnstat()
|
||||
quota_gb = float(config.get('azure_free_limit_gb', 15))
|
||||
az = m.azure_usage or {}
|
||||
used_gb = az.get('portal_data_transfer_out_gb')
|
||||
if used_gb is None:
|
||||
try:
|
||||
m.fetch_azure_usage()
|
||||
az = m.azure_usage or {}
|
||||
used_gb = az.get('portal_data_transfer_out_gb')
|
||||
except Exception as e:
|
||||
print('azure usage fetch failed:', e)
|
||||
mk = 'azure:' + str(az.get('month') or utcnow().strftime('%Y-%m'))
|
||||
pct = used_gb / quota_gb * 100 if (used_gb is not None and quota_gb) else 0.0
|
||||
subject, body = m.build_alert_mail(mk, used_gb or 0.0, quota_gb, pct, az)
|
||||
print('sending test email:')
|
||||
print('Subject:', subject)
|
||||
print(body)
|
||||
m.send_email(subject, body)
|
||||
print('sent')
|
||||
return 0
|
||||
run_service(config)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user