Compare commits

...
39 Commits
Author SHA1 Message Date
RoyirinRosmontis a563cf30f3 docs: nodes.json 位置改回后端目录(与 app.py 同级),同步 API.md 2026-10-09 12:46:08 +08:00
RoyirinRosmontisandRRRosmontis 813e54c919 server_config: §6.2 记录纯 IPv4 节点的 domain_resolver 必修项(jp 案例)
- jp(Azure 无 IPv6)双栈域名曾拨 AAAA 导致 network is unreachable 刷屏;修复采用 1.12+ 新写法
  dns.servers(local) + outbound.domain_resolver(strategy=ipv4_only)
- 明确 legacy domain_strategy 字段在 sing-box 1.13 已 FATAL 拒绝,禁止使用
- 注明 ru/hk2/us/aq 经实测无此问题,无需配置
2026-10-01 23:14:51 +08:00
RoyirinRosmontis 6f2e640881 更新 README.md 2026-09-23 14:49:13 +08:00
RoyirinRosmontis fa45751d33 更新 README.md 2026-09-23 14:48:19 +08:00
RoyirinRosmontis b44d50ee4d server_config: UDP 传输回滚(抗封优先),§6.4 转为留档
- 五节点撤除 Hysteria2/TUIC 入站,sing-box 恢复 vless+ss 原状(UDP 入站不再监听)
- §2.6 UDP 策略定稿:不部署 UDP 传输服务,UDP 需求走 UoT;保留 UDP 系统调优项
- 记录撤除理由(非标 UDP 端口封禁代价近零、CN 侧 QoS、IP 被烧会连带打死 TCP 服务);明确加密性无问题、风险在可封禁面
- §6.4 标注「已回滚,本节为留档」,保留配置片段/各节点 NAT·NSG 差异/端到端验证方法
- §10 验证清单中的 UDP 项标注仅适用于重新启用场景
2026-09-20 22:12:42 +08:00
RoyirinRosmontis 1cc4da8318 server_config: 如实记录 UDP 接入现状(服务端已部署、订阅不下发、无 fallback 组)
- §6.4 新增「接入现状」表:订阅无 hy2/tuic、代理组仅 select、lite 客户端仅 HTTP CONNECT+UoT、UDP 目前只能手工导入
- 记录日后接入订阅所需三步(nodes.json 字段 / app.py 生成 / fallback 组 REALITY 优先)及 fallback 非瞬时切换的注意点
2026-09-20 22:10:20 +08:00
RoyirinRosmontis 8809e40ec1 server_config: 启用 UDP 传输(Hysteria2/TUIC)+ UDP 系统调优,如实记录 KVM/LXC/NAT/NSG 差异
- README 新增 §6.4:Hysteria2/TUIC 配置片段、带宽声明与 Brutal 取舍、客户端链接格式、端到端验证方法
- 如实记录各节点差异(2026-09-20 实测):
  * hk2 NAT 段 4301-4320 UDP 1:1
  * ru(LXC NAT)外部只有宿主映射的 UDP 端口可达(23336+ 实测 connection refused),最终复用与 TCP 同号的 23333/23334
  * jp(Azure)NSG 仅放行 UDP 8445 → 单节点只能承载一个 UDP 协议,保留 TUIC
  * aq 为 IPv6-only,listen 用 ::
- §2.6 UDP 策略修订:由「不部署 UDP」改为「已启用 UDP 传输,同时保留 UoT」
- sysctl 模板补 udp_rmem_min/udp_wmem_min;rrpp-qdisc.sh 增加 rx-udp-gro-forwarding / rx-gro-list(aq 内核 5.10 不支持前者,LXC 无法设置)
- §10 验证清单补 UDP 项,并强调「只看监听不足以证明外部可达」
2026-09-20 22:05:57 +08:00
RoyirinRosmontis 375153b810 server_config: 系统级网络优化规范(KVM/LXC 差异 + qdisc 持久化 + sysctl.conf 覆盖坑)
- 新增 sysctl/99-zz-rrpp-network.{kvm,lxc}.conf 两套模板(99-zz- 前缀保证最后生效)
- 新增 systemd/rrpp-qdisc.service + scripts/rrpp-qdisc.sh(default_qdisc 只对新建网卡生效,需显式 replace)
- README §2.6:如实记录实测差异
  * KVM/完整VM:rmem/wmem、netdev_max_backlog、default_qdisc、qdisc 均可写
  * LXC/OpenVZ:上述项缺失或只读、eth0 为 noqueue、/usr/lib/sysctl.d 的 pid_max/fs.protected_* 报 permission denied(正常)
  * 坑:sysctl --system 会在 sysctl.d 之后加载 /etc/sysctl.conf,实测某节点旧文件把 tcp_mtu_probing 覆盖回 0
- 记录 UDP 策略:节点不部署 UDP 传输,UDP 需求统一走 UoT v2(§5.5)
- 更新 §9 服务表、§10 验证清单、§11 目录索引
2026-09-20 21:50:13 +08:00
DSH Agent 9a64853db4 server_config: 新增 RRPP 订阅后端规则集下发文档(PAC → classical 规则集)
- 新增 §12:PAC 不能直接下发的原因、三个规则集产物、PAC 更新后的再生成流程、
  mihomo/Shadowrocket 两种客户端接入方式与验证口径
- 新增 rrpp-subscription/:API.md(订阅端点/method/rules 参数/规则集说明)
  与 gen_rulesets.py(PAC → 客户端规则集转换脚本,幂等、原子替换)
- method 新增 shadowrocket(.conf:远程 RULE-SET + FINAL,不猜测 REALITY 参数)
- method=clash 新增 &rules=provider|inline|global
2026-09-19 18:46:54 +08:00
RRRosmontis bdd9d3498d fix(uot-relay): 数据报级错误不再杀隧道 + 地址族健壮性
实测复现:目标为关闭的 UDP 端口时,连接式 socket 收到 ICMP port unreachable
→ ConnectionRefusedError → 被兜底 except 接住并 stop.set() → 整条 UoT 隧道被关
(客户端见 'UoT TCP 流已关闭',多隧道同秒集体死亡)。

修复:
- 一律用未连接 UDP socket(不接收 ICMP 错误)
- send/recv 的数据报级错误只丢包继续;EAGAIN 等可写重试(1s 上限)
- 按地址族惰性建 socket(v4/v6 混用)、IPv6 recvfrom 4 元组归一化
- connect 模式预建 socket(修掉主循环空池即关连接的回归)

五节点已部署并验证:狂发 200 包到关闭端口/无路由目标后隧道仍存活;
UoT DNS 端到端 JP/US/AQ/HK2 通过,RU 为该节点出口 UDP 限制(实测 4/10)
导致的偶发超时,非 relay 问题。
2026-09-19 16:41:19 +08:00
RoyirinRosmontis 41204df78a release: publish 9999091904 2026-09-19 04:43:17 +08:00
RoyirinRosmontis d0e739eff1 fix(lite): hide tun network command windows 2026-09-19 04:36:54 +08:00
RoyirinRosmontis e406d614b7 refactor(lite): keep tunnel startup details out of main log 2026-09-19 04:29:24 +08:00
RoyirinRosmontis 357fd26a49 fix(lite): bundle tun runtime and quiet connection logs 2026-09-19 04:27:20 +08:00
RoyirinRosmontis 9d5bc527d3 feat(lite): add opt-in tun ipv6 mode 2026-09-19 04:20:34 +08:00
RoyirinRosmontis 2315735979 fix(lite): report tun worker startup errors 2026-09-19 04:07:27 +08:00
RoyirinRosmontis 889dcb88e5 style(lite): restore intended mode hover contrast 2026-09-19 04:01:02 +08:00
RoyirinRosmontis 9224ad33fe style(lite): swap mode card hover colors 2026-09-19 03:54:59 +08:00
RoyirinRosmontis 50364dc17a feat(lite): merge TUN mode with IPv6 routing 2026-09-19 03:41:41 +08:00
RRRosmontis 3e29012d9c server_config: CONNECT 出站超时 60s→10s,并补充排障口径
实测(2026-09):目标为黑洞 IP 时,nginx 出站超时 60s > lite 客户端 15s,
表现为客户端 'read operation timed out' 而非 502,形似代理卡死,实为超时错配。
改为 10s 后黑洞目标 10.2s 返回、正常目标 0.4-0.7s 不受影响(五节点已生效)。

README 新增 5.6 节:三个超时的口径 + 'CONNECT 502' 的排查顺序
(先判断目标是否本身不可达 → 再看 nginx 错误类型 → 最后才怀疑 CONNECT 模块),
并建议客户端日志记录原始域名(案例:Dropbox 旧 IP 失效导致误判)。
2026-09-19 01:23:38 +08:00
RoyirinRosmontis e4d57bc90d 更新 README.md 2026-09-19 00:41:12 +08:00
RoyirinRosmontis fd433fc43e 更新 README.md 2026-09-19 00:40:37 +08:00
RoyirinRosmontis a054a547e5 更新 README.md 2026-09-19 00:38:19 +08:00
RRRosmontis 2e56f82e7b server_config: 新增 UoT v2(UDP over TCP)支持
全节点(jp/us/ru/aq/hk2)已部署并实测通过(UoT DNS 端到端 ✅ + TCP 回归 ✅):

- nginx 模板:map $request 识别 sing-box UoT v2 magic 地址
  (sp.v2.udp-over-tcp.arpa)→ 127.0.0.1:15201;default_server 块加
  proxy_connect_address;read/send timeout 10m
- 新增 uot-relay/:Python 标准库 UoT v2 relay(connect/non-connect 双模式,
  仅监听 127.0.0.1,空闲 9 分钟断开)
- README 5.5 节:部署方法 + 客户端协议 + 四个实测坑
  ($connect_host 未注册变量 / data_timeout 与 read_timeout 同槽 /
  CONNECT 恒落 default_server / 拒绝端口 0)
