Compare commits

...
Author SHA1 Message Date
RoyirinRosmontis 02d36c72f7 重构:模块化 RRPP Lite 并更新入口 2026-09-16 23:13:28 +08:00
RoyirinRosmontis c37d8af821 新增:PAC 明确直连域名空模板 2026-09-16 21:45:48 +08:00
RoyirinRosmontis cddd3afd18 Merge remote-tracking branch 'origin/main' 2026-09-16 19:13:38 +08:00
RoyirinRosmontis 0603224de2 修复:完善 PAC 分流与启动取消 2026-09-16 19:10:12 +08:00
RoyirinRosmontis 3ce5e57dae 更新 README.md 2026-09-16 19:09:37 +08:00
RoyirinRosmontis 9376e35025 修复:PAC 规则解析与关键域名分流 2026-09-16 17:46:54 +08:00
RRRosmontis 110a526fc6 server_config: 同步伪装页为线上实际版本(md5 4eb73450)
仓库内 fake-site/index.html 为旧版(md5 e0f5cbcb),与四个生产节点实际
服务的版本不一致。已用线上(us 节点)实际抓取版本替换,恢复所有节点
2026-09-11 12:53:56 +08:00
RoyirinRosmontis 9b7b817cb7 更新 README.md 2026-09-11 02:06:47 +08:00
RoyirinRosmontis a39647d46f 更新 README.md 2026-09-11 02:06:21 +08:00
RoyirinRosmontis be02d1b17f 更新 README.md 2026-09-11 02:01:47 +08:00
RoyirinRosmontis 53c36e143f 更新 README.md 2026-09-11 02:01:30 +08:00
RoyirinRosmontis 23ba368737 修复:- 系统代理设置保存/恢复:覆盖前备份原 ProxyEnable/ProxyServer/ProxyOverride/AutoConfigURL(内存+settings.json 防崩溃丢失),关闭/退出时恢复用户原有代理而不是清空;启动残留清理只认本程序精确地址(127.0.0.1:1080 精确匹配 + PAC URL 正则),不再误删其他代理软件(如 Clash)在 127.0.0.1 上的 PAC
- CONNECT 目标解析支持 IPv6 字面量:剥掉方括号再拆 host:port,[::1]:443 不再解析成 "[::1]"(直连/白名单/内网绕过判定失效的根因)
- 更新包校验加固:release 附件按 RRPP_Lite_<tag>.exe 名字精确匹配(不再固定取第一个);下载后与 API 记录的 size 比对;发布流程附带 .sha256 边车时校验 SHA-256
- 上游代理认证注入按完整头名精确匹配:Proxy-Authorization 不再误判为已有 Authorization;注入位置紧跟请求行保证上游读到我们的认证
- 远程同步后节点按 settings 记录的域名重匹配:服务端调整节点顺序时不再静默连到别的节点;启动校验等待订阅同步完成(最多 15 秒)后再执行
- DNS 分流判定缓存加 300 秒 TTL:域名换 IP 后不再长期沿用旧的直连/隧道判断(_is_private_ip/_is_china_ip/_is_whitelisted 换 _ttl_cache,保留 cache_clear 兼容)
- is_proxy_on 状态判定同步收紧为本程序 PAC 地址(其他软件的 PAC 不再让状态灯误亮)
2026-09-11 01:56:04 +08:00
RoyirinRosmontis cb3552f595 修复:- 上次选中的节点在本机已不可用(如 IPv6 专用节点但本机无 IPv6 出口)时启动不再停留其上:新增 _verify_selected_node/_first_reachable_index,启动后台校验并自动回退到第一个可达节点、写入设置、刷新标题栏(原来只在点开启代理时才报错) 2026-09-10 21:18:25 +08:00
RoyirinRosmontis 2a20215d9e 修复:- 节点菜单 IPv6 专用节点不可用提示改为直接写在菜单项上([不可用:本机无 IPv6]),原来只挂在 tooltip 上不明显 2026-09-10 21:16:52 +08:00
RoyirinRosmontis c3286593a6 修复:
- IPv6 专用节点识别不到:服务端 subscription?method=rrpp_client 吐的是旧扁平格式,ipv6_only 字段被后端丢掉、只在 remark 里留 "(IPv6)"。判定改为三级:ipv6_only 字段 -> remark 含 IPv6 -> 域名解析兜底(解析结果只有 IPv6 无 IPv4)
- 可达性探测改用该节点自己的 HTTP 端口(原来固定按 443 探测),IPv6 地址直接取自解析结果,避免端口不对造成误判
- 本机无全局 IPv6 地址时判定不可达(菜单置灰,提示「不可用:本机无 IPv6」),有 IPv6 时只标注不禁用
- 节点可达性判定不再打印日志
2026-09-10 21:14:59 +08:00
RoyirinRosmontis 9e766d8132 新增功能:
- 节点菜单标注 IPv6 专用节点(nodes.json 的 ipv6_only 字段):本机有 IPv6 出口时标 [IPv6],没有时标 [IPv6 专用 · 本机不可用] 并置灰不可选(带原因 tooltip),避免选中后连不上
- 新增 _local_has_ipv6()(查本机全局 IPv6 地址)/_node_reachable()(ipv6_only 节点真实探测 IPv6 连通性,按域名缓存)/_node_ipv6_only();_reload_nodes() 时清空可达性缓存
- 开启代理时若当前节点 IPv6 专用且本机无 IPv6 出口,直接报错提示切换节点(不再静默失败断网)
- 节点菜单构建拆出 _build_node_menu() 便于离屏测试
2026-09-10 21:01:02 +08:00
RoyirinRosmontis 259a1ae41f 修复:
- 节点 HTTP 代理参数读取不到真实字段导致认证恒为 401:新增 _http_method(),_endpoint_port() 与 _proxy_auth_basic() 改为优先读取新版 nodes.json 的 methods.http.port/username/password,缺失时回退节点顶层键(兼容旧扁平结构)
- 实测原逻辑下所有节点均返回 401 Authorization Required(认证串为空,US 节点端口也从 1205 落到默认 443)
2026-09-10 20:56:12 +08:00
RRRosmontis bcb113e8c9 移除误提交的 __pycache__,补 .gitignore 2026-09-10 10:52:48 +08:00
RRRosmontis 6d204c77fb server_config: SS 升级 2022-blake3 + http 独立凭据 + fail2ban 基线
- SS 方法 aes-256-gcm → 2022-blake3-aes-256-gcm(SIP022 抗重放探测),
  PSK 为 base64(32字节) 格式;模板 config.json 同步更新
