Compare commits

...
Author SHA1 Message Date
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
RoyirinRosmontis 02d36c72f7 重构:模块化 RRPP Lite 并更新入口 2026-09-16 23:13:28 +08:00
RoyirinRosmontis c37d8af821 新增:PAC 明确直连域名空模板 2026-09-16 21:45:48 +08:00
RoyirinRosmontis cddd3afd18 Merge remote-tracking branch 'origin/main' 2026-09-16 19:13:38 +08:00
RoyirinRosmontis 0603224de2 修复:完善 PAC 分流与启动取消 2026-09-16 19:10:12 +08:00
RoyirinRosmontis 3ce5e57dae 更新 README.md 2026-09-16 19:09:37 +08:00
RoyirinRosmontis 9376e35025 修复:PAC 规则解析与关键域名分流 2026-09-16 17:46:54 +08:00
RRRosmontis 110a526fc6 server_config: 同步伪装页为线上实际版本(md5 4eb73450)
仓库内 fake-site/index.html 为旧版(md5 e0f5cbcb),与四个生产节点实际
服务的版本不一致。已用线上(us 节点)实际抓取版本替换,恢复所有节点
2026-09-11 12:53:56 +08:00
RoyirinRosmontis 9b7b817cb7 更新 README.md 2026-09-11 02:06:47 +08:00
RoyirinRosmontis a39647d46f 更新 README.md 2026-09-11 02:06:21 +08:00
RoyirinRosmontis be02d1b17f 更新 README.md 2026-09-11 02:01:47 +08:00
RoyirinRosmontis 53c36e143f 更新 README.md 2026-09-11 02:01:30 +08:00
RoyirinRosmontis 23ba368737 修复:- 系统代理设置保存/恢复:覆盖前备份原 ProxyEnable/ProxyServer/ProxyOverride/AutoConfigURL(内存+settings.json 防崩溃丢失),关闭/退出时恢复用户原有代理而不是清空;启动残留清理只认本程序精确地址(127.0.0.1:1080 精确匹配 + PAC URL 正则),不再误删其他代理软件(如 Clash)在 127.0.0.1 上的 PAC
- CONNECT 目标解析支持 IPv6 字面量:剥掉方括号再拆 host:port,[::1]:443 不再解析成 "[::1]"(直连/白名单/内网绕过判定失效的根因)
- 更新包校验加固:release 附件按 RRPP_Lite_<tag>.exe 名字精确匹配(不再固定取第一个);下载后与 API 记录的 size 比对;发布流程附带 .sha256 边车时校验 SHA-256
- 上游代理认证注入按完整头名精确匹配:Proxy-Authorization 不再误判为已有 Authorization;注入位置紧跟请求行保证上游读到我们的认证
- 远程同步后节点按 settings 记录的域名重匹配:服务端调整节点顺序时不再静默连到别的节点;启动校验等待订阅同步完成(最多 15 秒)后再执行
- DNS 分流判定缓存加 300 秒 TTL:域名换 IP 后不再长期沿用旧的直连/隧道判断(_is_private_ip/_is_china_ip/_is_whitelisted 换 _ttl_cache,保留 cache_clear 兼容)
- is_proxy_on 状态判定同步收紧为本程序 PAC 地址(其他软件的 PAC 不再让状态灯误亮)
2026-09-11 01:56:04 +08:00
45 changed files with 8954 additions and 3005 deletions
+10
View File
@@ -1,2 +1,12 @@
__pycache__/
*.pyc
# Local development state and reproducible build outputs.
.claude/
lite/.claude/
lite/backups/
lite/build/
lite/dist/
lite/RELEASE_WORKFLOW.md
*.bak
*.bak-*
+12 -6
View File
@@ -1,11 +1,17 @@
# Royirin Rosmontis Private Proxy
Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu
## 警告
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows .exe
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本。common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows 应用 (.exe)
- 因为 Windows 系统的安全机制,Release 发布的所有程序都为 Pyinstaller 打包版本,故启动速度可能较慢,尤其是首次启动。相对而言,Nuitka 打包的版本启动较快,但可能仅能在自己电脑上运行。如果需要 Nuitka 版本,仓库提供了对应的打包程序,请自备虚拟环境
## 如何区分 common 版本与 Lite 版本
### 如何区分 common 版本与 Lite 版本
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳
## 服务器侧配置手册 (部分内容过时)
https://rrrosmontis.icu/articles/index.html?id=00005
- https://rrrosmontis.icu/articles/index.html?id=00005
- 更推荐参考本仓库 server_config 文件夹中的 md 文件手册
## 若要直接使用仓库客户端对应的服务
请前往 https://sso.rrrosmontis.icu 注册 Rosmanka ID,然后与管理员商定使用细则
royirin@rrrosmontis.icu
+90
View File
@@ -0,0 +1,90 @@
# -*- mode: python ; coding: utf-8 -*-
from pathlib import Path
from PyInstaller.utils.hooks import collect_all
# 强制收集 PyQt5 全部模块及二进制文件
qt_datas, qt_binaries, qt_hiddenimports = collect_all('PyQt5')
# 只内置 config.json(普通设置)。nodes.json / pac_*.json 属用户数据:
# 运行时读 %LOCALAPPDATA%\RRPP(首启自动创建模板 / 从 CDN 拉取),不进 EXE。
_JSON_DATAS = [(str(Path(SPECPATH) / 'config.json'), '.')]
_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 感知,
# 避免程序在不同缩放比例的显示器之间移动时被 Windows 位图拉伸。
_MANIFEST = r'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="1.0.0.0" name="RRPP_Lite" type="win32"/>
<description>Royirin Rosmontis Private Proxy Lite</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor, system</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
</windowsSettings>
</application>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
</assembly>
'''
a = Analysis(
['main.py'],
pathex=[],
binaries=qt_binaries,
datas=[('ico.ico', '.')] + _JSON_DATAS + _TUN_RUNTIME + qt_datas,
hiddenimports=qt_hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
# 排除 Nuitka 构建链相关模块(venv 里装了 Nuitka,但 PyInstaller 产物不需要)
excludes=['nuitka', 'zstandard', 'ordered_set'],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Royirin Rosmontis Private Proxy Lite Python',
debug=False,
bootloader_ignore_signals=False,
strip=False,
# UPX 会压缩资源段(含图标),冷缓存下 Explorer 可能提取不到图标导致任务栏空白
upx=False,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['ico.ico'],
manifest=_MANIFEST,
)
+19
View File
@@ -0,0 +1,19 @@
@echo off
cd /d "%~dp0"
call ..\.venv\Scripts\python.exe -m PyInstaller --version >nul 2>&1
if errorlevel 1 (
echo PyInstaller not installed. Run first:
echo ..\.venv\Scripts\python.exe -m pip install pyinstaller
pause
exit /b 1
)
call ..\.venv\Scripts\python.exe -m PyInstaller RRPP_Lite.spec --noconfirm
if errorlevel 1 (
echo.
echo Build failed.
pause
exit /b 1
)
echo.
echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite Python.exe
pause
+46
View File
@@ -0,0 +1,46 @@
@echo off
cd /d "%~dp0"
call ..\.venv\Scripts\python.exe -m nuitka --version >nul 2>&1
if errorlevel 1 (
echo Nuitka not installed. Run first:
echo ..\.venv\Scripts\python.exe -m pip install nuitka zstandard
pause
exit /b 1
)
rem Remove prior artifacts and the SCons incremental state before every build.
rem The constants blob is embedded outside normal dependency tracking, so reuse
rem can leave new UI strings and settings keys out of the executable.
rd /s /q "dist\main.build" 2>nul
rd /s /q "dist\main.dist" 2>nul
rd /s /q "dist\main.onefile-build" 2>nul
rem Use Nuitka COFF objects for embedded constants to avoid compiler cache reuse.
rem Zig C23 embed caches do not reliably track embedded blob changes.
set NUITKA_RESOURCE_MODE=coff_obj
rem Convert config.json version to Windows file-version segments (each <= 65535).
for /f %%v in ('..\.venv\Scripts\python.exe -c "import json;v=json.load(open('config.json',encoding='utf-8'))['version'];print(f'9999.{int(v[4:8])}.{int(v[8:12])}.0')"') do set "FILEVER=%%v"
call ..\.venv\Scripts\python.exe -m nuitka ^
--standalone --onefile ^
--enable-plugin=pyqt5 ^
--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 ^
--windows-icon-from-ico=ico.ico ^
--windows-console-mode=disable ^
--company-name="Global Rosmanka Cloud Nexus Inc" ^
--product-name="R.R.P.P." ^
--file-version=%FILEVER% ^
--output-dir=dist ^
--output-filename="Royirin Rosmontis Private Proxy Lite.exe" ^
--assume-yes-for-downloads ^
main.py
if errorlevel 1 (
echo.
echo Build failed.
pause
exit /b 1
)
echo.
echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite.exe
pause
+4 -4
View File
@@ -1,6 +1,6 @@
{
"version": "",
"update_url": "",
"pac_base_url": "",
"nodes_url": ""
"version": "999909190333",
"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"
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+3831
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
{
"version": "",
"date": "",
"description": "PAC 智能分流的明确直连域名模板。强制代理规则和用户白名单优先级更高。",
"domains": []
}
-2912
View File
File diff suppressed because one or more lines are too long
+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'
+1
View File
@@ -0,0 +1 @@
"""RRPP Lite 的无 GUI 核心逻辑。"""
+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]
+91
View File
@@ -0,0 +1,91 @@
"""本地转发器的无状态报文解析与改写函数。"""
from urllib.parse import urlsplit
HTTP_METHODS = (
b"CONNECT ", b"GET ", b"POST ", b"PUT ", b"DELETE ",
b"HEAD ", b"OPTIONS ", b"PATCH ",
)
def starts_with_http_method(data: bytes) -> bool:
return any(data.startswith(method) for method in HTTP_METHODS)
def inject_proxy_auth(data: bytes, basic_auth: str) -> bytes:
"""为完整 HTTP 代理请求注入上游 Basic Authorization。"""
if not starts_with_http_method(data):
return data
end = data.find(b"\r\n\r\n")
if end == -1:
return data
head = data[:end]
first_end = head.find(b"\r\n")
request_line = head if first_end == -1 else head[:first_end]
headers = b"" if first_end == -1 else head[first_end + 2:]
for line in headers.split(b"\r\n"):
if b":" not in line:
continue
name = line[:line.index(b":")].strip().lower()
if name == b"authorization":
return data
auth_line = f"Authorization: Basic {basic_auth}\r\n".encode()
return request_line + b"\r\n" + auth_line + headers + data[end:]
def parse_connect_authority(first_line: str):
"""从 CONNECT 首行提取 ``(host, port)``,失败时返回 ``None``。"""
parts = first_line.split(" ")
if len(parts) < 2:
return None
authority = parts[1]
if authority.startswith("[") and "]" in authority:
try:
host = authority[1:authority.index("]")]
tail = authority[authority.index("]") + 1:]
if tail.startswith(":") and tail[1:].isdigit():
return host, int(tail[1:])
except ValueError:
pass
if ":" not in authority:
return None
host, _, port_s = authority.rpartition(":")
if not host or not port_s.isdigit():
return None
return host, int(port_s)
def parse_http_absolute(first_line: str):
"""从明文 HTTP 代理首行提取 ``(host, port)``,失败时返回 ``None``。"""
parts = first_line.split(" ")
if len(parts) < 2 or not parts[1].lower().startswith(("http://", "https://")):
return None
parsed = urlsplit(parts[1])
if not parsed.hostname:
return None
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return parsed.hostname, port
def force_http_close(buffer: bytes) -> bytes:
"""直连明文 HTTP 时强制 Connection: close,避免错复用连接。"""
end = buffer.find(b"\r\n\r\n")
if end == -1:
return buffer
head, tail = buffer[:end], buffer[end:]
lines = head.split(b"\r\n")
out = [lines[0]]
conn_seen = False
for line in lines[1:]:
low = line.lower()
if low.startswith(b"connection:"):
out.append(b"Connection: close")
conn_seen = True
elif low.startswith(b"proxy-connection:"):
continue
else:
out.append(line)
if not conn_seen:
out.append(b"Connection: close")
return b"\r\n".join(out) + tail
+82
View File
@@ -0,0 +1,82 @@
"""路由、白名单和转发器共用的网络基础函数。"""
import functools
import ipaddress
import socket
import threading
import time
def ttl_cache(seconds: int, maxsize: int = 16384):
"""带 TTL 的小型线程安全缓存,并保留 ``cache_clear`` 兼容接口。"""
def decorator(function):
store = {}
lock = threading.Lock()
@functools.wraps(function)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
now = time.time()
with lock:
hit = store.get(key)
if hit and now - hit[0] < seconds:
return hit[1]
value = function(*args, **kwargs)
with lock:
store[key] = (now, value)
if len(store) > maxsize:
cutoff = now - seconds
for cached_key, (timestamp, _) in list(store.items()):
if timestamp < cutoff:
del store[cached_key]
if len(store) > maxsize:
store.clear()
return value
def cache_clear():
with lock:
store.clear()
wrapper.cache_clear = cache_clear
return wrapper
return decorator
def is_private_ipv4_int(value: int) -> bool:
"""整数形式 IPv4 是否属于常见私网、回环或链路本地段。"""
return (
(value & 0xFF000000) == 0x0A000000
or (value & 0xFFF00000) == 0xAC100000
or (value & 0xFFFF0000) == 0xC0A80000
or (value & 0xFF000000) == 0x7F000000
or (value & 0xFFFF0000) == 0xA9FE0000
)
def resolve_host_ips(host: str):
"""解析主机名的 IPv4/IPv6 集合;字面量 IP 返回自身,失败返回空集合。"""
try:
return {ipaddress.ip_address(host)}
except ValueError:
pass
try:
return {
ipaddress.ip_address(address[4][0])
for address in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
}
except Exception:
return set()
def is_private_host(host: str) -> bool:
"""目标是否命中私网、回环或 IPv6 ULA/链路本地地址。"""
for address in resolve_host_ips(host):
if address.version == 4 and is_private_ipv4_int(int(address)):
return True
if address.version == 6 and (
address.is_loopback
or address.is_link_local
or address in ipaddress.ip_network("fc00::/7")
):
return True
return False
+36
View File
@@ -0,0 +1,36 @@
"""节点订阅的认证请求协议。"""
import base64
import json
import urllib.error
import urllib.request
class NodeAuthError(Exception):
"""订阅服务拒绝请求时携带的 HTTP 状态和用户可读原因。"""
def __init__(self, message, code=0):
super().__init__(message)
self.code = code
def b64url(value: str) -> str:
"""SSO 查询参数使用的无 padding URL-safe Base64。"""
return base64.urlsafe_b64encode(str(value).encode("utf-8")).decode("ascii").rstrip("=")
def fetch_nodes_auth(url, user, password, timeout=10):
"""直连拉取需 SSO 查询参数认证的 nodes.json。"""
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
separator = "&" if "?" in url else "?"
request_url = f"{url}{separator}user={b64url(user)}&password={b64url(password)}"
try:
with opener.open(request_url, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
body = ""
try:
body = error.read().decode("utf-8", "replace").strip()
except Exception:
pass
raise NodeAuthError(body or f"请求被拒绝 (HTTP {error.code})", error.code) from None
+95
View File
@@ -0,0 +1,95 @@
"""节点与登录凭据的本机加密存储。"""
import ctypes
import json
import os
class _DpapiBlob(ctypes.Structure):
_fields_ = [
("cbData", ctypes.c_uint32),
("pbData", ctypes.POINTER(ctypes.c_char)),
]
class NodeSecretsStore:
"""使用 Windows DPAPI 保存节点和 SSO 凭据;非 Windows 保持明文兼容。"""
def __init__(self, user_dir_getter):
self._user_dir_getter = user_dir_getter
@staticmethod
def protect_bytes(data: bytes) -> bytes:
crypt32 = ctypes.WinDLL("crypt32")
kernel32 = ctypes.WinDLL("kernel32")
crypt32.CryptProtectData.argtypes = [
ctypes.POINTER(_DpapiBlob), ctypes.c_wchar_p, ctypes.POINTER(_DpapiBlob),
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob),
]
crypt32.CryptProtectData.restype = ctypes.c_bool
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
buffer = ctypes.create_string_buffer(data, len(data))
blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
blob_out = _DpapiBlob()
if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None,
None, None, 0, ctypes.byref(blob_out)):
raise OSError("CryptProtectData 调用失败")
try:
return ctypes.string_at(blob_out.pbData, blob_out.cbData)
finally:
kernel32.LocalFree(blob_out.pbData)
@staticmethod
def unprotect_bytes(data: bytes) -> bytes:
crypt32 = ctypes.WinDLL("crypt32")
kernel32 = ctypes.WinDLL("kernel32")
crypt32.CryptUnprotectData.argtypes = [
ctypes.POINTER(_DpapiBlob), ctypes.POINTER(ctypes.c_wchar_p), ctypes.POINTER(_DpapiBlob),
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob),
]
crypt32.CryptUnprotectData.restype = ctypes.c_bool
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
buffer = ctypes.create_string_buffer(data, len(data))
blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
description = ctypes.c_wchar_p()
blob_out = _DpapiBlob()
if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(description), None,
None, None, 0, ctypes.byref(blob_out)):
raise OSError("CryptUnprotectData 调用失败")
try:
return ctypes.string_at(blob_out.pbData, blob_out.cbData)
finally:
kernel32.LocalFree(blob_out.pbData)
if description.value:
kernel32.LocalFree(description)
def encrypt_text(self, text: str) -> bytes:
if os.name != "nt":
return text.encode("utf-8")
return self.protect_bytes(text.encode("utf-8"))
def decrypt_bytes(self, blob: bytes) -> str:
if os.name != "nt":
return blob.decode("utf-8")
try:
return self.unprotect_bytes(blob).decode("utf-8")
except Exception:
return blob.decode("utf-8")
def save_auth(self, username, password):
directory = self._user_dir_getter()
directory.mkdir(parents=True, exist_ok=True)
text = json.dumps({"username": username, "password": password}, ensure_ascii=False)
(directory / "auth.json").write_bytes(self.encrypt_text(text))
def load_auth(self):
try:
path = self._user_dir_getter() / "auth.json"
if not path.is_file():
return None
data = json.loads(self.decrypt_bytes(path.read_bytes()))
if not (isinstance(data, dict) and data.get("username") and data.get("password")):
return None
return data
except Exception:
return None
+53
View File
@@ -0,0 +1,53 @@
"""PAC 数据文件的网络传输与安全落盘。"""
import json
import os
import urllib.request
from pathlib import Path
def read_version(path):
"""读取 PAC JSON 的 version/date;无效文件返回空字符串。"""
try:
data = json.loads(Path(path).read_text("utf-8"))
return str(data.get("version") or ""), str(data.get("date") or "")
except Exception:
return "", ""
def no_proxy_opener():
"""创建忽略环境/系统代理的直连 HTTP opener。"""
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
def fetch_json(url, timeout=15, tries=2):
"""以直连方式拉取 JSON;最后一次失败时保留原始异常。"""
opener = no_proxy_opener()
for attempt in range(tries):
try:
with opener.open(url, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except Exception:
if attempt == tries - 1:
raise
def validate_document(name, data):
"""校验各 PAC 文件的最小结构,避免错误响应覆盖有效缓存。"""
if not isinstance(data, dict):
return False
if name in ("pac_domains", "pac_direct_domains"):
return isinstance(data.get("domains"), list)
if name == "pac_ip_cn":
return isinstance(data.get("cidrs"), list)
return False
def atomic_write(directory, name, data):
"""将 PAC JSON 以 tmp + replace 原子写入指定用户目录。"""
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
target = directory / f"{name}.json"
temporary_path = directory / f"{name}.json.tmp"
temporary_path.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
os.replace(temporary_path, target)
+81
View File
@@ -0,0 +1,81 @@
"""PAC 路由引擎使用的纯算法,不依赖文件、网络或 Qt。"""
import ipaddress
import re
def merge_ip_ranges(cidrs, version):
"""将指定 IP 版本的 CIDR 合并为整数闭区间,供二分查找。"""
ranges = []
for cidr in cidrs:
network = ipaddress.ip_network(str(cidr).strip(), strict=False)
if network.version == version:
ranges.append((int(network.network_address), int(network.broadcast_address)))
ranges.sort()
merged = []
for start, end in ranges:
if merged and start <= merged[-1][1] + 1:
if end > merged[-1][1]:
merged[-1] = (merged[-1][0], end)
else:
merged.append((start, end))
return merged
def build_suffix_trie(suffixes):
"""按反向域名标签建立 Trie,适合大规模后缀规则的 O(标签数) 匹配。"""
root = {}
for suffix in suffixes:
node = root
for label in reversed(suffix.split(".")):
node = node.setdefault(label, {})
node[""] = True
return root
def suffix_trie_matches(host: str, trie) -> bool:
"""判断主机名是否命中一个后缀 Trie。"""
node = trie
for label in reversed(host.rstrip(".").lower().split(".")):
node = node.get(label)
if node is None:
return False
if node.get(""):
return True
return bool(node and node.get(""))
def parse_domain_rules(raw_rules, seed_suffixes=()):
"""解析后缀、``full:`` 与 ``regexp:`` 三种 PAC 域名规则。
返回 ``(suffixes, exact, regexes, invalid_regex_count)``。调用方决定
这些规则是“强制代理”还是“明确直连”,从而避免解析层携带业务策略。
"""
suffixes = set(seed_suffixes)
exact = set()
regexes = []
invalid_regexes = 0
for raw in raw_rules or ():
raw_rule = str(raw).strip()
rule = raw_rule.lower()
if not raw_rule:
continue
if rule.startswith("full:"):
host = rule[len("full:"):].rstrip(".")
if host:
exact.add(host)
continue
if rule.startswith("regexp:"):
pattern = raw_rule[len("regexp:"):]
if not pattern:
invalid_regexes += 1
continue
try:
regexes.append(re.compile(pattern, re.IGNORECASE))
except re.error:
invalid_regexes += 1
continue
suffix = rule.lstrip("*.").rstrip(".")
if suffix:
suffixes.add(suffix)
return suffixes, frozenset(exact), tuple(regexes), invalid_regexes
+34
View File
@@ -0,0 +1,34 @@
"""用户设置的 JSON 存储实现。"""
import json
import os
class JsonSettingsStore:
"""读取与原子写回一个 JSON 对象。
``path_getter`` 在每次操作时调用,保证测试或运行时切换用户目录后不会
意外写到旧目录。
"""
def __init__(self, path_getter):
self._path_getter = path_getter
def load(self):
path = self._path_getter()
try:
if not path.is_file():
return {}
data = json.loads(path.read_text("utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def save(self, data):
path = self._path_getter()
path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = path.with_suffix(path.suffix + ".tmp")
temporary_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n", "utf-8"
)
os.replace(temporary_path, path)
+843
View File
@@ -0,0 +1,843 @@
"""本地 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 _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(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(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 "已隐藏订阅地址"
+55
View File
@@ -0,0 +1,55 @@
"""线程安全的本地转发流量统计。"""
import threading
import time
class TrafficStats:
"""记录单次代理会话的上下行总量与采样速率。"""
def __init__(self, clock=time.time):
self._clock = clock
self._lock = threading.Lock()
self._up = 0
self._down = 0
self._prev_up = 0
self._prev_down = 0
self._prev_ts = 0.0
def add(self, up=0, down=0):
with self._lock:
self._up += up
self._down += down
def reset(self):
with self._lock:
self._up = self._down = 0
self._prev_up = self._prev_down = 0
self._prev_ts = self._clock()
def rate(self):
with self._lock:
now = self._clock()
if self._prev_ts > 0 and now > self._prev_ts:
elapsed = now - self._prev_ts
up_rate = int((self._up - self._prev_up) / elapsed)
down_rate = int((self._down - self._prev_down) / elapsed)
else:
up_rate = down_rate = 0
self._prev_up = self._up
self._prev_down = self._down
self._prev_ts = now
return up_rate, down_rate, self._up, self._down
def snapshot(self):
with self._lock:
return self._up, self._down
def format_bytes(value):
"""将字节数格式化为 UI 展示文本。"""
number = float(value)
for unit in ("B", "KB", "MB", "GB", "TB"):
if number < 1024 or unit == "TB":
return f"{int(number)} {unit}" if unit == "B" else f"{number:.1f} {unit}"
number /= 1024
+774
View File
@@ -0,0 +1,774 @@
"""路线 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 后端无法启动或停止。"""
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._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 = subprocess.run(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 = subprocess.run(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 = subprocess.run(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 = subprocess.run(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 = subprocess.run(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 = subprocess.run(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._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 = subprocess.run(["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:
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 = subprocess.run(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 = subprocess.run(["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 = subprocess.run(["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 = subprocess.run(["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 = subprocess.run(["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 = subprocess.run(
["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()
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()
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 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),
"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._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,
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)
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:
socket.inet_pton(socket.AF_INET, item)
except OSError as exc:
raise TunBackendError("当前 TUN 仅支持 IPv4 DNS 服务器") 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()
self._physical_ipv6_gateway, self._physical_ipv6_ifindex = self._physical_ipv6_route_info()
if not self._physical_ifindex:
raise TunBackendError("找不到物理默认路由,无法保证代理节点不进入 TUN 回环")
if 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.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()
+232
View File
@@ -0,0 +1,232 @@
"""按需提权的 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]):
self.command = command
self.log = 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 工作进程未连接")
_send(self._socket, {"command": command, "payload": payload or {}})
while True:
message = _receive(self._reader)
if message.get("type") == "log":
self.log(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")
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) -> None:
try:
_send(connection, {"type": "log", "message": str(message)}, send_lock)
except OSError:
pass
backend = None
try:
backend = backend_factory(emit)
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 启动参数无效")
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)
+4
View File
@@ -0,0 +1,4 @@
"""RRPP Lite 的 Qt 界面层。
核心代理与 PAC 逻辑不得依赖本包,以便后续在无 GUI 环境中独立测试。
"""
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
"""RRPP Lite 界面的共享色彩常量。"""
C_TITLE = "#8899aa"
C_GREEN = "#51cf66"
C_YELLOW = "#ffd43b"
C_RED = "#ff6b6b"
C_BG = "#ffffff"
C_CARD = "#f0f2f5"
C_BORDER = "#d0d5dd"
C_TEXT = "#333333"
C_SUBTEXT = "#889099"
+168
View File
@@ -0,0 +1,168 @@
"""无业务状态的可复用 Qt 控件。"""
from PyQt5.QtCore import Qt, QRectF, pyqtSignal
from PyQt5.QtGui import QColor, QPainter
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
_NL_BASE = f"color: {C_TITLE}; font-size: 12px;"
class NodeLabel(QLabel):
"""可点击标签:hover 显示下划线和手型光标,点击发出 clicked 信号。"""
clicked = pyqtSignal()
def __init__(self, text="", tooltip="点击切换节点", base=None, hover=None):
super().__init__(text)
self._base = base or _NL_BASE
self._hover = hover or self._base + " text-decoration: underline;"
self.setStyleSheet(self._base)
if tooltip:
self.setToolTip(tooltip)
def enterEvent(self, event):
self.setStyleSheet(self._hover)
self.setCursor(Qt.PointingHandCursor)
super().enterEvent(event)
def leaveEvent(self, event):
self.setStyleSheet(self._base)
self.unsetCursor()
super().leaveEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
self.clicked.emit()
super().mouseReleaseEvent(event)
class ToggleSwitch(QCheckBox):
"""苹果风格开关:绿/灰胶囊加滑动圆钮,自绘且不依赖图片资源。"""
def __init__(self, checked=False, parent=None):
super().__init__(parent)
self.setChecked(checked)
self.setFixedSize(42, 22)
self.setCursor(Qt.PointingHandCursor)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.setPen(Qt.NoPen)
painter.setBrush(QColor(C_GREEN) if self.isChecked() else QColor("#c8ccd4"))
painter.drawRoundedRect(QRectF(0, 0, 42, 22), 11, 11)
knob_x = 20.0 if self.isChecked() else 2.0
painter.setBrush(QColor("#ffffff"))
painter.drawEllipse(QRectF(knob_x, 2.0, 18.0, 18.0))
painter.end()
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self.setChecked(not self.isChecked())
self.repaint()
event.accept()
return
super().mousePressEvent(event)
class StatusDot(QLabel):
"""代理状态小圆点。"""
def __init__(self):
super().__init__()
self.setFixedSize(14, 14)
self.set_off()
def set_on(self):
self.setStyleSheet(f"""
QLabel {{
background-color: {C_GREEN};
border-radius: 7px;
border: 1px solid rgba(81, 207, 102, 50);
}}
""")
def set_off(self):
self.setStyleSheet(f"""
QLabel {{
background-color: {C_RED};
border-radius: 7px;
border: 1px solid rgba(255, 107, 107, 50);
}}
""")
def set_disabled(self):
self.setStyleSheet(f"""
QLabel {{
background-color: {C_BORDER};
border-radius: 7px;
border: 1px solid rgba(136, 153, 170, 50);
}}
""")
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.
-75
View File
@@ -1,75 +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 proxy_lite as pp
from PyQt5.QtWidgets import QApplication
# 构造窗口时不写用户数据目录(避免污染),构造后再指向真实 nodes.json
pp._user_init_local = lambda: None
app = QApplication(sys.argv)
w = pp.MainWindow()
w.timer.stop() # 停掉状态轮询,纯静态检查
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()} (应不变)")
# ── 上次选中的节点已不可用:启动时自动回退到第一个可达节点 ──────────
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]
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'}")
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'}")
# 再校验一次:已经是可达节点,不应再切换
ok3 = pp._verify_selected_node() is False
print(f" 再次校验不重复切换 {'PASS' if ok3 else 'FAIL'}")
if _bak is None:
(pp._USER_DIR / "settings.json").unlink(missing_ok=True)
else:
(pp._USER_DIR / "settings.json").write_bytes(_bak)
print("OK")
+99
View File
@@ -265,8 +265,99 @@ 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 帧头)。
**客户端协议**:认证与 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 安装
@@ -378,6 +469,7 @@ 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) |
```bash
cp systemd/*.service /etc/systemd/system/
@@ -396,6 +488,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 前可用)
```
### 验证清单
@@ -419,6 +512,9 @@ 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
# nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>'))
B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0)
curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \
@@ -440,6 +536,9 @@ server_config/
│ ├── 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/
+2 -2
View File
@@ -26,7 +26,7 @@
<span class="code-label">Error code 200</span>
</h1>
<div>
Visit <a href="https://example.com" target="_blank" rel="noopener noreferrer">example.com</a> for more information.
Visit <a href="https://rrrosmontis.icu" target="_blank" rel="noopener noreferrer">rrrosmontis.icu</a> for more information.
</div>
<div class="mt-3">2026-06-28 05:00:19 UTC</div>
</header>
@@ -90,7 +90,7 @@
<span class="cf-footer-separator sm:hidden">&bull;</span>
</span>
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance &amp; security by</span> <a rel="noopener noreferrer" href="https://example.com" id="brand_link" target="_blank">Example Site</a></span>
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance &amp; security by</span> <a rel="noopener noreferrer" href="https://rrrosmontis.icu" id="brand_link" target="_blank">Royirin Rosmontis</a></span>
</p>
</div><!-- /.error-footer -->
</div>
+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;
@@ -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
+171
View File
@@ -0,0 +1,171 @@
#!/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 转发,不对公网开放。
"""
import socket
import struct
import threading
import select
import time
import sys
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 15201
IDLE_TIMEOUT = 540 # 9 分钟无流量断开(须 < nginx proxy_connect_data_timeout 10m)
MAX_DGRAM = 65507 # UDP over IPv4 单包上限
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 handle(conn, peer):
conn.settimeout(IDLE_TIMEOUT)
f = conn.makefile("rb")
udp = None
stop = threading.Event()
try:
is_connect = read_exact(f, 1)[0]
req_dst = parse_addr(f)
log(f"conn from nginx: isConnect={is_connect} dst={req_dst}")
udp = socket.socket(socket.AF_INET6 if ":" in req_dst[0] else socket.AF_INET,
socket.SOCK_DGRAM)
udp.setblocking(False)
if is_connect:
udp.connect(req_dst) # 固定目标:连通式 UDP
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)
udp.send(data)
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)
udp.sendto(data, dst)
except Exception as e:
if not stop.is_set():
log(f"uplink end: {type(e).__name__}: {e}")
finally:
stop.set()
t = threading.Thread(target=uplink, daemon=True)
t.start()
# 主循环:UDP -> TCP,带空闲超时
last = time.time()
while not stop.is_set():
r, _, _ = select.select([udp], [], [], 2.0)
if r:
if is_connect:
data = udp.recv(MAX_DGRAM)
tcp_write(struct.pack(">H", len(data)) + data)
else:
data, src = udp.recvfrom(MAX_DGRAM)
tcp_write(encode_addr(*src) + struct.pack(">H", len(data)) + data)
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
if udp:
try:
udp.close()
except Exception:
pass
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()