2026-09-19 00:31:41 +08:00
RoyirinRosmontis e16ef58182 style(lite): unify compact settings buttons 2026-09-18 22:29:28 +08:00
RoyirinRosmontis f4d777113c feat(lite): add tray settings and reset controls 2026-09-18 21:43:09 +08:00
RoyirinRosmontis 26926f6469 feat(lite): enable built-in PAC by default and publish 9999091820 2026-09-18 20:39:34 +08:00
RoyirinRosmontis a0e2600399 feat(lite): add built-in PAC mode and publish 9999091820 2026-09-18 20:34:18 +08:00
RoyirinRosmontis 01de67610d feat(lite): add detailed runtime logs and publish 9999091817 2026-09-18 17:10:03 +08:00
RoyirinRosmontis 384a6107a8 style(lite): unify settings action labels 2026-09-18 16:46:33 +08:00
RoyirinRosmontis fb47f9e1d2 style(lite): compact settings dialog buttons 2026-09-18 16:43:05 +08:00
RoyirinRosmontis 71a67d7dca feat(lite): 持久化代理模式配置并发布 9999091816 2026-09-18 16:23:40 +08:00
RoyirinRosmontis 8063b6d4e8 feat(lite): 发布多跳代理跳板 9999091814 2026-09-18 14:40:44 +08:00
RoyirinRosmontis aac616eed9 fix: 修正发布版本时间戳 2026-09-17 17:07:52 +08:00
RoyirinRosmontis 9bbfe900c8 删除 lite/rrpp_client_nodes.template.json 2026-09-17 17:05:20 +08:00
RoyirinRosmontis c58ef59889 feat: 支持第三方节点订阅 2026-09-17 16:57:47 +08:00
RoyirinRosmontis 9dbaabd4a9 修复:Nuitka onefile 更新路径 2026-09-16 23:45:40 +08:00
RoyirinRosmontis 6673d28503 改进:显示更新下载进度并就地保存 2026-09-16 23:35:38 +08:00
RoyirinRosmontis 6fb2926627 清理:移除本地辅助脚本与测试工具 2026-09-16 23:20:16 +08:00
50 changed files with 6469 additions and 2126 deletions
+3 -4
View File
@@ -7,7 +7,6 @@ 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
lite/RELEASE_WORKFLOW.md
*.bak
*.bak-*
+8 -12
View File
@@ -1,17 +1,13 @@
# Royirin Rosmontis Private Proxy
Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu
## 警告
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows 应用 (.exe)
- 自构建的客户端永远不再维护。推荐使用 Clash Verge 或其他软件结合服务器端配置使用。
> 这些客户端使用了 HTTP 直接连接,具有流量识别的安全隐患。
- 其中,common 版本过于过时,具有严重的安全问题和大量功能缺失。Lite 版本(版本号开头为 9999 的版本)则更为安全,但部分功能(TUN 模式)严重不可用,且似乎无法修复。
- 在 Release 中保留了 Lite 版本的 Pyinstaller 构建产物,可能启动较慢。如果需要使用此客户端,可以考虑用 Nuitka 自行再次构建。
- 本仓库中的构建产物与源码没有做 Linux 系统的适配。
### 如何区分 common 版本与 Lite 版本
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳
## 服务器侧配置手册 (部分内容过时)
- https://rrrosmontis.icu/articles/index.html?id=00005
## 服务器侧配置手册
- https://rrrosmontis.icu/articles/index.html?id=00005 (部分内容过时)
- 更推荐参考本仓库 server_config 文件夹中的 md 文件手册
## 若要直接使用仓库客户端对应的服务
请前往 https://sso.rrrosmontis.icu 注册 Rosmanka ID,然后与管理员商定使用细则
royirin@rrrosmontis.icu
-148
View File
@@ -1,148 +0,0 @@
# 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 基线;保留失败现场的测试输出以便定位。不得通过覆盖用户的运行配置、订阅或构建目录来“修复”回归。
+7 -2
View File
@@ -9,6 +9,11 @@ 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'), '.')]
_TUN_RUNTIME = [
(str(Path(SPECPATH) / 'runtime' / name), '.')
for name in ('tun2socks.exe', 'wintun.dll', 'LICENSE.tun2socks.txt',
'LICENSE.wintun.txt', 'README.txt')
]
print(f"[spec] 打包 json 数据文件: {[Path(src).name for src, _ in _JSON_DATAS]}")
# 自定义 manifest:普通权限运行 + 声明 PerMonitorV2 DPI 感知,
@@ -46,9 +51,9 @@ _MANIFEST = r'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
a = Analysis(
['main.py'],
pathex=['..\\.venv\\Lib\\site-packages'],
pathex=[],
binaries=qt_binaries,
datas=[('ico.ico', '.')] + _JSON_DATAS + qt_datas,
datas=[('ico.ico', '.')] + _JSON_DATAS + _TUN_RUNTIME + qt_datas,
hiddenimports=qt_hiddenimports,
hookspath=[],
hooksconfig={},
+6
View File
@@ -21,7 +21,13 @@ for /f %%v in ('..\.venv\Scripts\python.exe -c "import json;v=json.load(open('co
call ..\.venv\Scripts\python.exe -m nuitka ^
--standalone --onefile ^
--enable-plugin=pyqt5 ^
--user-package-configuration-file=rrpp-tun.nuitka-package.config.yml ^
--include-data-files=config.json=config.json ^
--include-data-files=runtime/LICENSE.tun2socks.txt=LICENSE.tun2socks.txt ^
--include-data-files=runtime/LICENSE.wintun.txt=LICENSE.wintun.txt ^
--include-data-files=runtime/README.txt=README.txt ^
--include-data-files=runtime/tun2socks.exe=tun2socks.exe ^
--include-data-files=runtime/wintun.dll=wintun.dll ^
--windows-icon-from-ico=ico.ico ^
--windows-console-mode=disable ^
--company-name="Global Rosmanka Cloud Nexus Inc" ^
-12
View File
@@ -1,12 +0,0 @@
@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
@@ -1,12 +0,0 @@
@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
+4 -4
View File
@@ -1,6 +1,6 @@
{
"version": "",
"update_url": "",
"pac_base_url": "",
"nodes_url": ""
"version": "999909190441",
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client",
"update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
}
+1651 -334
View File
File diff suppressed because it is too large Load Diff
-126
View File
@@ -1,126 +0,0 @@
# -*- 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()
@@ -1,143 +0,0 @@
# -*- 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()
@@ -1,86 +0,0 @@
# -*- 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()
+20
View File
@@ -0,0 +1,20 @@
---
- module-name: 'main'
dlls:
- from_filenames:
relative_path: 'runtime'
prefixes:
- 'tun2socks'
suffixes:
- 'exe'
executable: 'yes'
dest_path: '.'
when: 'win32'
- from_filenames:
relative_path: 'runtime'
prefixes:
- 'wintun'
suffixes:
- 'dll'
dest_path: '.'
when: 'win32'
+152
View File
@@ -0,0 +1,152 @@
"""轻量 DNS 观察器,用于把 TUN 里的目标 IP 关联回解析域名。
TUN 送到 SOCKS5 的请求通常只带目标 IP。这里观察经 UoT 或直连的
UDP DNS 回包,保存短期的 ``IP -> 域名`` 映射供分流和日志使用。
"""
from __future__ import annotations
import ipaddress
import struct
import threading
import time
from typing import Callable
def _read_name(packet: bytes, offset: int) -> tuple[str, int]:
labels: list[str] = []
cursor = offset
next_offset = None
jumps = 0
while True:
if cursor >= len(packet):
raise ValueError("DNS 名称超出数据包")
length = packet[cursor]
if length == 0:
cursor += 1
if next_offset is None:
next_offset = cursor
break
if length & 0xC0 == 0xC0:
if cursor + 1 >= len(packet):
raise ValueError("DNS 压缩指针不完整")
pointer = ((length & 0x3F) << 8) | packet[cursor + 1]
if next_offset is None:
next_offset = cursor + 2
cursor = pointer
jumps += 1
if jumps > 16:
raise ValueError("DNS 名称压缩指针循环")
continue
if length > 63 or cursor + 1 + length > len(packet):
raise ValueError("DNS 标签不完整")
labels.append(packet[cursor + 1:cursor + 1 + length].decode("idna"))
cursor += 1 + length
return ".".join(labels).rstrip(".").lower(), int(next_offset)
def _question(packet: bytes) -> tuple[int, str, int]:
if len(packet) < 12:
raise ValueError("DNS 头不完整")
txid, flags, questions, _answers, _authority, _additional = struct.unpack(
"!HHHHHH", packet[:12]
)
if flags & 0x8000 or questions < 1:
raise ValueError("不是 DNS 查询")
name, offset = _read_name(packet, 12)
if offset + 4 > len(packet):
raise ValueError("DNS 问题段不完整")
return txid, name, offset + 4
class DnsObserver:
def __init__(self, log: Callable[[str], None] | None = None,
max_ttl: int = 300):
self.log = log or (lambda _message: None)
self.max_ttl = max(1, int(max_ttl))
self._lock = threading.RLock()
self._domain_to_ips: dict[str, tuple[float, tuple[str, ...]]] = {}
# 一个 CDN IP 可能同时服务多个域名;不能把最后一次 DNS 回包当成唯一身份。
self._ip_to_domains: dict[str, tuple[float, set[str]]] = {}
def observe_query(self, packet: bytes) -> str | None:
try:
_txid, domain, _ = _question(packet)
return domain or None
except (ValueError, UnicodeError):
return None
def observe_response(self, packet: bytes) -> tuple[str, tuple[str, ...]] | None:
try:
if len(packet) < 12:
raise ValueError("DNS 头不完整")
_txid, flags, questions, answers, _authority, _additional = struct.unpack(
"!HHHHHH", packet[:12]
)
if not (flags & 0x8000) or questions < 1:
raise ValueError("不是 DNS 回包")
domain, offset = _read_name(packet, 12)
if offset + 4 > len(packet):
raise ValueError("DNS 问题段不完整")
offset += 4
ips: list[str] = []
ttl = self.max_ttl
for _ in range(answers):
_owner, offset = _read_name(packet, offset)
if offset + 10 > len(packet):
raise ValueError("DNS 答案头不完整")
rtype, rclass, record_ttl, size = struct.unpack(
"!HHIH", packet[offset:offset + 10]
)
offset += 10
if offset + size > len(packet):
raise ValueError("DNS 答案数据不完整")
raw = packet[offset:offset + size]
offset += size
if rclass != 1 or rtype not in (1, 28):
continue
if (rtype == 1 and size != 4) or (rtype == 28 and size != 16):
continue
ips.append(str(ipaddress.ip_address(raw)))
ttl = min(ttl, max(1, int(record_ttl)))
if not domain or not ips:
return None
expires = time.monotonic() + min(ttl, self.max_ttl)
unique = tuple(dict.fromkeys(ips))
with self._lock:
self._domain_to_ips[domain] = (expires, unique)
for ip in unique:
previous = self._ip_to_domains.get(ip)
domains = set(previous[1]) if previous and previous[0] > time.monotonic() else set()
domains.add(domain)
self._ip_to_domains[ip] = (expires, domains)
return domain, unique
except (ValueError, UnicodeError, OSError):
return None
def lookup_ip(self, ip: str) -> str | None:
try:
normalized = str(ipaddress.ip_address(ip))
except ValueError:
return None
now = time.monotonic()
with self._lock:
item = self._ip_to_domains.get(normalized)
if not item:
return None
if item[0] <= now:
self._ip_to_domains.pop(normalized, None)
return None
return next(iter(item[1])) if len(item[1]) == 1 else None
def lookup_domain(self, domain: str) -> tuple[str, ...]:
key = str(domain).rstrip(".").lower()
now = time.monotonic()
with self._lock:
item = self._domain_to_ips.get(key)
if not item:
return ()
if item[0] <= now:
self._domain_to_ips.pop(key, None)
return ()
return item[1]
+857
View File
@@ -0,0 +1,857 @@
"""本地 SOCKS5 → HTTP CONNECT/UoT v2 桥接。
tun2socks 只需要一个标准 SOCKS5 出口;TCP 连接转成当前节点的 HTTP
CONNECT,UDP 连接转成 magic address 上的 UoT v2 流。系统代理设置完全不参与。
"""
from __future__ import annotations
import base64
import ipaddress
import select
import socket
import ssl
import struct
import threading
from typing import Any, Callable
from .dns_observer import DnsObserver
from .uot_protocol import (
Address, MAGIC_ADDRESS, encode_datagram_frame, encode_request,
read_datagram_frame,
)
# relay 的内网监听端口与外层 CONNECT 端口是两个概念:nginx 接收
# CONNECT magic-host:443 后,将字节流转发到 127.0.0.1:15201。
DEFAULT_UOT_RELAY_PORT = 15201
DEFAULT_UOT_CONNECT_PORT = 443
def _recv_exact(stream: socket.socket, size: int) -> bytes:
"""按 SOCKS5/TCP 协议长度读取,不能假设一次 recv 就拿全。"""
data = bytearray()
while len(data) < size:
chunk = stream.recv(size - len(data))
if not chunk:
raise ConnectionError("SOCKS5 连接在握手中断开")
data.extend(chunk)
return bytes(data)
def _node_http(node: dict[str, Any]) -> tuple[str, int, str, str]:
methods = (node.get("methods") or {}).get("http") or {}
host = str(node.get("domain") or "").strip()
port = methods.get("port") or node.get("port") or 443
username = methods.get("username") or node.get("username") or ""
password = methods.get("password") or node.get("password") or ""
if not host:
raise ValueError("节点缺少 HTTP CONNECT 地址")
return host, int(port), str(username), str(password)
def _node_uot_connect_port(node: dict[str, Any]) -> int:
"""返回 magic CONNECT 的对外端口,不能使用 0。"""
chain = node.get("_jump_chain") if isinstance(node, dict) else None
if isinstance(chain, list) and chain:
return _node_uot_connect_port(chain[-1])
methods = (node.get("methods") or {}).get("http") or {}
raw = methods.get("uot_connect_port")
if raw is None:
raw = node.get("uot_connect_port")
# 兼容早期把 uot_port 当作 CONNECT 端口的本地配置;服务端 nginx
# 会按 magic host 映射到隐藏的 15201 relay 端口。
if raw is None:
raw = methods.get("uot_port")
if raw is None:
raw = node.get("uot_port")
if raw is None:
raw = DEFAULT_UOT_CONNECT_PORT
port = int(raw)
if not 1 <= port <= 65535:
raise ValueError("UoT CONNECT 端口超出范围")
return port
def _socks_address(stream: socket.socket, kind: int | None = None) -> Address:
# SOCKS5 请求头已经读取过 ATYP 时直接传入;独立解析时才从流中读取。
if kind is None:
kind = _recv_exact(stream, 1)[0]
if kind == 1:
host = socket.inet_ntoa(_recv_exact(stream, 4))
elif kind == 4:
host = socket.inet_ntop(socket.AF_INET6, _recv_exact(stream, 16))
elif kind == 3:
size = _recv_exact(stream, 1)[0]
host = _recv_exact(stream, size).decode("idna")
else:
raise ValueError("SOCKS5 地址类型不支持")
port = struct.unpack("!H", _recv_exact(stream, 2))[0]
return Address(host, port)
def _pack_socks_udp(destination: Address, payload: bytes) -> bytes:
try:
ip = ipaddress.ip_address(destination.host)
except ValueError:
raw = destination.host.encode("idna")
return b"\x00\x00\x00\x03" + bytes([len(raw)]) + raw + struct.pack("!H", destination.port) + payload
atyp = 1 if ip.version == 4 else 4
return b"\x00\x00\x00" + bytes([atyp]) + ip.packed + struct.pack("!H", destination.port) + payload
def _unpack_socks_udp(packet: bytes) -> tuple[Address, bytes]:
if len(packet) < 4 or packet[:2] != b"\x00\x00":
raise ValueError("SOCKS5 UDP 包头无效")
if packet[2] != 0:
# RFC 1928 允许分片,但 tun2socks 当前不会发送分片;静默拼接会把
# 不完整的报文当成 DNS/QUIC 数据,造成更难排查的协议损坏。
raise ValueError("暂不支持 SOCKS5 UDP 分片")
atyp = packet[3]
offset = 4
if atyp == 1:
if len(packet) < offset + 4:
raise ValueError("SOCKS5 UDP IPv4 地址不完整")
host = socket.inet_ntoa(packet[offset:offset + 4]); offset += 4
elif atyp == 4:
if len(packet) < offset + 16:
raise ValueError("SOCKS5 UDP IPv6 地址不完整")
host = socket.inet_ntop(socket.AF_INET6, packet[offset:offset + 16]); offset += 16
elif atyp == 3:
if len(packet) < offset + 1:
raise ValueError("SOCKS5 UDP 域名长度不完整")
size = packet[offset]; offset += 1
if len(packet) < offset + size:
raise ValueError("SOCKS5 UDP 域名地址不完整")
host = packet[offset:offset + size].decode("idna"); offset += size
else:
raise ValueError("SOCKS5 UDP 地址类型不支持")
if len(packet) < offset + 2:
raise ValueError("SOCKS5 UDP 端口不完整")
port = struct.unpack("!H", packet[offset:offset + 2])[0]
return Address(host, port), packet[offset + 2:]
class HttpConnectDialer:
def __init__(self, node: dict[str, Any], timeout: float = 15.0,
source_address: str | None = None,
proxy_addresses: list[tuple] | None = None,
proxy_addresses_by_node: dict[str, list[tuple]] | None = None):
self.node = node
self.timeout = timeout
# TUN 启动后默认路由会指向虚拟网卡。节点本身必须从物理网卡发出,
# 否则上游 CONNECT 会再次进入本地 SOCKS,形成递归回环。
self.source_address = source_address
self.proxy_addresses = proxy_addresses
self.proxy_addresses_by_node = proxy_addresses_by_node or {}
@staticmethod
def _node_key(node: dict[str, Any]) -> str:
methods = (node.get("methods") or {}).get("http") or {}
return "|".join((str(node.get("domain") or ""),
str(methods.get("port") or node.get("port") or 443),
str(methods.get("username") or node.get("username") or "")))
@staticmethod
def _node_credentials(node: dict[str, Any]) -> tuple[str, str]:
methods = (node.get("methods") or {}).get("http") or {}
username = methods.get("username") or node.get("username") or ""
password = methods.get("password") or node.get("password") or ""
return str(username), str(password)
def _open_proxy_socket(self, host: str, port: int,
addresses: list[tuple] | None = None) -> socket.socket:
addresses = addresses or self.proxy_addresses
if not addresses:
addresses = [item[4] for item in socket.getaddrinfo(
host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)]
last_error: OSError | None = None
for sockaddr in addresses:
family = socket.AF_INET6 if len(sockaddr) == 4 else socket.AF_INET
raw = socket.socket(family, socket.SOCK_STREAM)
try:
raw.settimeout(self.timeout)
if self.source_address:
if family == socket.AF_INET and ":" not in self.source_address:
raw.bind((self.source_address, 0))
elif family == socket.AF_INET6 and ":" in self.source_address:
raw.bind((self.source_address, 0, 0, 0))
raw.connect(sockaddr)
return raw
except OSError as exc:
last_error = exc
raw.close()
if last_error:
raise last_error
raise OSError(f"无法解析 HTTP CONNECT 节点: {host}")
def _jump_addresses(self, node: dict[str, Any]) -> list[tuple] | None:
return self.proxy_addresses_by_node.get(self._node_key(node))
def _open_jump(self, target: Address) -> socket.socket:
"""通过 2-3 个 HTTP CONNECT 节点逐层建立 TLS,再连接最终目标。"""
chain = list(self.node.get("_jump_chain") or [])
if not 2 <= len(chain) <= 3:
raise RuntimeError("跳板链路必须包含 2-3 个节点")
ctx = ssl.create_default_context()
first = chain[0]
first_host, first_port, _username, _password = _node_http(first)
stream = self._open_proxy_socket(
first_host, first_port, self._jump_addresses(first))
try:
stream = ctx.wrap_socket(stream, server_hostname=first_host)
for index, next_node in enumerate(chain[1:], 1):
next_host, next_port, _username, _password = _node_http(next_node)
leftover = _http_connect_over(
stream, chain[index - 1], next_host, next_port, resolve_target=True)
stream = _LayeredTlsTransport(stream, ctx, next_host, leftover)
_http_connect_over(
stream, chain[-1], target.host, target.port, resolve_target=False)
return stream
except Exception:
try:
stream.close()
except OSError:
pass
raise
def open(self, target: Address) -> socket.socket:
if self.node.get("_jump_chain"):
return self._open_jump(target)
host, port, username, password = _node_http(self.node)
raw = self._open_proxy_socket(host, port)
ctx = ssl.create_default_context()
stream = ctx.wrap_socket(raw, server_hostname=host)
authority = f"[{target.host}]:{target.port}" if ":" in target.host else f"{target.host}:{target.port}"
auth = ""
if username or password:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
auth = f"Authorization: Basic {token}\r\n"
request = (
f"CONNECT {authority} HTTP/1.1\r\n"
f"Host: {authority}\r\n"
f"{auth}Connection: Keep-Alive\r\n\r\n"
).encode("ascii")
stream.sendall(request)
response = b""
while b"\r\n\r\n" not in response and len(response) < 65536:
chunk = stream.recv(4096)
if not chunk:
break
response += chunk
first = response.split(b"\r\n", 1)[0].decode("latin1", "replace")
if not first.startswith(("HTTP/1.1 200", "HTTP/1.0 200")):
stream.close()
raise ConnectionError(f"HTTP CONNECT 失败: {first[:160]}")
return stream
def _resolve_jump_address(host: str, port: int) -> tuple:
infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
infos.sort(key=lambda item: 0 if item[0] == socket.AF_INET else 1)
if not infos:
raise OSError(f"无法解析跳板地址: {host}")
return infos[0][4]
def _http_connect_over(transport, proxy_node: dict[str, Any], target_host: str,
target_port: int, *, resolve_target: bool) -> bytes:
"""在当前 TLS 层发送 CONNECT;中间跳板使用解析后的 IP,末跳保留目标名。"""
username, password = HttpConnectDialer._node_credentials(proxy_node)
auth_header = ""
if username or password:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
auth_header = f"Authorization: Basic {token}\r\n"
if resolve_target:
address = _resolve_jump_address(target_host, target_port)
authority = f"[{address[0]}]:{address[1]}" if ":" in address[0] else f"{address[0]}:{address[1]}"
else:
authority = f"[{target_host}]:{target_port}" if ":" in target_host else f"{target_host}:{target_port}"
request = (
f"CONNECT {authority} HTTP/1.1\r\n"
f"Host: {authority}\r\n"
f"{auth_header}Connection: Keep-Alive\r\n\r\n"
).encode("ascii")
transport.sendall(request)
response = b""
while b"\r\n\r\n" not in response and len(response) < 65536:
chunk = transport.recv(4096)
if not chunk:
break
response += chunk
marker = b"\r\n\r\n"
end = response.find(marker)
first = response.split(b"\r\n", 1)[0].decode("latin-1", "replace")
if not (first.startswith("HTTP/1.1 200") or first.startswith("HTTP/1.0 200")):
raise ConnectionError(f"跳板 CONNECT 失败: {first[:160]}")
return response[end + len(marker):] if end >= 0 else b""
class _LayeredTlsTransport:
"""在已有 TLS 字节流上承载下一层 TLS。"""
def __init__(self, transport, ctx, server_hostname: str, initial: bytes = b""):
self._transport = transport
self._incoming = ssl.MemoryBIO()
self._outgoing = ssl.MemoryBIO()
self._pending = initial
self._ssl = ctx.wrap_bio(
self._incoming, self._outgoing, server_side=False,
server_hostname=server_hostname)
self._handshake()
def _flush(self):
data = self._outgoing.read()
if data:
self._transport.sendall(data)
def _feed(self):
if self._pending:
data, self._pending = self._pending, b""
else:
data = self._transport.recv(16384)
if not data:
raise ConnectionError("分层 TLS 下层连接已关闭")
self._incoming.write(data)
def _handshake(self):
while True:
try:
self._ssl.do_handshake()
self._flush()
return
except ssl.SSLWantReadError:
self._flush()
self._feed()
except ssl.SSLWantWriteError:
self._flush()
def sendall(self, data):
view = memoryview(data)
while view:
try:
written = self._ssl.write(view)
view = view[written:]
self._flush()
except ssl.SSLWantReadError:
self._flush()
self._feed()
except ssl.SSLWantWriteError:
self._flush()
def recv(self, size):
while True:
try:
return self._ssl.read(size)
except ssl.SSLWantReadError:
self._flush()
self._feed()
except ssl.SSLWantWriteError:
self._flush()
def settimeout(self, value):
self._transport.settimeout(value)
def gettimeout(self):
return self._transport.gettimeout()
def close(self):
self._transport.close()
class Socks5Bridge:
def __init__(self, node: dict[str, Any], bypass_lan: bool = True,
route_rules: list[dict[str, Any]] | None = None,
log: Callable[[str], None] | None = None,
source_address: str | None = None,
proxy_addresses: list[tuple] | None = None,
direct_target_resolver: Callable[[str, str, int], tuple[str, int] | None] | None = None,
route_rule_resolver: Callable[[str], dict[str, Any] | None] | None = None,
route_node_resolver: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
proxy_addresses_by_node: dict[str, list[tuple]] | None = None,
direct_route_ensurer: Callable[[str], None] | None = None):
self.node = node
self.bypass_lan = bypass_lan
self.route_rules = route_rules or []
self.log = log or (lambda _message: None)
self.source_address = source_address
self.proxy_addresses = proxy_addresses
# 由 main.py 注入 Lite 的同一套 PAC/路由判定,TUN 不复制另一份规则语义。
self.direct_target_resolver = direct_target_resolver
self.route_rule_resolver = route_rule_resolver
self.route_node_resolver = route_node_resolver
self.proxy_addresses_by_node = proxy_addresses_by_node or {}
self.direct_route_ensurer = direct_route_ensurer
self.listener: socket.socket | None = None
self._running = threading.Event()
self._threads: list[threading.Thread] = []
self._up = 0
self._down = 0
self._counter_lock = threading.Lock()
self._dns = DnsObserver(log=self.log)
self._udp_resources: list[socket.socket] = []
self._udp_resources_lock = threading.RLock()
def start(self) -> int:
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.listener.bind(("127.0.0.1", 0))
self.listener.listen(32)
self.listener.settimeout(1)
self._running.set()
thread = threading.Thread(target=self._accept_loop, daemon=True)
thread.start(); self._threads.append(thread)
return self.listener.getsockname()[1]
def _accept_loop(self):
while self._running.is_set() and self.listener:
try:
client, _ = self.listener.accept()
except socket.timeout:
continue
except OSError:
break
thread = threading.Thread(target=self._client, args=(client,), daemon=True)
thread.start(); self._threads.append(thread)
def _client(self, client: socket.socket):
try:
client.settimeout(15)
greeting = _recv_exact(client, 2)
if greeting[0] != 5:
return
methods = _recv_exact(client, greeting[1])
if 0 not in methods:
client.sendall(b"\x05\xff")
return
client.sendall(b"\x05\x00")
header = _recv_exact(client, 4)
if header[0] != 5:
return
command = header[1]
destination = _socks_address(client, header[3])
if command == 1:
self.log(f"[桥接] TCP 目标: {self._target_label(destination)}")
self._tcp(client, destination)
elif command == 3:
self._udp(client)
else:
client.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00")
except Exception as exc:
# 停止 TUN 时主动关闭仍在转发的连接,Windows 可能返回
# WSAECONNRESET (10054);这是收尾动作,不应作为用户错误记录。
if self._running.is_set():
self.log(f"[桥接] SOCKS5 请求失败: {exc}")
finally:
try: client.close()
except OSError: pass
def _target_label(self, target: Address) -> str:
host = target.host
try:
ipaddress.ip_address(host)
except ValueError:
ips = self._dns.lookup_domain(host)
resolved = ",".join(ips) if ips else "未知"
return f"domain={host} ip={resolved} port={target.port}"
domain = self._dns.lookup_ip(host) or "未知"
return f"domain={domain} ip={host} port={target.port}"
def _policy_host(self, host: str) -> str:
"""对已观察到的唯一 DNS 映射应用域名规则;实际连接仍使用原目标 IP。"""
try:
ipaddress.ip_address(host)
except ValueError:
return host
return self._dns.lookup_ip(host) or host
def _direct_target(self, host: str, port: int) -> tuple[str, int] | None:
explicit = False
normalized = str(host).rstrip(".").lower()
for rule in self.route_rules:
if not isinstance(rule, dict) or not rule.get("enabled", True):
continue
if str(rule.get("action") or "direct").lower() != "direct":
continue
value = str(rule.get("domain") or rule.get("value") or "").strip().lower().rstrip(".")
if value.startswith("*."):
value = value[2:]
try:
if ipaddress.ip_address(normalized) in ipaddress.ip_network(value, strict=False):
explicit = True
except ValueError:
if normalized == value or normalized.endswith("." + value):
explicit = True
if explicit:
break
if not self.bypass_lan and not explicit:
return None
try:
address = ipaddress.ip_address(host)
return (host, port) if explicit or address.is_private else None
except ValueError:
try:
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
address = item[4][0].split("%", 1)[0]
if explicit or ipaddress.ip_address(address).is_private:
return address, port
except OSError:
pass
return None
def _route_rule(self, host: str, original_host: str | None = None) -> dict[str, Any] | None:
if self.route_rule_resolver is not None:
try:
rule = self.route_rule_resolver(host)
if isinstance(rule, dict):
return rule
if original_host and original_host != host:
rule = self.route_rule_resolver(original_host)
if isinstance(rule, dict):
return rule
except Exception as exc:
self.log(f"[路由] 规则判断失败: {exc}")
for rule in self.route_rules:
if not isinstance(rule, dict) or not rule.get("enabled", True):
continue
value = str(rule.get("domain") or rule.get("value") or "").strip().lower().rstrip(".")
normalized = str(host).rstrip(".").lower()
base = value[2:] if value.startswith("*.") else value
if base and (normalized == base or normalized.endswith("." + base)):
return rule
return None
def _direct_target_for(self, host: str, port: int,
policy_host: str | None = None) -> tuple[str, int] | None:
policy_host = policy_host or self._policy_host(host)
rule = self._route_rule(policy_host, host)
action = str((rule or {}).get("action") or "").lower()
if action == "node":
return None
if self.direct_target_resolver is not None:
try:
return self.direct_target_resolver(policy_host, host, port)
except Exception as exc:
self.log(f"[路由] PAC 直连判断失败: {exc}")
return None
if action == "direct":
return (host, port)
return self._direct_target(host, port)
def _route_node(self, rule: dict[str, Any] | None) -> dict[str, Any] | None:
if not rule or str(rule.get("action") or "").lower() != "node":
return None
if self.route_node_resolver is None:
return None
try:
node = self.route_node_resolver(rule)
return node if isinstance(node, dict) else None
except Exception as exc:
self.log(f"[路由] 固定节点解析失败: {exc}")
return None
@staticmethod
def _node_key(node: dict[str, Any]) -> str:
chain = node.get("_jump_chain")
if isinstance(chain, list) and chain:
return "jump|" + ">".join(
Socks5Bridge._node_key(item) for item in chain if isinstance(item, dict)
)
methods = (node.get("methods") or {}).get("http") or {}
return "|".join((str(node.get("domain") or ""), str(methods.get("port") or node.get("port") or 443),
str(methods.get("username") or node.get("username") or "")))
def _dialer(self, node: dict[str, Any]) -> HttpConnectDialer:
proxy_addresses = self.proxy_addresses
chain = node.get("_jump_chain")
if isinstance(chain, list) and chain:
proxy_addresses = self.proxy_addresses_by_node.get(
self._node_key(chain[0]))
return HttpConnectDialer(
node, source_address=self.source_address,
proxy_addresses=(proxy_addresses if node is self.node else
self.proxy_addresses_by_node.get(self._node_key(node), proxy_addresses)),
proxy_addresses_by_node=self.proxy_addresses_by_node,
)
def _ensure_direct_routes(self, host: str, port: int) -> None:
if self.direct_route_ensurer is None:
return
try:
self.direct_route_ensurer(str(ipaddress.ip_address(host)))
return
except ValueError:
pass
try:
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
self.direct_route_ensurer(str(item[4][0]).split("%", 1)[0])
except OSError:
pass
def _proxy_target(self, target: Address) -> Address:
"""为上游 CONNECT 恢复唯一 DNS 域名,给 IPv6 目标提供 IPv4 回退。"""
try:
ipaddress.ip_address(target.host)
except ValueError:
return target
domain = self._dns.lookup_ip(target.host)
if not domain:
return target
self.log(
f"[桥接] IPv6 目标回退域名解析: {target.host} -> {domain}:{target.port}"
)
return Address(domain, target.port)
def _open_target(self, target: Address) -> socket.socket:
policy_host = self._policy_host(target.host)
rule = self._route_rule(policy_host, target.host)
route_node = self._route_node(rule)
if rule and str(rule.get("action") or "").lower() == "node":
if route_node is None:
raise ConnectionError("域名路由指定的节点不可用")
return self._dialer(route_node).open(self._proxy_target(target))
direct = self._direct_target_for(target.host, target.port, policy_host)
if direct is not None:
self._ensure_direct_routes(direct[0], direct[1])
return socket.create_connection(direct, 15)
return self._dialer(self.node).open(self._proxy_target(target))
def _tcp(self, client: socket.socket, target: Address):
upstream = self._open_target(target)
upstream.settimeout(None); client.settimeout(None)
client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
self._relay(client, upstream)
def _relay(self, left: socket.socket, right: socket.socket):
sockets = [left, right]
try:
while self._running.is_set():
readable, _, _ = select.select(sockets, [], [], 1)
for source in readable:
data = source.recv(65536)
if not data: return
target = right if source is left else left
target.sendall(data)
with self._counter_lock:
if source is left: self._up += len(data)
else: self._down += len(data)
finally:
for item in sockets:
try: item.close()
except OSError: pass
def _udp(self, control: socket.socket):
# tun2socks v2.7 在 Windows 上通过 ListenPacket("udp", "")
# 创建 IPv6 wildcard socket(日志形如 [::]:port)。SOCKS5 返回的
# UDP relay 地址必须与这个地址族一致,否则 Windows 会在
# wsasendto 阶段以 WSAEINVAL 拒绝向 127.0.0.1:port 发包。
# 优先使用 IPv6 loopback;老系统没有 IPv6 时再回退 IPv4。
try:
udp = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
udp.bind(("::1", 0))
relay_address = b"\x04" + socket.inet_pton(socket.AF_INET6, "::1")
relay_bind = f"[::1]:{udp.getsockname()[1]}"
except OSError:
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.bind(("127.0.0.1", 0))
relay_address = b"\x01" + socket.inet_aton("127.0.0.1")
relay_bind = f"127.0.0.1:{udp.getsockname()[1]}"
port = udp.getsockname()[1]
udp.settimeout(1)
with self._udp_resources_lock:
self._udp_resources.append(udp)
control.sendall(b"\x05\x00\x00" + relay_address + struct.pack("!H", port))
self.log(f"[桥接] SOCKS5 UDP 关联已建立: {relay_bind}")
control.settimeout(1)
streams: dict[str, socket.socket] = {}
direct_flows: dict[tuple[str, int], tuple[socket.socket, tuple, Address]] = {}
malformed = 0
try:
while self._running.is_set():
try:
packet, peer = udp.recvfrom(65535)
except socket.timeout:
try:
if not control.recv(1): break
except socket.timeout:
continue
continue
try:
destination, payload = _unpack_socks_udp(packet)
except Exception as exc:
malformed += 1
if malformed <= 5:
self.log(
f"[桥接] SOCKS5 UDP 数据包解析失败: {exc} len={len(packet)}"
)
continue
if malformed:
malformed = 0
if not streams and not direct_flows:
self.log(
f"[桥接] SOCKS5 UDP 数据报: target={destination.host}:{destination.port} "
f"bytes={len(payload)}"
)
if destination.port == 53:
domain = self._dns.observe_query(payload)
if domain:
self.log(
f"[DNS] 查询: domain={domain} server={destination.host}:{destination.port}"
)
policy_host = self._policy_host(destination.host)
rule = self._route_rule(policy_host, destination.host)
route_node = self._route_node(rule)
if rule and str(rule.get("action") or "").lower() == "node" and route_node is None:
raise ConnectionError("UDP 域名路由指定的节点不可用")
direct = None if route_node is not None else self._direct_target_for(
destination.host, destination.port, policy_host)
if direct is not None:
key = (str(direct[0]), int(direct[1]))
flow = direct_flows.get(key)
if flow is None:
flow_socket, sockaddr = self._open_direct_udp(direct)
flow = (flow_socket, sockaddr, destination)
direct_flows[key] = flow
threading.Thread(
target=self._direct_udp_reader,
args=(flow_socket, udp, destination, peer),
daemon=True,
).start()
self.log(f"[UDP] 直连通道: target={destination.host}:{destination.port}")
flow_socket, direct_addr, _flow_destination = flow
flow_socket.sendto(payload, direct_addr)
with self._counter_lock:
self._up += len(payload)
continue
selected_node = route_node or self.node
stream_key = self._node_key(selected_node)
stream = streams.get(stream_key)
if stream is None:
self.log(
f"[UoT] 建立 UDP 通道: target={destination.host}:{destination.port}"
+ ("(固定节点)" if route_node is not None else "")
)
stream = self._dialer(selected_node).open(
Address(MAGIC_ADDRESS, _node_uot_connect_port(selected_node))
)
stream.settimeout(None)
stream.sendall(encode_request(False, destination))
streams[stream_key] = stream
with self._udp_resources_lock:
self._udp_resources.append(stream)
threading.Thread(
target=self._uot_reader, args=(stream, udp, peer), daemon=True
).start()
# UoT v2 non-connect 模式每个数据报都带目标地址。
stream.sendall(encode_datagram_frame(destination, payload))
with self._counter_lock:
self._up += len(payload)
finally:
udp.close()
for stream in streams.values():
try:
stream.close()
except OSError:
pass
for flow_socket, _sockaddr, _destination in direct_flows.values():
try:
flow_socket.close()
except OSError:
pass
with self._udp_resources_lock:
self._udp_resources = [
item for item in self._udp_resources
if item is not udp and item not in streams.values()
]
def _open_direct_udp_target(self, target: tuple[str, int]) -> tuple[socket.socket, tuple]:
host, port = target
last_error = None
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_DGRAM):
family, socktype, proto, _canonname, sockaddr = item
raw = socket.socket(family, socktype, proto)
try:
destination_ip = str(sockaddr[0]).split("%", 1)[0]
if self.direct_route_ensurer is not None:
self.direct_route_ensurer(destination_ip)
is_loopback = False
try:
is_loopback = ipaddress.ip_address(destination_ip).is_loopback
except ValueError:
pass
if (family == socket.AF_INET and self.source_address and ":" not in self.source_address
and not is_loopback):
raw.bind((self.source_address, 0))
elif (family == socket.AF_INET6 and self.source_address and ":" in self.source_address
and not is_loopback):
raw.bind((self.source_address, 0, 0, 0))
if family == socket.AF_INET:
try:
raw.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except OSError:
pass
raw.settimeout(1)
return raw, sockaddr
except OSError as exc:
last_error = exc
raw.close()
raise last_error or OSError(f"无法解析 UDP 目标: {host}")
def _open_direct_udp(self, target: tuple[str, int]) -> tuple[socket.socket, tuple]:
raw, sockaddr = self._open_direct_udp_target(target)
# 解析后的 sockaddr 直接用于 sendto,保留 IPv6 scope id。
return raw, sockaddr
def _direct_udp_reader(self, raw: socket.socket, udp: socket.socket,
destination: Address, peer):
try:
while self._running.is_set():
try:
payload, _source = raw.recvfrom(65535)
except socket.timeout:
continue
if not payload:
continue
if destination.port == 53:
observed = self._dns.observe_response(payload)
if observed:
domain, ips = observed
self.log(f"[DNS] 直连回包: domain={domain} ip={','.join(ips)}")
udp.sendto(_pack_socks_udp(destination, payload), peer)
with self._counter_lock:
self._down += len(payload)
except OSError:
pass
def _uot_reader(self, stream, udp, peer):
try:
while self._running.is_set():
destination, payload = read_datagram_frame(stream)
observed = self._dns.observe_response(payload)
if observed:
domain, ips = observed
self.log(f"[DNS] 回包: domain={domain} ip={','.join(ips)}")
udp.sendto(_pack_socks_udp(destination, payload), peer)
with self._counter_lock: self._down += len(payload)
except Exception as exc:
if self._running.is_set():
self.log(f"[UoT] UDP 回包读取失败: {exc}")
def stop(self):
self._running.clear()
if self.listener:
try: self.listener.close()
except OSError: pass
self.listener = None
with self._udp_resources_lock:
resources = list(self._udp_resources)
self._udp_resources.clear()
for resource in resources:
try:
resource.close()
except OSError:
pass
def traffic(self) -> tuple[int, int, int, int]:
with self._counter_lock:
return self._up, self._down, self._up, self._down
+137
View File
@@ -0,0 +1,137 @@
"""Windows 系统代理模式:注册表切换、原状态保存与异常退出恢复。"""
from __future__ import annotations
import ctypes
import re
from typing import Callable
PROXY_REG = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings"
BACKUP_KEY = "prev_proxy_state"
_VALUE_NAMES = ("ProxyEnable", "ProxyServer", "ProxyOverride", "AutoConfigURL")
def is_our_pac_url(value: str) -> bool:
return bool(re.fullmatch(r"http://127\.0\.0\.1:\d+/proxy\.pac", str(value).strip()))
def notify_proxy_change() -> None:
"""通知 WinINET 和顶层窗口重新读取系统代理。"""
try:
ctypes.windll.Wininet.InternetSetOptionW(None, 39, None, 0)
ctypes.windll.Wininet.InternetSetOptionW(None, 37, None, 0)
result = ctypes.c_ulong()
ctypes.windll.user32.SendMessageTimeoutW(
0xFFFF, 0x001A, 0, ctypes.c_wchar_p("InternetSettings"),
0x0002, 5000, ctypes.byref(result))
except Exception:
pass
class SystemProxyMode:
def __init__(self, *, run: Callable, settings_load: Callable,
settings_save: Callable, log: Callable,
endpoint: Callable[[], tuple[str, int]],
proxy_override: Callable[[bool], str]):
self.run = run
self.settings_load = settings_load
self.settings_save = settings_save
self.log = log
self.endpoint = endpoint
self.proxy_override = proxy_override
self._previous = None
def reg_query(self, name: str):
result = self.run(["reg", "query", PROXY_REG, "/v", name], warn=False)
if result.returncode:
return None
for line in (result.stdout or "").splitlines():
parts = line.strip().split(None, 2)
if len(parts) >= 3 and parts[0].lower() == name.lower():
return parts[1], parts[2]
return None
def _backup(self):
if self._previous is not None:
return
state = {name: list(value) if (value := self.reg_query(name)) else None
for name in _VALUE_NAMES}
self._previous = state
try:
self.settings_save({**self.settings_load(), BACKUP_KEY: state})
except Exception:
pass
def _restore(self):
state = self._previous
if state is None:
state = self.settings_load().get(BACKUP_KEY)
if isinstance(state, dict):
for name in _VALUE_NAMES:
value = state.get(name)
if value:
self.run(["reg", "add", PROXY_REG, "/v", name, "/t", value[0],
"/d", value[1], "/f"], warn=False)
else:
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
self.log("已恢复开启代理前的系统代理设置")
else:
for name in ("ProxyServer", "ProxyOverride", "AutoConfigURL"):
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
"REG_DWORD", "/d", "0", "/f"])
self._previous = None
try:
data = self.settings_load()
if BACKUP_KEY in data:
del data[BACKUP_KEY]
self.settings_save(data)
except Exception:
pass
def enable(self, pac_url=None, bypass_lan=False):
self._backup()
if pac_url:
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
"REG_DWORD", "/d", "0", "/f"])
for name in ("ProxyServer", "ProxyOverride"):
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
self.run(["reg", "add", PROXY_REG, "/v", "AutoConfigURL", "/t",
"REG_SZ", "/d", pac_url, "/f"])
self.log("系统代理已设置 (PAC)")
else:
host, port = self.endpoint()
address = f"{host}:{port}"
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
"REG_DWORD", "/d", "1", "/f"])
self.run(["reg", "add", PROXY_REG, "/v", "ProxyServer", "/t",
"REG_SZ", "/d", address, "/f"])
self.run(["reg", "add", PROXY_REG, "/v", "ProxyOverride", "/t",
"REG_SZ", "/d", self.proxy_override(bypass_lan), "/f"])
self.log(f"系统代理已设置: {address}")
notify_proxy_change()
def disable(self):
self._restore()
notify_proxy_change()
def is_on(self):
host, port = self.endpoint()
enabled = self.reg_query("ProxyEnable")
try:
fixed_enabled = bool(enabled and int(str(enabled[1]).strip(), 0) == 1)
except (TypeError, ValueError):
fixed_enabled = False
if fixed_enabled:
server = self.reg_query("ProxyServer")
return bool(server and (server[1] or "").strip() == f"{host}:{port}")
pac = self.reg_query("AutoConfigURL")
return bool(pac) and is_our_pac_url(pac[1] or "")
def is_stale(self):
host, port = self.endpoint()
server = self.reg_query("ProxyServer")
pac = self.reg_query("AutoConfigURL")
return (bool(server) and (server[1] or "").strip() == f"{host}:{port}") or \
(bool(pac) and is_our_pac_url(pac[1] or ""))
@@ -0,0 +1,53 @@
"""第三方 RRPP Client JSON 订阅的下载、校验与展示辅助。"""
import json
import urllib.request
from urllib.parse import urlsplit, urlunsplit
def fetch_nodes(url: str, timeout=15):
"""直连读取一个第三方订阅,避免首次导入受系统代理残留影响。"""
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
request = urllib.request.Request(
url,
headers={"Accept": "application/json", "User-Agent": "RRPP-Lite"},
)
with opener.open(request, timeout=timeout) as response:
return validate_nodes(json.loads(response.read().decode("utf-8-sig")))
def validate_nodes(data):
"""校验 RRPP Client 节点数组,返回可安全写入本地缓存的副本。"""
if not isinstance(data, list) or not data:
raise ValueError("订阅应为非空的 JSON 节点数组")
result = []
for index, node in enumerate(data, 1):
if not isinstance(node, dict):
raise ValueError(f"第 {index} 项不是节点对象")
domain = str(node.get("domain") or "").strip()
method = (node.get("methods") or {}).get("http") or {}
port = method.get("port", node.get("port"))
try:
port = int(port)
except (TypeError, ValueError):
port = 0
if not domain:
raise ValueError(f"第 {index} 项缺少 domain")
if not 1 <= port <= 65535:
raise ValueError(f"第 {index} 项的 HTTP 代理端口无效")
result.append(dict(node))
return result
def mask_url(url: str) -> str:
"""用于 UI 展示,隐藏查询参数和 URL 用户信息中的敏感令牌。"""
try:
parts = urlsplit(str(url))
host = parts.hostname or ""
if parts.port:
host = f"{host}:{parts.port}"
if parts.username:
host = "***@" + host
return urlunsplit((parts.scheme, host, parts.path, "…" if parts.query else "", ""))
except Exception:
return "已隐藏订阅地址"
+802
View File
@@ -0,0 +1,802 @@
"""路线 B 的 Windows TUN 后端:tun2socks + 自有 SOCKS5/UoT 桥接。
这里不启动 sing-box。tun2socks 只负责 Wintun 和用户态 TCP/IP 栈,
所有上游协议由本项目的 ``socks_bridge`` 处理:TCP 使用 HTTP CONNECT,
UDP 使用裸 UoT v2。
"""
from __future__ import annotations
import ctypes
import ipaddress
import json
import os
import shutil
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any, Callable
from .socks_bridge import Socks5Bridge
class TunBackendError(RuntimeError):
"""TUN 后端无法启动或停止。"""
def _run_hidden(command, **kwargs):
"""运行 Windows 网络命令时隐藏控制台窗口。
源码通过 test.bat 启动时这些子进程复用已有控制台;Nuitka GUI 没有父控制台,
未设置 CREATE_NO_WINDOW 就会为每次 PowerShell 查询弹出一个窗口。
"""
kwargs.setdefault("creationflags", getattr(subprocess, "CREATE_NO_WINDOW", 0))
return subprocess.run(command, **kwargs)
class TunBackend:
def __init__(self, user_dir: str | Path, engine_path: str | Path | None = None,
log: Callable[[str], None] | None = None,
detail_log: Callable[[str], None] | None = None):
self.user_dir = Path(user_dir)
self.engine_path = Path(engine_path) if engine_path else None
self.log = log or (lambda _message: None)
self.detail_log = detail_log or self.log
self._process: subprocess.Popen[str] | None = None
self._bridge: Socks5Bridge | None = None
self._lock = threading.RLock()
self._route_added = False
self._ipv6_route_added = False
self._lan_routes: list[str] = []
self._lan_ipv6_routes: list[str] = []
self._node_routes: list[str] = []
self._node_ipv6_routes: list[str] = []
self._physical_gateway = ""
self._physical_ifindex = ""
self._physical_ipv6_gateway = ""
self._physical_ipv6_ifindex = ""
self._device = "RRPP-TUN"
self._address = "10.255.0.1"
self._ipv6_address = "fd42:7272:5050::1"
self._dns_configured = False
self._tun_dns_servers = ("8.8.8.8", "1.1.1.1")
self._ipv6_enabled = False
self._bypass_lan = True
self._cleanup_pending = False
self._state_path = self.user_dir / "tun_state.json"
# Wintun 是三层虚拟接口,默认路由的下一跳使用接口自身地址。
self._gateway = self._address
self._ipv6_gateway = self._ipv6_address
self._recover_stale_state()
@staticmethod
def is_admin() -> bool:
if os.name != "nt":
return True
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def resolve_engine(self) -> Path:
candidates: list[Path] = []
if self.engine_path:
candidates.append(self.engine_path)
# 目录式发布:主 EXE 与 tun2socks.exe、wintun.dll 放在同一目录。
# PyInstaller onefile:主程序会把内置文件解压到 _MEIPASS。
executable_dir = Path(sys.executable).resolve().parent
meipass = getattr(sys, "_MEIPASS", None)
package_dir = Path(__file__).resolve().parents[1]
candidates.extend([
self.user_dir / "tun2socks.exe",
self.user_dir.parent / "tun2socks.exe",
executable_dir / "tun2socks.exe",
Path(meipass) / "tun2socks.exe" if meipass else Path("__rrpp_no_meipass__"),
package_dir / "tun2socks.exe",
package_dir / "runtime" / "tun2socks.exe",
])
found = shutil.which("tun2socks")
if found:
candidates.append(Path(found))
for candidate in candidates:
try:
path = candidate.expanduser().resolve()
except OSError:
continue
if path.is_file():
return path
raise TunBackendError(
"未找到 tun2socks.exe。请将 tun2socks.exe 和 wintun.dll 放在 TUN 程序目录,"
"或在 config.json 的 tun_engine_path 中填写完整路径。"
)
@staticmethod
def _physical_interface() -> str:
command = ["powershell", "-NoProfile", "-Command",
"(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | "
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
"Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias)"]
try:
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
value = (result.stdout or "").strip().splitlines()
if value and value[0].strip():
return value[0].strip()
except Exception:
pass
return ""
@staticmethod
def _physical_source_address(interface: str) -> str:
if not interface:
return ""
escaped = interface.replace("'", "''")
command = ["powershell", "-NoProfile", "-Command",
f"(Get-NetIPAddress -InterfaceAlias '{escaped}' -AddressFamily IPv4 "
"-AddressState Preferred | Select-Object -First 1 -ExpandProperty IPAddress)"]
try:
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
value = (result.stdout or "").strip().splitlines()
if value and value[0].strip():
return value[0].strip()
except Exception:
pass
return ""
@staticmethod
def _physical_route_info() -> tuple[str, str]:
"""返回当前物理默认路由的网关和接口索引。"""
command = ["powershell", "-NoProfile", "-Command",
"Get-NetRoute -DestinationPrefix '0.0.0.0/0' | "
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
"Sort-Object RouteMetric | Select-Object -First 1 "
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
try:
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
data = json.loads((result.stdout or "").strip())
if isinstance(data, dict):
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
except Exception:
pass
return "", ""
@staticmethod
def _physical_ipv6_route_info() -> tuple[str, str]:
"""返回当前物理 IPv6 默认路由的下一跳和接口索引。"""
command = ["powershell", "-NoProfile", "-Command",
"Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' | "
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
"Sort-Object RouteMetric | Select-Object -First 1 "
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
try:
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
data = json.loads((result.stdout or "").strip())
if isinstance(data, dict):
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
except Exception:
pass
return "", ""
@staticmethod
def _active_global_ipv6() -> bool | None:
"""返回是否存在公网 IPv6;无法查询时返回 None,避免误报已接管。"""
if os.name != "nt":
return False
command = [
"powershell", "-NoProfile", "-Command",
"@(Get-NetIPAddress -AddressFamily IPv6 -AddressState Preferred | "
"Where-Object { $_.IPAddress -notlike 'fe80:*' -and "
"$_.IPAddress -ne '::1' -and $_.IPAddress -notlike 'fc*' -and "
"$_.IPAddress -notlike 'fd*' }).IPAddress | ConvertTo-Json -Compress",
]
try:
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
if result.returncode:
return None
value = json.loads((result.stdout or "").strip() or "[]")
if isinstance(value, str):
return bool(value.strip())
return bool(value)
except Exception:
return None
@staticmethod
def _resolve_proxy_addresses(node: dict[str, Any]) -> list[tuple]:
methods = (node.get("methods") or {}).get("http") or {}
host = str(node.get("domain") or "").strip()
port = int(methods.get("port") or node.get("port") or 443)
if not host:
return []
addresses: list[tuple] = []
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
if item[4] not in addresses:
addresses.append(item[4])
return addresses
@staticmethod
def _node_chain(node: dict[str, Any]) -> list[dict[str, Any]]:
chain = node.get("_jump_chain") if isinstance(node, dict) else None
if isinstance(chain, list) and chain:
return [item for item in chain if isinstance(item, dict)]
return [node] if isinstance(node, dict) else []
@staticmethod
def _node_key(node: dict[str, Any]) -> str:
methods = (node.get("methods") or {}).get("http") or {}
return "|".join((str(node.get("domain") or ""),
str(methods.get("port") or node.get("port") or 443),
str(methods.get("username") or node.get("username") or "")))
def _add_node_routes(self, proxy_addresses: list[tuple]) -> None:
"""把 HTTP CONNECT 节点固定走物理网卡,防止默认 TUN 路由形成回环。"""
candidates_v4: list[str] = []
candidates_v6: list[str] = []
for sockaddr in proxy_addresses:
address = str(sockaddr[0])
try:
parsed = ipaddress.ip_address(address.split("%", 1)[0])
except ValueError:
continue
target = candidates_v4 if parsed.version == 4 else candidates_v6
normalized = parsed.compressed
if normalized not in target:
target.append(normalized)
if candidates_v4 and not self._physical_ifindex:
raise TunBackendError("找不到物理 IPv4 接口,无法固定代理节点路由")
for address in candidates_v4:
if address in self._node_routes:
continue
command = ["route", "ADD", address, "MASK", "255.255.255.255"]
if self._physical_gateway:
command.append(self._physical_gateway)
command.extend(["METRIC", "1", "IF", self._physical_ifindex])
result = _run_hidden(command, capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
if result.returncode:
detail = (result.stderr or result.stdout or "").strip()
# 已存在的等价路由不影响启动;其它错误继续暴露,避免静默回环。
if "already exists" not in detail.lower() and "已存在" not in detail:
raise TunBackendError(f"节点物理路由添加失败 ({address}): {detail[:200]}")
continue
self._node_routes.append(address)
self._write_state()
if candidates_v6 and not self._ipv6_enabled:
if not candidates_v4:
raise TunBackendError("IPv6 模式已关闭,代理节点仅包含 IPv6 地址")
candidates_v6 = []
if candidates_v6 and not self._physical_ipv6_ifindex:
# 双栈节点仍可使用 IPv4 地址;只有 IPv6-only 节点需要物理 IPv6 出口。
if candidates_v4:
candidates_v6 = []
else:
raise TunBackendError("代理节点仅包含 IPv6 地址,但找不到物理 IPv6 默认路由")
for address in candidates_v6:
if address in self._node_ipv6_routes:
continue
self._add_ipv6_physical_route(address)
def _add_ipv6_physical_route(self, address: str) -> None:
if not self._physical_ipv6_ifindex:
raise TunBackendError("找不到物理 IPv6 接口,无法固定 IPv6 路由")
command = ["interface", "ipv6", "add", "route", f"prefix={address}/128",
f"interface={self._physical_ipv6_ifindex}"]
if self._physical_ipv6_gateway and self._physical_ipv6_gateway != "::":
command.append(f"nexthop={self._physical_ipv6_gateway}")
command.append("metric=1")
result = _run_hidden(["netsh", *command], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
if result.returncode:
detail = (result.stderr or result.stdout or "").strip()
if "already exists" not in detail.lower() and "已存在" not in detail:
raise TunBackendError(f"IPv6 节点物理路由添加失败 ({address}): {detail[:200]}")
return
self._node_ipv6_routes.append(address)
self._write_state()
def _ensure_physical_route(self, address: str) -> None:
"""为 TUN 内部的直连目标补一条物理出口 /32 路由。"""
if os.name != "nt":
return
try:
parsed = ipaddress.ip_address(str(address).split("%", 1)[0])
except ValueError:
return
if parsed.version == 6:
if not self._ipv6_enabled:
raise TunBackendError("IPv6 模式未启用,无法添加 IPv6 物理路由")
normalized = parsed.compressed
if normalized in self._node_ipv6_routes:
return
self._add_ipv6_physical_route(normalized)
return
if not self._physical_ifindex:
raise TunBackendError("找不到物理 IPv4 接口,无法固定直连路由")
normalized = parsed.compressed
if normalized in self._node_routes:
return
command = ["route", "ADD", normalized, "MASK", "255.255.255.255"]
if self._physical_gateway:
command.append(self._physical_gateway)
command.extend(["METRIC", "1", "IF", self._physical_ifindex])
result = _run_hidden(command, capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
detail = (result.stderr or result.stdout or "").strip()
if result.returncode:
if "already exists" in detail.lower() or "已存在" in detail:
return
raise TunBackendError(f"直连目标物理路由添加失败 ({normalized}): {detail[:200]}")
self._node_routes.append(normalized)
self._write_state()
@staticmethod
def _cleanup_succeeded(result: subprocess.CompletedProcess[str]) -> bool:
if result.returncode == 0:
return True
detail = (result.stderr or result.stdout or "").strip().lower()
# 清理是幂等操作:路由或网卡已经消失时,目标状态已经达到。
return any(marker in detail for marker in (
"not found", "does not exist", "cannot find", "no such",
"找不到", "不存在", "未找到", "没有找到",
))
def _cleanup_netsh(self, args: list[str]) -> bool:
try:
result = _run_hidden(["netsh", *args], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
except Exception as exc:
self.detail_log(f"[TUN] 清理 netsh 失败: {exc}")
return False
if self._cleanup_succeeded(result):
return True
detail = (result.stderr or result.stdout or "").strip()
self.detail_log(f"[TUN] 清理 netsh 未完成: {detail[:300]}")
return False
def _remove_node_routes(self) -> bool:
remaining: list[str] = []
for address in self._node_routes:
try:
result = _run_hidden(["route", "DELETE", address], capture_output=True,
text=True, encoding="mbcs", errors="replace", timeout=15)
except Exception as exc:
self.detail_log(f"[TUN] 清理节点路由失败 ({address}): {exc}")
remaining.append(address)
continue
if not self._cleanup_succeeded(result):
detail = (result.stderr or result.stdout or "").strip()
self.detail_log(f"[TUN] 清理节点路由未完成 ({address}): {detail[:200]}")
remaining.append(address)
self._node_routes = remaining
remaining_v6: list[str] = []
for address in self._node_ipv6_routes:
command = ["interface", "ipv6", "delete", "route", f"prefix={address}/128"]
if self._physical_ipv6_ifindex:
command.append(f"interface={self._physical_ipv6_ifindex}")
try:
result = _run_hidden(["netsh", *command], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
except Exception as exc:
self.detail_log(f"[TUN] 清理 IPv6 节点路由失败 ({address}): {exc}")
remaining_v6.append(address)
continue
if not self._cleanup_succeeded(result):
detail = (result.stderr or result.stdout or "").strip()
self.detail_log(f"[TUN] 清理 IPv6 节点路由未完成 ({address}): {detail[:200]}")
remaining_v6.append(address)
self._node_ipv6_routes = remaining_v6
return not remaining and not remaining_v6
def _netsh(self, args: list[str], *, ignore_error: bool = False) -> None:
result = _run_hidden(["netsh", *args], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
if result.returncode and not ignore_error:
detail = (result.stderr or result.stdout or "").strip()
raise TunBackendError(f"netsh 操作失败: {detail[:300]}")
def _configure_routes(self) -> None:
for _ in range(20):
try:
probe = _run_hidden(
["powershell", "-NoProfile", "-Command",
f"Get-NetAdapter -Name '{self._device}' -ErrorAction SilentlyContinue"],
capture_output=True, text=True, timeout=3,
)
if probe.stdout and self._device.lower() in probe.stdout.lower():
break
except Exception:
pass
time.sleep(0.25)
else:
raise TunBackendError(f"tun2socks 未创建虚拟网卡: {self._device}")
self._netsh(["interface", "ipv4", "set", "address",
f"name={self._device}", "source=static",
f"addr={self._address}", "mask=255.255.255.252"])
self._netsh(["interface", "ipv4", "add", "route", "0.0.0.0/0",
f"{self._device}", self._gateway, "metric=1"])
self._route_added = True
self._write_state()
if self._ipv6_enabled:
self._netsh(["interface", "ipv6", "set", "address",
f"interface={self._device}", f"address={self._ipv6_address}",
"store=active"])
self._netsh(["interface", "ipv6", "add", "route", "prefix=::/0",
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
"metric=1"])
self._ipv6_route_added = True
self._write_state()
if not self._bypass_lan:
# Windows 物理网卡通常已有更具体的私网路由;补同前缀的 TUN
# 路由,确保“内网也代理”不会在进入 tun2socks 前被物理网卡截走。
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"169.254.0.0/16"):
self._netsh(["interface", "ipv4", "add", "route", network,
f"{self._device}", self._gateway, "metric=1"])
self._lan_routes.append(network)
self._write_state()
if self._ipv6_enabled:
for network in ("fc00::/7",):
self._netsh(["interface", "ipv6", "add", "route", f"prefix={network}",
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
"metric=1"])
self._lan_ipv6_routes.append(network)
self._write_state()
self._configure_dns()
def _configure_dns(self) -> None:
"""让 Windows DNS 请求也进入 TUN,避免沿用物理网卡 DNS。"""
if os.name != "nt":
return
ipv4 = [item for item in self._tun_dns_servers if ":" not in item]
ipv6 = [item for item in self._tun_dns_servers if ":" in item] if self._ipv6_enabled else []
if ipv4:
self._netsh([
"interface", "ipv4", "set", "dnsservers",
f"name={self._device}", "source=static",
f"address={ipv4[0]}", "register=none", "validate=no",
])
for index, secondary in enumerate(ipv4[1:], start=2):
self._netsh([
"interface", "ipv4", "add", "dnsservers",
f"name={self._device}", f"address={secondary}",
f"index={index}", "validate=no",
], ignore_error=True)
if ipv6:
self._netsh([
"interface", "ipv6", "set", "dnsservers",
f"name={self._device}", "source=static",
f"address={ipv6[0]}", "register=none", "validate=no",
], ignore_error=True)
for index, secondary in enumerate(ipv6[1:], start=2):
self._netsh([
"interface", "ipv6", "add", "dnsservers",
f"name={self._device}", f"address={secondary}",
f"index={index}", "validate=no",
], ignore_error=True)
self._dns_configured = True
self._write_state()
def _remove_routes(self) -> None:
cleanup_ok = True
if self._dns_configured:
ipv4_ok = self._cleanup_netsh([
"interface", "ipv4", "set", "dnsservers",
f"name={self._device}", "source=dhcp",
])
ipv6_ok = self._cleanup_netsh([
"interface", "ipv6", "set", "dnsservers",
f"name={self._device}", "source=dhcp",
])
cleanup_ok &= ipv4_ok and ipv6_ok
if ipv4_ok and ipv6_ok:
self._dns_configured = False
if self._route_added:
route_ok = self._cleanup_netsh(["interface", "ipv4", "delete", "route",
"0.0.0.0/0", f"{self._device}", self._gateway])
cleanup_ok &= route_ok
if route_ok:
self._route_added = False
if self._ipv6_route_added:
route_ok = self._cleanup_netsh(["interface", "ipv6", "delete", "route",
"prefix=::/0", f"interface={self._device}",
f"nexthop={self._ipv6_gateway}"])
cleanup_ok &= route_ok
if route_ok:
self._ipv6_route_added = False
remaining_lan: list[str] = []
for network in self._lan_routes:
route_ok = self._cleanup_netsh(["interface", "ipv4", "delete", "route",
network, f"{self._device}", self._gateway])
cleanup_ok &= route_ok
if not route_ok:
remaining_lan.append(network)
self._lan_routes = remaining_lan
remaining_lan_v6: list[str] = []
for network in self._lan_ipv6_routes:
route_ok = self._cleanup_netsh(["interface", "ipv6", "delete", "route",
f"prefix={network}", f"interface={self._device}",
f"nexthop={self._ipv6_gateway}"])
cleanup_ok &= route_ok
if not route_ok:
remaining_lan_v6.append(network)
self._lan_ipv6_routes = remaining_lan_v6
cleanup_ok &= self._remove_node_routes()
self._cleanup_pending = not cleanup_ok
if cleanup_ok:
self._clear_state()
else:
self._write_state()
self.detail_log("[TUN] 部分路由/DNS 清理失败,已保留状态并将在下次启动重试")
def _write_state(self) -> None:
"""记录本进程改过的路由/DNS,强杀后下次启动可恢复。"""
try:
self.user_dir.mkdir(parents=True, exist_ok=True)
payload = {
"device": self._device,
"address": self._address,
"gateway": self._gateway,
"ipv6_address": self._ipv6_address,
"ipv6_gateway": self._ipv6_gateway,
"lan_routes": list(self._lan_routes),
"lan_ipv6_routes": list(self._lan_ipv6_routes),
"bypass_lan": bool(self._bypass_lan),
"physical_gateway": self._physical_gateway,
"physical_ifindex": self._physical_ifindex,
"physical_ipv6_gateway": self._physical_ipv6_gateway,
"physical_ipv6_ifindex": self._physical_ipv6_ifindex,
"node_routes": list(self._node_routes),
"node_ipv6_routes": list(self._node_ipv6_routes),
"route_added": bool(self._route_added),
"ipv6_route_added": bool(self._ipv6_route_added),
"ipv6_enabled": bool(self._ipv6_enabled),
"dns_configured": bool(self._dns_configured),
"dns_servers": list(self._tun_dns_servers),
"cleanup_pending": bool(self._cleanup_pending),
"pid": os.getpid(),
}
temp = self._state_path.with_suffix(".tmp")
temp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
temp.replace(self._state_path)
except Exception as exc:
self.detail_log(f"[TUN] 状态记录失败: {exc}")
def _clear_state(self) -> None:
try:
self._state_path.unlink(missing_ok=True)
except Exception:
pass
@staticmethod
def _pid_alive(pid: Any) -> bool:
"""避免合并版误清理仍在运行的旧 TUN 实例。"""
if os.name != "nt":
return False
try:
number = int(pid)
if number <= 0:
return False
kernel = ctypes.windll.kernel32
kernel.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_bool, ctypes.c_ulong]
kernel.OpenProcess.restype = ctypes.c_void_p
kernel.GetExitCodeProcess.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
kernel.CloseHandle.argtypes = [ctypes.c_void_p]
handle = kernel.OpenProcess(0x1000, False, number)
if not handle:
return False
try:
code = ctypes.c_ulong()
return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))
and code.value == 259)
finally:
kernel.CloseHandle(handle)
except (TypeError, ValueError, OSError):
return False
def _recover_stale_state(self) -> None:
"""启动前清理上次崩溃/强杀留下的 RRPP-TUN 路由和 DNS。"""
try:
if not self._state_path.is_file():
return
state = json.loads(self._state_path.read_text(encoding="utf-8"))
if not state.get("cleanup_pending") and self._pid_alive(state.get("pid")):
raise TunBackendError("检测到另一个 TUN 实例仍在运行,请先关闭后再启动")
self._device = str(state.get("device") or self._device)
self._address = str(state.get("address") or self._address)
self._gateway = str(state.get("gateway") or self._gateway)
self._ipv6_address = str(state.get("ipv6_address") or self._ipv6_address)
self._ipv6_gateway = str(state.get("ipv6_gateway") or self._ipv6_gateway)
self._ipv6_enabled = bool(state.get("ipv6_enabled", bool(state.get("ipv6_route_added"))))
self._physical_ipv6_gateway = str(state.get("physical_ipv6_gateway") or "")
self._physical_ipv6_ifindex = str(state.get("physical_ipv6_ifindex") or "")
self._route_added = bool(state.get("route_added"))
self._ipv6_route_added = bool(state.get("ipv6_route_added"))
self._lan_routes = [str(item).strip() for item in (state.get("lan_routes") or [])
if str(item).strip()]
self._lan_ipv6_routes = [str(item).strip() for item in (state.get("lan_ipv6_routes") or [])
if str(item).strip()]
self._node_routes = [str(item).strip() for item in (state.get("node_routes") or [])
if str(item).strip()]
self._node_ipv6_routes = [str(item).strip() for item in (state.get("node_ipv6_routes") or [])
if str(item).strip()]
self._dns_configured = bool(state.get("dns_configured"))
self._cleanup_pending = True
self._remove_routes()
if self._cleanup_pending:
self.detail_log("[TUN] 上次异常退出的部分路由/DNS 仍未清理,状态已保留")
else:
self.log("已清理上次异常退出遗留的 TUN 路由和 DNS")
except TunBackendError:
raise
except Exception as exc:
self.detail_log(f"[TUN] 异常退出残留清理失败: {exc}")
def _read_output(self, process: subprocess.Popen[str]) -> None:
if process.stdout is None:
return
try:
for line in process.stdout:
line = line.rstrip()
if line:
self.detail_log(f"[TUN] {line}")
except OSError:
pass
def start(self, node: dict[str, Any], *, bypass_lan: bool = True,
route_rules: list[dict[str, Any]] | None = None,
dns_servers: tuple[str, ...] | list[str] | None = None,
ipv6_enabled: bool = False,
direct_target_resolver: Callable[[str, str, int], tuple[str, int] | None] | None = None,
route_rule_resolver: Callable[[str], dict[str, Any] | None] | None = None,
route_node_resolver: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
route_nodes: list[dict[str, Any]] | None = None) -> None:
with self._lock:
if self.status()["running"]:
raise TunBackendError("TUN 已经在运行")
if not self.is_admin():
raise TunBackendError("启动虚拟网卡 TUN 需要管理员权限")
self._bypass_lan = bool(bypass_lan)
self._ipv6_enabled = bool(ipv6_enabled)
if dns_servers is not None:
cleaned = tuple(str(item).strip() for item in dns_servers if str(item).strip())
if not cleaned:
raise TunBackendError("自定义 DNS 不能为空")
try:
for item in cleaned:
address = ipaddress.ip_address(item)
if address.is_unspecified or address.is_multicast:
raise ValueError(item)
except ValueError as exc:
raise TunBackendError("自定义 DNS 必须是有效的 IPv4/IPv6 地址") from exc
self._tun_dns_servers = cleaned[:3]
engine = self.resolve_engine()
interface = self._physical_interface()
source_address = self._physical_source_address(interface)
self._physical_gateway, self._physical_ifindex = self._physical_route_info()
if self._ipv6_enabled:
self._physical_ipv6_gateway, self._physical_ipv6_ifindex = self._physical_ipv6_route_info()
else:
self._physical_ipv6_gateway = self._physical_ipv6_ifindex = ""
if not self._physical_ifindex:
raise TunBackendError("找不到物理默认路由,无法保证代理节点不进入 TUN 回环")
if self._ipv6_enabled and not self._physical_ipv6_ifindex:
self.log("警告:未找到物理 IPv6 默认路由,IPv6 目标将仅尝试通过 IPv4 代理节点转发")
try:
proxy_addresses_by_node: dict[str, list[tuple]] = {}
connection_nodes = [node]
connection_nodes.extend(
(item.get("node") if isinstance(item.get("node"), dict) else item)
for item in (route_nodes or ())
if isinstance(item, dict)
)
for connection_node in connection_nodes:
for hop in self._node_chain(connection_node):
try:
addresses = self._resolve_proxy_addresses(hop)
except OSError as exc:
raise TunBackendError(f"无法解析 HTTP CONNECT 节点地址: {exc}") from exc
if not addresses:
raise TunBackendError("无法解析 HTTP CONNECT 节点地址")
self._add_node_routes(addresses)
proxy_addresses_by_node[self._node_key(hop)] = addresses
first_hop = self._node_chain(node)[0]
proxy_addresses = proxy_addresses_by_node.get(self._node_key(first_hop), [])
if not proxy_addresses:
raise TunBackendError("无法解析 HTTP CONNECT 节点地址")
for route_node in route_nodes or ():
if not isinstance(route_node, dict):
continue
route_value = route_node.get("node") if isinstance(route_node.get("node"), dict) else route_node
for hop in self._node_chain(route_value):
addresses = self._resolve_proxy_addresses(hop)
proxy_addresses_by_node[self._node_key(hop)] = addresses
if not source_address:
self.log("警告:未找到物理网卡 IPv4,节点连接可能无法绕过 TUN")
bridge = Socks5Bridge(
node, bypass_lan=bypass_lan, route_rules=route_rules, log=self.detail_log,
source_address=source_address or None,
proxy_addresses=proxy_addresses,
direct_target_resolver=direct_target_resolver,
route_rule_resolver=route_rule_resolver,
route_node_resolver=route_node_resolver,
proxy_addresses_by_node=proxy_addresses_by_node,
direct_route_ensurer=self._ensure_physical_route,
)
socks_port = bridge.start()
except Exception:
self._remove_routes()
raise
self._bridge = bridge
command = [str(engine), "--device", self._device,
"--proxy", f"socks5://127.0.0.1:{socks_port}"]
# tun2socks 的 Windows socket 选项会把 UDP/TCP socket 绑定到
# --interface 指定的网卡;本项目的 SOCKS relay 在本机回环地址,
# 绑定 WLAN 会让 UDP 回环投递失效。Windows 官方用法也不传该参数。
# 节点连接已经通过物理 /32 路由和 source_address 绕过 TUN,
# 因此 Windows 不需要 --interface;Linux/macOS 仍保留原行为。
if interface and os.name != "nt":
command.extend(["--interface", interface])
self.detail_log(f"本地 SOCKS5 桥接: 127.0.0.1:{socks_port}")
try:
self._process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
threading.Thread(target=self._read_output, args=(self._process,), daemon=True).start()
time.sleep(0.5)
if self._process.poll() is not None:
raise TunBackendError(f"tun2socks 启动失败,退出码 {self._process.returncode}")
self._configure_routes()
self._write_state()
self.log(f"虚拟网卡 TUN 已启动,进程 PID={self._process.pid}")
except Exception:
bridge.stop()
self._remove_routes()
self._bridge = None
process = self._process
self._process = None
if process is not None and process.poll() is None:
process.terminate()
raise
def stop(self) -> None:
with self._lock:
process = self._process
bridge = self._bridge
self._process = None
self._bridge = None
self._remove_routes()
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
if bridge is not None:
bridge.stop()
if process is not None:
self.log("虚拟网卡 TUN 已停止")
def status(self) -> dict[str, Any]:
with self._lock:
process = self._process
if process is None:
return {"running": False, "pid": None, "exit_code": None}
code = process.poll()
if code is not None:
self._process = None
self._remove_routes()
bridge = self._bridge
self._bridge = None
if bridge is not None:
bridge.stop()
return {"running": False, "pid": process.pid, "exit_code": code}
return {"running": True, "pid": process.pid, "exit_code": None}
def traffic(self) -> tuple[int, int, int, int]:
if self._bridge is None:
return 0, 0, 0, 0
return self._bridge.traffic()
+253
View File
@@ -0,0 +1,253 @@
"""按需提权的 TUN 控制通道;GUI 与管理员工作进程共用同一个入口。"""
from __future__ import annotations
import ctypes
import hmac
import json
import os
import secrets
import socket
import subprocess
import threading
import time
from typing import Any, Callable
WORKER_FLAG = "--rrpp-tun-worker"
_MAX_MESSAGE = 1024 * 1024
class TunModeError(RuntimeError):
pass
def _send(sock: socket.socket, value: dict[str, Any], lock: threading.Lock | None = None) -> None:
payload = (json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
if len(payload) > _MAX_MESSAGE:
raise TunModeError("TUN 控制消息过大")
if lock is None:
sock.sendall(payload)
else:
with lock:
sock.sendall(payload)
def _receive(reader) -> dict[str, Any]:
line = reader.readline(_MAX_MESSAGE + 1)
if not line or len(line) > _MAX_MESSAGE or not line.endswith(b"\n"):
raise TunModeError("TUN 工作进程连接已断开或消息无效")
value = json.loads(line.decode("utf-8"))
if not isinstance(value, dict):
raise TunModeError("TUN 控制消息格式错误")
return value
class TunModeController:
"""普通权限 GUI 仅在首次开启 TUN 时启动管理员工作进程。"""
def __init__(self, command: list[str], log: Callable[[str], None],
detail_log: Callable[[str], None] | None = None):
self.command = command
self.log = log
self.detail_log = detail_log or log
self._lock = threading.RLock()
self._socket: socket.socket | None = None
self._reader = None
def _launch(self, port: int, token: str) -> None:
args = [*self.command[1:], WORKER_FLAG, str(port), token]
if os.name == "nt":
# PyInstaller onefile 的提权子进程必须独立解包,以便 GUI 退出后仍可清理网络。
previous = os.environ.get("PYINSTALLER_RESET_ENVIRONMENT")
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
try:
result = ctypes.windll.shell32.ShellExecuteW(
None, "runas", self.command[0], subprocess.list2cmdline(args),
os.path.dirname(os.path.abspath(self.command[0])), 0,
)
finally:
if previous is None:
os.environ.pop("PYINSTALLER_RESET_ENVIRONMENT", None)
else:
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = previous
if result <= 32:
raise TunModeError("管理员授权已取消或无法启动 TUN 工作进程")
else:
# 非 Windows 仅供无网卡的协议测试;真实 TUN 后端仍限定 Windows。
subprocess.Popen([self.command[0], *args], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
def start(self, payload: dict[str, Any], cancel_event=None) -> None:
with self._lock:
if self._socket is not None:
raise TunModeError("TUN 工作进程已经启动")
token = secrets.token_hex(32)
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(2)
deadline = time.monotonic() + 90
try:
self._launch(listener.getsockname()[1], token)
while True:
if cancel_event is not None and cancel_event.is_set():
raise TunModeError("用户取消了开启代理")
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TunModeError("等待 TUN 管理员工作进程超时")
listener.settimeout(min(0.5, remaining))
try:
connection, _peer = listener.accept()
except socket.timeout:
continue
connection.settimeout(3)
reader = connection.makefile("rb")
try:
hello = _receive(reader)
valid = (hello.get("type") == "hello" and
hmac.compare_digest(str(hello.get("token") or ""), token))
except Exception:
valid = False
if valid:
connection.settimeout(120)
self._socket, self._reader = connection, reader
break
reader.close()
connection.close()
if cancel_event is not None and cancel_event.is_set():
raise TunModeError("用户取消了开启代理")
self._request("start", payload)
except Exception:
self.close()
raise
finally:
listener.close()
def _request(self, command: str, payload: dict[str, Any] | None = None) -> Any:
if self._socket is None or self._reader is None:
raise TunModeError("TUN 工作进程未连接")
try:
_send(self._socket, {"command": command, "payload": payload or {}})
while True:
message = _receive(self._reader)
if message.get("type") == "log":
callback = self.detail_log if message.get("detail") else self.log
callback(str(message.get("message") or ""))
continue
if message.get("type") != "result":
raise TunModeError("TUN 工作进程响应无效")
if not message.get("ok"):
raise TunModeError(str(message.get("error") or "TUN 操作失败"))
return message.get("value")
except TunModeError:
raise
except OSError as exc:
raise TunModeError(f"TUN 工作进程连接中止: {exc}") from exc
def status(self) -> dict[str, Any]:
with self._lock:
if self._socket is None:
return {"running": False, "pid": None, "exit_code": None}
try:
return self._request("status")
except (OSError, TunModeError, ValueError):
self.close()
return {"running": False, "pid": None, "exit_code": None}
def traffic(self) -> tuple[int, int, int, int]:
with self._lock:
if self._socket is None:
return 0, 0, 0, 0
try:
return tuple(self._request("traffic"))
except (OSError, TunModeError, ValueError):
return 0, 0, 0, 0
def stop(self) -> None:
with self._lock:
try:
if self._socket is not None:
self._request("stop")
finally:
self.close()
def close(self) -> None:
with self._lock:
if self._socket is not None:
try:
self._request("quit")
except (OSError, TunModeError, ValueError):
pass
reader, connection = self._reader, self._socket
self._reader = self._socket = None
if reader is not None:
reader.close()
if connection is not None:
connection.close()
def run_tun_worker(port: int, token: str, backend_factory,
start_backend: Callable[[Any, dict[str, Any]], None]) -> None:
"""管理员进程入口。主 GUI 断开后,必定清理本进程建立的网络状态。"""
connection = socket.create_connection(("127.0.0.1", int(port)), timeout=15)
connection.settimeout(None)
reader = connection.makefile("rb")
send_lock = threading.Lock()
_send(connection, {"type": "hello", "token": token}, send_lock)
def emit(message: str, detail: bool = False) -> None:
try:
_send(connection, {"type": "log", "message": str(message),
"detail": bool(detail)}, send_lock)
except OSError:
pass
class _WorkerLogger:
def __call__(self, message: str) -> None:
emit(message)
def detail(self, message: str) -> None:
emit(message, detail=True)
logger = _WorkerLogger()
backend = None
try:
while True:
try:
request = _receive(reader)
except TunModeError:
break
command = request.get("command")
try:
if command == "start":
if not isinstance(request.get("payload"), dict):
raise TunModeError("TUN 启动参数无效")
# 延迟创建后端,使初始化失败也能通过控制协议返回具体原因,
# 避免 GUI 只看到 Windows 的 10053 连接中止错误。
if backend is None:
backend = backend_factory(logger)
start_backend(backend, request["payload"])
value = None
elif command == "stop":
backend.stop()
value = None
elif command == "status":
value = backend.status()
elif command == "traffic":
value = backend.traffic()
elif command == "quit":
backend.stop()
value = None
else:
raise TunModeError("未知的 TUN 控制命令")
_send(connection, {"type": "result", "ok": True, "value": value}, send_lock)
except Exception as exc:
_send(connection, {"type": "result", "ok": False, "error": str(exc)}, send_lock)
if command == "quit":
break
finally:
if backend is not None:
backend.stop()
reader.close()
connection.close()
+120
View File
@@ -0,0 +1,120 @@
"""SagerNet UDP over TCP v2 帧编解码。
UoT 只负责把 UDP 数据报装入已有的可靠 TCP 字节流;外层连接仍由
HTTP CONNECT + TLS 建立。此模块不依赖 sing-box。
"""
from __future__ import annotations
import ipaddress
import socket
import struct
from dataclasses import dataclass
MAGIC_ADDRESS = "sp.v2.udp-over-tcp.arpa"
MAX_DATAGRAM = 65535
@dataclass(frozen=True)
class Address:
host: str
port: int
def encode_address(host: str, port: int) -> bytes:
host = str(host).strip()
if not 0 <= int(port) <= 65535:
raise ValueError("端口超出范围")
try:
addr = ipaddress.ip_address(host)
except ValueError:
raw = host.encode("idna")
if not 1 <= len(raw) <= 255:
raise ValueError("域名长度超出 UoT 限制")
return b"\x02" + bytes([len(raw)]) + raw + struct.pack("!H", int(port))
if addr.version == 4:
return b"\x00" + addr.packed + struct.pack("!H", int(port))
return b"\x01" + addr.packed + struct.pack("!H", int(port))
def decode_address(data: bytes, offset: int = 0) -> tuple[Address, int]:
if offset >= len(data):
raise ValueError("UoT 地址头不完整")
atyp = data[offset]
offset += 1
if atyp == 0:
size = 4
elif atyp == 1:
size = 16
elif atyp == 2:
if offset >= len(data):
raise ValueError("UoT 域名长度缺失")
size = data[offset]
offset += 1
else:
raise ValueError(f"未知 UoT 地址类型: {atyp}")
end = offset + size
if end + 2 > len(data):
raise ValueError("UoT 地址头不完整")
raw = data[offset:end]
port = struct.unpack("!H", data[end:end + 2])[0]
if atyp == 0:
host = str(ipaddress.IPv4Address(raw))
elif atyp == 1:
host = str(ipaddress.IPv6Address(raw))
else:
host = raw.decode("idna")
return Address(host, port), end + 2
def encode_request(is_connect: bool, destination: Address) -> bytes:
return (b"\x01" if is_connect else b"\x00") + encode_address(
destination.host, destination.port
)
def encode_connect_frame(payload: bytes) -> bytes:
if len(payload) > MAX_DATAGRAM:
raise ValueError("UoT 数据报超过 65535 字节")
return struct.pack("!H", len(payload)) + payload
def encode_datagram_frame(destination: Address, payload: bytes) -> bytes:
if len(payload) > MAX_DATAGRAM:
raise ValueError("UoT 数据报超过 65535 字节")
return encode_address(destination.host, destination.port) + struct.pack("!H", len(payload)) + payload
def read_exact(stream, size: int) -> bytes:
chunks = []
remaining = size
while remaining:
chunk = stream.recv(remaining)
if not chunk:
raise ConnectionError("UoT TCP 流已关闭")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def read_connect_frame(stream) -> bytes:
size = struct.unpack("!H", read_exact(stream, 2))[0]
return read_exact(stream, size)
def read_datagram_frame(stream) -> tuple[Address, bytes]:
# 地址是变长的,只能先读 ATYP,再按类型补齐。
head = read_exact(stream, 1)
atyp = head[0]
if atyp == 0:
rest = read_exact(stream, 6)
elif atyp == 1:
rest = read_exact(stream, 18)
elif atyp == 2:
length = read_exact(stream, 1)
rest = length + read_exact(stream, length[0] + 2)
else:
raise ValueError(f"未知 UoT 地址类型: {atyp}")
address, _ = decode_address(head + rest)
size = struct.unpack("!H", read_exact(stream, 2))[0]
return address, read_exact(stream, size)
+891 -32
View File
File diff suppressed because it is too large Load Diff
+65 -1
View File
@@ -2,7 +2,23 @@
from PyQt5.QtCore import Qt, QRectF, pyqtSignal
from PyQt5.QtGui import QColor, QPainter
from PyQt5.QtWidgets import QCheckBox, QLabel
from PyQt5.QtWidgets import QCheckBox, QLabel, QFrame, QHBoxLayout
try:
from PyQt5.QtWidgets import QGraphicsDropShadowEffect
except ImportError: # 轻量测试桩不提供图形效果类,真实 PyQt5 仍使用阴影。
class QGraphicsDropShadowEffect: # type: ignore[no-redef]
def __init__(self, *_args, **_kwargs):
pass
def setBlurRadius(self, *_args):
pass
def setOffset(self, *_args):
pass
def setColor(self, *_args):
pass
from .theme import C_BORDER, C_GREEN, C_RED, C_TITLE
@@ -102,3 +118,51 @@ class StatusDot(QLabel):
border: 1px solid rgba(136, 153, 170, 50);
}}
""")
class ProxyModeCard(QFrame):
"""整个卡片可点击;描边表示选择,圆点表示所选模式的运行状态。"""
clicked = pyqtSignal()
def __init__(self, title: str, parent=None):
super().__init__(parent)
self.setCursor(Qt.PointingHandCursor)
self._glow = QGraphicsDropShadowEffect(self)
self._glow.setBlurRadius(12)
self._glow.setOffset(0, 1)
self.setGraphicsEffect(self._glow)
row = QHBoxLayout(self)
row.setContentsMargins(11, 10, 11, 10)
row.setSpacing(5)
self.title = QLabel(title)
self.title.setStyleSheet("font-weight: bold; background: transparent;")
self.dot = StatusDot()
for child in (self.title, self.dot):
child.setAttribute(Qt.WA_TransparentForMouseEvents)
row.addWidget(self.title)
row.addStretch()
row.addWidget(self.dot)
self.set_state(False, False)
def set_state(self, selected: bool, running: bool) -> None:
border = C_TITLE if selected else "#e2e8ef"
background = "#f5f7f9" if selected else "#fafcfe"
self.setStyleSheet(
f"QFrame {{ background-color: {background}; border: {'2px' if selected else '1px'} "
f"solid {border}; border-radius: 8px; }}"
"QFrame:hover { border-color: #cbd6e2; background-color: #ffffff; }"
"QLabel { border: none; }"
)
self._glow.setColor(QColor(136, 153, 170, 105 if selected else 52))
if not selected:
self.dot.set_disabled()
elif running:
self.dot.set_on()
else:
self.dot.set_off()
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton and self.rect().contains(event.pos()):
self.clicked.emit()
super().mouseReleaseEvent(event)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Jason Lyu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+84
View File
@@ -0,0 +1,84 @@
Prebuilt Binaries License
-------------------------
1. DEFINITIONS. "Software" means the precise contents of the "wintun.dll"
files that are included in the .zip file that contains this document as
downloaded from wintun.net/builds.
2. LICENSE GRANT. WireGuard LLC grants to you a non-exclusive and
non-transferable right to use Software for lawful purposes under certain
obligations and limited rights as set forth in this agreement.
3. RESTRICTIONS. Software is owned and copyrighted by WireGuard LLC. It is
licensed, not sold. Title to Software and all associated intellectual
property rights are retained by WireGuard. You must not:
a. reverse engineer, decompile, disassemble, extract from, or otherwise
modify the Software;
b. modify or create derivative work based upon Software in whole or in
parts, except insofar as only the API interfaces of the "wintun.h" file
distributed alongside the Software (the "Permitted API") are used;
c. remove any proprietary notices, labels, or copyrights from the Software;
d. resell, redistribute, lease, rent, transfer, sublicense, or otherwise
transfer rights of the Software without the prior written consent of
WireGuard LLC, except insofar as the Software is distributed alongside
other software that uses the Software only via the Permitted API;
e. use the name of WireGuard LLC, the WireGuard project, the Wintun
project, or the names of its contributors to endorse or promote products
derived from the Software without specific prior written consent.
4. LIMITED WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTY OF
ANY KIND. WIREGUARD LLC HEREBY EXCLUDES AND DISCLAIMS ALL IMPLIED OR
STATUTORY WARRANTIES, INCLUDING ANY WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE, QUALITY, NON-INFRINGEMENT, TITLE, RESULTS,
EFFORTS, OR QUIET ENJOYMENT. THERE IS NO WARRANTY THAT THE PRODUCT WILL BE
ERROR-FREE OR WILL FUNCTION WITHOUT INTERRUPTION. YOU ASSUME THE ENTIRE
RISK FOR THE RESULTS OBTAINED USING THE PRODUCT. TO THE EXTENT THAT
WIREGUARD LLC MAY NOT DISCLAIM ANY WARRANTY AS A MATTER OF APPLICABLE LAW,
THE SCOPE AND DURATION OF SUCH WARRANTY WILL BE THE MINIMUM PERMITTED UNDER
SUCH LAW. ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS AND
WARRANTIES, INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR
A PARTICULAR PURPOSE OR NON-INFRINGEMENT ARE DISCLAIMED, EXCEPT TO THE
EXTENT THAT THESE DISCLAIMERS ARE HELD TO BE LEGALLY INVALID.
5. LIMITATION OF LIABILITY. To the extent not prohibited by law, in no event
WireGuard LLC or any third-party-developer will be liable for any lost
revenue, profit or data or for special, indirect, consequential, incidental
or punitive damages, however caused regardless of the theory of liability,
arising out of or related to the use of or inability to use Software, even
if WireGuard LLC has been advised of the possibility of such damages.
Solely you are responsible for determining the appropriateness of using
Software and accept full responsibility for all risks associated with its
exercise of rights under this agreement, including but not limited to the
risks and costs of program errors, compliance with applicable laws, damage
to or loss of data, programs or equipment, and unavailability or
interruption of operations. The foregoing limitations will apply even if
the above stated warranty fails of its essential purpose. You acknowledge,
that it is in the nature of software that software is complex and not
completely free of errors. In no event shall WireGuard LLC or any
third-party-developer be liable to you under any theory for any damages
suffered by you or any user of Software or for any special, incidental,
indirect, consequential or similar damages (including without limitation
damages for loss of business profits, business interruption, loss of
business information or any other pecuniary loss) arising out of the use or
inability to use Software, even if WireGuard LLC has been advised of the
possibility of such damages and regardless of the legal or quitable theory
(contract, tort, or otherwise) upon which the claim is based.
6. TERMINATION. This agreement is affected until terminated. You may
terminate this agreement at any time. This agreement will terminate
immediately without notice from WireGuard LLC if you fail to comply with
the terms and conditions of this agreement. Upon termination, you must
delete Software and all copies of Software and cease all forms of
distribution of Software.
7. SEVERABILITY. If any provision of this agreement is held to be
unenforceable, this agreement will remain in effect with the provision
omitted, unless omission would frustrate the intent of the parties, in
which case this agreement will immediately terminate.
8. RESERVATION OF RIGHTS. All rights not expressly granted in this agreement
are reserved by WireGuard LLC. For example, WireGuard LLC reserves the
right at any time to cease development of Software, to alter distribution
details, features, specifications, capabilities, functions, licensing
terms, release dates, APIs, ABIs, general availability, or other
characteristics of the Software.
+12
View File
@@ -0,0 +1,12 @@
RRPP Lite TUN 目录验证运行时
- tun2socks.exe: xjasonlyu/tun2socks v2.7.0, Windows amd64
SHA-256: 076b3c3d6a372ba3ef49f2b415a4105f70c30a3ed3caaed7979390e649892559
- wintun.dll: WireGuard Wintun 0.14.1, amd64
SHA-256: e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce
来源:
https://github.com/xjasonlyu/tun2socks/releases/tag/v2.7.0
https://www.wintun.net/
许可证文件见 LICENSE.tun2socks.txt 和 LICENSE.wintun.txt。
Binary file not shown.
Binary file not shown.
-4
View File
@@ -1,4 +0,0 @@
@echo off
cd /d "%~dp0"
call ..\.venv\Scripts\python.exe main.py
pause
-10
View File
@@ -1,10 +0,0 @@
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
@@ -1,6 +0,0 @@
class QFont: pass
class QIcon: pass
class QPixmap: pass
class QImage: pass
class QColor: pass
class QPainter: pass
-28
View File
@@ -1,28 +0,0 @@
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
@@ -1,2 +0,0 @@
# PyQt5 桩模块:仅供 WSL 下 import main 做路由逻辑测试,
# 决策函数(_decide_direct 等)不依赖任何 Qt 对象。
-127
View File
@@ -1,127 +0,0 @@
# -*- 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
@@ -1,21 +0,0 @@
[
{
"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
@@ -1,151 +0,0 @@
# -*- 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
@@ -1,114 +0,0 @@
# -*- 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
@@ -1,739 +0,0 @@
# -*- 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)
+404 -2
View File
@@ -22,6 +22,7 @@
- [9. systemd 服务文件](#9-systemd-服务文件)
- [10. 启动顺序与验证清单](#10-启动顺序与验证清单)
- [11. 目录结构与文件索引](#11-目录结构与文件索引)
- [12. RRPP 订阅后端(PAC → 规则集下发)](#12-rrpp-订阅后端pac--规则集下发)
---
@@ -157,6 +158,110 @@ fail2ban-client status sshd # 确认 jail 在跑
> - 只加 fail2ban、**不动 sshd 认证方式**(root 登录等现状保留)。
> - 如需同时护 nginx CONNECT 的 401 爆破,可再加 nginx jail(凭据够强时可不做)。
### 2.6 系统级网络优化(KVM / LXC 差异)与 qdisc 持久化
> 模板:[sysctl/99-zz-rrpp-network.kvm.conf](sysctl/99-zz-rrpp-network.kvm.conf)(完整 VM,含 Azure 等)、
> [sysctl/99-zz-rrpp-network.lxc.conf](sysctl/99-zz-rrpp-network.lxc.conf)(容器)
> 落地位置统一为 `/etc/sysctl.d/99-zz-rrpp-network.conf`;
> qdisc 伴侣:[systemd/rrpp-qdisc.service](systemd/rrpp-qdisc.service) + [scripts/rrpp-qdisc.sh](scripts/rrpp-qdisc.sh)。
```bash
# KVM / 完整 VM
install -m 644 sysctl/99-zz-rrpp-network.kvm.conf /etc/sysctl.d/99-zz-rrpp-network.conf
# LXC / OpenVZ 容器
install -m 644 sysctl/99-zz-rrpp-network.lxc.conf /etc/sysctl.d/99-zz-rrpp-network.conf
# qdisc 持久化(仅完整 VM 有意义;容器内网卡是 noqueue,脚本会自动跳过)
install -m 755 scripts/rrpp-qdisc.sh /usr/local/sbin/rrpp-qdisc.sh
install -m 644 systemd/rrpp-qdisc.service /etc/systemd/system/
systemctl daemon-reload && systemctl enable --now rrpp-qdisc
sysctl --system
```
**为什么文件名是 `99-zz-`**:sysctl 按文件名字典序加载、**后者覆盖前者**。`99-zz-` 保证本文件排在最后,
避免被其它 `99-*.conf` 覆盖。
**⚠ 最容易踩的坑:`/etc/sysctl.conf`**。`sysctl --system` 会在**所有 sysctl.d 之后**再加载 `/etc/sysctl.conf`。
2026-09 实测:某节点(aq)存在 2022 年的一键优化版 `/etc/sysctl.conf`,把 `tcp_mtu_probing` 覆盖回 `0`,
导致 sysctl.d 里的设置完全不生效(现象:`sysctl -n` 与文件内容不一致)。
**处理**:该文件里凡是与本规范重叠的键,逐行注释掉(保留其非冲突的调优,如 conntrack、rps、swappiness),
或直接把本规范内容追加到该文件末尾。排查方法:
```bash
sysctl -p /etc/sysctl.d/99-zz-rrpp-network.conf # 显式加载后应看到期望值
sysctl --system 2>/dev/null | grep -n tcp_mtu_probing | tail -5 # 看谁在最后覆盖
```
**KVM / 完整 VM 可写(本规范设置项)**
| 项 | 值 | 作用 |
|---|---|---|
| `tcp_congestion_control` / `core.default_qdisc` | `bbr` / `fq` | BBR 需 fq 搭配,否则 bufferbloat |
| `tcp_mtu_probing` | 1 | PMTUD 黑洞缓解(见 2.4) |
| `tcp_fastopen` | 3 | 客户端+服务端 TFO,省一次 RTT |
| `tcp_slow_start_after_idle` | 0 | 空闲后不回退慢启动(长连接代理有益) |
| `tcp_tw_reuse` / `tcp_fin_timeout` | 2 / 15 | TIME-WAIT 复用与回收 |
| `tcp_keepalive_time/intvl/probes` | 300 / 30 / 5 | 及时清理死连接(默认 7200 过长) |
| `ip_local_port_range` | 10240 65000 | 出站端口范围,防高并发端口耗尽 |
| `core.somaxconn` / `tcp_max_syn_backlog` | 32768 | accept / 半连接队列 |
| `core.netdev_max_backlog` | 32768 | 网卡收包队列(默认 1000 易突发丢包) |
| `core.rmem_max` / `wmem_max` | 33554432 | socket 缓冲上限(默认 208KB 偏小) |
| `tcp_rmem` / `tcp_wmem` | 4K/128K/32M、4K/16K/32M | 自动调优范围 |
**LXC / OpenVZ 容器不可用(由宿主机控制,实测 2026-09)**
| 项 | 现象 |
|---|---|
| `core.rmem_max` / `core.wmem_max` | **不存在**或 `-r--r--r--` 只读 |
| `core.default_qdisc` | **不存在**(`sysctl` 报 cannot stat) |
| `core.netdev_max_backlog` | 缺失 |
| 默认路由网卡根 qdisc | `noqueue`,**无法设置 fq** |
| `/usr/lib/sysctl.d` 的 `kernel.pid_max`、`fs.protected_*` | `permission denied`——容器内属**正常现象**,与本文档无关 |
> 结论:容器节点只能调 **netns 级**项(拥塞算法、MTU 探测、TFO、TW 复用、keepalive、
> somaxconn、syn backlog、本地端口范围);socket 缓冲上限与 qdisc 需服务商在**宿主机**设置。
> 因此容器节点想进一步提升,应优先在**应用层/传输层**做(例如走 UoT 而不是直连 UDP)。
**qdisc 为什么必须单独用 systemd 单元**:`net.core.default_qdisc=fq` **只对新建网卡生效**;
已存在的网卡即使重启也可能仍是 `pfifo_fast` / `fq_codel`(实测:aq 为 `pfifo_fast`、us 为 `fq_codel`)。
`rrpp-qdisc.service` 在开机后显式执行 `tc qdisc replace dev <默认路由网卡> root fq` 补齐;
脚本对容器(`noqueue`)会自动跳过。
**UDP 策略(2026-09-20 定稿)**:UDP 传输(Hysteria2 / TUIC)曾于当日全节点部署并端到端验证,
但基于「**抗封优先**」的评估,**当晚已全部撤除**(详情与留档见 §6.4)。现行口径:
- **节点不部署 UDP 传输服务**;UDP 需求统一走 **UoT v2**(§5.5)——数据报封装进 TCP CONNECT 隧道,
经 nginx magic 地址转本机 `127.0.0.1:15201` relay 解封装;
- 抗封主路径 = **REALITY + Vision(TCP)**,不做 fallback 组(订阅仍为 `select`);
- 节点上的 **UDP 系统调优项保留**(无害,且对 UoT / SS-UDP 有轻微收益)。
撤除理由(实测 + 推演):非标准 UDP 端口被封对 GFW **几乎零代价**(不像封 TCP 443 会误伤大量正常 HTTPS);
UDP 在 CN 长期被 QoS/限速;且一旦 IP 因 UDP 特征被判为代理,会**连带打死同 IP 上的 TCP 服务**(REALITY/SS/nginx)。
加密性本身没有问题(QUIC=TLS1.3,实测证书校验强制、错凭据被拒)——风险在**可封禁面**,不在密码学。
节点侧 UDP 相关系统调优(保留):
| 项 | 值 / 说明 |
|---|---|
| `net.ipv4.udp_rmem_min` / `udp_wmem_min` | `8192`(容器内可写,实测 LXC 同样生效) |
| `net.core.rmem_max` / `wmem_max` | `33554432`(KVM 可写;**LXC 缺失/只读**) |
| `rx-udp-gro-forwarding` / `rx-gro-list`(ethtool) | 由 `rrpp-qdisc.sh` 开机开启;**实测支持情况**:us/jp ✅ 两项;aq(内核 5.10)仅 `rx-gro-list`;**LXC 容器无 netdev 权限,无法设置** |
| QUIC/UDP 吞吐 | 受宿主机 socket 缓冲上限约束——**LXC 节点上这是硬约束**,需服务商在宿主调 `rmem_max` |
**明确不采用的手段**:锐速/LotServer(闭源、绑定旧内核)、net-speeder(重复包、已废弃)、
`tcp_tw_recycle`(4.12 起内核已移除,开启会破坏 NAT 后客户端)、`tcp_notsent_lowat` 激进值
(可能压制高 BDP 吞吐)、`busy_poll`/`busy_read`(1 vCPU 机器上代价大于收益)、各类"一键优化脚本"
(多为本文档子集 + 心理安慰,且常与 sysctl.d 冲突)。
**落地状态(2026-09-20)**
| 节点 | 虚拟化 | 落地情况 |
|---|---|---|
| hk2 | LXC(Debian 12) | LXC 模板;`bbr` + `mtu_probing=1` + `fastopen=3` + 队列/端口项;缓冲与 qdisc 受宿主限制 |
| ru | LXC(Debian 13 cloud 内核) | 同上(`rmem_max`/`wmem_max` 只读) |
| us | KVM(Debian 12) | KVM 模板 + fq(原 `fq_codel`) |
| aq | KVM(Debian 11 cloud) | KVM 模板 + fq(原 `pfifo_fast`);并注释其 `/etc/sysctl.conf` 中 17 行冲突键(原 `tcp_mtu_probing=0`) |
| jp | Azure VM(Ubuntu 6.17) | KVM 模板(经 `sudo` 落地;原有 `99-tune.conf` 保留) |
---
## 3. 伪装站点页面
@@ -265,8 +370,114 @@ chmod 644 /etc/nginx/.htpasswd # worker 以 nobody 运行,必须可读
1. `map` 判定私网/回环段 + cron 维护的本机公网 IP 规则 → 直连伪装页
2. `/__proxy` 的 Loop-Guard 头兜底 → 二次进入返回 508
### 5.5 路线 B 裸 UoT relay
TUN 客户端的 UDP 会先通过认证 CONNECT 请求
`sp.v2.udp-over-tcp.arpa:443`。当前 hk2 使用的
`ngx_http_proxy_connect_module` 没有注册 `$connect_host`,因此在 fallback
server 实际接收的 CONNECT 请求上用 `$request` 正则匹配,再把连接转到
`127.0.0.1:15201`;nginx 只负责 TLS、Basic Auth 和字节流转发,不解析 UoT。relay 使用仓库中的
`[uot_relay.py](uot_relay.py)`,只监听回环地址,不需要 sing-box:
```bash
install -m 755 uot_relay.py /opt/rrpp-uot-relay/uot_relay.py
install -m 644 systemd/uot-relay.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now uot-relay
ss -lntp | grep 127.0.0.1:15201
nginx -t && systemctl reload nginx
```
客户端的 CONNECT 端口必须大于 0,当前统一使用 `443`;`15201` 是服务器回环
relay 的内部端口,不写入客户端 CONNECT 目标。nginx fallback 块使用
`proxy_connect_read_timeout 10m` 和 `proxy_connect_send_timeout 10m`,不要再同时
配置会产生 duplicate 的 `proxy_connect_data_timeout`。
---
### 5.5 UoT(UDP over TCP)支持
HTTP CONNECT 只隧道 TCP。要让 UDP 应用(DNS/游戏/QUIC 等)走代理,需 **UoT v2**
(SagerNet 专有协议,见 sing-box 文档 `configuration/shared/udp-over-tcp`):
客户端把 UDP 数据报封装进 TCP 流,经 CONNECT 隧道发到 magic 地址
`sp.v2.udp-over-tcp.arpa`,由本机 **UoT relay** 解封装后转发真实 UDP 目标。
```
客户端 TUN/UDP ──UoT v2──▶ TLS HTTP CONNECT ──▶ nginx proxy_connect
──▶ 127.0.0.1:15201(uot-relay)──▶ 真实 UDP 目标
```
**relay 部署**(Python 标准库实现,见 [uot-relay/uot_relay.py](uot-relay/uot_relay.py)):
```bash
install -d /opt/rrpp-uot-relay
install -m 755 uot-relay/uot_relay.py /opt/rrpp-uot-relay/uot_relay.py
install -m 644 uot-relay/uot-relay.service /etc/systemd/system/uot-relay.service
systemctl daemon-reload && systemctl enable --now uot-relay
```
relay 只监听 `127.0.0.1:15201`,空闲 9 分钟断开(须小于 nginx 隧道超时 10m)。
支持 UoT v2 两种模式:`isConnect=1` 固定目标、`isConnect=0` 每帧自带目标。
**nginx 侧**(模板已含,[nginx/nginx.conf](nginx/nginx.conf)):`map $request` 识别
magic 地址 → `127.0.0.1:15201`,其余 CONNECT 走 `$connect_addr` 默认解析;
`proxy_connect_address $rrpp_uot_connect_address;` 放在 **default_server 块**。
**四个实测坑(2026-09 全节点部署验证)**:
1. 该模块版本**未注册 `$connect_host` 变量**(README 有、源码无)→ 必须用
`$request` 正则匹配(任何阶段可用);
2. `proxy_connect_data_timeout` 与 `proxy_connect_read_timeout` **写同一配置槽**,
同时写报 duplicate → 长连接超时用 read/send timeout 设 10m;
3. **所有 CONNECT 请求都落在 `default_server`(fallback)块**——nginx 按请求
Host 头选虚拟主机,CONNECT 的 Host 是目标域名,永远匹配不到 `server_name`;
4. 模块**拒绝 CONNECT 端口 0**(`port < 1` 返回 400)→ 客户端连 magic 地址时
端口必须非 0(用 443 即可,relay 不读 CONNECT 端口,目标取自 UoT 帧头)。
**健壮性要求(2026-09 实测修复,别踩)**:
- relay 必须用**未连接**的 UDP socket(`sendto`/`recvfrom`)。用连接式 socket 时,
目标侧 ICMP(port unreachable 等)会变成 `ConnectionRefusedError` 在 send/recv 上抛出,
若被兜底 `except` 接住并 `stop.set()`,**一条数据报出错就杀掉整条 UoT 隧道**
(客户端表现为 "UoT TCP 流已关闭"、多条隧道同秒集体死亡)。
- 数据报级错误(`BlockingIOError`/EAGAIN、`ENETUNREACH`、`EHOSTUNREACH`、`EMSGSIZE`)
**只丢当前包**,EAGAIN 先等可写再重试;只有 TCP EOF、协议错误、空闲超时才关隧道。
- 按地址族惰性建 socket,non-connect 模式可混用 IPv4/IPv6 目标;IPv6 `recvfrom`
返回 4 元组需归一化。
- connect 模式要**预建** socket(否则主循环首轮无可 select 对象,会立刻关连接)。
**节点出口差异(实测)**:RU 节点到境外 DNS(8.8.8.8:53)UDP 成功率仅约 4/10
(俄 ISP 限制),表现为 UoT DNS 偶发超时——属该节点出口特性,非 relay 故障。
此类节点的 UDP 业务建议换用当地可达目标,或走其它节点。
**客户端协议**:认证与 TCP CONNECT 相同(TLS + Basic Auth),然后
`CONNECT sp.v2.udp-over-tcp.arpa:443 HTTP/1.1`,200 之后收发 UoT v2 帧:
请求头 `| isConnect u8 | ATYP u8 | address | port u16be |`,
connect 模式帧 `| length u16be | data |`,non-connect 帧每帧自带目标地址。
### 5.6 CONNECT 超时口径(避免客户端"卡死")
三个超时要按"客户端最没耐心"来配:
| 项 | 值 | 说明 |
|---|---|---|
| `proxy_connect_connect_timeout` | **10s** | 出站连接目标;须 < 客户端超时(lite 客户端 15s) |
| `proxy_connect_read_timeout` / `send_timeout` | 10m | 隧道空闲超时(UoT 长连接需要;注意与 data_timeout 同槽位) |
**典型故障**:目标 IP 是黑洞(不发 RST)时,若 nginx 出站超时设 60s 而客户端 15s 超时,
客户端会表现为 `The read operation timed out` 而不是收到 502 —— 看起来像"代理卡死",
实际是超时错配。2026-09 实测:改为 10s 后,黑洞目标从 >25s 降至 10.2s 返回,
正常目标(0.4~0.7s)不受影响。
**排查"CONNECT 502"的正确顺序**(重要):
1. 先看目标是否**本身就不可达**——从多个网络(国内/日本/美国/香港)实测该 IP:端口,
若都失败则是目标问题(服务下线/过期 IP/黑洞),不是代理问题;
2. 再看 nginx error.log:`Connection refused` / `timed out` / `Network is unreachable`
分别对应目标拒绝、目标黑洞、路由不可达;
3. 最后才怀疑 CONNECT 模块本身。
> 客户端日志只记 IP 不记域名时,无法判断"是不是 DNS 过期解析"。
> 建议客户端日志同时记录原始域名(实测案例:某 Dropbox 旧 IP 已失效,
> 域名现已解析到其它地址,客户端仍在拨旧 IP)。
## 6. sing-box(VLESS+REALITY / Shadowsocks)
### 6.1 安装
@@ -282,6 +493,16 @@ install -m 755 sing-box-$SINGBOX_VER-linux-amd64/sing-box /usr/local/bin/sing-bo
完整配置见 [sing-box/config.json](sing-box/config.json)。要点:
- **VLESS+REALITY**:每节点独立 UUID/密钥对/short_id,SNI 全节点统一,flow `xtls-rprx-vision`;REALITY handshake server 用 `<伪装SNI域名>:443`
- **纯 IPv4 节点**(如 Azure 不给 IPv6 的 jp):direct 出站**必须**配 `domain_resolver`,
否则双栈域名会解析出 AAAA 并拨向不可达的 IPv6(日志刷 `network is unreachable`,
jp 实测每小时数十条)。1.12+ 写法(legacy `domain_strategy` 字段在 sing-box 1.13
已直接 FATAL 拒绝,勿用):
```json
"dns": {"servers": [{"type": "local", "tag": "local-dns"}]},
"outbounds": [{"type": "direct", "tag": "direct",
"domain_resolver": {"server": "local-dns", "strategy": "ipv4_only"}}]
```
已验证:jp 需要(2026-10-01 已修);ru/hk2/us/aq 有 IPv6 出口或实测无此错误,不需要。
- **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 系列
@@ -321,6 +542,94 @@ EOF
---
### 6.4 Hysteria2 / TUIC(UDP 传输)— ⚠️ 已回滚,本节为留档
> **状态:已回滚(2026-09-20 当晚)。** 当日曾在五节点部署并逐组合端到端验证通过(见下表与「验证方法」),
> 随后因「**绝对不可以被 GFW 封禁**」这一优先级,**全部撤除**——五节点 sing-box 已恢复为
> `vless + shadowsocks` 原状,新增的 UDP 入站不再监听。
>
> 本节保留作**留档**:配置片段、各节点 NAT/NSG 差异、端到端验证方法(尤其"只看监听不足以证明外部可达"
> 这个坑)在日后重新启用或换机时仍然有效。若要重新启用,照本节执行即可,**但请先读 §2.6 的撤除理由**。
TCP 系协议在跨境丢包链路上受拥塞控制限制(BBR 已缓解但仍有天花板);QUIC 系
(**Hysteria2** 的 Brutal CC、**TUIC v5**)能显著改善高丢包下的吞吐与时延。
两者都是 **sing-box 内建**,无需额外二进制;UDP 需求也可走 UoT(§5.5)。
```json
// Hysteria2(sing-box inbound)
{ "type": "hysteria2", "tag": "hy2-in-<端口>", "listen": "0.0.0.0", "listen_port": "<端口>",
"up_mbps": 100, "down_mbps": 100,
"obfs": { "type": "salamander", "password": "<obfs密码>" },
"users": [ { "name": "<节点>-hy2", "password": "<hy2密码>" } ],
"tls": { "enabled": true, "server_name": "<节点域名>",
"certificate_path": "/etc/nginx/ssl/<节点域名>/fullchain.pem",
"key_path": "/etc/nginx/ssl/<节点域名>/privkey.pem", "alpn": ["h3"] },
"masquerade": "file:///var/www/html" }
// TUIC v5
{ "type": "tuic", "tag": "tuic-in-<端口>", "listen": "0.0.0.0", "listen_port": "<端口>",
"users": [ { "name": "<节点>-tuic", "uuid": "<UUID>", "password": "<密码>" } ],
"congestion_control": "bbr",
"tls": { /* 同上 */ } }
```
**要点**
- **带宽声明决定 Brutal**:`up_mbps`/`down_mbps` 不写时,服务端会命令客户端改用 BBR;
写了才允许 Hysteria CC(Brutal)。默认 100 Mbps,计量流量的节点给 50。
Brutal **不遵守拥塞控制**,设错会占满链路甚至被服务商判定异常流量——务必按实际带宽设。
- `obfs: salamander` 给 QUIC 加一层混淆(对抗 QUIC 特征识别);`masquerade` 指向伪装页,
未认证访问返回与 nginx 一致的页面。
- TUIC 保持 `zero_rtt_handshake` 关闭(默认,0-RTT 可被重放)。
- 凭据**每节点独立**,与 VLESS/SS/CONNECT 同策略。
**各节点端口与部署差异(2026-09-20 实测,务必照抄结论)**
| 节点 | 虚拟化 | Hysteria2 (UDP) | TUIC (UDP) | 差异 / 坑 |
|---|---|---|---|---|
| hk2 | LXC(NAT) | 4305 | 4306 | NAT 段 4301-4320 UDP 1:1(探针 + 外部回扫实测) |
| us | KVM | 1208 | 1209 | 公网直连,UDP 任意端口可用 |
| ru | LXC(NAT) | 23333 | 23334 | **外部只有宿主已映射的 UDP 端口可达**:容器内 bind 任意端口都"正常",但 23336+ 实测直接 `connection refused`(ICMP port unreachable);最终复用 **23333/23334**——与 nginx/vless 占用的 **TCP 同号**,UDP 与 TCP 互不冲突 |
| aq | KVM(IPv6-only) | 8444 | 8445 | 节点为 IPv6 出口,`listen` 用 `"::"`;客户端须走 IPv6 |
| jp | Azure VM | 未部署 | 8445 | **Azure NSG 只放行 UDP 8445**(443/80/8080/8088/18080/8443/9444 的 UDP 面实测均不可达)→ 单节点只能承载一个 UDP 协议;要加 Hysteria2 须先在 NSG 放行第二个 UDP 端口 |
**客户端链接格式(手工导入用)**
```
hysteria2://<password>@<域名>:<端口>/?sni=<域名>&alpn=h3&obfs=salamander&obfs-password=<obfs密码>#备注
tuic://<uuid>:<password>@<域名>:<端口>/?congestion_control=bbr&alpn=h3&sni=<域名>#备注
```
**接入现状(2026-09-20,务必先读)**
| 层 | 状态 |
|---|---|
| 服务端 | ⛔ **已撤除**(原:五节点部署并端到端验证)——2026-09-20 当晚按抗封优先回滚 |
| **订阅下发** | ❌ 从未下发(`app.py` 无 hysteria2/tuic 生成逻辑,`nodes.json` 无对应字段;订阅里只有 http / vless / ss),且已决定不做 |
| **代理组** | ❌ 订阅只生成一个 `RRPP`(`type: select`,手动选节点),**没有 fallback / url-test**,因此**不存在"UDP 挂了自动回落 TCP"** |
| lite 客户端(Windows) | 仅 HTTP CONNECT + UoT v2(UDP 走 magic 地址),**不使用 UDP 入站**,且节点为手动切换、无自动故障转移 |
| 结论 | UDP 通道目前**只能手工导入**上面的链接使用;抗封主路径仍是 **REALITY+Vision(TCP)**,UDP 属可选加速,未接入客户端链路 |
> 若日后要接入订阅,需要三步:① `nodes.json` 每节点补 `methods.hysteria2` / `methods.tuic`;
> ② `app.py` 增加 mihomo `type: hysteria2` / `type: tuic` 生成与分享链接;
> ③ 代理组改为**每节点一个 `fallback` 组、REALITY 排第一**(否则"UDP 挂了回落 TCP"不成立)。
> 注意 mihomo 的 fallback 依赖健康检查周期(默认 300s),切换不是瞬时的。
**验证方法(端到端,不能只看监听)**
```bash
# 在任意机器用 sing-box 作客户端:socks 入站 + 上述 outbound,然后
curl -s -m 20 -x socks5h://127.0.0.1:11080 -o /dev/null -w '%{http_code}\n' http://example.com/ # 期望 200
```
`ss -lunp` 看到端口在听**不足以**证明外部可达:ru(NAT 未映射)与 jp(NSG 未放行)
都是"监听正常但外部不可达"的真实案例。
⚠️ 代价与限制:UDP 在部分运营商会被 QoS/封禁;Brutal 对同链路其他流量不友好;
计量流量的节点(如 jp,15GB/月)注意消耗;LXC 容器内 socket 缓冲上限受宿主机约束。
---
## 7. mtg(MTProto 代理)
Telegram 内置代理,Fake TLS 模式抗封锁。用 [mtg](https://github.com/9seconds/mtg)(Go 单二进制):
@@ -378,6 +687,8 @@ mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <上面生成的s
| nginx | [systemd/nginx.service](systemd/nginx.service) |
| sing-box | [systemd/sing-box.service](systemd/sing-box.service) |
| mtg | [systemd/mtg.service](systemd/mtg.service) |
| uot-relay | [uot-relay/uot-relay.service](uot-relay/uot-relay.service) |
| rrpp-qdisc(fq 队列,见 2.6) | [systemd/rrpp-qdisc.service](systemd/rrpp-qdisc.service) |
```bash
cp systemd/*.service /etc/systemd/system/
@@ -396,6 +707,7 @@ sysctl --system # 1. BBR
systemctl start sing-box # 2. VLESS/SS
systemctl start mtg # 3. MTProto
systemctl start nginx # 4. nginx(最后,依赖证书/伪装页/规则文件)
systemctl start uot-relay # 5. UoT relay(须在 nginx 前可用)
```
### 验证清单
@@ -405,6 +717,13 @@ systemctl start nginx # 4. nginx(最后,依赖证书/伪装
sysctl net.ipv4.tcp_congestion_control # => bbr
sysctl net.ipv4.tcp_mtu_probing # => 1(PMTUD 黑洞缓解,见 2.4)
# 系统级网络优化(见 2.6):确认 sysctl.d 的值真的生效(注意 /etc/sysctl.conf 覆盖坑)
sysctl -n net.core.somaxconn net.ipv4.tcp_max_syn_backlog net.ipv4.tcp_fastopen \
net.ipv4.tcp_slow_start_after_idle net.ipv4.ip_local_port_range
# KVM/完整 VM 追加:net.core.netdev_max_backlog / rmem_max / wmem_max
tc qdisc show | grep -v noqueue # 期望默认路由网卡为 fq(容器内为 noqueue,属正常)
systemctl is-active rrpp-qdisc # 期望 active(容器内该单元会自行跳过)
# fail2ban
fail2ban-client status sshd # => jail 存在(见 2.5)
@@ -419,6 +738,15 @@ s=socket.connect(('<节点域名>',<mtproto端口>)) if False else socket.create
s.sendall(b'\x16\x03\x01'+struct.pack('>H',100)+os.urandom(100))
print('resp:',s.recv(8).hex())" # 期望首字节 15 (TLS alert)
# UoT relay(127.0.0.1:15201 监听 + CONNECT magic 地址)
ss -tlnp | grep 15201 # => uot_relay.py 监听 127.0.0.1:15201
# UDP 传输(Hysteria2/TUIC)——已于 2026-09-20 撤除;以下仅当重新启用时才适用
# 必须端到端验证,不能只看监听
ss -lunp | grep -E ':<hy2端口>|:<tuic端口>' # 仅确认服务在听
# 再用 sing-box 客户端 + curl 经由 socks 实测(见 6.4「验证方法」)
ethtool -k <网卡> | grep -E 'rx-udp-gro-forwarding|rx-gro-list' # KVM 期望 on;LXC 容器不可设
# nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>'))
B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0)
curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \
@@ -436,22 +764,96 @@ curl -sk https://<节点域名>:<nginx端口>/ -o /dev/null -w '%{http_code}\n'
```
server_config/
├── README.md ← 本文档
├── sysctl/
│ ├── 99-zz-rrpp-network.kvm.conf ← 系统级网络优化(KVM/完整 VM,见 §2.6)
│ └── 99-zz-rrpp-network.lxc.conf ← 系统级网络优化(LXC/OpenVZ 容器,见 §2.6)
├── nginx/
│ ├── nginx.conf ← 完整 nginx 配置(占位符版)
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
│ └── conf.d/ ← 预留:额外站点配置
├── uot-relay/
│ ├── uot_relay.py ← UoT v2 relay(UDP over TCP 解封装)
│ └── uot-relay.service ← systemd unit
├── sing-box/
│ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
├── systemd/
│ ├── nginx.service
│ ├── sing-box.service
│ └── mtg.service
│ ├── mtg.service
│ └── rrpp-qdisc.service ← 开机把默认网卡 qdisc 设为 fq(见 §2.6)
├── scripts/
│ └── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
│ ├── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
│ └── rrpp-qdisc.sh ← fq 队列设置脚本(rrpp-qdisc.service 调用)
├── cron/
│ └── sync-nginx-local-ips ← cron 定义(每 5 分钟)
├── rrpp-subscription/ ← RRPP 订阅后端(bj,见 §12)
│ ├── API.md ← 订阅端点 / method / 分层 / 规则集文档
│ └── gen_rulesets.py ← PAC → 客户端规则集转换脚本
└── fake-site/
└── index.html ← 伪装站点页面(所有节点共用同一份)
```
> 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。
---
## 12. RRPP 订阅后端(PAC → 规则集下发)
订阅服务运行在 **bj**(`C:/BtSoft/nginx/others/backEnd/api-rrrosmontis-icu/RRPP/`,nssm 服务 `RRPPSubBackend`,监听 `127.0.0.1:5010`,由 nginx `api.rrrosmontis.icu` 反代)。
完整端点说明见 `rrpp-subscription/API.md`(bj 上同名文件为主副本,两处内容保持一致)。
### 12.1 PAC 为什么不能直接下发
PAC 是浏览器/系统用的 JS 自动配置,**mihomo/Clash 与 Shadowrocket 都读不了**。两者唯一共同能吃的格式是
「每行自带规则类型的规则行文本」(Clash 的 `behavior: classical`):
- mihomo → `rule-providers`(`behavior: classical` + `format: text`)
- Shadowrocket → `RULE-SET,<url>,<策略>`(官方手册:规则集内容需包含规则类型)
因此由后端 `gen_rulesets.py` 把 PAC 数据转成三个 `.list`,两种客户端共用同一份文件。
### 12.2 规则集产物
| 规则集 | 文件(`https://cdn.rrrosmontis.icu/RRPP/PAC/`) | 规则数 | 策略 |
|---|---|---|---|
| `rrpp-proxy` | `rrpp-proxy.list` | 3943(`DOMAIN` 539 + `DOMAIN-SUFFIX` 3404) | `RRPP` / `PROXY` |
| `rrpp-direct` | `rrpp-direct.list` | 16(`DOMAIN-SUFFIX`) | `DIRECT` |
| `rrpp-cn-ip` | `rrpp-cn-ip.list` | 7456(`IP-CIDR`) | `DIRECT` |
顺序(与 PAC 语义一致):强制代理 → 直连种子 → 中国 IP 直连 → `MATCH` 全代理。
### 12.3 维护:PAC 更新后重新生成
```bash
# bj 上执行(Windows:C:/BtSoft/nginx/others/htmlResources/cdn/RRPP/PAC/ 为生成目录,同时即 CDN 根)
python gen_rulesets.py # 幂等:内容无变化则不写盘;原子替换
python gen_rulesets.py --check # 只预览差异,不写盘
```
产物文件名与 URL 固定,**订阅端与客户端均无需改动**:mihomo 按 `interval: 86400` 自动更新,
Shadowrocket 在「配置 → 规则集URL」中自动更新。
### 12.4 客户端接入
```bash
# mihomo / Clash Verge:规则走 rule-providers(YAML 约 2.4KB)
...&method=clash
# 不认 rule-providers,或要「导入一次到位」:规则内联(约 440KB)
...&method=clash&rules=inline
# Shadowrocket:只下发规则([General] + 远程 RULE-SET + FINAL,不含节点)
...&method=shadowrocket
# 兜底:全流量走代理(旧行为)
...&method=clash&rules=global
```
> `method=shadowrocket` 的 `.conf` 不写 `[Proxy]` 段——Shadowrocket 的 REALITY 参数写法官方未文档化,不猜测;
> 策略 `PROXY` 指向首页当前选中的节点,节点用 `clash`/`v2ray` 订阅导入。
> 想在小火箭里「一次导入节点+规则」,用 `method=clash&rules=inline`。
### 12.5 验证口径
- 生成:11415 条规则(`regexp:` 跳过 7、非法 1)
- CDN:三个 URL HTTP 200,md5 与本地一致(`806808b8` / `8f89397e` / `3e75093d`)
- mihomo v1.19.31 真实内核:`-t` 通过;运行时 `GET /providers/rules` 报 `3943 / 16 / 7456`(`behavior=Classical`),
落地文件字节数与生成一致;`rules=inline` 亦通过 `-t`
- Shadowrocket 为 iOS 应用,未做实机验证(依官方手册 `RULE-SET` 与 Clash YAML 兼容说明实现)
+23 -6
View File
@@ -16,6 +16,13 @@ http {
default 0;
"~^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)" 1;
}
# UoT v2 magic CONNECT 必须在 fallback server 中命中;当前模块版本没有
# $connect_host,使用原始请求行匹配。客户端请求 magic-host:443,nginx
# 将字节流转到本机裸 relay 127.0.0.1:15201。
map $request $rrpp_uot_connect_address {
default $connect_addr;
"~^CONNECT[[:space:]]+sp\.v2\.udp-over-tcp\.arpa" 127.0.0.1:15201;
}
include mime.types;
default_type application/octet-stream;
sendfile on;
@@ -44,9 +51,14 @@ http {
proxy_connect;
proxy_connect_allow all;
proxy_connect_connect_timeout 60s;
proxy_connect_read_timeout 120s;
proxy_connect_send_timeout 120s;
proxy_connect_address $rrpp_uot_connect_address;
# 出站连接超时须小于客户端超时(lite 客户端 15s):
# 目标黑洞时快速返回 502,避免客户端"read operation timed out"干等
proxy_connect_connect_timeout 10s;
# 注:此模块版本 proxy_connect_data_timeout 与 proxy_connect_read_timeout
# 写同一字段(同时配置报 duplicate),长连接超时用 read/send timeout 设置
proxy_connect_read_timeout 10m;
proxy_connect_send_timeout 10m;
location / {
auth_basic off;
@@ -92,9 +104,14 @@ http {
proxy_connect;
proxy_connect_allow all;
proxy_connect_connect_timeout 60s;
proxy_connect_read_timeout 120s;
proxy_connect_send_timeout 120s;
proxy_connect_address $rrpp_uot_connect_address;
# 出站连接超时须小于客户端超时(lite 客户端 15s):
# 目标黑洞时快速返回 502,避免客户端"read operation timed out"干等
proxy_connect_connect_timeout 10s;
# 注:此模块版本 proxy_connect_data_timeout 与 proxy_connect_read_timeout
# 写同一字段(同时配置报 duplicate),长连接超时用 read/send timeout 设置
proxy_connect_read_timeout 10m;
proxy_connect_send_timeout 10m;
location / {
auth_basic off;
+152
View File
@@ -0,0 +1,152 @@
# RRPP 订阅服务 · SSO 认证接入文档
架构:`客户端 → nginx(api.rrrosmontis.icu) → RRPP 后端(127.0.0.1:5010) → SSO(127.0.0.1:4550) → nodes.json(国家分层)`
## 1. 客户端公开端点
### 统一订阅端点
```
GET https://api.rrrosmontis.icu/v1/rrpp/subscription?method=<类型>&user=<b64url邮箱>&password=<b64url密码>[&prefer=<协议>][&rules=<模式>]
```
| 参数 | 必填 | 说明 |
|---|---|---|
| `method` | 是 | `clash` / `v2ray` / `shadowrocket` / `rrpp_client` |
| `user` | 是 | URL-safe base64 编码的邮箱 |
| `password` | 是 | URL-safe base64 编码的密码 |
| `prefer` | 否 | `vless` / `ss` / `http`,指定优先协议;缺省 `vless`,无则回退 vless→ss→http |
| `rules` | 否 | 仅 `method=clash`:`provider`(默认) / `inline` / `global`,见 §7 |
| `token` | 否 | SSO 会话 token(免密,代登录态);与 `user`/`password` 二选一 |
### 旧端点(兼容)
```
GET /v1/rrpp/nodes_update?user=<b64>&password=<b64>
```
等价于 `method=rrpp_client`。
## 2. method 说明
| method | Content-Type | 内容 | 适用客户端 |
|---|---|---|---|
| `clash` | `text/yaml` | Clash YAML:`proxies` + `proxy-groups(RRPP)` + `rule-providers` + `rules`(备注 `R.R.P.P. - 国家代码`) | mihomo / Clash Verge / ClashX Meta |
| `v2ray` | `text/plain` | base64 节点串(`vless://` / `ss://` / `http://`) | v2rayN / Shadowrocket / Quantumult |
| `shadowrocket` | `text/plain` | Shadowrocket `.conf`:`[General]` + 远程 `RULE-SET` + `FINAL`(**不含节点**) | Shadowrocket |
| `rrpp_client` | `application/json` | 旧格式节点 JSON(仅 http,remark = `服务商 流量`) | RRPP 自建客户端 |
## 3. 返回码
| 码 | 场景 |
|---|---|
| 200 | 成功 |
| 401 | 凭据缺失/SSO 认证失败 |
| 403 | tier=0(RRPP 未开通) |
| 404 | method 未知,或 `rules` 取值非法 |
| 502 | SSO 不可达 |
| 503 | nodes.json 缺失/损坏,或该 tier 无可用节点 |
## 4. nodes.json 格式(国家分层)
`C:/BtSoft/nginx/others/backEnd/api-rrrosmontis-icu/RRPP/nodes.json`,顶层为国家数组:
```jsonc
[
{
"country": "JP", // 国家代码
"available_level": [1, 2, 3], // 可见等级
"provider": "Azure", // 服务商
"traffic": "15G", // 流量限额
"domain": "iam.aeolianchi.me", // 域名
"ipv6_only": false, // 可选,true 时备注加 (IPv6)
"methods": { // 可用协议
"http": { "port": ..., "username": ..., "password": ... },
"vless": { "port": ..., "uuid": ..., "flow": ..., "sni": ..., "public_key": ..., "short_id": ... },
"ss": { "port": ..., "method": ..., "password": ... }
}
},
...
]
```
## 5. 协议选择逻辑
- `prefer` 指定则优先用该协议
- 缺省默认 `vless`(最好用)
- 该国家无 `prefer` 协议时按 `vless → ss → http` 回退
- `rrpp_client` 固定只用 `http`(兼容性),备注 = `provider + traffic`(IPv6 加 `(IPv6)`)
## 6. SSO 认证
- 后端调 SSO `POST http://127.0.0.1:4550/v1/sso/service/rrpp/nodes`(带 `X-SSO-Service-Token` + `X-Real-IP`)
- SSO 返回 `{tier: 0|1|2|3}`,0=未开通
- 服务密钥读 `sso-rrrosmontis-icu/secrets.json` 的 `service_tokens.rrpp`
## 7. 分流规则(PAC → 规则集,随订阅下发)
### 7.1 为什么不是直接下发 PAC
PAC 是给浏览器/操作系统用的 JS 自动配置;**mihomo/Clash 与 Shadowrocket 都不能读 PAC**。
两者唯一共同能吃的格式是「每行自带规则类型的规则行文本」(Clash 术语 `behavior: classical`):
- mihomo:`rule-providers`(`behavior: classical` + `format: text`)
- Shadowrocket:`RULE-SET,<url>,<策略>`(官方手册要求「规则集的内容需包含规则类型」)
因此把 PAC 数据**在后端一次性转换成三个规则集文件**,两种客户端共用同一份文件。
### 7.2 规则集文件(CDN 公开)
生成目录即 CDN 根下的 `RRPP/PAC/`,公开可访问、无鉴权(规则集本身不含节点信息):
| 规则集 | 文件 | 规则数 | 组成 | 策略 |
|---|---|---|---|---|
| `rrpp-proxy` | `rrpp-proxy.list` | 3943 | 539 `DOMAIN` + 3404 `DOMAIN-SUFFIX`(强制代理域名) | `RRPP` / `PROXY` |
| `rrpp-direct` | `rrpp-direct.list` | 16 | 16 `DOMAIN-SUFFIX`(保守直连种子) | `DIRECT` |
| `rrpp-cn-ip` | `rrpp-cn-ip.list` | 7456 | 7456 `IP-CIDR`(中国 IP 段) | `DIRECT` |
URL:`https://cdn.rrrosmontis.icu/RRPP/PAC/<文件名>`
体积:120366 / 386 / 177443 字节,合计 11415 条规则。
### 7.3 规则顺序(与 PAC 语义一致)
```
RULE-SET,rrpp-proxy → RRPP # 强制代理优先(PAC 中强制代理规则优先级更高)
RULE-SET,rrpp-direct → DIRECT # 直连种子
RULE-SET,rrpp-cn-ip → DIRECT # 中国 IP 直连
MATCH → RRPP # 其余全走代理
```
### 7.4 维护流程(PAC 更新后)
转换脚本 `gen_rulesets.py`(与本文件同目录,另在仓库 `server_config/rrpp-subscription/` 存有副本):
```bash
python gen_rulesets.py # 读 PAC 目录 → 原子覆写三个 .list(幂等,内容不变则不写)
python gen_rulesets.py --check # 只检查将有哪些变化,不写盘
python gen_rulesets.py --pac-dir <目录> --out-dir <目录> --quiet
```
- 输入:`pac_domains.json` / `pac_direct_domains.json` / `pac_ip_cn.json`
- 转换规则:`full:x`→`DOMAIN,x`;`keyword:x`→`DOMAIN-KEYWORD,x`;`regexp:`/`include:`→跳过并计数;行内 `#` 注释剥离;`*.x`/普通域名→`DOMAIN-SUFFIX,x`
- 输出排序去重、LF 换行、无注释,写盘用临时文件 + `os.replace` 原子替换
- **订阅端无需任何改动**:文件名与 URL 固定,客户端按 `interval` 自动拉新规则
### 7.5 客户端接入
| 场景 | 用法 |
|---|---|
| mihomo / Clash Verge(推荐) | `&method=clash` → 规则走 `rule-providers`,YAML 约 2.4KB,规则每 24h 自动更新 |
| 客户端不认 `rule-providers`,或想「导入一次到位」 | `&method=clash&rules=inline` → 规则内联进 YAML(约 440KB),导入即含节点+全部规则 |
| Shadowrocket(只想要规则,节点另行导入) | `&method=shadowrocket` → `.conf`,规则走远程 `RULE-SET`(在「配置 → 规则集URL」中可见并自动更新) |
| Shadowrocket(想一次导入节点+规则) | 用 `&method=clash&rules=inline`(小火箭兼容 Clash YAML,含节点信息的标准 Clash 链接可同时导入配置与节点) |
| 兜底 / 排障 | `&rules=global` → 只有 `MATCH,RRPP`,全流量走代理(等价旧行为) |
> `method=shadowrocket` 的 `.conf` **不写 `[Proxy]` 段**:Shadowrocket 的 REALITY 参数写法官方未文档化,不猜测;
> 该配置的策略 `PROXY` 指向首页当前选中的节点,节点请用 `clash`/`v2ray` 订阅导入。
### 7.6 验证记录(2026-09-19)
- 生成:11415 条规则;`regexp:` 跳过 7 条、非法 1 条
- CDN:三个 URL 均 HTTP 200,`md5` 与本地生成一致(`806808b8` / `8f89397e` / `3e75093d`)
- **mihomo v1.19.31 真实内核运行验证**:`-t` 校验通过;运行时 `GET /providers/rules` 显示
`rrpp-proxy=3943`、`rrpp-direct=16`、`rrpp-cn-ip=7456`(`behavior=Classical`),落地文件字节数与生成完全一致;
`&rules=inline` 配置亦通过 `-t` 校验
- Shadowrocket 为 iOS 应用,**未做实机验证**,仅依据官方手册的 `RULE-SET` / Clash YAML 兼容说明实现
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RRPP PAC → 规则集转换器
把 PAC 数据文件(JSON)转换成 **mihomo(Clash) 与 Shadowrocket 通用**的
"classical 规则行"文本规则集——每行都含规则类型:
pac_direct_domains.json -> rrpp-direct.list (DOMAIN-SUFFIX,<域名>)
pac_domains.json -> rrpp-proxy.list (DOMAIN-SUFFIX,<域名>)
pac_ip_cn.json -> rrpp-cn-ip.list (IP-CIDR,<网段>)
为什么用这个格式(两端通吃):
* mihomo : rule-providers { behavior: classical, format: text }
* Shadowrocket: RULE-SET,<url>,<策略>
(官方要求"规则集的内容需包含规则类型")
三种规则集由客户端按各自策略引用:
rrpp-proxy.list -> 代理(强制代理域名)
rrpp-direct.list -> 直连(保守直连种子)
rrpp-cn-ip.list -> 直连(中国 IP 段)
用法
----
python gen_rulesets.py # 就地生成(内容有变化才写盘)
python gen_rulesets.py --check # 只检查/报告,不写盘
python gen_rulesets.py --pac-dir DIR --out-dir DIR
python gen_rulesets.py --quiet
维护
----
PAC 更新后重新执行本脚本即可。脚本是**幂等**的:内容与现有文件一致时不会重写;
写入采用 临时文件 + 原子替换,失败不会损坏已有文件。输出不含注释,只有规则行。
"""
import argparse
import hashlib
import ipaddress
import json
import os
import sys
# ---------- 默认路径(bj 生产环境) ----------
DEFAULT_PAC_DIR = r'C:/BtSoft/nginx/others/htmlResources/cdn/RRPP/PAC'
DEFAULT_OUT_DIR = DEFAULT_PAC_DIR # 与 PAC 同目录,由 CDN 直接对外提供
# 映射:PAC 文件 -> (输出文件, JSON 中的列表字段, 类型)
# 类型 domains = 域名表(含 v2fly 前缀:full:/keyword:/regexp:/行内注释)
# 类型 cidrs = IP 网段表
RULESETS = [
('pac_domains.json', 'rrpp-proxy.list', 'domains'),
('pac_direct_domains.json', 'rrpp-direct.list', 'domains'),
('pac_ip_cn.json', 'rrpp-cn-ip.list', 'cidrs'),
]
def log(msg, quiet=False):
if not quiet:
print(msg, flush=True)
def read_json(path):
"""读取 JSON;容忍 BOM。"""
with open(path, 'r', encoding='utf-8-sig') as f:
return json.load(f)
def _valid_domain(s):
if not s or len(s) > 253 or '.' not in s:
return False
if any(c in s for c in ' /\\:@?#[]'):
return False
return True
def convert_domain_entry(v):
"""把一条域名表条目转成规则行。
支持 v2fly geosite 前缀与行内注释(PAC 数据实测存在):
full:<域名> -> DOMAIN,<域名> (精确匹配)
keyword:<关键词> -> DOMAIN-KEYWORD,<关键词>
regexp:<正则> -> 跳过(两端都无法表达域名正则)
<域名> # 注释 -> DOMAIN-SUFFIX,<域名>
返回 (规则行 或 None, 跳过原因 或 None)。
"""
s = str(v or '')
if '#' in s: # 行内注释(如 "apple.xn--czr694b # apple.商标")
s = s.split('#', 1)[0]
s = s.strip().lower()
if not s:
return None, 'empty'
if s.startswith('regexp:'):
return None, 'regexp'
if s.startswith('include:'):
return None, 'include'
if s.startswith('full:'):
dom = s[5:].strip().lstrip('*.').rstrip('.')
return (f'DOMAIN,{dom}', None) if _valid_domain(dom) else (None, 'invalid')
if s.startswith('keyword:'):
kw = s[8:].strip()
return (f'DOMAIN-KEYWORD,{kw}', None) if kw else (None, 'invalid')
if s.startswith('domain:'):
s = s[7:].strip()
dom = s.lstrip('*.').rstrip('.') # 通配前缀统一按后缀匹配
return (f'DOMAIN-SUFFIX,{dom}', None) if _valid_domain(dom) else (None, 'invalid')
def norm_cidr(v):
"""CIDR 规范化/校验:返回规范化网段,非法返回 None。"""
s = str(v or '').strip()
if not s:
return None
try:
net = ipaddress.ip_network(s, strict=False)
except ValueError:
return None
return str(net.network_address) + '/' + str(net.prefixlen)
def build_lines(kind, data, quiet=False):
"""由 PAC JSON 生成规则行(排序去重),返回 (lines, stats)。"""
field = 'domains' if kind == 'domains' else 'cidrs'
raw = data.get(field) if isinstance(data, dict) else None
if not isinstance(raw, list):
raise ValueError(f'字段 {field} 不是列表')
seen, stats, out = set(), {}, []
for item in raw:
if kind == 'domains':
line, why = convert_domain_entry(item)
else:
c = norm_cidr(item)
line, why = (f'IP-CIDR,{c}', None) if c else (None, 'invalid')
if line is None:
stats[why] = stats.get(why, 0) + 1
continue
if line not in seen:
seen.add(line)
out.append(line)
out.sort()
if stats:
detail = ' '.join(f'{k}={v}' for k, v in sorted(stats.items()))
log(f' ! 跳过条目: {detail}', quiet)
return out, stats
def write_if_changed(path, text, check_only=False):
"""内容有变化才原子写入。返回 (changed, md5)。"""
data = text.encode('utf-8')
md5 = hashlib.md5(data).hexdigest()
old = None
if os.path.exists(path):
with open(path, 'rb') as f:
old = f.read()
if old == data:
return False, md5
if check_only:
return True, md5
tmp = path + '.tmp'
with open(tmp, 'wb') as f: # 二进制写:确保 LF、无 BOM
f.write(data)
os.replace(tmp, path)
return True, md5
def main():
ap = argparse.ArgumentParser(description='RRPP PAC -> 规则集转换器')
ap.add_argument('--pac-dir', default=DEFAULT_PAC_DIR, help='PAC JSON 所在目录')
ap.add_argument('--out-dir', default=DEFAULT_OUT_DIR, help='规则集输出目录')
ap.add_argument('--check', action='store_true', help='只检查,不写盘')
ap.add_argument('--quiet', action='store_true')
args = ap.parse_args()
if not os.path.isdir(args.pac_dir):
print(f'错误:PAC 目录不存在: {args.pac_dir}', file=sys.stderr)
return 2
os.makedirs(args.out_dir, exist_ok=True)
total, changed_cnt, failed = 0, 0, 0
log(f'PAC 目录 : {args.pac_dir}')
log(f'输出目录 : {args.out_dir}{" (--check 模式,不写盘)" if args.check else ""}')
log('')
for src_name, out_name, kind in RULESETS:
src_path = os.path.join(args.pac_dir, src_name)
out_path = os.path.join(args.out_dir, out_name)
if not os.path.exists(src_path):
print(f' [跳过] {src_name} 不存在', file=sys.stderr)
failed += 1
continue
try:
data = read_json(src_path)
lines, _stats = build_lines(kind, data, args.quiet)
text = '\n'.join(lines) + '\n'
changed, md5 = write_if_changed(out_path, text, args.check)
size = len(text.encode('utf-8'))
ver = data.get('version', '-') if isinstance(data, dict) else '-'
state = ('已更新' if changed else '无变化') if not args.check else ('需更新' if changed else '无变化')
log(f' {src_name:26s} -> {out_name:18s} {len(lines):5d} 行 {size/1024:7.1f} KB '
f'ver={ver:12s} md5={md5[:8]} [{state}]')
total += len(lines)
changed_cnt += 1 if changed else 0
except Exception as e:
print(f' [失败] {src_name}: {type(e).__name__}: {e}', file=sys.stderr)
failed += 1
log('')
log(f'合计 {total} 条规则,{changed_cnt} 个文件{"需更新" if args.check else "已更新"},{failed} 个失败')
return 1 if failed else 0
if __name__ == '__main__':
sys.exit(main())
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# ============================================================
# RRPP 网络接口调优脚本(开机执行)
# 1) 把默认路由网卡的根 qdisc 设为 fq —— BBR 的最佳搭档
# (net.core.default_qdisc=fq 只对"新创建"网卡生效,已存在网卡需显式 replace)
# 2) 开启 UDP GRO 转发 —— QUIC 系协议(Hysteria2 / TUIC)转发性能
# 容器(LXC/OpenVZ)内网卡为 noqueue、且无 netdev 权限,本脚本自动跳过。
# ============================================================
set -uo pipefail
IFACE=$(ip -4 route show default | awk '{print $5; exit}')
[ -n "${IFACE:-}" ] || { echo "未找到默认路由网卡,跳过"; exit 0; }
if tc qdisc show dev "$IFACE" 2>/dev/null | grep -q " noqueue"; then
echo "$IFACE 为 noqueue(容器环境),跳过 qdisc 与网卡 offload 设置"
exit 0
fi
tc qdisc replace dev "$IFACE" root fq 2>/dev/null && echo "已设置 $IFACE root qdisc = fq" || echo "设置 qdisc 失败(可忽略)"
ethtool -K "$IFACE" rx-udp-gro-forwarding on 2>/dev/null && echo "已开启 $IFACE rx-udp-gro-forwarding" || echo "$IFACE 不支持 rx-udp-gro-forwarding"
ethtool -K "$IFACE" rx-gro-list on 2>/dev/null && echo "已开启 $IFACE rx-gro-list" || echo "$IFACE 不支持 rx-gro-list"
@@ -0,0 +1,38 @@
# ============================================================
# RRPP 节点系统级网络优化(KVM / 完整虚拟机版)
# 部署位置:/etc/sysctl.d/99-zz-rrpp-network.conf
# 配套:systemd/rrpp-qdisc.service + scripts/rrpp-qdisc.sh(fq 队列 + UDP GRO)
# 文件名 99-zz- 前缀保证最后生效;注意 /etc/sysctl.conf 会更晚加载(见 README §2.6)
# ============================================================
# --- 拥塞控制与队列 ---
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq
# --- PMTUD 黑洞缓解 ---
net.ipv4.tcp_mtu_probing = 1
# --- 握手 / 时延 ---
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_slow_start_after_idle = 0
# --- 连接生命周期 ---
net.ipv4.tcp_tw_reuse = 2
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5
net.ipv4.ip_local_port_range = 10240 65000
# --- 队列深度与缓冲(TCP)---
net.core.somaxconn = 32768
net.ipv4.tcp_max_syn_backlog = 32768
net.core.netdev_max_backlog = 32768
net.core.rmem_max = 33554432
net.core.wmem_max = 33554432
net.ipv4.tcp_rmem = 4096 131072 33554432
net.ipv4.tcp_wmem = 4096 16384 33554432
# --- UDP(Hysteria2 / TUIC / UoT 的 socket 缓冲下限)---
net.ipv4.udp_rmem_min = 8192
net.ipv4.udp_wmem_min = 8192
@@ -0,0 +1,22 @@
# ============================================================
# RRPP 节点系统级网络优化(LXC / OpenVZ 容器版)
# 部署位置:/etc/sysctl.d/99-zz-rrpp-network.conf
# 容器差异:net.core.rmem_max / wmem_max / default_qdisc / netdev_max_backlog
# 缺失或只读;eth0 为 noqueue,qdisc/网卡 offload 不可设(由宿主机控制)。
# net.ipv4.udp_rmem_min / udp_wmem_min 在部分容器内只读,若报错可忽略。
# ============================================================
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_slow_start_after_idle = 0
net.ipv4.tcp_tw_reuse = 2
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5
net.ipv4.ip_local_port_range = 10240 65000
net.core.somaxconn = 32768
net.ipv4.tcp_max_syn_backlog = 32768
net.ipv4.udp_rmem_min = 8192
net.ipv4.udp_wmem_min = 8192
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=RRPP: set fq qdisc on default-route interface (BBR companion)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/rrpp-qdisc.sh
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,19 @@
[Unit]
Description=RRPP bare UDP over TCP v2 relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
Restart=always
RestartSec=2
User=rrpp
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/rrpp
[Install]
WantedBy=multi-user.target
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=RRPP bare UDP over TCP v2 relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
Restart=always
RestartSec=2
User=rrpp
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/rrpp
[Install]
WantedBy=multi-user.target
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=RRPP UoT v2 relay (UDP over TCP decapsulator, localhost only)
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
Restart=on-failure
RestartSec=5
# 双保险:只许监听本机(脚本本身 bind 127.0.0.1)
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RRPP UoT v2 relay — UDP over TCP 解封装服务(sing-box UoT v2 协议)
协议(SagerNet 专有,见 sing-box 文档 configuration/shared/udp-over-tcp):
请求头: | isConnect u8 | ATYP u8 | address | port u16be |
isConnect=1: 固定目标模式,后续帧全部是 length+data,发往请求头里的目标
isConnect=0: 每帧自带目标,帧格式同 v1: | ATYP | address | port | length u16be | data |
connect 帧: | length u16be | data |
non-connect 帧: | ATYP | address | port | length u16be | data |
监听 127.0.0.1,仅供本机 nginx proxy_connect 转发,不对公网开放。
健壮性设计(2026-09 修复,实测复现过隧道被误杀):
* 一律使用「未连接」UDP socket(sendto/recvfrom)——连接式 socket 会把目标侧
ICMP(port unreachable 等)转成 ConnectionRefusedError,在 send/recv 上抛异常,
旧实现因此把整条 UoT 隧道关掉(客户端表现为 "UoT TCP 流已关闭")。
* 数据报级错误(BlockingIOError/EAGAIN、ENETUNREACH、EHOSTUNREACH、EMSGSIZE
等)只丢当前包并继续;EAGAIN 先等可写再重试(有总时限)。
* 只有 TCP 侧 EOF、UoT 协议错误或空闲超时才关闭隧道。
* 按地址族惰性建 socket,non-connect 模式可混用 IPv4/IPv6 目标。
"""
import errno
import select
import socket
import struct
import threading
import time
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 15201
IDLE_TIMEOUT = 540 # 9 分钟无流量断开(须 < nginx proxy_connect 隧道超时 10m)
MAX_DGRAM = 65507 # UDP over IPv4 单包上限
SEND_RETRY_TOTAL = 1.0 # EAGAIN 时最多等待 1 秒可写,超时则丢弃该包
RECV_ERROR_STREAK_LIMIT = 200 # 连续 recv 错误上限(防病态自旋),超过视为隧道异常
LOG_PREFIX = "[uot-relay]"
def log(msg):
print(f"{LOG_PREFIX} {time.strftime('%Y-%m-%d %H:%M:%S')} {msg}", flush=True)
def read_exact(f, n):
buf = b""
while len(buf) < n:
chunk = f.read(n - len(buf))
if not chunk:
raise EOFError("connection closed")
buf += chunk
return buf
def parse_addr(f):
"""从流中读取 SOCKS 格式地址(ATYP + address + port)。"""
atyp = read_exact(f, 1)[0]
if atyp == 0x00:
addr = socket.inet_ntop(socket.AF_INET, read_exact(f, 4))
elif atyp == 0x01:
addr = socket.inet_ntop(socket.AF_INET6, read_exact(f, 16))
elif atyp == 0x02:
ln = read_exact(f, 1)[0]
addr = read_exact(f, ln).decode("utf-8", "replace")
else:
raise ValueError(f"bad ATYP {atyp:#x}")
port = struct.unpack(">H", read_exact(f, 2))[0]
return addr, port
def encode_addr(addr, port):
"""SOCKS 格式地址编码(用于 non-connect 模式的回包)。"""
try:
return b"\x00" + socket.inet_pton(socket.AF_INET, addr) + struct.pack(">H", port)
except OSError:
pass
try:
return b"\x01" + socket.inet_pton(socket.AF_INET6, addr) + struct.pack(">H", port)
except OSError:
pass
d = addr.encode("utf-8")
if len(d) > 255:
raise ValueError("domain too long")
return b"\x02" + bytes([len(d)]) + d + struct.pack(">H", port)
def norm_src(src):
"""recvfrom 地址归一化:IPv4 为 (host, port),IPv6 为 4 元组。"""
return src[0], src[1]
class UdpPool:
"""按地址族惰性创建「未连接」UDP socket(v4/v6 可混用)。"""
def __init__(self):
self.socks = {}
def get(self, addr):
fam = socket.AF_INET6 if ":" in addr else socket.AF_INET
s = self.socks.get(fam)
if s is None:
s = socket.socket(fam, socket.SOCK_DGRAM)
s.setblocking(False)
self.socks[fam] = s
return s
def all(self):
return list(self.socks.values())
def close(self):
for s in self.socks.values():
try:
s.close()
except Exception:
pass
self.socks.clear()
def send_dgram(sock, data, dst):
"""发送一个 UDP 数据报。
返回 True=已发出,False=本包丢弃(**不是**连接级错误,调用方继续跑)。
EAGAIN 时等可写重试(有总时限);其余 OSError 一律丢弃本包。
"""
deadline = time.time() + SEND_RETRY_TOTAL
while True:
try:
sock.sendto(data, dst)
return True
except BlockingIOError:
if time.time() >= deadline:
return False
try:
select.select([], [sock], [], 0.1)
except Exception:
return False
except InterruptedError:
continue
except OSError:
# ENETUNREACH / EHOSTUNREACH / EMSGSIZE / EACCES ...
return False
def handle(conn, peer):
conn.settimeout(IDLE_TIMEOUT)
f = conn.makefile("rb")
pool = UdpPool()
stop = threading.Event()
fixed_dst = None
try:
is_connect = read_exact(f, 1)[0]
req_dst = parse_addr(f)
log(f"conn from nginx: isConnect={is_connect} dst={req_dst}")
if is_connect:
fixed_dst = req_dst
pool.get(fixed_dst[0]) # 预建 socket:主循环首轮才有可 select 的对象
write_lock = threading.Lock()
def tcp_write(data):
with write_lock:
conn.sendall(data)
def uplink():
"""TCP -> UDP:逐帧解析并发送;单包错误只丢包,不关隧道。"""
try:
while not stop.is_set():
if is_connect:
ln = struct.unpack(">H", read_exact(f, 2))[0]
if ln > MAX_DGRAM:
raise ValueError(f"datagram too large: {ln}")
data = read_exact(f, ln)
send_dgram(pool.get(fixed_dst[0]), data, fixed_dst)
else:
dst = parse_addr(f)
ln = struct.unpack(">H", read_exact(f, 2))[0]
if ln > MAX_DGRAM:
raise ValueError(f"datagram too large: {ln}")
data = read_exact(f, ln)
send_dgram(pool.get(dst[0]), data, dst)
except Exception as e:
if not stop.is_set():
log(f"uplink end: {type(e).__name__}: {e}")
finally:
stop.set()
threading.Thread(target=uplink, daemon=True).start()
# 主循环:UDP -> TCP(带空闲超时)
last = time.time()
err_streak = 0
while not stop.is_set():
socks = pool.all()
if not socks:
# non-connect 模式:socket 由第一帧触发创建,空池时等待而非关连接
if time.time() - last > IDLE_TIMEOUT:
log("idle timeout (no datagram), closing")
break
time.sleep(0.2)
continue
try:
r, _, _ = select.select(socks, [], [], 2.0)
except OSError:
break
got = False
for s in r:
try:
data, src = s.recvfrom(MAX_DGRAM)
except (BlockingIOError, InterruptedError):
continue
except OSError as e:
# 单包级错误(含 ICMP 触发的错误):丢包继续,不关隧道
err_streak += 1
if err_streak % 50 == 1:
log(f"recv error (datagram dropped, streak={err_streak}): "
f"{errno.errorcode.get(e.errno, e.errno)} {e}")
if err_streak >= RECV_ERROR_STREAK_LIMIT:
log("recv error streak limit reached, closing tunnel")
stop.set()
break
continue
err_streak = 0
a, p = norm_src(src)
if is_connect:
if (a, p) != fixed_dst:
continue # 固定目标模式:只回传目标自身的包
tcp_write(struct.pack(">H", len(data)) + data)
else:
tcp_write(encode_addr(a, p) + struct.pack(">H", len(data)) + data)
got = True
if got:
last = time.time()
elif time.time() - last > IDLE_TIMEOUT:
log("idle timeout, closing")
break
except Exception as e:
log(f"conn error: {type(e).__name__}: {e}")
finally:
stop.set()
try:
f.close()
except Exception:
pass
try:
conn.close()
except Exception:
pass
pool.close()
def main():
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((LISTEN_HOST, LISTEN_PORT))
srv.listen(128)
log(f"listening on {LISTEN_HOST}:{LISTEN_PORT}")
while True:
conn, peer = srv.accept()
threading.Thread(target=handle, args=(conn, peer), daemon=True).start()
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""RRPP 裸 UoT v2 relay。
nginx 的 proxy_connect 模块负责 TLS、Basic Auth 和 CONNECT,relay 只监听
127.0.0.1:15201,收到的首字节就是 UoT v2 request,不再解析 HTTP。
"""
from __future__ import annotations
import ipaddress
import logging
import socket
import struct
import threading
MAGIC = "sp.v2.udp-over-tcp.arpa"
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 15201
MAX_PACKET = 65535
IDLE_TIMEOUT = 9 * 60
def read_exact(conn: socket.socket, size: int) -> bytes:
data = bytearray()
while len(data) < size:
chunk = conn.recv(size - len(data))
if not chunk:
raise ConnectionError("客户端连接已关闭")
data.extend(chunk)
return bytes(data)
def read_address(conn: socket.socket):
atyp = read_exact(conn, 1)[0]
if atyp == 0:
host = str(ipaddress.IPv4Address(read_exact(conn, 4)))
elif atyp == 1:
host = str(ipaddress.IPv6Address(read_exact(conn, 16)))
elif atyp == 2:
size = read_exact(conn, 1)[0]
host = read_exact(conn, size).decode("idna")
else:
raise ValueError(f"未知 UoT 地址类型: {atyp}")
return host, struct.unpack("!H", read_exact(conn, 2))[0]
def encode_address(host: str, port: int) -> bytes:
try:
addr = ipaddress.ip_address(host)
except ValueError:
raw = host.encode("idna")
return b"\x02" + bytes([len(raw)]) + raw + struct.pack("!H", port)
return (b"\x00" if addr.version == 4 else b"\x01") + addr.packed + struct.pack("!H", port)
def resolve_destination(host: str, port: int):
return socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_DGRAM)[0][4]
def handle(conn: socket.socket, peer):
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.settimeout(0.2)
# 与 nginx 的 10 分钟读写超时错开,空闲连接由 relay 先回收。
conn.settimeout(IDLE_TIMEOUT)
send_lock = threading.Lock()
try:
is_connect = read_exact(conn, 1)[0]
first_target = read_address(conn)
logging.info("UoT client %s target=%s:%s connect=%s", peer, *first_target, is_connect)
def udp_reader():
while True:
try:
data, source = udp.recvfrom(MAX_PACKET)
except socket.timeout:
continue
except OSError:
return
try:
with send_lock:
if is_connect:
conn.sendall(struct.pack("!H", len(data)) + data)
else:
conn.sendall(encode_address(source[0], source[1]) +
struct.pack("!H", len(data)) + data)
except OSError:
return
threading.Thread(target=udp_reader, daemon=True).start()
if is_connect:
while True:
length = struct.unpack("!H", read_exact(conn, 2))[0]
payload = read_exact(conn, length)
udp.sendto(payload, resolve_destination(*first_target))
else:
while True:
destination = read_address(conn)
length = struct.unpack("!H", read_exact(conn, 2))[0]
payload = read_exact(conn, length)
udp.sendto(payload, resolve_destination(*destination))
except (ConnectionError, OSError, ValueError) as exc:
logging.info("UoT client %s closed: %s", peer, exc)
finally:
udp.close()
conn.close()
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((LISTEN_HOST, LISTEN_PORT))
listener.listen(128)
logging.info("RRPP UoT relay listening on %s:%d", LISTEN_HOST, LISTEN_PORT)
while True:
conn, peer = listener.accept()
threading.Thread(target=handle, args=(conn, peer), daemon=True).start()
if __name__ == "__main__":
main()