- nginx CONNECT Basic Auth 改为每节点独立密码;补充无 apache2-utils 时
  用 openssl passwd -6 的替代写法;注明 htpasswd 实时读取无需 reload
- 新增 2.5 节 fail2ban sshd 基线(含 systemd backend 与 LXC 注意事项)
- 验证清单加 fail2ban 检查项

落实状态:jp/us SS 已升级并验证;四节点 http 独立凭据已生效并验证;
fail2ban 已在 jp/us/ru/aq 全部运行(sshd jail)。
2026-09-10 10:52:35 +08:00
RRRosmontis 63837eb9d8 server_config: 密钥体系改为每节点独立(VLESS UUID/REALITY 密钥对/short_id、SS 密码)
2026-09-10 起 jp/us/ru/aq 四节点已落实独立密钥;SNI 仍全节点统一。
旧「全套共用一套」表述同步修正(0 节/6.2/6.3)。
2026-09-10 10:12:31 +08:00
RRRosmontis efa7e25f66 server_config: 新增 PMTUD 黑洞缓解(tcp_mtu_probing)与 REALITY 公钥推导验证要求
- 2.4 节:客户端路径 PMTUD 黑洞导致「TCP 通但 TLS 握手稳定超时」,
  节点开启 net.ipv4.tcp_mtu_probing=1(本次已在 us/ru/aq 落实,jp 原有)
- 6.3 节:public_key 必须由 private_key 做 X25519 推导逐字符验证,禁止手抄
  (手抄错 1 字符曾致全节点 REALITY 握手失败)
- 验证清单补充 mtu_probing 检查项
2026-09-10 09:49:19 +08:00
RRRosmontis 20d2532e8f server_config: 重写为完整部署规范(BBR/nginx CONNECT/sing-box/mtg)
- 新增完整 README.md 部署规范:架构、端口规划、BBR 跨境优化、
  nginx 源码编译+CONNECT 隧道、sing-box(VLESS+REALITY/SS)、mtg(MTProto)、
  本机 IP 自动识别、systemd、启动顺序与验证清单
- nginx/nginx.conf:占位符版,proxy_connect_allow all + 回环保护
- sing-box/config.json:占位符版,VLESS+REALITY + SS
- systemd/:nginx/sing-box/mtg 服务文件
- scripts/sync-nginx-local-ips.sh:去除真实域名,占位符版
- fake-site/index.html:伪装页(清除真实品牌域名)
- 移除过时:traffic-monitor/、pac/、conf.d 具体站点配置
- 所有敏感值(IP/域名/端口/密钥)均以 <占位符> 表示
2026-09-10 00:22:03 +08:00
RoyirinRosmontis 17cdb276ea 修复: - 节点认证方式升级为 SSO:user/password 按 URL-safe Base64 作为查询参数拼接在 config.json 的 nodes_url 后(旧 nginx auth_basic 已废弃),服务端返回的 401/403/429 等中文原因直接透出到登录框,登录框输入占位改为 SSO 邮箱 2026-09-03 22:40:50 +08:00
55 changed files with 4573 additions and 3250 deletions
+13
View File
@@ -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
+10 -4
View File
@@ -1,11 +1,17 @@
# Royirin Rosmontis Private Proxy
Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu
## 警告 ## 警告
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本 - 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新 > common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows .exe - 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows 应用 (.exe)
### 如何区分 common 版本与 Lite 版本
## 如何区分 common 版本与 Lite 版本
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳 - 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
+148
View File
@@ -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 基线;保留失败现场的测试输出以便定位。不得通过覆盖用户的运行配置、订阅或构建目录来“修复”回归。
+85
View File
@@ -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,
)
+19
View File
@@ -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
+42
View File
@@ -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
+12
View File
@@ -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
+12
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

File diff suppressed because it is too large Load Diff
+126
View File
@@ -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()
+6
View File
@@ -0,0 +1,6 @@
{
"version": "",
"date": "",
"description": "PAC 智能分流的明确直连域名模板。强制代理规则和用户白名单优先级更高。",
"domains": []
}
+1
View File
@@ -0,0 +1 @@
"""RRPP Lite 的无 GUI 核心逻辑。"""
+91
View File
@@ -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
+82
View File
@@ -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
+36
View File
@@ -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
+95
View File
@@ -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
+53
View File
@@ -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)
+81
View File
@@ -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
+34
View File
@@ -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)
+55
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
"""RRPP Lite 的 Qt 界面层。
核心代理与 PAC 逻辑不得依赖本包,以便后续在无 GUI 环境中独立测试。
"""
+468
View File
@@ -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()
+11
View File
@@ -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"
+104
View File
@@ -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);
}}
""")
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
call ..\.venv\Scripts\python.exe main.py
pause
+10
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
class QFont: pass
class QIcon: pass
class QPixmap: pass
class QImage: pass
class QColor: pass
class QPainter: pass
+28
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# PyQt5 桩模块:仅供 WSL 下 import main 做路由逻辑测试,
# 决策函数(_decide_direct 等)不依赖任何 Qt 对象。
+127
View File
@@ -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")
+21
View File
@@ -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 测试节点"
}
]
+151
View File
@@ -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()
+114
View File
@@ -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)
+739
View File
@@ -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
View File
@@ -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/ server_config/
├── README.md ← 本文档
├── nginx/ ├── nginx/
│ ├── nginx.conf │ ├── nginx.conf ← 完整 nginx 配置(占位符版)
│ ├── conf.d/ │ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
│ │ ├── api.aeolianchi.me.conf │ └── conf.d/ ← 预留:额外站点配置
│ │ └── autodiscover.aeolianchi.me.conf
│ └── local_ip_rules.conf.example
├── sing-box/ ├── sing-box/
│ └── config.json │ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
├── traffic-monitor/ ├── systemd/
│ ├── config.json │ ├── nginx.service
│ ├── traffic_monitor.py │ ├── sing-box.service
│ └── traffic-monitor.service │ └── mtg.service
├── scripts/ ├── scripts/
│ ├── sync-nginx-local-ips.sh │ └── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
│ └── rrpp-stats ├── cron/
├── pac/ │ └── sync-nginx-local-ips ← cron 定义(每 5 分钟)
│ ├── proxy.pac └── fake-site/
│ └── proxy_wg.pac └── index.html ← 伪装站点页面(所有节点共用同一份)
└── cron/
├── crontab.txt
└── sync-nginx-local-ips
``` ```
> 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。
-2
View File
@@ -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 维护) # nginx 本机公网 IP 直连规则(自动生成,勿手改;由 sync-nginx-local-ips.sh 维护)
# 公网 IP 变更后本文件会被重写并 reload nginx # 公网 IP 变更后本文件会被重写并 reload nginx
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; } # 每条规则对应一个本机公网 IP(出站 IP + 节点域名 A 记录)
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; } if ($host = "<PUBLIC_IP_1>") { rewrite ^(.*)$ /__local$1 last; }
if ($host = "<PUBLIC_IP_2>") { rewrite ^(.*)$ /__local$1 last; }
+12 -43
View File
@@ -1,4 +1,3 @@
load_module /usr/lib/nginx/modules/ngx_http_proxy_connect_module.so;
worker_processes auto; worker_processes auto;
worker_rlimit_nofile 65535; worker_rlimit_nofile 65535;
events { events {
@@ -25,45 +24,15 @@ http {
keepalive_timeout 65; keepalive_timeout 65;
keepalive_requests 100; keepalive_requests 100;
# 80 端口:iam.aeolianchi.me 强制跳转到 HTTPS(保留 ACME 验证路径) # <nginx端口>:主域名 HTTPS 站点 + 认证 CONNECT 代理
server { server {
listen 80; listen <nginx端口> ssl;
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;
}
}
server {
listen 443 ssl;
http2 on; http2 on;
server_name XXX; server_name <节点域名>;
root /var/www/html; root /var/www/html;
ssl_certificate /etc/nginx/ssl/XXX/fullchain.pem; ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/XXX/privkey.pem; ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5; ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m; ssl_session_cache shared:SSL:10m;
@@ -74,14 +43,14 @@ http {
auth_basic_user_file /etc/nginx/.htpasswd; auth_basic_user_file /etc/nginx/.htpasswd;
proxy_connect; proxy_connect;
proxy_connect_allow 443 563; proxy_connect_allow all;
proxy_connect_connect_timeout 60s; proxy_connect_connect_timeout 60s;
proxy_connect_read_timeout 120s; proxy_connect_read_timeout 120s;
proxy_connect_send_timeout 120s; proxy_connect_send_timeout 120s;
location / { location / {
auth_basic off; auth_basic off;
if ($host != "iam.aeolianchi.me") { if ($host != "<节点域名>") {
rewrite ^ /__proxy last; rewrite ^ /__proxy last;
} }
index index.html; index index.html;
@@ -104,14 +73,14 @@ http {
} }
} }
# 443 端口 fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页 # <nginx端口> fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
server { server {
listen 443 ssl default_server; listen <nginx端口> ssl default_server;
http2 on; http2 on;
server_name _; server_name _;
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem; ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/iam.aeolianchi.me/privkey.pem; ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5; ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m; ssl_session_cache shared:SSL:10m;
@@ -122,7 +91,7 @@ http {
auth_basic_user_file /etc/nginx/.htpasswd; auth_basic_user_file /etc/nginx/.htpasswd;
proxy_connect; proxy_connect;
proxy_connect_allow 443 563; proxy_connect_allow all;
proxy_connect_connect_timeout 60s; proxy_connect_connect_timeout 60s;
proxy_connect_read_timeout 120s; proxy_connect_read_timeout 120s;
proxy_connect_send_timeout 120s; proxy_connect_send_timeout 120s;
-647
View File
@@ -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";
}
-648
View File
@@ -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";
}
-4
View File
@@ -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 #!/bin/bash
# sync-nginx-local-ips.sh # sync-nginx-local-ips.sh
# 自动维护 nginx 本机公网 IP 直连规则(/etc/nginx/local_ip_rules.conf): # 自动维护 nginx 本机公网 IP 直连规则(/etc/nginx/local_ip_rules.conf):
# 出站公网 IP(ipify)+ iam.aeolianchi.me 的 A 记录 = 本机当前公网 IP 集合。 # 出站公网 IP(ipify)+ <节点域名> 的 A 记录 = 本机当前公网 IP 集合。
# 公网 IP 变更时重写规则文件并 reload nginx(仅变更时 reload)。 # 公网 IP 变更时重写规则文件并 reload nginx(仅变更时 reload)。
# 由 /etc/cron.d/sync-nginx-local-ips 每 5 分钟调用一次。 # 由 /etc/cron.d/sync-nginx-local-ips 每 5 分钟调用一次。
#
# 部署时把 DOMAIN 改为本节点域名。
set -u set -u
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RULES=/etc/nginx/local_ip_rules.conf RULES=/etc/nginx/local_ip_rules.conf
DOMAIN=iam.aeolianchi.me DOMAIN=<节点域名>
ips="" ips=""
# 出站公网 IP # 出站公网 IP
+12 -4
View File
@@ -1,6 +1,6 @@
{ {
"log": { "log": {
"level": "warn", "level": "info",
"timestamp": true "timestamp": true
}, },
"inbounds": [ "inbounds": [
@@ -8,7 +8,7 @@
"type": "vless", "type": "vless",
"tag": "vless-reality-in", "tag": "vless-reality-in",
"listen": "0.0.0.0", "listen": "0.0.0.0",
"listen_port": 8443, "listen_port": "<vless端口>",
"users": [ "users": [
{ {
"uuid": "<VLESS_UUID>", "uuid": "<VLESS_UUID>",
@@ -17,11 +17,11 @@
], ],
"tls": { "tls": {
"enabled": true, "enabled": true,
"server_name": "www.apple.com", "server_name": "<伪装SNI域名>",
"reality": { "reality": {
"enabled": true, "enabled": true,
"handshake": { "handshake": {
"server": "www.apple.com", "server": "<伪装SNI域名>",
"server_port": 443 "server_port": 443
}, },
"private_key": "<REALITY_PRIVATE_KEY>", "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": [ "outbounds": [
+12
View File
@@ -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
+16
View File
@@ -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
+12
View File
@@ -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
-41
View File
@@ -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())