Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a563cf30f3 | ||
|
|
813e54c919 | ||
|
|
6f2e640881 | ||
|
|
fa45751d33 | ||
|
|
b44d50ee4d | ||
|
|
1cc4da8318 | ||
|
|
8809e40ec1 | ||
|
|
375153b810 | ||
|
|
9a64853db4 | ||
|
|
bdd9d3498d | ||
|
|
41204df78a | ||
|
|
d0e739eff1 | ||
|
|
e406d614b7 | ||
|
|
357fd26a49 | ||
|
|
9d5bc527d3 | ||
|
|
2315735979 | ||
|
|
889dcb88e5 | ||
|
|
9224ad33fe | ||
|
|
50364dc17a | ||
|
|
3e29012d9c | ||
|
|
e4d57bc90d | ||
|
|
fd433fc43e | ||
|
|
a054a547e5 | ||
|
|
2e56f82e7b | ||
|
|
e16ef58182 | ||
|
|
f4d777113c | ||
|
|
26926f6469 | ||
|
|
a0e2600399 | ||
|
|
01de67610d | ||
|
|
384a6107a8 | ||
|
|
fb47f9e1d2 | ||
|
|
71a67d7dca | ||
|
|
8063b6d4e8 | ||
|
|
aac616eed9 | ||
|
|
9bbfe900c8 | ||
|
|
c58ef59889 | ||
|
|
9dbaabd4a9 | ||
|
|
6673d28503 | ||
|
|
6fb2926627 | ||
|
|
02d36c72f7 | ||
|
|
c37d8af821 | ||
|
|
cddd3afd18 | ||
|
|
0603224de2 | ||
|
|
3ce5e57dae | ||
|
|
9376e35025 | ||
|
|
110a526fc6 | ||
|
|
9b7b817cb7 | ||
|
|
a39647d46f | ||
|
|
be02d1b17f | ||
|
|
53c36e143f | ||
|
|
23ba368737 | ||
|
|
cb3552f595 | ||
|
|
2a20215d9e | ||
|
|
c3286593a6 | ||
|
|
9e766d8132 | ||
|
|
259a1ae41f | ||
|
|
bcb113e8c9 | ||
|
|
6d204c77fb | ||
|
|
63837eb9d8 | ||
|
|
efa7e25f66 | ||
|
|
20d2532e8f | ||
|
|
17cdb276ea | ||
|
|
f8eba465a9 | ||
|
|
53e9d1e31a | ||
|
|
59393b9ae9 | ||
|
|
89b5c09e95 | ||
|
|
b15beb97b4 | ||
|
|
567d395d6f | ||
|
|
c2c3bd1426 | ||
|
|
2aeb327638 | ||
|
|
717b7569de | ||
|
|
2120009c8c | ||
|
|
5620f0051e | ||
|
|
f8e8fd1acd | ||
|
|
d1369d02d4 | ||
|
|
ebccfb4165 | ||
|
|
b9a35684ac | ||
|
|
7e6c95f78a | ||
|
|
54385d72ce | ||
|
|
2652213a10 | ||
|
|
4afb810078 | ||
|
|
3519fbe9e3 | ||
|
|
a08ca154c9 | ||
|
|
f92c3f52e0 | ||
|
|
22498cb884 | ||
|
|
8e550cba6b | ||
|
|
863d6ccc57 |
+12
@@ -0,0 +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-*
|
||||
@@ -1,11 +1,13 @@
|
||||
# Royirin Rosmontis Private Proxy
|
||||
Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu
|
||||
|
||||
## 警告
|
||||
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本
|
||||
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
|
||||
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows .exe
|
||||
- 自构建的客户端永远不再维护。推荐使用 Clash Verge 或其他软件结合服务器端配置使用。
|
||||
> 这些客户端使用了 HTTP 直接连接,具有流量识别的安全隐患。
|
||||
- 其中,common 版本过于过时,具有严重的安全问题和大量功能缺失。Lite 版本(版本号开头为 9999 的版本)则更为安全,但部分功能(TUN 模式)严重不可用,且似乎无法修复。
|
||||
- 在 Release 中保留了 Lite 版本的 Pyinstaller 构建产物,可能启动较慢。如果需要使用此客户端,可以考虑用 Nuitka 自行再次构建。
|
||||
- 本仓库中的构建产物与源码没有做 Linux 系统的适配。
|
||||
|
||||
|
||||
## 如何区分 common 版本与 Lite 版本
|
||||
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳
|
||||
|
||||
## 服务器侧配置手册 (部分内容过时)
|
||||
https://rrrosmontis.icu/articles/index.html?id=00005
|
||||
## 服务器侧配置手册
|
||||
- https://rrrosmontis.icu/articles/index.html?id=00005 (部分内容过时)
|
||||
- 更推荐参考本仓库 server_config 文件夹中的 md 文件手册
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
call ..\.venv\Scripts\python.exe -m PyInstaller --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo PyInstaller not installed. Run first:
|
||||
echo ..\.venv\Scripts\python.exe -m pip install pyinstaller
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
call ..\.venv\Scripts\python.exe -m PyInstaller RRPP_Lite.spec --noconfirm
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Build failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite Python.exe
|
||||
pause
|
||||
@@ -0,0 +1,48 @@
|
||||
@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 ^
|
||||
--include-data-files=runtime/tun2socks.exe=tun2socks.exe ^
|
||||
--include-data-files=runtime/wintun.dll=wintun.dll ^
|
||||
--windows-icon-from-ico=ico.ico ^
|
||||
--windows-console-mode=disable ^
|
||||
--company-name="Global Rosmanka Cloud Nexus Inc" ^
|
||||
--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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "",
|
||||
"update_url": "",
|
||||
"pac_base_url": "",
|
||||
"nodes_url": ""
|
||||
"version": "999909190441",
|
||||
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
|
||||
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client",
|
||||
"update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
+3845
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "",
|
||||
"date": "",
|
||||
"description": "PAC 智能分流的明确直连域名模板。强制代理规则和用户白名单优先级更高。",
|
||||
"domains": []
|
||||
}
|
||||
-2221
File diff suppressed because one or more lines are too long
@@ -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'
|
||||
@@ -0,0 +1 @@
|
||||
"""RRPP Lite 的无 GUI 核心逻辑。"""
|
||||
@@ -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]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""本地转发器的无状态报文解析与改写函数。"""
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
HTTP_METHODS = (
|
||||
b"CONNECT ", b"GET ", b"POST ", b"PUT ", b"DELETE ",
|
||||
b"HEAD ", b"OPTIONS ", b"PATCH ",
|
||||
)
|
||||
|
||||
|
||||
def starts_with_http_method(data: bytes) -> bool:
|
||||
return any(data.startswith(method) for method in HTTP_METHODS)
|
||||
|
||||
|
||||
def inject_proxy_auth(data: bytes, basic_auth: str) -> bytes:
|
||||
"""为完整 HTTP 代理请求注入上游 Basic Authorization。"""
|
||||
if not starts_with_http_method(data):
|
||||
return data
|
||||
end = data.find(b"\r\n\r\n")
|
||||
if end == -1:
|
||||
return data
|
||||
head = data[:end]
|
||||
first_end = head.find(b"\r\n")
|
||||
request_line = head if first_end == -1 else head[:first_end]
|
||||
headers = b"" if first_end == -1 else head[first_end + 2:]
|
||||
for line in headers.split(b"\r\n"):
|
||||
if b":" not in line:
|
||||
continue
|
||||
name = line[:line.index(b":")].strip().lower()
|
||||
if name == b"authorization":
|
||||
return data
|
||||
auth_line = f"Authorization: Basic {basic_auth}\r\n".encode()
|
||||
return request_line + b"\r\n" + auth_line + headers + data[end:]
|
||||
|
||||
|
||||
def parse_connect_authority(first_line: str):
|
||||
"""从 CONNECT 首行提取 ``(host, port)``,失败时返回 ``None``。"""
|
||||
parts = first_line.split(" ")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
authority = parts[1]
|
||||
if authority.startswith("[") and "]" in authority:
|
||||
try:
|
||||
host = authority[1:authority.index("]")]
|
||||
tail = authority[authority.index("]") + 1:]
|
||||
if tail.startswith(":") and tail[1:].isdigit():
|
||||
return host, int(tail[1:])
|
||||
except ValueError:
|
||||
pass
|
||||
if ":" not in authority:
|
||||
return None
|
||||
host, _, port_s = authority.rpartition(":")
|
||||
if not host or not port_s.isdigit():
|
||||
return None
|
||||
return host, int(port_s)
|
||||
|
||||
|
||||
def parse_http_absolute(first_line: str):
|
||||
"""从明文 HTTP 代理首行提取 ``(host, port)``,失败时返回 ``None``。"""
|
||||
parts = first_line.split(" ")
|
||||
if len(parts) < 2 or not parts[1].lower().startswith(("http://", "https://")):
|
||||
return None
|
||||
parsed = urlsplit(parts[1])
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
return parsed.hostname, port
|
||||
|
||||
|
||||
def force_http_close(buffer: bytes) -> bytes:
|
||||
"""直连明文 HTTP 时强制 Connection: close,避免错复用连接。"""
|
||||
end = buffer.find(b"\r\n\r\n")
|
||||
if end == -1:
|
||||
return buffer
|
||||
head, tail = buffer[:end], buffer[end:]
|
||||
lines = head.split(b"\r\n")
|
||||
out = [lines[0]]
|
||||
conn_seen = False
|
||||
for line in lines[1:]:
|
||||
low = line.lower()
|
||||
if low.startswith(b"connection:"):
|
||||
out.append(b"Connection: close")
|
||||
conn_seen = True
|
||||
elif low.startswith(b"proxy-connection:"):
|
||||
continue
|
||||
else:
|
||||
out.append(line)
|
||||
if not conn_seen:
|
||||
out.append(b"Connection: close")
|
||||
return b"\r\n".join(out) + tail
|
||||
@@ -0,0 +1,82 @@
|
||||
"""路由、白名单和转发器共用的网络基础函数。"""
|
||||
|
||||
import functools
|
||||
import ipaddress
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def ttl_cache(seconds: int, maxsize: int = 16384):
|
||||
"""带 TTL 的小型线程安全缓存,并保留 ``cache_clear`` 兼容接口。"""
|
||||
def decorator(function):
|
||||
store = {}
|
||||
lock = threading.Lock()
|
||||
|
||||
@functools.wraps(function)
|
||||
def wrapper(*args, **kwargs):
|
||||
key = (args, tuple(sorted(kwargs.items())))
|
||||
now = time.time()
|
||||
with lock:
|
||||
hit = store.get(key)
|
||||
if hit and now - hit[0] < seconds:
|
||||
return hit[1]
|
||||
value = function(*args, **kwargs)
|
||||
with lock:
|
||||
store[key] = (now, value)
|
||||
if len(store) > maxsize:
|
||||
cutoff = now - seconds
|
||||
for cached_key, (timestamp, _) in list(store.items()):
|
||||
if timestamp < cutoff:
|
||||
del store[cached_key]
|
||||
if len(store) > maxsize:
|
||||
store.clear()
|
||||
return value
|
||||
|
||||
def cache_clear():
|
||||
with lock:
|
||||
store.clear()
|
||||
|
||||
wrapper.cache_clear = cache_clear
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def is_private_ipv4_int(value: int) -> bool:
|
||||
"""整数形式 IPv4 是否属于常见私网、回环或链路本地段。"""
|
||||
return (
|
||||
(value & 0xFF000000) == 0x0A000000
|
||||
or (value & 0xFFF00000) == 0xAC100000
|
||||
or (value & 0xFFFF0000) == 0xC0A80000
|
||||
or (value & 0xFF000000) == 0x7F000000
|
||||
or (value & 0xFFFF0000) == 0xA9FE0000
|
||||
)
|
||||
|
||||
|
||||
def resolve_host_ips(host: str):
|
||||
"""解析主机名的 IPv4/IPv6 集合;字面量 IP 返回自身,失败返回空集合。"""
|
||||
try:
|
||||
return {ipaddress.ip_address(host)}
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return {
|
||||
ipaddress.ip_address(address[4][0])
|
||||
for address in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def is_private_host(host: str) -> bool:
|
||||
"""目标是否命中私网、回环或 IPv6 ULA/链路本地地址。"""
|
||||
for address in resolve_host_ips(host):
|
||||
if address.version == 4 and is_private_ipv4_int(int(address)):
|
||||
return True
|
||||
if address.version == 6 and (
|
||||
address.is_loopback
|
||||
or address.is_link_local
|
||||
or address in ipaddress.ip_network("fc00::/7")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,36 @@
|
||||
"""节点订阅的认证请求协议。"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
class NodeAuthError(Exception):
|
||||
"""订阅服务拒绝请求时携带的 HTTP 状态和用户可读原因。"""
|
||||
|
||||
def __init__(self, message, code=0):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def b64url(value: str) -> str:
|
||||
"""SSO 查询参数使用的无 padding URL-safe Base64。"""
|
||||
return base64.urlsafe_b64encode(str(value).encode("utf-8")).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def fetch_nodes_auth(url, user, password, timeout=10):
|
||||
"""直连拉取需 SSO 查询参数认证的 nodes.json。"""
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
separator = "&" if "?" in url else "?"
|
||||
request_url = f"{url}{separator}user={b64url(user)}&password={b64url(password)}"
|
||||
try:
|
||||
with opener.open(request_url, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
body = ""
|
||||
try:
|
||||
body = error.read().decode("utf-8", "replace").strip()
|
||||
except Exception:
|
||||
pass
|
||||
raise NodeAuthError(body or f"请求被拒绝 (HTTP {error.code})", error.code) from None
|
||||
@@ -0,0 +1,95 @@
|
||||
"""节点与登录凭据的本机加密存储。"""
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
class _DpapiBlob(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbData", ctypes.c_uint32),
|
||||
("pbData", ctypes.POINTER(ctypes.c_char)),
|
||||
]
|
||||
|
||||
|
||||
class NodeSecretsStore:
|
||||
"""使用 Windows DPAPI 保存节点和 SSO 凭据;非 Windows 保持明文兼容。"""
|
||||
|
||||
def __init__(self, user_dir_getter):
|
||||
self._user_dir_getter = user_dir_getter
|
||||
|
||||
@staticmethod
|
||||
def protect_bytes(data: bytes) -> bytes:
|
||||
crypt32 = ctypes.WinDLL("crypt32")
|
||||
kernel32 = ctypes.WinDLL("kernel32")
|
||||
crypt32.CryptProtectData.argtypes = [
|
||||
ctypes.POINTER(_DpapiBlob), ctypes.c_wchar_p, ctypes.POINTER(_DpapiBlob),
|
||||
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob),
|
||||
]
|
||||
crypt32.CryptProtectData.restype = ctypes.c_bool
|
||||
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
||||
buffer = ctypes.create_string_buffer(data, len(data))
|
||||
blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
|
||||
blob_out = _DpapiBlob()
|
||||
if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None,
|
||||
None, None, 0, ctypes.byref(blob_out)):
|
||||
raise OSError("CryptProtectData 调用失败")
|
||||
try:
|
||||
return ctypes.string_at(blob_out.pbData, blob_out.cbData)
|
||||
finally:
|
||||
kernel32.LocalFree(blob_out.pbData)
|
||||
|
||||
@staticmethod
|
||||
def unprotect_bytes(data: bytes) -> bytes:
|
||||
crypt32 = ctypes.WinDLL("crypt32")
|
||||
kernel32 = ctypes.WinDLL("kernel32")
|
||||
crypt32.CryptUnprotectData.argtypes = [
|
||||
ctypes.POINTER(_DpapiBlob), ctypes.POINTER(ctypes.c_wchar_p), ctypes.POINTER(_DpapiBlob),
|
||||
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob),
|
||||
]
|
||||
crypt32.CryptUnprotectData.restype = ctypes.c_bool
|
||||
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
||||
buffer = ctypes.create_string_buffer(data, len(data))
|
||||
blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
|
||||
description = ctypes.c_wchar_p()
|
||||
blob_out = _DpapiBlob()
|
||||
if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(description), None,
|
||||
None, None, 0, ctypes.byref(blob_out)):
|
||||
raise OSError("CryptUnprotectData 调用失败")
|
||||
try:
|
||||
return ctypes.string_at(blob_out.pbData, blob_out.cbData)
|
||||
finally:
|
||||
kernel32.LocalFree(blob_out.pbData)
|
||||
if description.value:
|
||||
kernel32.LocalFree(description)
|
||||
|
||||
def encrypt_text(self, text: str) -> bytes:
|
||||
if os.name != "nt":
|
||||
return text.encode("utf-8")
|
||||
return self.protect_bytes(text.encode("utf-8"))
|
||||
|
||||
def decrypt_bytes(self, blob: bytes) -> str:
|
||||
if os.name != "nt":
|
||||
return blob.decode("utf-8")
|
||||
try:
|
||||
return self.unprotect_bytes(blob).decode("utf-8")
|
||||
except Exception:
|
||||
return blob.decode("utf-8")
|
||||
|
||||
def save_auth(self, username, password):
|
||||
directory = self._user_dir_getter()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps({"username": username, "password": password}, ensure_ascii=False)
|
||||
(directory / "auth.json").write_bytes(self.encrypt_text(text))
|
||||
|
||||
def load_auth(self):
|
||||
try:
|
||||
path = self._user_dir_getter() / "auth.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
data = json.loads(self.decrypt_bytes(path.read_bytes()))
|
||||
if not (isinstance(data, dict) and data.get("username") and data.get("password")):
|
||||
return None
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,53 @@
|
||||
"""PAC 数据文件的网络传输与安全落盘。"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def read_version(path):
|
||||
"""读取 PAC JSON 的 version/date;无效文件返回空字符串。"""
|
||||
try:
|
||||
data = json.loads(Path(path).read_text("utf-8"))
|
||||
return str(data.get("version") or ""), str(data.get("date") or "")
|
||||
except Exception:
|
||||
return "", ""
|
||||
|
||||
|
||||
def no_proxy_opener():
|
||||
"""创建忽略环境/系统代理的直连 HTTP opener。"""
|
||||
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
|
||||
|
||||
def fetch_json(url, timeout=15, tries=2):
|
||||
"""以直连方式拉取 JSON;最后一次失败时保留原始异常。"""
|
||||
opener = no_proxy_opener()
|
||||
for attempt in range(tries):
|
||||
try:
|
||||
with opener.open(url, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except Exception:
|
||||
if attempt == tries - 1:
|
||||
raise
|
||||
|
||||
|
||||
def validate_document(name, data):
|
||||
"""校验各 PAC 文件的最小结构,避免错误响应覆盖有效缓存。"""
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
if name in ("pac_domains", "pac_direct_domains"):
|
||||
return isinstance(data.get("domains"), list)
|
||||
if name == "pac_ip_cn":
|
||||
return isinstance(data.get("cidrs"), list)
|
||||
return False
|
||||
|
||||
|
||||
def atomic_write(directory, name, data):
|
||||
"""将 PAC JSON 以 tmp + replace 原子写入指定用户目录。"""
|
||||
directory = Path(directory)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
target = directory / f"{name}.json"
|
||||
temporary_path = directory / f"{name}.json.tmp"
|
||||
temporary_path.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
|
||||
os.replace(temporary_path, target)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""PAC 路由引擎使用的纯算法,不依赖文件、网络或 Qt。"""
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
|
||||
def merge_ip_ranges(cidrs, version):
|
||||
"""将指定 IP 版本的 CIDR 合并为整数闭区间,供二分查找。"""
|
||||
ranges = []
|
||||
for cidr in cidrs:
|
||||
network = ipaddress.ip_network(str(cidr).strip(), strict=False)
|
||||
if network.version == version:
|
||||
ranges.append((int(network.network_address), int(network.broadcast_address)))
|
||||
ranges.sort()
|
||||
merged = []
|
||||
for start, end in ranges:
|
||||
if merged and start <= merged[-1][1] + 1:
|
||||
if end > merged[-1][1]:
|
||||
merged[-1] = (merged[-1][0], end)
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def build_suffix_trie(suffixes):
|
||||
"""按反向域名标签建立 Trie,适合大规模后缀规则的 O(标签数) 匹配。"""
|
||||
root = {}
|
||||
for suffix in suffixes:
|
||||
node = root
|
||||
for label in reversed(suffix.split(".")):
|
||||
node = node.setdefault(label, {})
|
||||
node[""] = True
|
||||
return root
|
||||
|
||||
|
||||
def suffix_trie_matches(host: str, trie) -> bool:
|
||||
"""判断主机名是否命中一个后缀 Trie。"""
|
||||
node = trie
|
||||
for label in reversed(host.rstrip(".").lower().split(".")):
|
||||
node = node.get(label)
|
||||
if node is None:
|
||||
return False
|
||||
if node.get(""):
|
||||
return True
|
||||
return bool(node and node.get(""))
|
||||
|
||||
|
||||
def parse_domain_rules(raw_rules, seed_suffixes=()):
|
||||
"""解析后缀、``full:`` 与 ``regexp:`` 三种 PAC 域名规则。
|
||||
|
||||
返回 ``(suffixes, exact, regexes, invalid_regex_count)``。调用方决定
|
||||
这些规则是“强制代理”还是“明确直连”,从而避免解析层携带业务策略。
|
||||
"""
|
||||
suffixes = set(seed_suffixes)
|
||||
exact = set()
|
||||
regexes = []
|
||||
invalid_regexes = 0
|
||||
for raw in raw_rules or ():
|
||||
raw_rule = str(raw).strip()
|
||||
rule = raw_rule.lower()
|
||||
if not raw_rule:
|
||||
continue
|
||||
if rule.startswith("full:"):
|
||||
host = rule[len("full:"):].rstrip(".")
|
||||
if host:
|
||||
exact.add(host)
|
||||
continue
|
||||
if rule.startswith("regexp:"):
|
||||
pattern = raw_rule[len("regexp:"):]
|
||||
if not pattern:
|
||||
invalid_regexes += 1
|
||||
continue
|
||||
try:
|
||||
regexes.append(re.compile(pattern, re.IGNORECASE))
|
||||
except re.error:
|
||||
invalid_regexes += 1
|
||||
continue
|
||||
suffix = rule.lstrip("*.").rstrip(".")
|
||||
if suffix:
|
||||
suffixes.add(suffix)
|
||||
return suffixes, frozenset(exact), tuple(regexes), invalid_regexes
|
||||
@@ -0,0 +1,34 @@
|
||||
"""用户设置的 JSON 存储实现。"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
class JsonSettingsStore:
|
||||
"""读取与原子写回一个 JSON 对象。
|
||||
|
||||
``path_getter`` 在每次操作时调用,保证测试或运行时切换用户目录后不会
|
||||
意外写到旧目录。
|
||||
"""
|
||||
|
||||
def __init__(self, path_getter):
|
||||
self._path_getter = path_getter
|
||||
|
||||
def load(self):
|
||||
path = self._path_getter()
|
||||
try:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
data = json.loads(path.read_text("utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def save(self, data):
|
||||
path = self._path_getter()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_path = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2) + "\n", "utf-8"
|
||||
)
|
||||
os.replace(temporary_path, path)
|
||||
@@ -0,0 +1,857 @@
|
||||
"""本地 SOCKS5 → HTTP CONNECT/UoT v2 桥接。
|
||||
|
||||
tun2socks 只需要一个标准 SOCKS5 出口;TCP 连接转成当前节点的 HTTP
|
||||
CONNECT,UDP 连接转成 magic address 上的 UoT v2 流。系统代理设置完全不参与。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import select
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import threading
|
||||
from typing import Any, Callable
|
||||
|
||||
from .dns_observer import DnsObserver
|
||||
from .uot_protocol import (
|
||||
Address, MAGIC_ADDRESS, encode_datagram_frame, encode_request,
|
||||
read_datagram_frame,
|
||||
)
|
||||
|
||||
# relay 的内网监听端口与外层 CONNECT 端口是两个概念:nginx 接收
|
||||
# CONNECT magic-host:443 后,将字节流转发到 127.0.0.1:15201。
|
||||
DEFAULT_UOT_RELAY_PORT = 15201
|
||||
DEFAULT_UOT_CONNECT_PORT = 443
|
||||
|
||||
|
||||
def _recv_exact(stream: socket.socket, size: int) -> bytes:
|
||||
"""按 SOCKS5/TCP 协议长度读取,不能假设一次 recv 就拿全。"""
|
||||
data = bytearray()
|
||||
while len(data) < size:
|
||||
chunk = stream.recv(size - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("SOCKS5 连接在握手中断开")
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def _node_http(node: dict[str, Any]) -> tuple[str, int, str, str]:
|
||||
methods = (node.get("methods") or {}).get("http") or {}
|
||||
host = str(node.get("domain") or "").strip()
|
||||
port = methods.get("port") or node.get("port") or 443
|
||||
username = methods.get("username") or node.get("username") or ""
|
||||
password = methods.get("password") or node.get("password") or ""
|
||||
if not host:
|
||||
raise ValueError("节点缺少 HTTP CONNECT 地址")
|
||||
return host, int(port), str(username), str(password)
|
||||
|
||||
|
||||
def _node_uot_connect_port(node: dict[str, Any]) -> int:
|
||||
"""返回 magic CONNECT 的对外端口,不能使用 0。"""
|
||||
chain = node.get("_jump_chain") if isinstance(node, dict) else None
|
||||
if isinstance(chain, list) and chain:
|
||||
return _node_uot_connect_port(chain[-1])
|
||||
methods = (node.get("methods") or {}).get("http") or {}
|
||||
raw = methods.get("uot_connect_port")
|
||||
if raw is None:
|
||||
raw = node.get("uot_connect_port")
|
||||
# 兼容早期把 uot_port 当作 CONNECT 端口的本地配置;服务端 nginx
|
||||
# 会按 magic host 映射到隐藏的 15201 relay 端口。
|
||||
if raw is None:
|
||||
raw = methods.get("uot_port")
|
||||
if raw is None:
|
||||
raw = node.get("uot_port")
|
||||
if raw is None:
|
||||
raw = DEFAULT_UOT_CONNECT_PORT
|
||||
port = int(raw)
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("UoT CONNECT 端口超出范围")
|
||||
return port
|
||||
|
||||
|
||||
def _socks_address(stream: socket.socket, kind: int | None = None) -> Address:
|
||||
# SOCKS5 请求头已经读取过 ATYP 时直接传入;独立解析时才从流中读取。
|
||||
if kind is None:
|
||||
kind = _recv_exact(stream, 1)[0]
|
||||
if kind == 1:
|
||||
host = socket.inet_ntoa(_recv_exact(stream, 4))
|
||||
elif kind == 4:
|
||||
host = socket.inet_ntop(socket.AF_INET6, _recv_exact(stream, 16))
|
||||
elif kind == 3:
|
||||
size = _recv_exact(stream, 1)[0]
|
||||
host = _recv_exact(stream, size).decode("idna")
|
||||
else:
|
||||
raise ValueError("SOCKS5 地址类型不支持")
|
||||
port = struct.unpack("!H", _recv_exact(stream, 2))[0]
|
||||
return Address(host, port)
|
||||
|
||||
|
||||
def _pack_socks_udp(destination: Address, payload: bytes) -> bytes:
|
||||
try:
|
||||
ip = ipaddress.ip_address(destination.host)
|
||||
except ValueError:
|
||||
raw = destination.host.encode("idna")
|
||||
return b"\x00\x00\x00\x03" + bytes([len(raw)]) + raw + struct.pack("!H", destination.port) + payload
|
||||
atyp = 1 if ip.version == 4 else 4
|
||||
return b"\x00\x00\x00" + bytes([atyp]) + ip.packed + struct.pack("!H", destination.port) + payload
|
||||
|
||||
|
||||
def _unpack_socks_udp(packet: bytes) -> tuple[Address, bytes]:
|
||||
if len(packet) < 4 or packet[:2] != b"\x00\x00":
|
||||
raise ValueError("SOCKS5 UDP 包头无效")
|
||||
if packet[2] != 0:
|
||||
# RFC 1928 允许分片,但 tun2socks 当前不会发送分片;静默拼接会把
|
||||
# 不完整的报文当成 DNS/QUIC 数据,造成更难排查的协议损坏。
|
||||
raise ValueError("暂不支持 SOCKS5 UDP 分片")
|
||||
atyp = packet[3]
|
||||
offset = 4
|
||||
if atyp == 1:
|
||||
if len(packet) < offset + 4:
|
||||
raise ValueError("SOCKS5 UDP IPv4 地址不完整")
|
||||
host = socket.inet_ntoa(packet[offset:offset + 4]); offset += 4
|
||||
elif atyp == 4:
|
||||
if len(packet) < offset + 16:
|
||||
raise ValueError("SOCKS5 UDP IPv6 地址不完整")
|
||||
host = socket.inet_ntop(socket.AF_INET6, packet[offset:offset + 16]); offset += 16
|
||||
elif atyp == 3:
|
||||
if len(packet) < offset + 1:
|
||||
raise ValueError("SOCKS5 UDP 域名长度不完整")
|
||||
size = packet[offset]; offset += 1
|
||||
if len(packet) < offset + size:
|
||||
raise ValueError("SOCKS5 UDP 域名地址不完整")
|
||||
host = packet[offset:offset + size].decode("idna"); offset += size
|
||||
else:
|
||||
raise ValueError("SOCKS5 UDP 地址类型不支持")
|
||||
if len(packet) < offset + 2:
|
||||
raise ValueError("SOCKS5 UDP 端口不完整")
|
||||
port = struct.unpack("!H", packet[offset:offset + 2])[0]
|
||||
return Address(host, port), packet[offset + 2:]
|
||||
|
||||
|
||||
class HttpConnectDialer:
|
||||
def __init__(self, node: dict[str, Any], timeout: float = 15.0,
|
||||
source_address: str | None = None,
|
||||
proxy_addresses: list[tuple] | None = None,
|
||||
proxy_addresses_by_node: dict[str, list[tuple]] | None = None):
|
||||
self.node = node
|
||||
self.timeout = timeout
|
||||
# TUN 启动后默认路由会指向虚拟网卡。节点本身必须从物理网卡发出,
|
||||
# 否则上游 CONNECT 会再次进入本地 SOCKS,形成递归回环。
|
||||
self.source_address = source_address
|
||||
self.proxy_addresses = proxy_addresses
|
||||
self.proxy_addresses_by_node = proxy_addresses_by_node or {}
|
||||
|
||||
@staticmethod
|
||||
def _node_key(node: dict[str, Any]) -> str:
|
||||
methods = (node.get("methods") or {}).get("http") or {}
|
||||
return "|".join((str(node.get("domain") or ""),
|
||||
str(methods.get("port") or node.get("port") or 443),
|
||||
str(methods.get("username") or node.get("username") or "")))
|
||||
|
||||
@staticmethod
|
||||
def _node_credentials(node: dict[str, Any]) -> tuple[str, str]:
|
||||
methods = (node.get("methods") or {}).get("http") or {}
|
||||
username = methods.get("username") or node.get("username") or ""
|
||||
password = methods.get("password") or node.get("password") or ""
|
||||
return str(username), str(password)
|
||||
|
||||
def _open_proxy_socket(self, host: str, port: int,
|
||||
addresses: list[tuple] | None = None) -> socket.socket:
|
||||
addresses = addresses or self.proxy_addresses
|
||||
if not addresses:
|
||||
addresses = [item[4] for item in socket.getaddrinfo(
|
||||
host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)]
|
||||
last_error: OSError | None = None
|
||||
for sockaddr in addresses:
|
||||
family = socket.AF_INET6 if len(sockaddr) == 4 else socket.AF_INET
|
||||
raw = socket.socket(family, socket.SOCK_STREAM)
|
||||
try:
|
||||
raw.settimeout(self.timeout)
|
||||
if self.source_address:
|
||||
if family == socket.AF_INET and ":" not in self.source_address:
|
||||
raw.bind((self.source_address, 0))
|
||||
elif family == socket.AF_INET6 and ":" in self.source_address:
|
||||
raw.bind((self.source_address, 0, 0, 0))
|
||||
raw.connect(sockaddr)
|
||||
return raw
|
||||
except OSError as exc:
|
||||
last_error = exc
|
||||
raw.close()
|
||||
if last_error:
|
||||
raise last_error
|
||||
raise OSError(f"无法解析 HTTP CONNECT 节点: {host}")
|
||||
|
||||
def _jump_addresses(self, node: dict[str, Any]) -> list[tuple] | None:
|
||||
return self.proxy_addresses_by_node.get(self._node_key(node))
|
||||
|
||||
def _open_jump(self, target: Address) -> socket.socket:
|
||||
"""通过 2-3 个 HTTP CONNECT 节点逐层建立 TLS,再连接最终目标。"""
|
||||
chain = list(self.node.get("_jump_chain") or [])
|
||||
if not 2 <= len(chain) <= 3:
|
||||
raise RuntimeError("跳板链路必须包含 2-3 个节点")
|
||||
ctx = ssl.create_default_context()
|
||||
first = chain[0]
|
||||
first_host, first_port, _username, _password = _node_http(first)
|
||||
stream = self._open_proxy_socket(
|
||||
first_host, first_port, self._jump_addresses(first))
|
||||
try:
|
||||
stream = ctx.wrap_socket(stream, server_hostname=first_host)
|
||||
for index, next_node in enumerate(chain[1:], 1):
|
||||
next_host, next_port, _username, _password = _node_http(next_node)
|
||||
leftover = _http_connect_over(
|
||||
stream, chain[index - 1], next_host, next_port, resolve_target=True)
|
||||
stream = _LayeredTlsTransport(stream, ctx, next_host, leftover)
|
||||
_http_connect_over(
|
||||
stream, chain[-1], target.host, target.port, resolve_target=False)
|
||||
return stream
|
||||
except Exception:
|
||||
try:
|
||||
stream.close()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def open(self, target: Address) -> socket.socket:
|
||||
if self.node.get("_jump_chain"):
|
||||
return self._open_jump(target)
|
||||
host, port, username, password = _node_http(self.node)
|
||||
raw = self._open_proxy_socket(host, port)
|
||||
ctx = ssl.create_default_context()
|
||||
stream = ctx.wrap_socket(raw, server_hostname=host)
|
||||
authority = f"[{target.host}]:{target.port}" if ":" in target.host else f"{target.host}:{target.port}"
|
||||
auth = ""
|
||||
if username or password:
|
||||
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||
auth = f"Authorization: Basic {token}\r\n"
|
||||
request = (
|
||||
f"CONNECT {authority} HTTP/1.1\r\n"
|
||||
f"Host: {authority}\r\n"
|
||||
f"{auth}Connection: Keep-Alive\r\n\r\n"
|
||||
).encode("ascii")
|
||||
stream.sendall(request)
|
||||
response = b""
|
||||
while b"\r\n\r\n" not in response and len(response) < 65536:
|
||||
chunk = stream.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response += chunk
|
||||
first = response.split(b"\r\n", 1)[0].decode("latin1", "replace")
|
||||
if not first.startswith(("HTTP/1.1 200", "HTTP/1.0 200")):
|
||||
stream.close()
|
||||
raise ConnectionError(f"HTTP CONNECT 失败: {first[:160]}")
|
||||
return stream
|
||||
|
||||
|
||||
def _resolve_jump_address(host: str, port: int) -> tuple:
|
||||
infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
infos.sort(key=lambda item: 0 if item[0] == socket.AF_INET else 1)
|
||||
if not infos:
|
||||
raise OSError(f"无法解析跳板地址: {host}")
|
||||
return infos[0][4]
|
||||
|
||||
|
||||
def _http_connect_over(transport, proxy_node: dict[str, Any], target_host: str,
|
||||
target_port: int, *, resolve_target: bool) -> bytes:
|
||||
"""在当前 TLS 层发送 CONNECT;中间跳板使用解析后的 IP,末跳保留目标名。"""
|
||||
username, password = HttpConnectDialer._node_credentials(proxy_node)
|
||||
auth_header = ""
|
||||
if username or password:
|
||||
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||
auth_header = f"Authorization: Basic {token}\r\n"
|
||||
if resolve_target:
|
||||
address = _resolve_jump_address(target_host, target_port)
|
||||
authority = f"[{address[0]}]:{address[1]}" if ":" in address[0] else f"{address[0]}:{address[1]}"
|
||||
else:
|
||||
authority = f"[{target_host}]:{target_port}" if ":" in target_host else f"{target_host}:{target_port}"
|
||||
request = (
|
||||
f"CONNECT {authority} HTTP/1.1\r\n"
|
||||
f"Host: {authority}\r\n"
|
||||
f"{auth_header}Connection: Keep-Alive\r\n\r\n"
|
||||
).encode("ascii")
|
||||
transport.sendall(request)
|
||||
response = b""
|
||||
while b"\r\n\r\n" not in response and len(response) < 65536:
|
||||
chunk = transport.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response += chunk
|
||||
marker = b"\r\n\r\n"
|
||||
end = response.find(marker)
|
||||
first = response.split(b"\r\n", 1)[0].decode("latin-1", "replace")
|
||||
if not (first.startswith("HTTP/1.1 200") or first.startswith("HTTP/1.0 200")):
|
||||
raise ConnectionError(f"跳板 CONNECT 失败: {first[:160]}")
|
||||
return response[end + len(marker):] if end >= 0 else b""
|
||||
|
||||
|
||||
class _LayeredTlsTransport:
|
||||
"""在已有 TLS 字节流上承载下一层 TLS。"""
|
||||
|
||||
def __init__(self, transport, ctx, server_hostname: str, initial: bytes = b""):
|
||||
self._transport = transport
|
||||
self._incoming = ssl.MemoryBIO()
|
||||
self._outgoing = ssl.MemoryBIO()
|
||||
self._pending = initial
|
||||
self._ssl = ctx.wrap_bio(
|
||||
self._incoming, self._outgoing, server_side=False,
|
||||
server_hostname=server_hostname)
|
||||
self._handshake()
|
||||
|
||||
def _flush(self):
|
||||
data = self._outgoing.read()
|
||||
if data:
|
||||
self._transport.sendall(data)
|
||||
|
||||
def _feed(self):
|
||||
if self._pending:
|
||||
data, self._pending = self._pending, b""
|
||||
else:
|
||||
data = self._transport.recv(16384)
|
||||
if not data:
|
||||
raise ConnectionError("分层 TLS 下层连接已关闭")
|
||||
self._incoming.write(data)
|
||||
|
||||
def _handshake(self):
|
||||
while True:
|
||||
try:
|
||||
self._ssl.do_handshake()
|
||||
self._flush()
|
||||
return
|
||||
except ssl.SSLWantReadError:
|
||||
self._flush()
|
||||
self._feed()
|
||||
except ssl.SSLWantWriteError:
|
||||
self._flush()
|
||||
|
||||
def sendall(self, data):
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
try:
|
||||
written = self._ssl.write(view)
|
||||
view = view[written:]
|
||||
self._flush()
|
||||
except ssl.SSLWantReadError:
|
||||
self._flush()
|
||||
self._feed()
|
||||
except ssl.SSLWantWriteError:
|
||||
self._flush()
|
||||
|
||||
def recv(self, size):
|
||||
while True:
|
||||
try:
|
||||
return self._ssl.read(size)
|
||||
except ssl.SSLWantReadError:
|
||||
self._flush()
|
||||
self._feed()
|
||||
except ssl.SSLWantWriteError:
|
||||
self._flush()
|
||||
|
||||
def settimeout(self, value):
|
||||
self._transport.settimeout(value)
|
||||
|
||||
def gettimeout(self):
|
||||
return self._transport.gettimeout()
|
||||
|
||||
def close(self):
|
||||
self._transport.close()
|
||||
|
||||
|
||||
class Socks5Bridge:
|
||||
def __init__(self, node: dict[str, Any], bypass_lan: bool = True,
|
||||
route_rules: list[dict[str, Any]] | None = None,
|
||||
log: Callable[[str], None] | None = None,
|
||||
source_address: str | None = None,
|
||||
proxy_addresses: list[tuple] | None = None,
|
||||
direct_target_resolver: Callable[[str, str, int], tuple[str, int] | None] | None = None,
|
||||
route_rule_resolver: Callable[[str], dict[str, Any] | None] | None = None,
|
||||
route_node_resolver: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
|
||||
proxy_addresses_by_node: dict[str, list[tuple]] | None = None,
|
||||
direct_route_ensurer: Callable[[str], None] | None = None):
|
||||
self.node = node
|
||||
self.bypass_lan = bypass_lan
|
||||
self.route_rules = route_rules or []
|
||||
self.log = log or (lambda _message: None)
|
||||
self.source_address = source_address
|
||||
self.proxy_addresses = proxy_addresses
|
||||
# 由 main.py 注入 Lite 的同一套 PAC/路由判定,TUN 不复制另一份规则语义。
|
||||
self.direct_target_resolver = direct_target_resolver
|
||||
self.route_rule_resolver = route_rule_resolver
|
||||
self.route_node_resolver = route_node_resolver
|
||||
self.proxy_addresses_by_node = proxy_addresses_by_node or {}
|
||||
self.direct_route_ensurer = direct_route_ensurer
|
||||
self.listener: socket.socket | None = None
|
||||
self._running = threading.Event()
|
||||
self._threads: list[threading.Thread] = []
|
||||
self._up = 0
|
||||
self._down = 0
|
||||
self._counter_lock = threading.Lock()
|
||||
self._dns = DnsObserver(log=self.log)
|
||||
self._udp_resources: list[socket.socket] = []
|
||||
self._udp_resources_lock = threading.RLock()
|
||||
|
||||
def start(self) -> int:
|
||||
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.listener.bind(("127.0.0.1", 0))
|
||||
self.listener.listen(32)
|
||||
self.listener.settimeout(1)
|
||||
self._running.set()
|
||||
thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
thread.start(); self._threads.append(thread)
|
||||
return self.listener.getsockname()[1]
|
||||
|
||||
def _accept_loop(self):
|
||||
while self._running.is_set() and self.listener:
|
||||
try:
|
||||
client, _ = self.listener.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
thread = threading.Thread(target=self._client, args=(client,), daemon=True)
|
||||
thread.start(); self._threads.append(thread)
|
||||
|
||||
def _client(self, client: socket.socket):
|
||||
try:
|
||||
client.settimeout(15)
|
||||
greeting = _recv_exact(client, 2)
|
||||
if greeting[0] != 5:
|
||||
return
|
||||
methods = _recv_exact(client, greeting[1])
|
||||
if 0 not in methods:
|
||||
client.sendall(b"\x05\xff")
|
||||
return
|
||||
client.sendall(b"\x05\x00")
|
||||
header = _recv_exact(client, 4)
|
||||
if header[0] != 5:
|
||||
return
|
||||
command = header[1]
|
||||
destination = _socks_address(client, header[3])
|
||||
if command == 1:
|
||||
self.log(f"[桥接] TCP 目标: {self._target_label(destination)}")
|
||||
self._tcp(client, destination)
|
||||
elif command == 3:
|
||||
self._udp(client)
|
||||
else:
|
||||
client.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||
except Exception as exc:
|
||||
# 停止 TUN 时主动关闭仍在转发的连接,Windows 可能返回
|
||||
# WSAECONNRESET (10054);这是收尾动作,不应作为用户错误记录。
|
||||
if self._running.is_set():
|
||||
self.log(f"[桥接] SOCKS5 请求失败: {exc}")
|
||||
finally:
|
||||
try: client.close()
|
||||
except OSError: pass
|
||||
|
||||
def _target_label(self, target: Address) -> str:
|
||||
host = target.host
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
ips = self._dns.lookup_domain(host)
|
||||
resolved = ",".join(ips) if ips else "未知"
|
||||
return f"domain={host} ip={resolved} port={target.port}"
|
||||
domain = self._dns.lookup_ip(host) or "未知"
|
||||
return f"domain={domain} ip={host} port={target.port}"
|
||||
|
||||
def _policy_host(self, host: str) -> str:
|
||||
"""对已观察到的唯一 DNS 映射应用域名规则;实际连接仍使用原目标 IP。"""
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return host
|
||||
return self._dns.lookup_ip(host) or host
|
||||
|
||||
def _direct_target(self, host: str, port: int) -> tuple[str, int] | None:
|
||||
explicit = False
|
||||
normalized = str(host).rstrip(".").lower()
|
||||
for rule in self.route_rules:
|
||||
if not isinstance(rule, dict) or not rule.get("enabled", True):
|
||||
continue
|
||||
if str(rule.get("action") or "direct").lower() != "direct":
|
||||
continue
|
||||
value = str(rule.get("domain") or rule.get("value") or "").strip().lower().rstrip(".")
|
||||
if value.startswith("*."):
|
||||
value = value[2:]
|
||||
try:
|
||||
if ipaddress.ip_address(normalized) in ipaddress.ip_network(value, strict=False):
|
||||
explicit = True
|
||||
except ValueError:
|
||||
if normalized == value or normalized.endswith("." + value):
|
||||
explicit = True
|
||||
if explicit:
|
||||
break
|
||||
if not self.bypass_lan and not explicit:
|
||||
return None
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
return (host, port) if explicit or address.is_private else None
|
||||
except ValueError:
|
||||
try:
|
||||
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
|
||||
address = item[4][0].split("%", 1)[0]
|
||||
if explicit or ipaddress.ip_address(address).is_private:
|
||||
return address, port
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _route_rule(self, host: str, original_host: str | None = None) -> dict[str, Any] | None:
|
||||
if self.route_rule_resolver is not None:
|
||||
try:
|
||||
rule = self.route_rule_resolver(host)
|
||||
if isinstance(rule, dict):
|
||||
return rule
|
||||
if original_host and original_host != host:
|
||||
rule = self.route_rule_resolver(original_host)
|
||||
if isinstance(rule, dict):
|
||||
return rule
|
||||
except Exception as exc:
|
||||
self.log(f"[路由] 规则判断失败: {exc}")
|
||||
for rule in self.route_rules:
|
||||
if not isinstance(rule, dict) or not rule.get("enabled", True):
|
||||
continue
|
||||
value = str(rule.get("domain") or rule.get("value") or "").strip().lower().rstrip(".")
|
||||
normalized = str(host).rstrip(".").lower()
|
||||
base = value[2:] if value.startswith("*.") else value
|
||||
if base and (normalized == base or normalized.endswith("." + base)):
|
||||
return rule
|
||||
return None
|
||||
|
||||
def _direct_target_for(self, host: str, port: int,
|
||||
policy_host: str | None = None) -> tuple[str, int] | None:
|
||||
policy_host = policy_host or self._policy_host(host)
|
||||
rule = self._route_rule(policy_host, host)
|
||||
action = str((rule or {}).get("action") or "").lower()
|
||||
if action == "node":
|
||||
return None
|
||||
if self.direct_target_resolver is not None:
|
||||
try:
|
||||
return self.direct_target_resolver(policy_host, host, port)
|
||||
except Exception as exc:
|
||||
self.log(f"[路由] PAC 直连判断失败: {exc}")
|
||||
return None
|
||||
if action == "direct":
|
||||
return (host, port)
|
||||
return self._direct_target(host, port)
|
||||
|
||||
def _route_node(self, rule: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not rule or str(rule.get("action") or "").lower() != "node":
|
||||
return None
|
||||
if self.route_node_resolver is None:
|
||||
return None
|
||||
try:
|
||||
node = self.route_node_resolver(rule)
|
||||
return node if isinstance(node, dict) else None
|
||||
except Exception as exc:
|
||||
self.log(f"[路由] 固定节点解析失败: {exc}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _node_key(node: dict[str, Any]) -> str:
|
||||
chain = node.get("_jump_chain")
|
||||
if isinstance(chain, list) and chain:
|
||||
return "jump|" + ">".join(
|
||||
Socks5Bridge._node_key(item) for item in chain if isinstance(item, dict)
|
||||
)
|
||||
methods = (node.get("methods") or {}).get("http") or {}
|
||||
return "|".join((str(node.get("domain") or ""), str(methods.get("port") or node.get("port") or 443),
|
||||
str(methods.get("username") or node.get("username") or "")))
|
||||
|
||||
def _dialer(self, node: dict[str, Any]) -> HttpConnectDialer:
|
||||
proxy_addresses = self.proxy_addresses
|
||||
chain = node.get("_jump_chain")
|
||||
if isinstance(chain, list) and chain:
|
||||
proxy_addresses = self.proxy_addresses_by_node.get(
|
||||
self._node_key(chain[0]))
|
||||
return HttpConnectDialer(
|
||||
node, source_address=self.source_address,
|
||||
proxy_addresses=(proxy_addresses if node is self.node else
|
||||
self.proxy_addresses_by_node.get(self._node_key(node), proxy_addresses)),
|
||||
proxy_addresses_by_node=self.proxy_addresses_by_node,
|
||||
)
|
||||
|
||||
def _ensure_direct_routes(self, host: str, port: int) -> None:
|
||||
if self.direct_route_ensurer is None:
|
||||
return
|
||||
try:
|
||||
self.direct_route_ensurer(str(ipaddress.ip_address(host)))
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
|
||||
self.direct_route_ensurer(str(item[4][0]).split("%", 1)[0])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _proxy_target(self, target: Address) -> Address:
|
||||
"""为上游 CONNECT 恢复唯一 DNS 域名,给 IPv6 目标提供 IPv4 回退。"""
|
||||
try:
|
||||
ipaddress.ip_address(target.host)
|
||||
except ValueError:
|
||||
return target
|
||||
domain = self._dns.lookup_ip(target.host)
|
||||
if not domain:
|
||||
return target
|
||||
self.log(
|
||||
f"[桥接] IPv6 目标回退域名解析: {target.host} -> {domain}:{target.port}"
|
||||
)
|
||||
return Address(domain, target.port)
|
||||
|
||||
def _open_target(self, target: Address) -> socket.socket:
|
||||
policy_host = self._policy_host(target.host)
|
||||
rule = self._route_rule(policy_host, target.host)
|
||||
route_node = self._route_node(rule)
|
||||
if rule and str(rule.get("action") or "").lower() == "node":
|
||||
if route_node is None:
|
||||
raise ConnectionError("域名路由指定的节点不可用")
|
||||
return self._dialer(route_node).open(self._proxy_target(target))
|
||||
direct = self._direct_target_for(target.host, target.port, policy_host)
|
||||
if direct is not None:
|
||||
self._ensure_direct_routes(direct[0], direct[1])
|
||||
return socket.create_connection(direct, 15)
|
||||
return self._dialer(self.node).open(self._proxy_target(target))
|
||||
|
||||
def _tcp(self, client: socket.socket, target: Address):
|
||||
upstream = self._open_target(target)
|
||||
upstream.settimeout(None); client.settimeout(None)
|
||||
client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||
self._relay(client, upstream)
|
||||
|
||||
def _relay(self, left: socket.socket, right: socket.socket):
|
||||
sockets = [left, right]
|
||||
try:
|
||||
while self._running.is_set():
|
||||
readable, _, _ = select.select(sockets, [], [], 1)
|
||||
for source in readable:
|
||||
data = source.recv(65536)
|
||||
if not data: return
|
||||
target = right if source is left else left
|
||||
target.sendall(data)
|
||||
with self._counter_lock:
|
||||
if source is left: self._up += len(data)
|
||||
else: self._down += len(data)
|
||||
finally:
|
||||
for item in sockets:
|
||||
try: item.close()
|
||||
except OSError: pass
|
||||
|
||||
def _udp(self, control: socket.socket):
|
||||
# tun2socks v2.7 在 Windows 上通过 ListenPacket("udp", "")
|
||||
# 创建 IPv6 wildcard socket(日志形如 [::]:port)。SOCKS5 返回的
|
||||
# UDP relay 地址必须与这个地址族一致,否则 Windows 会在
|
||||
# wsasendto 阶段以 WSAEINVAL 拒绝向 127.0.0.1:port 发包。
|
||||
# 优先使用 IPv6 loopback;老系统没有 IPv6 时再回退 IPv4。
|
||||
try:
|
||||
udp = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
|
||||
udp.bind(("::1", 0))
|
||||
relay_address = b"\x04" + socket.inet_pton(socket.AF_INET6, "::1")
|
||||
relay_bind = f"[::1]:{udp.getsockname()[1]}"
|
||||
except OSError:
|
||||
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
udp.bind(("127.0.0.1", 0))
|
||||
relay_address = b"\x01" + socket.inet_aton("127.0.0.1")
|
||||
relay_bind = f"127.0.0.1:{udp.getsockname()[1]}"
|
||||
port = udp.getsockname()[1]
|
||||
udp.settimeout(1)
|
||||
with self._udp_resources_lock:
|
||||
self._udp_resources.append(udp)
|
||||
control.sendall(b"\x05\x00\x00" + relay_address + struct.pack("!H", port))
|
||||
self.log(f"[桥接] SOCKS5 UDP 关联已建立: {relay_bind}")
|
||||
control.settimeout(1)
|
||||
streams: dict[str, socket.socket] = {}
|
||||
direct_flows: dict[tuple[str, int], tuple[socket.socket, tuple, Address]] = {}
|
||||
malformed = 0
|
||||
try:
|
||||
while self._running.is_set():
|
||||
try:
|
||||
packet, peer = udp.recvfrom(65535)
|
||||
except socket.timeout:
|
||||
try:
|
||||
if not control.recv(1): break
|
||||
except socket.timeout:
|
||||
continue
|
||||
continue
|
||||
try:
|
||||
destination, payload = _unpack_socks_udp(packet)
|
||||
except Exception as exc:
|
||||
malformed += 1
|
||||
if malformed <= 5:
|
||||
self.log(
|
||||
f"[桥接] SOCKS5 UDP 数据包解析失败: {exc} len={len(packet)}"
|
||||
)
|
||||
continue
|
||||
if malformed:
|
||||
malformed = 0
|
||||
if not streams and not direct_flows:
|
||||
self.log(
|
||||
f"[桥接] SOCKS5 UDP 数据报: target={destination.host}:{destination.port} "
|
||||
f"bytes={len(payload)}"
|
||||
)
|
||||
if destination.port == 53:
|
||||
domain = self._dns.observe_query(payload)
|
||||
if domain:
|
||||
self.log(
|
||||
f"[DNS] 查询: domain={domain} server={destination.host}:{destination.port}"
|
||||
)
|
||||
policy_host = self._policy_host(destination.host)
|
||||
rule = self._route_rule(policy_host, destination.host)
|
||||
route_node = self._route_node(rule)
|
||||
if rule and str(rule.get("action") or "").lower() == "node" and route_node is None:
|
||||
raise ConnectionError("UDP 域名路由指定的节点不可用")
|
||||
direct = None if route_node is not None else self._direct_target_for(
|
||||
destination.host, destination.port, policy_host)
|
||||
if direct is not None:
|
||||
key = (str(direct[0]), int(direct[1]))
|
||||
flow = direct_flows.get(key)
|
||||
if flow is None:
|
||||
flow_socket, sockaddr = self._open_direct_udp(direct)
|
||||
flow = (flow_socket, sockaddr, destination)
|
||||
direct_flows[key] = flow
|
||||
threading.Thread(
|
||||
target=self._direct_udp_reader,
|
||||
args=(flow_socket, udp, destination, peer),
|
||||
daemon=True,
|
||||
).start()
|
||||
self.log(f"[UDP] 直连通道: target={destination.host}:{destination.port}")
|
||||
flow_socket, direct_addr, _flow_destination = flow
|
||||
flow_socket.sendto(payload, direct_addr)
|
||||
with self._counter_lock:
|
||||
self._up += len(payload)
|
||||
continue
|
||||
|
||||
selected_node = route_node or self.node
|
||||
stream_key = self._node_key(selected_node)
|
||||
stream = streams.get(stream_key)
|
||||
if stream is None:
|
||||
self.log(
|
||||
f"[UoT] 建立 UDP 通道: target={destination.host}:{destination.port}"
|
||||
+ ("(固定节点)" if route_node is not None else "")
|
||||
)
|
||||
stream = self._dialer(selected_node).open(
|
||||
Address(MAGIC_ADDRESS, _node_uot_connect_port(selected_node))
|
||||
)
|
||||
stream.settimeout(None)
|
||||
stream.sendall(encode_request(False, destination))
|
||||
streams[stream_key] = stream
|
||||
with self._udp_resources_lock:
|
||||
self._udp_resources.append(stream)
|
||||
threading.Thread(
|
||||
target=self._uot_reader, args=(stream, udp, peer), daemon=True
|
||||
).start()
|
||||
# UoT v2 non-connect 模式每个数据报都带目标地址。
|
||||
stream.sendall(encode_datagram_frame(destination, payload))
|
||||
with self._counter_lock:
|
||||
self._up += len(payload)
|
||||
finally:
|
||||
udp.close()
|
||||
for stream in streams.values():
|
||||
try:
|
||||
stream.close()
|
||||
except OSError:
|
||||
pass
|
||||
for flow_socket, _sockaddr, _destination in direct_flows.values():
|
||||
try:
|
||||
flow_socket.close()
|
||||
except OSError:
|
||||
pass
|
||||
with self._udp_resources_lock:
|
||||
self._udp_resources = [
|
||||
item for item in self._udp_resources
|
||||
if item is not udp and item not in streams.values()
|
||||
]
|
||||
|
||||
def _open_direct_udp_target(self, target: tuple[str, int]) -> tuple[socket.socket, tuple]:
|
||||
host, port = target
|
||||
last_error = None
|
||||
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_DGRAM):
|
||||
family, socktype, proto, _canonname, sockaddr = item
|
||||
raw = socket.socket(family, socktype, proto)
|
||||
try:
|
||||
destination_ip = str(sockaddr[0]).split("%", 1)[0]
|
||||
if self.direct_route_ensurer is not None:
|
||||
self.direct_route_ensurer(destination_ip)
|
||||
is_loopback = False
|
||||
try:
|
||||
is_loopback = ipaddress.ip_address(destination_ip).is_loopback
|
||||
except ValueError:
|
||||
pass
|
||||
if (family == socket.AF_INET and self.source_address and ":" not in self.source_address
|
||||
and not is_loopback):
|
||||
raw.bind((self.source_address, 0))
|
||||
elif (family == socket.AF_INET6 and self.source_address and ":" in self.source_address
|
||||
and not is_loopback):
|
||||
raw.bind((self.source_address, 0, 0, 0))
|
||||
if family == socket.AF_INET:
|
||||
try:
|
||||
raw.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
except OSError:
|
||||
pass
|
||||
raw.settimeout(1)
|
||||
return raw, sockaddr
|
||||
except OSError as exc:
|
||||
last_error = exc
|
||||
raw.close()
|
||||
raise last_error or OSError(f"无法解析 UDP 目标: {host}")
|
||||
|
||||
def _open_direct_udp(self, target: tuple[str, int]) -> tuple[socket.socket, tuple]:
|
||||
raw, sockaddr = self._open_direct_udp_target(target)
|
||||
# 解析后的 sockaddr 直接用于 sendto,保留 IPv6 scope id。
|
||||
return raw, sockaddr
|
||||
|
||||
def _direct_udp_reader(self, raw: socket.socket, udp: socket.socket,
|
||||
destination: Address, peer):
|
||||
try:
|
||||
while self._running.is_set():
|
||||
try:
|
||||
payload, _source = raw.recvfrom(65535)
|
||||
except socket.timeout:
|
||||
continue
|
||||
if not payload:
|
||||
continue
|
||||
if destination.port == 53:
|
||||
observed = self._dns.observe_response(payload)
|
||||
if observed:
|
||||
domain, ips = observed
|
||||
self.log(f"[DNS] 直连回包: domain={domain} ip={','.join(ips)}")
|
||||
udp.sendto(_pack_socks_udp(destination, payload), peer)
|
||||
with self._counter_lock:
|
||||
self._down += len(payload)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _uot_reader(self, stream, udp, peer):
|
||||
try:
|
||||
while self._running.is_set():
|
||||
destination, payload = read_datagram_frame(stream)
|
||||
observed = self._dns.observe_response(payload)
|
||||
if observed:
|
||||
domain, ips = observed
|
||||
self.log(f"[DNS] 回包: domain={domain} ip={','.join(ips)}")
|
||||
udp.sendto(_pack_socks_udp(destination, payload), peer)
|
||||
with self._counter_lock: self._down += len(payload)
|
||||
except Exception as exc:
|
||||
if self._running.is_set():
|
||||
self.log(f"[UoT] UDP 回包读取失败: {exc}")
|
||||
|
||||
def stop(self):
|
||||
self._running.clear()
|
||||
if self.listener:
|
||||
try: self.listener.close()
|
||||
except OSError: pass
|
||||
self.listener = None
|
||||
with self._udp_resources_lock:
|
||||
resources = list(self._udp_resources)
|
||||
self._udp_resources.clear()
|
||||
for resource in resources:
|
||||
try:
|
||||
resource.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def traffic(self) -> tuple[int, int, int, int]:
|
||||
with self._counter_lock:
|
||||
return self._up, self._down, self._up, self._down
|
||||
@@ -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 "已隐藏订阅地址"
|
||||
@@ -0,0 +1,55 @@
|
||||
"""线程安全的本地转发流量统计。"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class TrafficStats:
|
||||
"""记录单次代理会话的上下行总量与采样速率。"""
|
||||
|
||||
def __init__(self, clock=time.time):
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._up = 0
|
||||
self._down = 0
|
||||
self._prev_up = 0
|
||||
self._prev_down = 0
|
||||
self._prev_ts = 0.0
|
||||
|
||||
def add(self, up=0, down=0):
|
||||
with self._lock:
|
||||
self._up += up
|
||||
self._down += down
|
||||
|
||||
def reset(self):
|
||||
with self._lock:
|
||||
self._up = self._down = 0
|
||||
self._prev_up = self._prev_down = 0
|
||||
self._prev_ts = self._clock()
|
||||
|
||||
def rate(self):
|
||||
with self._lock:
|
||||
now = self._clock()
|
||||
if self._prev_ts > 0 and now > self._prev_ts:
|
||||
elapsed = now - self._prev_ts
|
||||
up_rate = int((self._up - self._prev_up) / elapsed)
|
||||
down_rate = int((self._down - self._prev_down) / elapsed)
|
||||
else:
|
||||
up_rate = down_rate = 0
|
||||
self._prev_up = self._up
|
||||
self._prev_down = self._down
|
||||
self._prev_ts = now
|
||||
return up_rate, down_rate, self._up, self._down
|
||||
|
||||
def snapshot(self):
|
||||
with self._lock:
|
||||
return self._up, self._down
|
||||
|
||||
|
||||
def format_bytes(value):
|
||||
"""将字节数格式化为 UI 展示文本。"""
|
||||
number = float(value)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if number < 1024 or unit == "TB":
|
||||
return f"{int(number)} {unit}" if unit == "B" else f"{number:.1f} {unit}"
|
||||
number /= 1024
|
||||
@@ -0,0 +1,802 @@
|
||||
"""路线 B 的 Windows TUN 后端:tun2socks + 自有 SOCKS5/UoT 桥接。
|
||||
|
||||
这里不启动 sing-box。tun2socks 只负责 Wintun 和用户态 TCP/IP 栈,
|
||||
所有上游协议由本项目的 ``socks_bridge`` 处理:TCP 使用 HTTP CONNECT,
|
||||
UDP 使用裸 UoT v2。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .socks_bridge import Socks5Bridge
|
||||
|
||||
|
||||
class TunBackendError(RuntimeError):
|
||||
"""TUN 后端无法启动或停止。"""
|
||||
|
||||
|
||||
def _run_hidden(command, **kwargs):
|
||||
"""运行 Windows 网络命令时隐藏控制台窗口。
|
||||
|
||||
源码通过 test.bat 启动时这些子进程复用已有控制台;Nuitka GUI 没有父控制台,
|
||||
未设置 CREATE_NO_WINDOW 就会为每次 PowerShell 查询弹出一个窗口。
|
||||
"""
|
||||
kwargs.setdefault("creationflags", getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
return subprocess.run(command, **kwargs)
|
||||
|
||||
|
||||
class TunBackend:
|
||||
def __init__(self, user_dir: str | Path, engine_path: str | Path | None = None,
|
||||
log: Callable[[str], None] | None = None,
|
||||
detail_log: Callable[[str], None] | None = None):
|
||||
self.user_dir = Path(user_dir)
|
||||
self.engine_path = Path(engine_path) if engine_path else None
|
||||
self.log = log or (lambda _message: None)
|
||||
self.detail_log = detail_log or self.log
|
||||
self._process: subprocess.Popen[str] | None = None
|
||||
self._bridge: Socks5Bridge | None = None
|
||||
self._lock = threading.RLock()
|
||||
self._route_added = False
|
||||
self._ipv6_route_added = False
|
||||
self._lan_routes: list[str] = []
|
||||
self._lan_ipv6_routes: list[str] = []
|
||||
self._node_routes: list[str] = []
|
||||
self._node_ipv6_routes: list[str] = []
|
||||
self._physical_gateway = ""
|
||||
self._physical_ifindex = ""
|
||||
self._physical_ipv6_gateway = ""
|
||||
self._physical_ipv6_ifindex = ""
|
||||
self._device = "RRPP-TUN"
|
||||
self._address = "10.255.0.1"
|
||||
self._ipv6_address = "fd42:7272:5050::1"
|
||||
self._dns_configured = False
|
||||
self._tun_dns_servers = ("8.8.8.8", "1.1.1.1")
|
||||
self._ipv6_enabled = False
|
||||
self._bypass_lan = True
|
||||
self._cleanup_pending = False
|
||||
self._state_path = self.user_dir / "tun_state.json"
|
||||
# Wintun 是三层虚拟接口,默认路由的下一跳使用接口自身地址。
|
||||
self._gateway = self._address
|
||||
self._ipv6_gateway = self._ipv6_address
|
||||
self._recover_stale_state()
|
||||
|
||||
@staticmethod
|
||||
def is_admin() -> bool:
|
||||
if os.name != "nt":
|
||||
return True
|
||||
try:
|
||||
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def resolve_engine(self) -> Path:
|
||||
candidates: list[Path] = []
|
||||
if self.engine_path:
|
||||
candidates.append(self.engine_path)
|
||||
# 目录式发布:主 EXE 与 tun2socks.exe、wintun.dll 放在同一目录。
|
||||
# PyInstaller onefile:主程序会把内置文件解压到 _MEIPASS。
|
||||
executable_dir = Path(sys.executable).resolve().parent
|
||||
meipass = getattr(sys, "_MEIPASS", None)
|
||||
package_dir = Path(__file__).resolve().parents[1]
|
||||
candidates.extend([
|
||||
self.user_dir / "tun2socks.exe",
|
||||
self.user_dir.parent / "tun2socks.exe",
|
||||
executable_dir / "tun2socks.exe",
|
||||
Path(meipass) / "tun2socks.exe" if meipass else Path("__rrpp_no_meipass__"),
|
||||
package_dir / "tun2socks.exe",
|
||||
package_dir / "runtime" / "tun2socks.exe",
|
||||
])
|
||||
found = shutil.which("tun2socks")
|
||||
if found:
|
||||
candidates.append(Path(found))
|
||||
for candidate in candidates:
|
||||
try:
|
||||
path = candidate.expanduser().resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if path.is_file():
|
||||
return path
|
||||
raise TunBackendError(
|
||||
"未找到 tun2socks.exe。请将 tun2socks.exe 和 wintun.dll 放在 TUN 程序目录,"
|
||||
"或在 config.json 的 tun_engine_path 中填写完整路径。"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _physical_interface() -> str:
|
||||
command = ["powershell", "-NoProfile", "-Command",
|
||||
"(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | "
|
||||
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
|
||||
"Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias)"]
|
||||
try:
|
||||
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
|
||||
value = (result.stdout or "").strip().splitlines()
|
||||
if value and value[0].strip():
|
||||
return value[0].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _physical_source_address(interface: str) -> str:
|
||||
if not interface:
|
||||
return ""
|
||||
escaped = interface.replace("'", "''")
|
||||
command = ["powershell", "-NoProfile", "-Command",
|
||||
f"(Get-NetIPAddress -InterfaceAlias '{escaped}' -AddressFamily IPv4 "
|
||||
"-AddressState Preferred | Select-Object -First 1 -ExpandProperty IPAddress)"]
|
||||
try:
|
||||
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
|
||||
value = (result.stdout or "").strip().splitlines()
|
||||
if value and value[0].strip():
|
||||
return value[0].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _physical_route_info() -> tuple[str, str]:
|
||||
"""返回当前物理默认路由的网关和接口索引。"""
|
||||
command = ["powershell", "-NoProfile", "-Command",
|
||||
"Get-NetRoute -DestinationPrefix '0.0.0.0/0' | "
|
||||
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
|
||||
"Sort-Object RouteMetric | Select-Object -First 1 "
|
||||
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
|
||||
try:
|
||||
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
|
||||
data = json.loads((result.stdout or "").strip())
|
||||
if isinstance(data, dict):
|
||||
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
|
||||
except Exception:
|
||||
pass
|
||||
return "", ""
|
||||
|
||||
@staticmethod
|
||||
def _physical_ipv6_route_info() -> tuple[str, str]:
|
||||
"""返回当前物理 IPv6 默认路由的下一跳和接口索引。"""
|
||||
command = ["powershell", "-NoProfile", "-Command",
|
||||
"Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' | "
|
||||
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
|
||||
"Sort-Object RouteMetric | Select-Object -First 1 "
|
||||
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
|
||||
try:
|
||||
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
|
||||
data = json.loads((result.stdout or "").strip())
|
||||
if isinstance(data, dict):
|
||||
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
|
||||
except Exception:
|
||||
pass
|
||||
return "", ""
|
||||
|
||||
@staticmethod
|
||||
def _active_global_ipv6() -> bool | None:
|
||||
"""返回是否存在公网 IPv6;无法查询时返回 None,避免误报已接管。"""
|
||||
if os.name != "nt":
|
||||
return False
|
||||
command = [
|
||||
"powershell", "-NoProfile", "-Command",
|
||||
"@(Get-NetIPAddress -AddressFamily IPv6 -AddressState Preferred | "
|
||||
"Where-Object { $_.IPAddress -notlike 'fe80:*' -and "
|
||||
"$_.IPAddress -ne '::1' -and $_.IPAddress -notlike 'fc*' -and "
|
||||
"$_.IPAddress -notlike 'fd*' }).IPAddress | ConvertTo-Json -Compress",
|
||||
]
|
||||
try:
|
||||
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
|
||||
if result.returncode:
|
||||
return None
|
||||
value = json.loads((result.stdout or "").strip() or "[]")
|
||||
if isinstance(value, str):
|
||||
return bool(value.strip())
|
||||
return bool(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_proxy_addresses(node: dict[str, Any]) -> list[tuple]:
|
||||
methods = (node.get("methods") or {}).get("http") or {}
|
||||
host = str(node.get("domain") or "").strip()
|
||||
port = int(methods.get("port") or node.get("port") or 443)
|
||||
if not host:
|
||||
return []
|
||||
addresses: list[tuple] = []
|
||||
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
|
||||
if item[4] not in addresses:
|
||||
addresses.append(item[4])
|
||||
return addresses
|
||||
|
||||
@staticmethod
|
||||
def _node_chain(node: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
chain = node.get("_jump_chain") if isinstance(node, dict) else None
|
||||
if isinstance(chain, list) and chain:
|
||||
return [item for item in chain if isinstance(item, dict)]
|
||||
return [node] if isinstance(node, dict) else []
|
||||
|
||||
@staticmethod
|
||||
def _node_key(node: dict[str, Any]) -> str:
|
||||
methods = (node.get("methods") or {}).get("http") or {}
|
||||
return "|".join((str(node.get("domain") or ""),
|
||||
str(methods.get("port") or node.get("port") or 443),
|
||||
str(methods.get("username") or node.get("username") or "")))
|
||||
|
||||
def _add_node_routes(self, proxy_addresses: list[tuple]) -> None:
|
||||
"""把 HTTP CONNECT 节点固定走物理网卡,防止默认 TUN 路由形成回环。"""
|
||||
candidates_v4: list[str] = []
|
||||
candidates_v6: list[str] = []
|
||||
for sockaddr in proxy_addresses:
|
||||
address = str(sockaddr[0])
|
||||
try:
|
||||
parsed = ipaddress.ip_address(address.split("%", 1)[0])
|
||||
except ValueError:
|
||||
continue
|
||||
target = candidates_v4 if parsed.version == 4 else candidates_v6
|
||||
normalized = parsed.compressed
|
||||
if normalized not in target:
|
||||
target.append(normalized)
|
||||
if candidates_v4 and not self._physical_ifindex:
|
||||
raise TunBackendError("找不到物理 IPv4 接口,无法固定代理节点路由")
|
||||
for address in candidates_v4:
|
||||
if address in self._node_routes:
|
||||
continue
|
||||
command = ["route", "ADD", address, "MASK", "255.255.255.255"]
|
||||
if self._physical_gateway:
|
||||
command.append(self._physical_gateway)
|
||||
command.extend(["METRIC", "1", "IF", self._physical_ifindex])
|
||||
result = _run_hidden(command, capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
if result.returncode:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
# 已存在的等价路由不影响启动;其它错误继续暴露,避免静默回环。
|
||||
if "already exists" not in detail.lower() and "已存在" not in detail:
|
||||
raise TunBackendError(f"节点物理路由添加失败 ({address}): {detail[:200]}")
|
||||
continue
|
||||
self._node_routes.append(address)
|
||||
self._write_state()
|
||||
if candidates_v6 and not self._ipv6_enabled:
|
||||
if not candidates_v4:
|
||||
raise TunBackendError("IPv6 模式已关闭,代理节点仅包含 IPv6 地址")
|
||||
candidates_v6 = []
|
||||
if candidates_v6 and not self._physical_ipv6_ifindex:
|
||||
# 双栈节点仍可使用 IPv4 地址;只有 IPv6-only 节点需要物理 IPv6 出口。
|
||||
if candidates_v4:
|
||||
candidates_v6 = []
|
||||
else:
|
||||
raise TunBackendError("代理节点仅包含 IPv6 地址,但找不到物理 IPv6 默认路由")
|
||||
for address in candidates_v6:
|
||||
if address in self._node_ipv6_routes:
|
||||
continue
|
||||
self._add_ipv6_physical_route(address)
|
||||
|
||||
def _add_ipv6_physical_route(self, address: str) -> None:
|
||||
if not self._physical_ipv6_ifindex:
|
||||
raise TunBackendError("找不到物理 IPv6 接口,无法固定 IPv6 路由")
|
||||
command = ["interface", "ipv6", "add", "route", f"prefix={address}/128",
|
||||
f"interface={self._physical_ipv6_ifindex}"]
|
||||
if self._physical_ipv6_gateway and self._physical_ipv6_gateway != "::":
|
||||
command.append(f"nexthop={self._physical_ipv6_gateway}")
|
||||
command.append("metric=1")
|
||||
result = _run_hidden(["netsh", *command], capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
if result.returncode:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
if "already exists" not in detail.lower() and "已存在" not in detail:
|
||||
raise TunBackendError(f"IPv6 节点物理路由添加失败 ({address}): {detail[:200]}")
|
||||
return
|
||||
self._node_ipv6_routes.append(address)
|
||||
self._write_state()
|
||||
|
||||
def _ensure_physical_route(self, address: str) -> None:
|
||||
"""为 TUN 内部的直连目标补一条物理出口 /32 路由。"""
|
||||
if os.name != "nt":
|
||||
return
|
||||
try:
|
||||
parsed = ipaddress.ip_address(str(address).split("%", 1)[0])
|
||||
except ValueError:
|
||||
return
|
||||
if parsed.version == 6:
|
||||
if not self._ipv6_enabled:
|
||||
raise TunBackendError("IPv6 模式未启用,无法添加 IPv6 物理路由")
|
||||
normalized = parsed.compressed
|
||||
if normalized in self._node_ipv6_routes:
|
||||
return
|
||||
self._add_ipv6_physical_route(normalized)
|
||||
return
|
||||
if not self._physical_ifindex:
|
||||
raise TunBackendError("找不到物理 IPv4 接口,无法固定直连路由")
|
||||
normalized = parsed.compressed
|
||||
if normalized in self._node_routes:
|
||||
return
|
||||
command = ["route", "ADD", normalized, "MASK", "255.255.255.255"]
|
||||
if self._physical_gateway:
|
||||
command.append(self._physical_gateway)
|
||||
command.extend(["METRIC", "1", "IF", self._physical_ifindex])
|
||||
result = _run_hidden(command, capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
if result.returncode:
|
||||
if "already exists" in detail.lower() or "已存在" in detail:
|
||||
return
|
||||
raise TunBackendError(f"直连目标物理路由添加失败 ({normalized}): {detail[:200]}")
|
||||
self._node_routes.append(normalized)
|
||||
self._write_state()
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_succeeded(result: subprocess.CompletedProcess[str]) -> bool:
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
detail = (result.stderr or result.stdout or "").strip().lower()
|
||||
# 清理是幂等操作:路由或网卡已经消失时,目标状态已经达到。
|
||||
return any(marker in detail for marker in (
|
||||
"not found", "does not exist", "cannot find", "no such",
|
||||
"找不到", "不存在", "未找到", "没有找到",
|
||||
))
|
||||
|
||||
def _cleanup_netsh(self, args: list[str]) -> bool:
|
||||
try:
|
||||
result = _run_hidden(["netsh", *args], capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
except Exception as exc:
|
||||
self.detail_log(f"[TUN] 清理 netsh 失败: {exc}")
|
||||
return False
|
||||
if self._cleanup_succeeded(result):
|
||||
return True
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
self.detail_log(f"[TUN] 清理 netsh 未完成: {detail[:300]}")
|
||||
return False
|
||||
|
||||
def _remove_node_routes(self) -> bool:
|
||||
remaining: list[str] = []
|
||||
for address in self._node_routes:
|
||||
try:
|
||||
result = _run_hidden(["route", "DELETE", address], capture_output=True,
|
||||
text=True, encoding="mbcs", errors="replace", timeout=15)
|
||||
except Exception as exc:
|
||||
self.detail_log(f"[TUN] 清理节点路由失败 ({address}): {exc}")
|
||||
remaining.append(address)
|
||||
continue
|
||||
if not self._cleanup_succeeded(result):
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
self.detail_log(f"[TUN] 清理节点路由未完成 ({address}): {detail[:200]}")
|
||||
remaining.append(address)
|
||||
self._node_routes = remaining
|
||||
remaining_v6: list[str] = []
|
||||
for address in self._node_ipv6_routes:
|
||||
command = ["interface", "ipv6", "delete", "route", f"prefix={address}/128"]
|
||||
if self._physical_ipv6_ifindex:
|
||||
command.append(f"interface={self._physical_ipv6_ifindex}")
|
||||
try:
|
||||
result = _run_hidden(["netsh", *command], capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
except Exception as exc:
|
||||
self.detail_log(f"[TUN] 清理 IPv6 节点路由失败 ({address}): {exc}")
|
||||
remaining_v6.append(address)
|
||||
continue
|
||||
if not self._cleanup_succeeded(result):
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
self.detail_log(f"[TUN] 清理 IPv6 节点路由未完成 ({address}): {detail[:200]}")
|
||||
remaining_v6.append(address)
|
||||
self._node_ipv6_routes = remaining_v6
|
||||
return not remaining and not remaining_v6
|
||||
|
||||
def _netsh(self, args: list[str], *, ignore_error: bool = False) -> None:
|
||||
result = _run_hidden(["netsh", *args], capture_output=True, text=True,
|
||||
encoding="mbcs", errors="replace", timeout=15)
|
||||
if result.returncode and not ignore_error:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
raise TunBackendError(f"netsh 操作失败: {detail[:300]}")
|
||||
|
||||
def _configure_routes(self) -> None:
|
||||
for _ in range(20):
|
||||
try:
|
||||
probe = _run_hidden(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
f"Get-NetAdapter -Name '{self._device}' -ErrorAction SilentlyContinue"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
if probe.stdout and self._device.lower() in probe.stdout.lower():
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.25)
|
||||
else:
|
||||
raise TunBackendError(f"tun2socks 未创建虚拟网卡: {self._device}")
|
||||
self._netsh(["interface", "ipv4", "set", "address",
|
||||
f"name={self._device}", "source=static",
|
||||
f"addr={self._address}", "mask=255.255.255.252"])
|
||||
self._netsh(["interface", "ipv4", "add", "route", "0.0.0.0/0",
|
||||
f"{self._device}", self._gateway, "metric=1"])
|
||||
self._route_added = True
|
||||
self._write_state()
|
||||
if self._ipv6_enabled:
|
||||
self._netsh(["interface", "ipv6", "set", "address",
|
||||
f"interface={self._device}", f"address={self._ipv6_address}",
|
||||
"store=active"])
|
||||
self._netsh(["interface", "ipv6", "add", "route", "prefix=::/0",
|
||||
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
|
||||
"metric=1"])
|
||||
self._ipv6_route_added = True
|
||||
self._write_state()
|
||||
if not self._bypass_lan:
|
||||
# Windows 物理网卡通常已有更具体的私网路由;补同前缀的 TUN
|
||||
# 路由,确保“内网也代理”不会在进入 tun2socks 前被物理网卡截走。
|
||||
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"169.254.0.0/16"):
|
||||
self._netsh(["interface", "ipv4", "add", "route", network,
|
||||
f"{self._device}", self._gateway, "metric=1"])
|
||||
self._lan_routes.append(network)
|
||||
self._write_state()
|
||||
if self._ipv6_enabled:
|
||||
for network in ("fc00::/7",):
|
||||
self._netsh(["interface", "ipv6", "add", "route", f"prefix={network}",
|
||||
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
|
||||
"metric=1"])
|
||||
self._lan_ipv6_routes.append(network)
|
||||
self._write_state()
|
||||
self._configure_dns()
|
||||
|
||||
def _configure_dns(self) -> None:
|
||||
"""让 Windows DNS 请求也进入 TUN,避免沿用物理网卡 DNS。"""
|
||||
if os.name != "nt":
|
||||
return
|
||||
ipv4 = [item for item in self._tun_dns_servers if ":" not in item]
|
||||
ipv6 = [item for item in self._tun_dns_servers if ":" in item] if self._ipv6_enabled else []
|
||||
if ipv4:
|
||||
self._netsh([
|
||||
"interface", "ipv4", "set", "dnsservers",
|
||||
f"name={self._device}", "source=static",
|
||||
f"address={ipv4[0]}", "register=none", "validate=no",
|
||||
])
|
||||
for index, secondary in enumerate(ipv4[1:], start=2):
|
||||
self._netsh([
|
||||
"interface", "ipv4", "add", "dnsservers",
|
||||
f"name={self._device}", f"address={secondary}",
|
||||
f"index={index}", "validate=no",
|
||||
], ignore_error=True)
|
||||
if ipv6:
|
||||
self._netsh([
|
||||
"interface", "ipv6", "set", "dnsservers",
|
||||
f"name={self._device}", "source=static",
|
||||
f"address={ipv6[0]}", "register=none", "validate=no",
|
||||
], ignore_error=True)
|
||||
for index, secondary in enumerate(ipv6[1:], start=2):
|
||||
self._netsh([
|
||||
"interface", "ipv6", "add", "dnsservers",
|
||||
f"name={self._device}", f"address={secondary}",
|
||||
f"index={index}", "validate=no",
|
||||
], ignore_error=True)
|
||||
self._dns_configured = True
|
||||
self._write_state()
|
||||
|
||||
def _remove_routes(self) -> None:
|
||||
cleanup_ok = True
|
||||
if self._dns_configured:
|
||||
ipv4_ok = self._cleanup_netsh([
|
||||
"interface", "ipv4", "set", "dnsservers",
|
||||
f"name={self._device}", "source=dhcp",
|
||||
])
|
||||
ipv6_ok = self._cleanup_netsh([
|
||||
"interface", "ipv6", "set", "dnsservers",
|
||||
f"name={self._device}", "source=dhcp",
|
||||
])
|
||||
cleanup_ok &= ipv4_ok and ipv6_ok
|
||||
if ipv4_ok and ipv6_ok:
|
||||
self._dns_configured = False
|
||||
if self._route_added:
|
||||
route_ok = self._cleanup_netsh(["interface", "ipv4", "delete", "route",
|
||||
"0.0.0.0/0", f"{self._device}", self._gateway])
|
||||
cleanup_ok &= route_ok
|
||||
if route_ok:
|
||||
self._route_added = False
|
||||
if self._ipv6_route_added:
|
||||
route_ok = self._cleanup_netsh(["interface", "ipv6", "delete", "route",
|
||||
"prefix=::/0", f"interface={self._device}",
|
||||
f"nexthop={self._ipv6_gateway}"])
|
||||
cleanup_ok &= route_ok
|
||||
if route_ok:
|
||||
self._ipv6_route_added = False
|
||||
remaining_lan: list[str] = []
|
||||
for network in self._lan_routes:
|
||||
route_ok = self._cleanup_netsh(["interface", "ipv4", "delete", "route",
|
||||
network, f"{self._device}", self._gateway])
|
||||
cleanup_ok &= route_ok
|
||||
if not route_ok:
|
||||
remaining_lan.append(network)
|
||||
self._lan_routes = remaining_lan
|
||||
remaining_lan_v6: list[str] = []
|
||||
for network in self._lan_ipv6_routes:
|
||||
route_ok = self._cleanup_netsh(["interface", "ipv6", "delete", "route",
|
||||
f"prefix={network}", f"interface={self._device}",
|
||||
f"nexthop={self._ipv6_gateway}"])
|
||||
cleanup_ok &= route_ok
|
||||
if not route_ok:
|
||||
remaining_lan_v6.append(network)
|
||||
self._lan_ipv6_routes = remaining_lan_v6
|
||||
cleanup_ok &= self._remove_node_routes()
|
||||
self._cleanup_pending = not cleanup_ok
|
||||
if cleanup_ok:
|
||||
self._clear_state()
|
||||
else:
|
||||
self._write_state()
|
||||
self.detail_log("[TUN] 部分路由/DNS 清理失败,已保留状态并将在下次启动重试")
|
||||
|
||||
def _write_state(self) -> None:
|
||||
"""记录本进程改过的路由/DNS,强杀后下次启动可恢复。"""
|
||||
try:
|
||||
self.user_dir.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"device": self._device,
|
||||
"address": self._address,
|
||||
"gateway": self._gateway,
|
||||
"ipv6_address": self._ipv6_address,
|
||||
"ipv6_gateway": self._ipv6_gateway,
|
||||
"lan_routes": list(self._lan_routes),
|
||||
"lan_ipv6_routes": list(self._lan_ipv6_routes),
|
||||
"bypass_lan": bool(self._bypass_lan),
|
||||
"physical_gateway": self._physical_gateway,
|
||||
"physical_ifindex": self._physical_ifindex,
|
||||
"physical_ipv6_gateway": self._physical_ipv6_gateway,
|
||||
"physical_ipv6_ifindex": self._physical_ipv6_ifindex,
|
||||
"node_routes": list(self._node_routes),
|
||||
"node_ipv6_routes": list(self._node_ipv6_routes),
|
||||
"route_added": bool(self._route_added),
|
||||
"ipv6_route_added": bool(self._ipv6_route_added),
|
||||
"ipv6_enabled": bool(self._ipv6_enabled),
|
||||
"dns_configured": bool(self._dns_configured),
|
||||
"dns_servers": list(self._tun_dns_servers),
|
||||
"cleanup_pending": bool(self._cleanup_pending),
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
temp = self._state_path.with_suffix(".tmp")
|
||||
temp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
temp.replace(self._state_path)
|
||||
except Exception as exc:
|
||||
self.detail_log(f"[TUN] 状态记录失败: {exc}")
|
||||
|
||||
def _clear_state(self) -> None:
|
||||
try:
|
||||
self._state_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _pid_alive(pid: Any) -> bool:
|
||||
"""避免合并版误清理仍在运行的旧 TUN 实例。"""
|
||||
if os.name != "nt":
|
||||
return False
|
||||
try:
|
||||
number = int(pid)
|
||||
if number <= 0:
|
||||
return False
|
||||
kernel = ctypes.windll.kernel32
|
||||
kernel.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_bool, ctypes.c_ulong]
|
||||
kernel.OpenProcess.restype = ctypes.c_void_p
|
||||
kernel.GetExitCodeProcess.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
|
||||
kernel.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||
handle = kernel.OpenProcess(0x1000, False, number)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
code = ctypes.c_ulong()
|
||||
return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))
|
||||
and code.value == 259)
|
||||
finally:
|
||||
kernel.CloseHandle(handle)
|
||||
except (TypeError, ValueError, OSError):
|
||||
return False
|
||||
|
||||
def _recover_stale_state(self) -> None:
|
||||
"""启动前清理上次崩溃/强杀留下的 RRPP-TUN 路由和 DNS。"""
|
||||
try:
|
||||
if not self._state_path.is_file():
|
||||
return
|
||||
state = json.loads(self._state_path.read_text(encoding="utf-8"))
|
||||
if not state.get("cleanup_pending") and self._pid_alive(state.get("pid")):
|
||||
raise TunBackendError("检测到另一个 TUN 实例仍在运行,请先关闭后再启动")
|
||||
self._device = str(state.get("device") or self._device)
|
||||
self._address = str(state.get("address") or self._address)
|
||||
self._gateway = str(state.get("gateway") or self._gateway)
|
||||
self._ipv6_address = str(state.get("ipv6_address") or self._ipv6_address)
|
||||
self._ipv6_gateway = str(state.get("ipv6_gateway") or self._ipv6_gateway)
|
||||
self._ipv6_enabled = bool(state.get("ipv6_enabled", bool(state.get("ipv6_route_added"))))
|
||||
self._physical_ipv6_gateway = str(state.get("physical_ipv6_gateway") or "")
|
||||
self._physical_ipv6_ifindex = str(state.get("physical_ipv6_ifindex") or "")
|
||||
self._route_added = bool(state.get("route_added"))
|
||||
self._ipv6_route_added = bool(state.get("ipv6_route_added"))
|
||||
self._lan_routes = [str(item).strip() for item in (state.get("lan_routes") or [])
|
||||
if str(item).strip()]
|
||||
self._lan_ipv6_routes = [str(item).strip() for item in (state.get("lan_ipv6_routes") or [])
|
||||
if str(item).strip()]
|
||||
self._node_routes = [str(item).strip() for item in (state.get("node_routes") or [])
|
||||
if str(item).strip()]
|
||||
self._node_ipv6_routes = [str(item).strip() for item in (state.get("node_ipv6_routes") or [])
|
||||
if str(item).strip()]
|
||||
self._dns_configured = bool(state.get("dns_configured"))
|
||||
self._cleanup_pending = True
|
||||
self._remove_routes()
|
||||
if self._cleanup_pending:
|
||||
self.detail_log("[TUN] 上次异常退出的部分路由/DNS 仍未清理,状态已保留")
|
||||
else:
|
||||
self.log("已清理上次异常退出遗留的 TUN 路由和 DNS")
|
||||
except TunBackendError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.detail_log(f"[TUN] 异常退出残留清理失败: {exc}")
|
||||
|
||||
def _read_output(self, process: subprocess.Popen[str]) -> None:
|
||||
if process.stdout is None:
|
||||
return
|
||||
try:
|
||||
for line in process.stdout:
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
self.detail_log(f"[TUN] {line}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def start(self, node: dict[str, Any], *, bypass_lan: bool = True,
|
||||
route_rules: list[dict[str, Any]] | None = None,
|
||||
dns_servers: tuple[str, ...] | list[str] | None = None,
|
||||
ipv6_enabled: bool = False,
|
||||
direct_target_resolver: Callable[[str, str, int], tuple[str, int] | None] | None = None,
|
||||
route_rule_resolver: Callable[[str], dict[str, Any] | None] | None = None,
|
||||
route_node_resolver: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
|
||||
route_nodes: list[dict[str, Any]] | None = None) -> None:
|
||||
with self._lock:
|
||||
if self.status()["running"]:
|
||||
raise TunBackendError("TUN 已经在运行")
|
||||
if not self.is_admin():
|
||||
raise TunBackendError("启动虚拟网卡 TUN 需要管理员权限")
|
||||
self._bypass_lan = bool(bypass_lan)
|
||||
self._ipv6_enabled = bool(ipv6_enabled)
|
||||
if dns_servers is not None:
|
||||
cleaned = tuple(str(item).strip() for item in dns_servers if str(item).strip())
|
||||
if not cleaned:
|
||||
raise TunBackendError("自定义 DNS 不能为空")
|
||||
try:
|
||||
for item in cleaned:
|
||||
address = ipaddress.ip_address(item)
|
||||
if address.is_unspecified or address.is_multicast:
|
||||
raise ValueError(item)
|
||||
except ValueError as exc:
|
||||
raise TunBackendError("自定义 DNS 必须是有效的 IPv4/IPv6 地址") from exc
|
||||
self._tun_dns_servers = cleaned[:3]
|
||||
engine = self.resolve_engine()
|
||||
interface = self._physical_interface()
|
||||
source_address = self._physical_source_address(interface)
|
||||
self._physical_gateway, self._physical_ifindex = self._physical_route_info()
|
||||
if self._ipv6_enabled:
|
||||
self._physical_ipv6_gateway, self._physical_ipv6_ifindex = self._physical_ipv6_route_info()
|
||||
else:
|
||||
self._physical_ipv6_gateway = self._physical_ipv6_ifindex = ""
|
||||
if not self._physical_ifindex:
|
||||
raise TunBackendError("找不到物理默认路由,无法保证代理节点不进入 TUN 回环")
|
||||
if self._ipv6_enabled and not self._physical_ipv6_ifindex:
|
||||
self.log("警告:未找到物理 IPv6 默认路由,IPv6 目标将仅尝试通过 IPv4 代理节点转发")
|
||||
try:
|
||||
proxy_addresses_by_node: dict[str, list[tuple]] = {}
|
||||
connection_nodes = [node]
|
||||
connection_nodes.extend(
|
||||
(item.get("node") if isinstance(item.get("node"), dict) else item)
|
||||
for item in (route_nodes or ())
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
for connection_node in connection_nodes:
|
||||
for hop in self._node_chain(connection_node):
|
||||
try:
|
||||
addresses = self._resolve_proxy_addresses(hop)
|
||||
except OSError as exc:
|
||||
raise TunBackendError(f"无法解析 HTTP CONNECT 节点地址: {exc}") from exc
|
||||
if not addresses:
|
||||
raise TunBackendError("无法解析 HTTP CONNECT 节点地址")
|
||||
self._add_node_routes(addresses)
|
||||
proxy_addresses_by_node[self._node_key(hop)] = addresses
|
||||
first_hop = self._node_chain(node)[0]
|
||||
proxy_addresses = proxy_addresses_by_node.get(self._node_key(first_hop), [])
|
||||
if not proxy_addresses:
|
||||
raise TunBackendError("无法解析 HTTP CONNECT 节点地址")
|
||||
for route_node in route_nodes or ():
|
||||
if not isinstance(route_node, dict):
|
||||
continue
|
||||
route_value = route_node.get("node") if isinstance(route_node.get("node"), dict) else route_node
|
||||
for hop in self._node_chain(route_value):
|
||||
addresses = self._resolve_proxy_addresses(hop)
|
||||
proxy_addresses_by_node[self._node_key(hop)] = addresses
|
||||
if not source_address:
|
||||
self.log("警告:未找到物理网卡 IPv4,节点连接可能无法绕过 TUN")
|
||||
bridge = Socks5Bridge(
|
||||
node, bypass_lan=bypass_lan, route_rules=route_rules, log=self.detail_log,
|
||||
source_address=source_address or None,
|
||||
proxy_addresses=proxy_addresses,
|
||||
direct_target_resolver=direct_target_resolver,
|
||||
route_rule_resolver=route_rule_resolver,
|
||||
route_node_resolver=route_node_resolver,
|
||||
proxy_addresses_by_node=proxy_addresses_by_node,
|
||||
direct_route_ensurer=self._ensure_physical_route,
|
||||
)
|
||||
socks_port = bridge.start()
|
||||
except Exception:
|
||||
self._remove_routes()
|
||||
raise
|
||||
self._bridge = bridge
|
||||
command = [str(engine), "--device", self._device,
|
||||
"--proxy", f"socks5://127.0.0.1:{socks_port}"]
|
||||
# tun2socks 的 Windows socket 选项会把 UDP/TCP socket 绑定到
|
||||
# --interface 指定的网卡;本项目的 SOCKS relay 在本机回环地址,
|
||||
# 绑定 WLAN 会让 UDP 回环投递失效。Windows 官方用法也不传该参数。
|
||||
# 节点连接已经通过物理 /32 路由和 source_address 绕过 TUN,
|
||||
# 因此 Windows 不需要 --interface;Linux/macOS 仍保留原行为。
|
||||
if interface and os.name != "nt":
|
||||
command.extend(["--interface", interface])
|
||||
self.detail_log(f"本地 SOCKS5 桥接: 127.0.0.1:{socks_port}")
|
||||
try:
|
||||
self._process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
threading.Thread(target=self._read_output, args=(self._process,), daemon=True).start()
|
||||
time.sleep(0.5)
|
||||
if self._process.poll() is not None:
|
||||
raise TunBackendError(f"tun2socks 启动失败,退出码 {self._process.returncode}")
|
||||
self._configure_routes()
|
||||
self._write_state()
|
||||
self.log(f"虚拟网卡 TUN 已启动,进程 PID={self._process.pid}")
|
||||
except Exception:
|
||||
bridge.stop()
|
||||
self._remove_routes()
|
||||
self._bridge = None
|
||||
process = self._process
|
||||
self._process = None
|
||||
if process is not None and process.poll() is None:
|
||||
process.terminate()
|
||||
raise
|
||||
|
||||
def stop(self) -> None:
|
||||
with self._lock:
|
||||
process = self._process
|
||||
bridge = self._bridge
|
||||
self._process = None
|
||||
self._bridge = None
|
||||
self._remove_routes()
|
||||
if process is not None and process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
if bridge is not None:
|
||||
bridge.stop()
|
||||
if process is not None:
|
||||
self.log("虚拟网卡 TUN 已停止")
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
process = self._process
|
||||
if process is None:
|
||||
return {"running": False, "pid": None, "exit_code": None}
|
||||
code = process.poll()
|
||||
if code is not None:
|
||||
self._process = None
|
||||
self._remove_routes()
|
||||
bridge = self._bridge
|
||||
self._bridge = None
|
||||
if bridge is not None:
|
||||
bridge.stop()
|
||||
return {"running": False, "pid": process.pid, "exit_code": code}
|
||||
return {"running": True, "pid": process.pid, "exit_code": None}
|
||||
|
||||
def traffic(self) -> tuple[int, int, int, int]:
|
||||
if self._bridge is None:
|
||||
return 0, 0, 0, 0
|
||||
return self._bridge.traffic()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""按需提权的 TUN 控制通道;GUI 与管理员工作进程共用同一个入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
WORKER_FLAG = "--rrpp-tun-worker"
|
||||
_MAX_MESSAGE = 1024 * 1024
|
||||
|
||||
|
||||
class TunModeError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _send(sock: socket.socket, value: dict[str, Any], lock: threading.Lock | None = None) -> None:
|
||||
payload = (json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
if len(payload) > _MAX_MESSAGE:
|
||||
raise TunModeError("TUN 控制消息过大")
|
||||
if lock is None:
|
||||
sock.sendall(payload)
|
||||
else:
|
||||
with lock:
|
||||
sock.sendall(payload)
|
||||
|
||||
|
||||
def _receive(reader) -> dict[str, Any]:
|
||||
line = reader.readline(_MAX_MESSAGE + 1)
|
||||
if not line or len(line) > _MAX_MESSAGE or not line.endswith(b"\n"):
|
||||
raise TunModeError("TUN 工作进程连接已断开或消息无效")
|
||||
value = json.loads(line.decode("utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise TunModeError("TUN 控制消息格式错误")
|
||||
return value
|
||||
|
||||
|
||||
class TunModeController:
|
||||
"""普通权限 GUI 仅在首次开启 TUN 时启动管理员工作进程。"""
|
||||
|
||||
def __init__(self, command: list[str], log: Callable[[str], None],
|
||||
detail_log: Callable[[str], None] | None = None):
|
||||
self.command = command
|
||||
self.log = log
|
||||
self.detail_log = detail_log or log
|
||||
self._lock = threading.RLock()
|
||||
self._socket: socket.socket | None = None
|
||||
self._reader = None
|
||||
|
||||
def _launch(self, port: int, token: str) -> None:
|
||||
args = [*self.command[1:], WORKER_FLAG, str(port), token]
|
||||
if os.name == "nt":
|
||||
# PyInstaller onefile 的提权子进程必须独立解包,以便 GUI 退出后仍可清理网络。
|
||||
previous = os.environ.get("PYINSTALLER_RESET_ENVIRONMENT")
|
||||
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
|
||||
try:
|
||||
result = ctypes.windll.shell32.ShellExecuteW(
|
||||
None, "runas", self.command[0], subprocess.list2cmdline(args),
|
||||
os.path.dirname(os.path.abspath(self.command[0])), 0,
|
||||
)
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("PYINSTALLER_RESET_ENVIRONMENT", None)
|
||||
else:
|
||||
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = previous
|
||||
if result <= 32:
|
||||
raise TunModeError("管理员授权已取消或无法启动 TUN 工作进程")
|
||||
else:
|
||||
# 非 Windows 仅供无网卡的协议测试;真实 TUN 后端仍限定 Windows。
|
||||
subprocess.Popen([self.command[0], *args], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
|
||||
def start(self, payload: dict[str, Any], cancel_event=None) -> None:
|
||||
with self._lock:
|
||||
if self._socket is not None:
|
||||
raise TunModeError("TUN 工作进程已经启动")
|
||||
token = secrets.token_hex(32)
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(2)
|
||||
deadline = time.monotonic() + 90
|
||||
try:
|
||||
self._launch(listener.getsockname()[1], token)
|
||||
while True:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise TunModeError("用户取消了开启代理")
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TunModeError("等待 TUN 管理员工作进程超时")
|
||||
listener.settimeout(min(0.5, remaining))
|
||||
try:
|
||||
connection, _peer = listener.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
connection.settimeout(3)
|
||||
reader = connection.makefile("rb")
|
||||
try:
|
||||
hello = _receive(reader)
|
||||
valid = (hello.get("type") == "hello" and
|
||||
hmac.compare_digest(str(hello.get("token") or ""), token))
|
||||
except Exception:
|
||||
valid = False
|
||||
if valid:
|
||||
connection.settimeout(120)
|
||||
self._socket, self._reader = connection, reader
|
||||
break
|
||||
reader.close()
|
||||
connection.close()
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise TunModeError("用户取消了开启代理")
|
||||
self._request("start", payload)
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
def _request(self, command: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
if self._socket is None or self._reader is None:
|
||||
raise TunModeError("TUN 工作进程未连接")
|
||||
try:
|
||||
_send(self._socket, {"command": command, "payload": payload or {}})
|
||||
while True:
|
||||
message = _receive(self._reader)
|
||||
if message.get("type") == "log":
|
||||
callback = self.detail_log if message.get("detail") else self.log
|
||||
callback(str(message.get("message") or ""))
|
||||
continue
|
||||
if message.get("type") != "result":
|
||||
raise TunModeError("TUN 工作进程响应无效")
|
||||
if not message.get("ok"):
|
||||
raise TunModeError(str(message.get("error") or "TUN 操作失败"))
|
||||
return message.get("value")
|
||||
except TunModeError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise TunModeError(f"TUN 工作进程连接中止: {exc}") from exc
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self._socket is None:
|
||||
return {"running": False, "pid": None, "exit_code": None}
|
||||
try:
|
||||
return self._request("status")
|
||||
except (OSError, TunModeError, ValueError):
|
||||
self.close()
|
||||
return {"running": False, "pid": None, "exit_code": None}
|
||||
|
||||
def traffic(self) -> tuple[int, int, int, int]:
|
||||
with self._lock:
|
||||
if self._socket is None:
|
||||
return 0, 0, 0, 0
|
||||
try:
|
||||
return tuple(self._request("traffic"))
|
||||
except (OSError, TunModeError, ValueError):
|
||||
return 0, 0, 0, 0
|
||||
|
||||
def stop(self) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
if self._socket is not None:
|
||||
self._request("stop")
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._socket is not None:
|
||||
try:
|
||||
self._request("quit")
|
||||
except (OSError, TunModeError, ValueError):
|
||||
pass
|
||||
reader, connection = self._reader, self._socket
|
||||
self._reader = self._socket = None
|
||||
if reader is not None:
|
||||
reader.close()
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
|
||||
|
||||
def run_tun_worker(port: int, token: str, backend_factory,
|
||||
start_backend: Callable[[Any, dict[str, Any]], None]) -> None:
|
||||
"""管理员进程入口。主 GUI 断开后,必定清理本进程建立的网络状态。"""
|
||||
connection = socket.create_connection(("127.0.0.1", int(port)), timeout=15)
|
||||
connection.settimeout(None)
|
||||
reader = connection.makefile("rb")
|
||||
send_lock = threading.Lock()
|
||||
_send(connection, {"type": "hello", "token": token}, send_lock)
|
||||
|
||||
def emit(message: str, detail: bool = False) -> None:
|
||||
try:
|
||||
_send(connection, {"type": "log", "message": str(message),
|
||||
"detail": bool(detail)}, send_lock)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
class _WorkerLogger:
|
||||
def __call__(self, message: str) -> None:
|
||||
emit(message)
|
||||
|
||||
def detail(self, message: str) -> None:
|
||||
emit(message, detail=True)
|
||||
|
||||
logger = _WorkerLogger()
|
||||
|
||||
backend = None
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
request = _receive(reader)
|
||||
except TunModeError:
|
||||
break
|
||||
command = request.get("command")
|
||||
try:
|
||||
if command == "start":
|
||||
if not isinstance(request.get("payload"), dict):
|
||||
raise TunModeError("TUN 启动参数无效")
|
||||
# 延迟创建后端,使初始化失败也能通过控制协议返回具体原因,
|
||||
# 避免 GUI 只看到 Windows 的 10053 连接中止错误。
|
||||
if backend is None:
|
||||
backend = backend_factory(logger)
|
||||
start_backend(backend, request["payload"])
|
||||
value = None
|
||||
elif command == "stop":
|
||||
backend.stop()
|
||||
value = None
|
||||
elif command == "status":
|
||||
value = backend.status()
|
||||
elif command == "traffic":
|
||||
value = backend.traffic()
|
||||
elif command == "quit":
|
||||
backend.stop()
|
||||
value = None
|
||||
else:
|
||||
raise TunModeError("未知的 TUN 控制命令")
|
||||
_send(connection, {"type": "result", "ok": True, "value": value}, send_lock)
|
||||
except Exception as exc:
|
||||
_send(connection, {"type": "result", "ok": False, "error": str(exc)}, send_lock)
|
||||
if command == "quit":
|
||||
break
|
||||
finally:
|
||||
if backend is not None:
|
||||
backend.stop()
|
||||
reader.close()
|
||||
connection.close()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
"""RRPP Lite 的 Qt 界面层。
|
||||
|
||||
核心代理与 PAC 逻辑不得依赖本包,以便后续在无 GUI 环境中独立测试。
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
"""RRPP Lite 界面的共享色彩常量。"""
|
||||
|
||||
C_TITLE = "#8899aa"
|
||||
C_GREEN = "#51cf66"
|
||||
C_YELLOW = "#ffd43b"
|
||||
C_RED = "#ff6b6b"
|
||||
C_BG = "#ffffff"
|
||||
C_CARD = "#f0f2f5"
|
||||
C_BORDER = "#d0d5dd"
|
||||
C_TEXT = "#333333"
|
||||
C_SUBTEXT = "#889099"
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
+852
-19
@@ -1,26 +1,859 @@
|
||||
# server_config
|
||||
# RRPP 服务端部署规范
|
||||
|
||||
> 一套统一的跨境代理节点部署流程,适用于 Debian/Ubuntu 系 Linux。
|
||||
> 每台节点运行相同的服务栈:**BBR 拥塞控制 + nginx CONNECT 隧道 + sing-box(VLESS+REALITY / Shadowsocks) + mtg(MTProto)**。
|
||||
>
|
||||
> 本文档为**通用规范**,所有 IP / 域名 / 端口 / 密钥均以**占位符**表示,部署时替换为实际值。
|
||||
> 凡标注 `<...>` 的均为占位符,不要原样照抄。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [0. 架构总览](#0-架构总览)
|
||||
- [1. 端口与服务规划](#1-端口与服务规划)
|
||||
- [2. 系统基础优化(BBR 跨境丢包优化)](#2-系统基础优化bbr-跨境丢包优化)
|
||||
- [3. 伪装站点页面](#3-伪装站点页面)
|
||||
- [4. HTTPS 证书(acme.sh DNS-01)](#4-https-证书acmesh-dns-01)
|
||||
- [5. nginx 源码编译 + CONNECT 隧道](#5-nginx-源码编译--connect-隧道)
|
||||
- [6. sing-box(VLESS+REALITY / Shadowsocks)](#6-sing-boxvlessreality--shadowsocks)
|
||||
- [7. mtg(MTProto 代理)](#7-mtgmtproto-代理)
|
||||
- [8. 本机公网 IP 自动识别(防自代理回环)](#8-本机公网-ip-自动识别防自代理回环)
|
||||
- [9. systemd 服务文件](#9-systemd-服务文件)
|
||||
- [10. 启动顺序与验证清单](#10-启动顺序与验证清单)
|
||||
- [11. 目录结构与文件索引](#11-目录结构与文件索引)
|
||||
- [12. RRPP 订阅后端(PAC → 规则集下发)](#12-rrpp-订阅后端pac--规则集下发)
|
||||
|
||||
---
|
||||
|
||||
## 0. 架构总览
|
||||
|
||||
```
|
||||
客户端 ──► nginx(443/<nginx端口>) ──┬─► CONNECT 隧道(任意端口, 全端口放行)
|
||||
│ └─► 伪装页面(直接访问/IP访问)
|
||||
│
|
||||
├─► sing-box(<vless端口>) ──► VLESS+REALITY(vision)
|
||||
├─► sing-box(<ss端口>) ──► Shadowsocks(2022-blake3-aes-256-gcm)
|
||||
└─► mtg(<mtproto端口>) ──► MTProto(Fake TLS, Telegram 内置代理)
|
||||
```
|
||||
|
||||
**四层代理各司其职:**
|
||||
|
||||
| 层 | 组件 | 协议 | 适用场景 |
|
||||
|---|---|---|---|
|
||||
| HTTP 隧道 | nginx | CONNECT + Basic Auth | 浏览器/curl/系统代理,全端口 WebSocket 透传 |
|
||||
| 通用代理 | sing-box | VLESS+REALITY | Shadowrocket/Clash/v2rayN 客户端 |
|
||||
| 兼容旧客户端 | sing-box | Shadowsocks | Shadowrocket 2.x 等不支持 REALITY 的客户端 || Telegram 专用 | mtg | MTProto Fake TLS | Telegram 内置代理,抗封锁 |
|
||||
|
||||
**核心密钥体系(2026-09 起全部每节点独立):**
|
||||
- VLESS+REALITY:每节点**独立** UUID、REALITY 密钥对、short_id;SNI 仍全节点统一(用 `<伪装SNI域名>`,选一个高流量、TLS 指纹稳定的知名站点域名)
|
||||
- Shadowsocks:每节点**独立** PSK,方法 `2022-blake3-aes-256-gcm`(SIP022,抗重放探测)
|
||||
- nginx CONNECT:每节点**独立** Basic Auth 密码(客户端按节点携带凭据,无兼容性问题)
|
||||
- MTProto:每台**独立 secret**(Fake TLS 类型,fronting 到 `<伪装SNI域名>`)
|
||||
|
||||
> 独立化的目的:单节点密钥泄漏/被污染时不波及其余节点,且换钥粒度更小。
|
||||
> 代价是订阅/nodes.json 必须逐节点维护,改任一项后必须做端到端验证(见 6.3、第 10 节)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 端口与服务规划
|
||||
|
||||
端口全部落在**同一区段**便于防火墙管理(示例用 1200–1220,可按需调整):
|
||||
|
||||
| 端口 | 服务 | 说明 |
|
||||
|---|---|---|
|
||||
| `<nginx端口>` | nginx | HTTPS 站点 + CONNECT 隧道(示例 `1205`) |
|
||||
| `<vless端口>` | sing-box | VLESS+REALITY(示例 `1206`) |
|
||||
| `<ss端口>` | sing-box | Shadowsocks(示例 `1207`) |
|
||||
| `<mtproto端口>` | mtg | MTProto(示例 `1210`) |
|
||||
|
||||
> 节点 A 用 `80xx` 区段、节点 B 用 `120xx` 区段皆可,**只要同节点内不冲突、且运营商/防火墙放行**。
|
||||
> 防火墙放行:`iptables -A INPUT -p tcp -m multiport --dports <nginx端口>,<vless端口>,<ss端口>,<mtproto端口> -j ACCEPT`
|
||||
|
||||
**域名规划:** 每个节点一个独立域名 `<节点域名>`(如 `node1.example.com`),A 记录指向节点公网 IP。acme.sh 用 DNS-01 签证书,无需 80 端口可达。
|
||||
|
||||
---
|
||||
|
||||
## 2. 系统基础优化(BBR 跨境丢包优化)
|
||||
|
||||
BBR(Bottleneck Bandwidth and RTT)+ fq 队列,显著改善跨境 TCP 丢包/高延迟下的吞吐。
|
||||
|
||||
### 2.1 启用 BBR
|
||||
|
||||
写入 `/etc/sysctl.d/99-bbr.conf`:
|
||||
|
||||
```conf
|
||||
net.core.default_qdisc=fq
|
||||
net.ipv4.tcp_congestion_control=bbr
|
||||
```
|
||||
|
||||
应用并验证:
|
||||
|
||||
```bash
|
||||
modprobe tcp_bbr 2>/dev/null || true # 部分内核需手动加载模块
|
||||
sysctl --system
|
||||
sysctl net.ipv4.tcp_congestion_control net.core.default_qdisc
|
||||
# 期望输出: bbr / fq
|
||||
```
|
||||
|
||||
> **内核要求**:BBR 需 Linux 4.9+。`default_qdisc=fq` 在部分新内核(6.12+ cloud 内核)可能无此参数,仅保留 `tcp_congestion_control=bbr` 即可,不影响 BBR 生效。
|
||||
|
||||
### 2.2 文件描述符
|
||||
|
||||
nginx worker 高并发需要提高 limit,在 `/etc/security/limits.conf` 追加:
|
||||
|
||||
```
|
||||
* soft nofile 65535
|
||||
* hard nofile 65535
|
||||
```
|
||||
|
||||
### 2.3 时区与 NTP
|
||||
|
||||
代理证书校验、日志排障都依赖准确时间:
|
||||
|
||||
```bash
|
||||
timedatectl set-timezone Asia/Shanghai
|
||||
apt install -y chrony && systemctl enable --now chrony
|
||||
```
|
||||
|
||||
### 2.4 PMTUD 黑洞缓解(tcp_mtu_probing)
|
||||
|
||||
**症状**:部分客户端网络上出现"TCP 能连上、但 TLS 握手稳定超时"
|
||||
(如 `_ssl.c:1011 handshake operation timed out`),而服务器从其他网络探测一切正常。
|
||||
原因是客户端↔服务器路径上某跳 MTU 变小且 ICMP Fragmentation Needed 被过滤
|
||||
(PMTUD 黑洞),服务器发出的满尺寸大包永远到不了客户端。
|
||||
|
||||
开启 RFC 4821 PLPMTUD,让服务器在探测失败后自动降低 MSS 恢复通信。
|
||||
写入 `/etc/sysctl.d/99-mtu-probing.conf`:
|
||||
|
||||
```conf
|
||||
net.ipv4.tcp_mtu_probing = 1
|
||||
```
|
||||
|
||||
```bash
|
||||
sysctl --system
|
||||
sysctl net.ipv4.tcp_mtu_probing # 期望输出: 1
|
||||
```
|
||||
|
||||
> 只影响命中黑洞的连接,正常连接无感知,可安全全节点开启。
|
||||
|
||||
### 2.5 fail2ban(sshd 爆破防护)
|
||||
|
||||
节点 SSH 暴露在公网,爆破尝试是常态(实测单节点累计 3 万+ 次)。装 fail2ban 护 sshd:
|
||||
|
||||
```bash
|
||||
apt install -y fail2ban
|
||||
cat > /etc/fail2ban/jail.d/sshd.local << 'EOF'
|
||||
[sshd]
|
||||
enabled = true
|
||||
backend = systemd
|
||||
EOF
|
||||
systemctl enable --now fail2ban
|
||||
fail2ban-client status sshd # 确认 jail 在跑
|
||||
```
|
||||
|
||||
> - `backend = systemd`:无 `/var/log/auth.log` 的最小化系统(无 rsyslog)必须加这行,
|
||||
> 否则 fail2ban 启动报 "Have not found any log file for sshd jail"。
|
||||
> - LXC 容器也能正常封禁(实测 ban/unban 链路可用)。
|
||||
> - 只加 fail2ban、**不动 sshd 认证方式**(root 登录等现状保留)。
|
||||
> - 如需同时护 nginx CONNECT 的 401 爆破,可再加 nginx jail(凭据够强时可不做)。
|
||||
|
||||
### 2.6 系统级网络优化(KVM / LXC 差异)与 qdisc 持久化
|
||||
|
||||
> 模板:[sysctl/99-zz-rrpp-network.kvm.conf](sysctl/99-zz-rrpp-network.kvm.conf)(完整 VM,含 Azure 等)、
|
||||
> [sysctl/99-zz-rrpp-network.lxc.conf](sysctl/99-zz-rrpp-network.lxc.conf)(容器)
|
||||
> 落地位置统一为 `/etc/sysctl.d/99-zz-rrpp-network.conf`;
|
||||
> qdisc 伴侣:[systemd/rrpp-qdisc.service](systemd/rrpp-qdisc.service) + [scripts/rrpp-qdisc.sh](scripts/rrpp-qdisc.sh)。
|
||||
|
||||
```bash
|
||||
# KVM / 完整 VM
|
||||
install -m 644 sysctl/99-zz-rrpp-network.kvm.conf /etc/sysctl.d/99-zz-rrpp-network.conf
|
||||
# LXC / OpenVZ 容器
|
||||
install -m 644 sysctl/99-zz-rrpp-network.lxc.conf /etc/sysctl.d/99-zz-rrpp-network.conf
|
||||
# qdisc 持久化(仅完整 VM 有意义;容器内网卡是 noqueue,脚本会自动跳过)
|
||||
install -m 755 scripts/rrpp-qdisc.sh /usr/local/sbin/rrpp-qdisc.sh
|
||||
install -m 644 systemd/rrpp-qdisc.service /etc/systemd/system/
|
||||
systemctl daemon-reload && systemctl enable --now rrpp-qdisc
|
||||
sysctl --system
|
||||
```
|
||||
|
||||
**为什么文件名是 `99-zz-`**:sysctl 按文件名字典序加载、**后者覆盖前者**。`99-zz-` 保证本文件排在最后,
|
||||
避免被其它 `99-*.conf` 覆盖。
|
||||
|
||||
**⚠ 最容易踩的坑:`/etc/sysctl.conf`**。`sysctl --system` 会在**所有 sysctl.d 之后**再加载 `/etc/sysctl.conf`。
|
||||
2026-09 实测:某节点(aq)存在 2022 年的一键优化版 `/etc/sysctl.conf`,把 `tcp_mtu_probing` 覆盖回 `0`,
|
||||
导致 sysctl.d 里的设置完全不生效(现象:`sysctl -n` 与文件内容不一致)。
|
||||
**处理**:该文件里凡是与本规范重叠的键,逐行注释掉(保留其非冲突的调优,如 conntrack、rps、swappiness),
|
||||
或直接把本规范内容追加到该文件末尾。排查方法:
|
||||
|
||||
```bash
|
||||
sysctl -p /etc/sysctl.d/99-zz-rrpp-network.conf # 显式加载后应看到期望值
|
||||
sysctl --system 2>/dev/null | grep -n tcp_mtu_probing | tail -5 # 看谁在最后覆盖
|
||||
```
|
||||
|
||||
**KVM / 完整 VM 可写(本规范设置项)**
|
||||
|
||||
| 项 | 值 | 作用 |
|
||||
|---|---|---|
|
||||
| `tcp_congestion_control` / `core.default_qdisc` | `bbr` / `fq` | BBR 需 fq 搭配,否则 bufferbloat |
|
||||
| `tcp_mtu_probing` | 1 | PMTUD 黑洞缓解(见 2.4) |
|
||||
| `tcp_fastopen` | 3 | 客户端+服务端 TFO,省一次 RTT |
|
||||
| `tcp_slow_start_after_idle` | 0 | 空闲后不回退慢启动(长连接代理有益) |
|
||||
| `tcp_tw_reuse` / `tcp_fin_timeout` | 2 / 15 | TIME-WAIT 复用与回收 |
|
||||
| `tcp_keepalive_time/intvl/probes` | 300 / 30 / 5 | 及时清理死连接(默认 7200 过长) |
|
||||
| `ip_local_port_range` | 10240 65000 | 出站端口范围,防高并发端口耗尽 |
|
||||
| `core.somaxconn` / `tcp_max_syn_backlog` | 32768 | accept / 半连接队列 |
|
||||
| `core.netdev_max_backlog` | 32768 | 网卡收包队列(默认 1000 易突发丢包) |
|
||||
| `core.rmem_max` / `wmem_max` | 33554432 | socket 缓冲上限(默认 208KB 偏小) |
|
||||
| `tcp_rmem` / `tcp_wmem` | 4K/128K/32M、4K/16K/32M | 自动调优范围 |
|
||||
|
||||
**LXC / OpenVZ 容器不可用(由宿主机控制,实测 2026-09)**
|
||||
|
||||
| 项 | 现象 |
|
||||
|---|---|
|
||||
| `core.rmem_max` / `core.wmem_max` | **不存在**或 `-r--r--r--` 只读 |
|
||||
| `core.default_qdisc` | **不存在**(`sysctl` 报 cannot stat) |
|
||||
| `core.netdev_max_backlog` | 缺失 |
|
||||
| 默认路由网卡根 qdisc | `noqueue`,**无法设置 fq** |
|
||||
| `/usr/lib/sysctl.d` 的 `kernel.pid_max`、`fs.protected_*` | `permission denied`——容器内属**正常现象**,与本文档无关 |
|
||||
|
||||
> 结论:容器节点只能调 **netns 级**项(拥塞算法、MTU 探测、TFO、TW 复用、keepalive、
|
||||
> somaxconn、syn backlog、本地端口范围);socket 缓冲上限与 qdisc 需服务商在**宿主机**设置。
|
||||
> 因此容器节点想进一步提升,应优先在**应用层/传输层**做(例如走 UoT 而不是直连 UDP)。
|
||||
|
||||
**qdisc 为什么必须单独用 systemd 单元**:`net.core.default_qdisc=fq` **只对新建网卡生效**;
|
||||
已存在的网卡即使重启也可能仍是 `pfifo_fast` / `fq_codel`(实测:aq 为 `pfifo_fast`、us 为 `fq_codel`)。
|
||||
`rrpp-qdisc.service` 在开机后显式执行 `tc qdisc replace dev <默认路由网卡> root fq` 补齐;
|
||||
脚本对容器(`noqueue`)会自动跳过。
|
||||
|
||||
**UDP 策略(2026-09-20 定稿)**:UDP 传输(Hysteria2 / TUIC)曾于当日全节点部署并端到端验证,
|
||||
但基于「**抗封优先**」的评估,**当晚已全部撤除**(详情与留档见 §6.4)。现行口径:
|
||||
|
||||
- **节点不部署 UDP 传输服务**;UDP 需求统一走 **UoT v2**(§5.5)——数据报封装进 TCP CONNECT 隧道,
|
||||
经 nginx magic 地址转本机 `127.0.0.1:15201` relay 解封装;
|
||||
- 抗封主路径 = **REALITY + Vision(TCP)**,不做 fallback 组(订阅仍为 `select`);
|
||||
- 节点上的 **UDP 系统调优项保留**(无害,且对 UoT / SS-UDP 有轻微收益)。
|
||||
|
||||
撤除理由(实测 + 推演):非标准 UDP 端口被封对 GFW **几乎零代价**(不像封 TCP 443 会误伤大量正常 HTTPS);
|
||||
UDP 在 CN 长期被 QoS/限速;且一旦 IP 因 UDP 特征被判为代理,会**连带打死同 IP 上的 TCP 服务**(REALITY/SS/nginx)。
|
||||
加密性本身没有问题(QUIC=TLS1.3,实测证书校验强制、错凭据被拒)——风险在**可封禁面**,不在密码学。
|
||||
|
||||
节点侧 UDP 相关系统调优(保留):
|
||||
|
||||
| 项 | 值 / 说明 |
|
||||
|---|---|
|
||||
| `net.ipv4.udp_rmem_min` / `udp_wmem_min` | `8192`(容器内可写,实测 LXC 同样生效) |
|
||||
| `net.core.rmem_max` / `wmem_max` | `33554432`(KVM 可写;**LXC 缺失/只读**) |
|
||||
| `rx-udp-gro-forwarding` / `rx-gro-list`(ethtool) | 由 `rrpp-qdisc.sh` 开机开启;**实测支持情况**:us/jp ✅ 两项;aq(内核 5.10)仅 `rx-gro-list`;**LXC 容器无 netdev 权限,无法设置** |
|
||||
| QUIC/UDP 吞吐 | 受宿主机 socket 缓冲上限约束——**LXC 节点上这是硬约束**,需服务商在宿主调 `rmem_max` |
|
||||
|
||||
**明确不采用的手段**:锐速/LotServer(闭源、绑定旧内核)、net-speeder(重复包、已废弃)、
|
||||
`tcp_tw_recycle`(4.12 起内核已移除,开启会破坏 NAT 后客户端)、`tcp_notsent_lowat` 激进值
|
||||
(可能压制高 BDP 吞吐)、`busy_poll`/`busy_read`(1 vCPU 机器上代价大于收益)、各类"一键优化脚本"
|
||||
(多为本文档子集 + 心理安慰,且常与 sysctl.d 冲突)。
|
||||
|
||||
**落地状态(2026-09-20)**
|
||||
|
||||
| 节点 | 虚拟化 | 落地情况 |
|
||||
|---|---|---|
|
||||
| hk2 | LXC(Debian 12) | LXC 模板;`bbr` + `mtu_probing=1` + `fastopen=3` + 队列/端口项;缓冲与 qdisc 受宿主限制 |
|
||||
| ru | LXC(Debian 13 cloud 内核) | 同上(`rmem_max`/`wmem_max` 只读) |
|
||||
| us | KVM(Debian 12) | KVM 模板 + fq(原 `fq_codel`) |
|
||||
| aq | KVM(Debian 11 cloud) | KVM 模板 + fq(原 `pfifo_fast`);并注释其 `/etc/sysctl.conf` 中 17 行冲突键(原 `tcp_mtu_probing=0`) |
|
||||
| jp | Azure VM(Ubuntu 6.17) | KVM 模板(经 `sudo` 落地;原有 `99-tune.conf` 保留) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 伪装站点页面
|
||||
|
||||
直接访问节点/IP 时返回一个普通网页(而非代理错误),降低被探测的特征。
|
||||
|
||||
放置一个静态 `index.html` 到 `/var/www/html/index.html`:
|
||||
- 任意抓取的合法 HTML 页面(博客/门户首页快照均可)
|
||||
- **所有节点用同一份**(md5 一致),避免逐节点特征差异
|
||||
|
||||
```bash
|
||||
mkdir -p /var/www/html
|
||||
# index.html 为本仓库 fake-site/index.html 的拷贝
|
||||
cp fake-site/index.html /var/www/html/index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTPS 证书(acme.sh DNS-01)
|
||||
|
||||
nginx 站点与 CONNECT 隧道共用一张证书。用 [acme.sh](https://github.com/acmesh-official/acme.sh) + DNS-01(以阿里云 DNS 为例,其他 DNS 商同理):
|
||||
|
||||
```bash
|
||||
curl https://get.acme.sh | sh -s email=<你的邮箱>
|
||||
export Ali_Key="<阿里云AccessKey>"
|
||||
export Ali_Secret="<阿里云Secret>"
|
||||
|
||||
~/.acme.sh/acme.sh --issue --dns dns_ali -d <节点域名> --keylength ec-256
|
||||
|
||||
# 安装到 nginx 读取路径
|
||||
mkdir -p /etc/nginx/ssl/<节点域名>
|
||||
~/.acme.sh/acme.sh --install-cert -d <节点域名> --ecc \
|
||||
--fullchain-file /etc/nginx/ssl/<节点域名>/fullchain.pem \
|
||||
--key-file /etc/nginx/ssl/<节点域名>/privkey.pem
|
||||
```
|
||||
|
||||
acme.sh 自带 cron 自动续期。证书路径固定为 `/etc/nginx/ssl/<节点域名>/{fullchain,privkey}.pem`。
|
||||
|
||||
---
|
||||
|
||||
## 5. nginx 源码编译 + CONNECT 隧道
|
||||
|
||||
标准 nginx 不自带 CONNECT 方法支持,需打 [ngx_http_proxy_connect_module](https://github.com/chobits/ngx_http_proxy_connect_module) 补丁后**源码编译**。
|
||||
|
||||
### 5.1 编译
|
||||
|
||||
```bash
|
||||
apt install -y build-essential libssl-dev libpcre3-dev zlib1g-dev curl wget
|
||||
|
||||
NGINX_VER=1.26.3
|
||||
cd /usr/local/src
|
||||
curl -LO http://nginx.org/download/nginx-$NGINX_VER.tar.gz
|
||||
tar xzf nginx-$NGINX_VER.tar.gz
|
||||
|
||||
git clone https://github.com/chobits/ngx_http_proxy_connect_module.git
|
||||
cd nginx-$NGINX_VER
|
||||
patch -p1 < ../ngx_http_proxy_connect_module/patch/proxy_connect_rewrite_102101.patch
|
||||
|
||||
./configure \
|
||||
--prefix=/usr/local/nginx \
|
||||
--conf-path=/etc/nginx/nginx.conf \
|
||||
--pid-path=/var/run/nginx.pid \
|
||||
--error-log-path=/var/log/nginx/error.log \
|
||||
--http-log-path=/var/log/nginx/access.log \
|
||||
--with-http_ssl_module \
|
||||
--with-http_v2_module \
|
||||
--with-http_realip_module \
|
||||
--with-http_stub_status_module \
|
||||
--with-http_gzip_static_module \
|
||||
--add-module=../ngx_http_proxy_connect_module
|
||||
make -j$(nproc) && make install
|
||||
```
|
||||
|
||||
> 若发行版 nginx 已自带 proxy_connect 补丁(极少见),可用 `apt install nginx` 跳过编译,但源码编译是通用做法。
|
||||
|
||||
### 5.2 Basic Auth 凭据
|
||||
|
||||
CONNECT 代理需要 Basic Auth(用户名/密码),**每节点独立密码**(客户端配置按节点携带凭据,无兼容性问题)。生成 htpasswd:
|
||||
|
||||
```bash
|
||||
apt install -y apache2-utils
|
||||
htpasswd -bc /etc/nginx/.htpasswd <代理用户名> '<本节点独立密码>'
|
||||
chmod 644 /etc/nginx/.htpasswd # worker 以 nobody 运行,必须可读
|
||||
```
|
||||
|
||||
> 无 apache2-utils 的机器可用 openssl 生成(sha512-crypt,nginx 同样支持):
|
||||
> `echo "<用户名>:$(openssl passwd -6 '<密码>')" > /etc/nginx/.htpasswd`
|
||||
> htpasswd 是**每次请求实时读取**的,改密码无需 reload nginx。
|
||||
|
||||
### 5.3 nginx.conf
|
||||
|
||||
完整配置见 [nginx/nginx.conf](nginx/nginx.conf)。**关键设计**:
|
||||
|
||||
- **两个 server 块共用 `<nginx端口>`**:
|
||||
- 主块 `server_name <节点域名>`:证书站点 + `location /` 返回伪装页;非本域名一律 rewrite 到 `/__proxy` 转发
|
||||
- fallback 块 `default_server`:其他 SNI 也接受 CONNECT 代理;本机/私网/公网 IP 直连返回伪装页,其余走 `/__proxy`
|
||||
- `proxy_connect_allow all;`:**全端口** CONNECT 放行(codex/WebSocket 走任意端口均通)。若需限制,改回 `proxy_connect_allow 443 563;`
|
||||
- `auth_basic`:仅 CONNECT/正向代理需要认证,`location /` 伪装页 `auth_basic off`
|
||||
- **自代理回环保护**:`/__proxy` 转发时带 `X-Proxy-Loop-Guard` 头,二次进入返回 508 防死循环
|
||||
- `/__local`:本机 IP 直连时剥前缀返回伪装页
|
||||
- `map $host $is_local`:私网/回环段判定;公网 IP 由 cron 脚本动态写入 `local_ip_rules.conf`
|
||||
|
||||
### 5.4 自代理回环保护说明
|
||||
|
||||
正向代理转发目标若指向**本机自己**(如域名公网 IP 变更、或代理目标就是本节点),会无限循环。两层防护:
|
||||
1. `map` 判定私网/回环段 + cron 维护的本机公网 IP 规则 → 直连伪装页
|
||||
2. `/__proxy` 的 Loop-Guard 头兜底 → 二次进入返回 508
|
||||
|
||||
### 5.5 路线 B 裸 UoT relay
|
||||
|
||||
TUN 客户端的 UDP 会先通过认证 CONNECT 请求
|
||||
`sp.v2.udp-over-tcp.arpa:443`。当前 hk2 使用的
|
||||
`ngx_http_proxy_connect_module` 没有注册 `$connect_host`,因此在 fallback
|
||||
server 实际接收的 CONNECT 请求上用 `$request` 正则匹配,再把连接转到
|
||||
`127.0.0.1:15201`;nginx 只负责 TLS、Basic Auth 和字节流转发,不解析 UoT。relay 使用仓库中的
|
||||
`[uot_relay.py](uot_relay.py)`,只监听回环地址,不需要 sing-box:
|
||||
|
||||
```bash
|
||||
install -m 755 uot_relay.py /opt/rrpp-uot-relay/uot_relay.py
|
||||
install -m 644 systemd/uot-relay.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now uot-relay
|
||||
ss -lntp | grep 127.0.0.1:15201
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
客户端的 CONNECT 端口必须大于 0,当前统一使用 `443`;`15201` 是服务器回环
|
||||
relay 的内部端口,不写入客户端 CONNECT 目标。nginx fallback 块使用
|
||||
`proxy_connect_read_timeout 10m` 和 `proxy_connect_send_timeout 10m`,不要再同时
|
||||
配置会产生 duplicate 的 `proxy_connect_data_timeout`。
|
||||
|
||||
---
|
||||
|
||||
### 5.5 UoT(UDP over TCP)支持
|
||||
|
||||
HTTP CONNECT 只隧道 TCP。要让 UDP 应用(DNS/游戏/QUIC 等)走代理,需 **UoT v2**
|
||||
(SagerNet 专有协议,见 sing-box 文档 `configuration/shared/udp-over-tcp`):
|
||||
客户端把 UDP 数据报封装进 TCP 流,经 CONNECT 隧道发到 magic 地址
|
||||
`sp.v2.udp-over-tcp.arpa`,由本机 **UoT relay** 解封装后转发真实 UDP 目标。
|
||||
|
||||
```
|
||||
客户端 TUN/UDP ──UoT v2──▶ TLS HTTP CONNECT ──▶ nginx proxy_connect
|
||||
──▶ 127.0.0.1:15201(uot-relay)──▶ 真实 UDP 目标
|
||||
```
|
||||
|
||||
**relay 部署**(Python 标准库实现,见 [uot-relay/uot_relay.py](uot-relay/uot_relay.py)):
|
||||
|
||||
```bash
|
||||
install -d /opt/rrpp-uot-relay
|
||||
install -m 755 uot-relay/uot_relay.py /opt/rrpp-uot-relay/uot_relay.py
|
||||
install -m 644 uot-relay/uot-relay.service /etc/systemd/system/uot-relay.service
|
||||
systemctl daemon-reload && systemctl enable --now uot-relay
|
||||
```
|
||||
|
||||
relay 只监听 `127.0.0.1:15201`,空闲 9 分钟断开(须小于 nginx 隧道超时 10m)。
|
||||
支持 UoT v2 两种模式:`isConnect=1` 固定目标、`isConnect=0` 每帧自带目标。
|
||||
|
||||
**nginx 侧**(模板已含,[nginx/nginx.conf](nginx/nginx.conf)):`map $request` 识别
|
||||
magic 地址 → `127.0.0.1:15201`,其余 CONNECT 走 `$connect_addr` 默认解析;
|
||||
`proxy_connect_address $rrpp_uot_connect_address;` 放在 **default_server 块**。
|
||||
|
||||
**四个实测坑(2026-09 全节点部署验证)**:
|
||||
1. 该模块版本**未注册 `$connect_host` 变量**(README 有、源码无)→ 必须用
|
||||
`$request` 正则匹配(任何阶段可用);
|
||||
2. `proxy_connect_data_timeout` 与 `proxy_connect_read_timeout` **写同一配置槽**,
|
||||
同时写报 duplicate → 长连接超时用 read/send timeout 设 10m;
|
||||
3. **所有 CONNECT 请求都落在 `default_server`(fallback)块**——nginx 按请求
|
||||
Host 头选虚拟主机,CONNECT 的 Host 是目标域名,永远匹配不到 `server_name`;
|
||||
4. 模块**拒绝 CONNECT 端口 0**(`port < 1` 返回 400)→ 客户端连 magic 地址时
|
||||
端口必须非 0(用 443 即可,relay 不读 CONNECT 端口,目标取自 UoT 帧头)。
|
||||
|
||||
**健壮性要求(2026-09 实测修复,别踩)**:
|
||||
- relay 必须用**未连接**的 UDP socket(`sendto`/`recvfrom`)。用连接式 socket 时,
|
||||
目标侧 ICMP(port unreachable 等)会变成 `ConnectionRefusedError` 在 send/recv 上抛出,
|
||||
若被兜底 `except` 接住并 `stop.set()`,**一条数据报出错就杀掉整条 UoT 隧道**
|
||||
(客户端表现为 "UoT TCP 流已关闭"、多条隧道同秒集体死亡)。
|
||||
- 数据报级错误(`BlockingIOError`/EAGAIN、`ENETUNREACH`、`EHOSTUNREACH`、`EMSGSIZE`)
|
||||
**只丢当前包**,EAGAIN 先等可写再重试;只有 TCP EOF、协议错误、空闲超时才关隧道。
|
||||
- 按地址族惰性建 socket,non-connect 模式可混用 IPv4/IPv6 目标;IPv6 `recvfrom`
|
||||
返回 4 元组需归一化。
|
||||
- connect 模式要**预建** socket(否则主循环首轮无可 select 对象,会立刻关连接)。
|
||||
|
||||
**节点出口差异(实测)**:RU 节点到境外 DNS(8.8.8.8:53)UDP 成功率仅约 4/10
|
||||
(俄 ISP 限制),表现为 UoT DNS 偶发超时——属该节点出口特性,非 relay 故障。
|
||||
此类节点的 UDP 业务建议换用当地可达目标,或走其它节点。
|
||||
|
||||
**客户端协议**:认证与 TCP CONNECT 相同(TLS + Basic Auth),然后
|
||||
`CONNECT sp.v2.udp-over-tcp.arpa:443 HTTP/1.1`,200 之后收发 UoT v2 帧:
|
||||
请求头 `| isConnect u8 | ATYP u8 | address | port u16be |`,
|
||||
connect 模式帧 `| length u16be | data |`,non-connect 帧每帧自带目标地址。
|
||||
|
||||
### 5.6 CONNECT 超时口径(避免客户端"卡死")
|
||||
|
||||
三个超时要按"客户端最没耐心"来配:
|
||||
|
||||
| 项 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| `proxy_connect_connect_timeout` | **10s** | 出站连接目标;须 < 客户端超时(lite 客户端 15s) |
|
||||
| `proxy_connect_read_timeout` / `send_timeout` | 10m | 隧道空闲超时(UoT 长连接需要;注意与 data_timeout 同槽位) |
|
||||
|
||||
**典型故障**:目标 IP 是黑洞(不发 RST)时,若 nginx 出站超时设 60s 而客户端 15s 超时,
|
||||
客户端会表现为 `The read operation timed out` 而不是收到 502 —— 看起来像"代理卡死",
|
||||
实际是超时错配。2026-09 实测:改为 10s 后,黑洞目标从 >25s 降至 10.2s 返回,
|
||||
正常目标(0.4~0.7s)不受影响。
|
||||
|
||||
**排查"CONNECT 502"的正确顺序**(重要):
|
||||
1. 先看目标是否**本身就不可达**——从多个网络(国内/日本/美国/香港)实测该 IP:端口,
|
||||
若都失败则是目标问题(服务下线/过期 IP/黑洞),不是代理问题;
|
||||
2. 再看 nginx error.log:`Connection refused` / `timed out` / `Network is unreachable`
|
||||
分别对应目标拒绝、目标黑洞、路由不可达;
|
||||
3. 最后才怀疑 CONNECT 模块本身。
|
||||
|
||||
> 客户端日志只记 IP 不记域名时,无法判断"是不是 DNS 过期解析"。
|
||||
> 建议客户端日志同时记录原始域名(实测案例:某 Dropbox 旧 IP 已失效,
|
||||
> 域名现已解析到其它地址,客户端仍在拨旧 IP)。
|
||||
|
||||
## 6. sing-box(VLESS+REALITY / Shadowsocks)
|
||||
|
||||
### 6.1 安装
|
||||
|
||||
```bash
|
||||
SINGBOX_VER=1.13.19
|
||||
curl -LO https://github.com/SagerNet/sing-box/releases/download/v$SINGBOX_VER/sing-box-$SINGBOX_VER-linux-amd64.tar.gz
|
||||
tar xzf sing-box-*.tar.gz
|
||||
install -m 755 sing-box-$SINGBOX_VER-linux-amd64/sing-box /usr/local/bin/sing-box
|
||||
```
|
||||
|
||||
### 6.2 配置
|
||||
|
||||
完整配置见 [sing-box/config.json](sing-box/config.json)。要点:
|
||||
- **VLESS+REALITY**:每节点独立 UUID/密钥对/short_id,SNI 全节点统一,flow `xtls-rprx-vision`;REALITY handshake server 用 `<伪装SNI域名>:443`
|
||||
- **纯 IPv4 节点**(如 Azure 不给 IPv6 的 jp):direct 出站**必须**配 `domain_resolver`,
|
||||
否则双栈域名会解析出 AAAA 并拨向不可达的 IPv6(日志刷 `network is unreachable`,
|
||||
jp 实测每小时数十条)。1.12+ 写法(legacy `domain_strategy` 字段在 sing-box 1.13
|
||||
已直接 FATAL 拒绝,勿用):
|
||||
```json
|
||||
"dns": {"servers": [{"type": "local", "tag": "local-dns"}]},
|
||||
"outbounds": [{"type": "direct", "tag": "direct",
|
||||
"domain_resolver": {"server": "local-dns", "strategy": "ipv4_only"}}]
|
||||
```
|
||||
已验证:jp 需要(2026-10-01 已修);ru/hk2/us/aq 有 IPv6 出口或实测无此错误,不需要。
|
||||
- **Shadowsocks**:`2022-blake3-aes-256-gcm`(SIP022:会话子密钥 + 抗重放探测,显著优于老 AEAD `aes-256-gcm`)+ 每节点独立 PSK,独立端口(兼容不支持 REALITY 的老客户端)
|
||||
- **PSK 格式要求**:密码必须是 base64 编码的 32 字节密钥(`openssl rand -base64 32`),不是任意字符串;改错格式 sing-box 启动即报错
|
||||
- sing-box / Shadowrocket / mihomo 均支持 2022-blake3 系列
|
||||
- 无 SOCKS5 inbound(已按需移除;如需可在 inbounds 追加 `type: socks`)
|
||||
|
||||
### 6.3 REALITY 密钥生成
|
||||
|
||||
**每台节点独立生成一套**(互不共用):
|
||||
|
||||
```bash
|
||||
# 生成私钥/公钥(sing-box 1.13+ 内置 reality 密钥生成)
|
||||
sing-box generate reality-keypair
|
||||
# 输出 private_key / public_key,记录备用
|
||||
# short_id 用 8 字节随机 hex:
|
||||
openssl rand -hex 8
|
||||
# UUID:
|
||||
sing-box generate uuid
|
||||
```
|
||||
|
||||
> 私钥只放服务端配置;公钥给客户端订阅。UUID/密钥对/short_id 每节点独立。
|
||||
|
||||
**公钥必须推导验证,禁止手抄。** 订阅/节点配置里的 `public_key` 与服务端
|
||||
`private_key` 必须严格配对(X25519 推导),曾发生手抄错一个字符导致全节点
|
||||
REALITY 握手失败(客户端报 `reality verification failed`)的事故。改配置后验证:
|
||||
|
||||
```bash
|
||||
python3 - << 'EOF'
|
||||
import base64
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
priv = base64.urlsafe_b64decode('<REALITY_PRIVATE_KEY>' + '==')
|
||||
pub = X25519PrivateKey.from_private_bytes(priv).public_key().public_bytes(
|
||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
print(base64.urlsafe_b64encode(pub).decode().rstrip('=')) # 应与下发的 public_key 逐字符一致
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.4 Hysteria2 / TUIC(UDP 传输)— ⚠️ 已回滚,本节为留档
|
||||
|
||||
> **状态:已回滚(2026-09-20 当晚)。** 当日曾在五节点部署并逐组合端到端验证通过(见下表与「验证方法」),
|
||||
> 随后因「**绝对不可以被 GFW 封禁**」这一优先级,**全部撤除**——五节点 sing-box 已恢复为
|
||||
> `vless + shadowsocks` 原状,新增的 UDP 入站不再监听。
|
||||
>
|
||||
> 本节保留作**留档**:配置片段、各节点 NAT/NSG 差异、端到端验证方法(尤其"只看监听不足以证明外部可达"
|
||||
> 这个坑)在日后重新启用或换机时仍然有效。若要重新启用,照本节执行即可,**但请先读 §2.6 的撤除理由**。
|
||||
|
||||
TCP 系协议在跨境丢包链路上受拥塞控制限制(BBR 已缓解但仍有天花板);QUIC 系
|
||||
(**Hysteria2** 的 Brutal CC、**TUIC v5**)能显著改善高丢包下的吞吐与时延。
|
||||
两者都是 **sing-box 内建**,无需额外二进制;UDP 需求也可走 UoT(§5.5)。
|
||||
|
||||
```json
|
||||
// Hysteria2(sing-box inbound)
|
||||
{ "type": "hysteria2", "tag": "hy2-in-<端口>", "listen": "0.0.0.0", "listen_port": "<端口>",
|
||||
"up_mbps": 100, "down_mbps": 100,
|
||||
"obfs": { "type": "salamander", "password": "<obfs密码>" },
|
||||
"users": [ { "name": "<节点>-hy2", "password": "<hy2密码>" } ],
|
||||
"tls": { "enabled": true, "server_name": "<节点域名>",
|
||||
"certificate_path": "/etc/nginx/ssl/<节点域名>/fullchain.pem",
|
||||
"key_path": "/etc/nginx/ssl/<节点域名>/privkey.pem", "alpn": ["h3"] },
|
||||
"masquerade": "file:///var/www/html" }
|
||||
|
||||
// TUIC v5
|
||||
{ "type": "tuic", "tag": "tuic-in-<端口>", "listen": "0.0.0.0", "listen_port": "<端口>",
|
||||
"users": [ { "name": "<节点>-tuic", "uuid": "<UUID>", "password": "<密码>" } ],
|
||||
"congestion_control": "bbr",
|
||||
"tls": { /* 同上 */ } }
|
||||
```
|
||||
|
||||
**要点**
|
||||
|
||||
- **带宽声明决定 Brutal**:`up_mbps`/`down_mbps` 不写时,服务端会命令客户端改用 BBR;
|
||||
写了才允许 Hysteria CC(Brutal)。默认 100 Mbps,计量流量的节点给 50。
|
||||
Brutal **不遵守拥塞控制**,设错会占满链路甚至被服务商判定异常流量——务必按实际带宽设。
|
||||
- `obfs: salamander` 给 QUIC 加一层混淆(对抗 QUIC 特征识别);`masquerade` 指向伪装页,
|
||||
未认证访问返回与 nginx 一致的页面。
|
||||
- TUIC 保持 `zero_rtt_handshake` 关闭(默认,0-RTT 可被重放)。
|
||||
- 凭据**每节点独立**,与 VLESS/SS/CONNECT 同策略。
|
||||
|
||||
**各节点端口与部署差异(2026-09-20 实测,务必照抄结论)**
|
||||
|
||||
| 节点 | 虚拟化 | Hysteria2 (UDP) | TUIC (UDP) | 差异 / 坑 |
|
||||
|---|---|---|---|---|
|
||||
| hk2 | LXC(NAT) | 4305 | 4306 | NAT 段 4301-4320 UDP 1:1(探针 + 外部回扫实测) |
|
||||
| us | KVM | 1208 | 1209 | 公网直连,UDP 任意端口可用 |
|
||||
| ru | LXC(NAT) | 23333 | 23334 | **外部只有宿主已映射的 UDP 端口可达**:容器内 bind 任意端口都"正常",但 23336+ 实测直接 `connection refused`(ICMP port unreachable);最终复用 **23333/23334**——与 nginx/vless 占用的 **TCP 同号**,UDP 与 TCP 互不冲突 |
|
||||
| aq | KVM(IPv6-only) | 8444 | 8445 | 节点为 IPv6 出口,`listen` 用 `"::"`;客户端须走 IPv6 |
|
||||
| jp | Azure VM | 未部署 | 8445 | **Azure NSG 只放行 UDP 8445**(443/80/8080/8088/18080/8443/9444 的 UDP 面实测均不可达)→ 单节点只能承载一个 UDP 协议;要加 Hysteria2 须先在 NSG 放行第二个 UDP 端口 |
|
||||
|
||||
**客户端链接格式(手工导入用)**
|
||||
|
||||
```
|
||||
hysteria2://<password>@<域名>:<端口>/?sni=<域名>&alpn=h3&obfs=salamander&obfs-password=<obfs密码>#备注
|
||||
tuic://<uuid>:<password>@<域名>:<端口>/?congestion_control=bbr&alpn=h3&sni=<域名>#备注
|
||||
```
|
||||
|
||||
**接入现状(2026-09-20,务必先读)**
|
||||
|
||||
| 层 | 状态 |
|
||||
|---|---|
|
||||
| 服务端 | ⛔ **已撤除**(原:五节点部署并端到端验证)——2026-09-20 当晚按抗封优先回滚 |
|
||||
| **订阅下发** | ❌ 从未下发(`app.py` 无 hysteria2/tuic 生成逻辑,`nodes.json` 无对应字段;订阅里只有 http / vless / ss),且已决定不做 |
|
||||
| **代理组** | ❌ 订阅只生成一个 `RRPP`(`type: select`,手动选节点),**没有 fallback / url-test**,因此**不存在"UDP 挂了自动回落 TCP"** |
|
||||
| lite 客户端(Windows) | 仅 HTTP CONNECT + UoT v2(UDP 走 magic 地址),**不使用 UDP 入站**,且节点为手动切换、无自动故障转移 |
|
||||
| 结论 | UDP 通道目前**只能手工导入**上面的链接使用;抗封主路径仍是 **REALITY+Vision(TCP)**,UDP 属可选加速,未接入客户端链路 |
|
||||
|
||||
> 若日后要接入订阅,需要三步:① `nodes.json` 每节点补 `methods.hysteria2` / `methods.tuic`;
|
||||
> ② `app.py` 增加 mihomo `type: hysteria2` / `type: tuic` 生成与分享链接;
|
||||
> ③ 代理组改为**每节点一个 `fallback` 组、REALITY 排第一**(否则"UDP 挂了回落 TCP"不成立)。
|
||||
> 注意 mihomo 的 fallback 依赖健康检查周期(默认 300s),切换不是瞬时的。
|
||||
|
||||
**验证方法(端到端,不能只看监听)**
|
||||
|
||||
```bash
|
||||
# 在任意机器用 sing-box 作客户端:socks 入站 + 上述 outbound,然后
|
||||
curl -s -m 20 -x socks5h://127.0.0.1:11080 -o /dev/null -w '%{http_code}\n' http://example.com/ # 期望 200
|
||||
```
|
||||
|
||||
`ss -lunp` 看到端口在听**不足以**证明外部可达:ru(NAT 未映射)与 jp(NSG 未放行)
|
||||
都是"监听正常但外部不可达"的真实案例。
|
||||
|
||||
⚠️ 代价与限制:UDP 在部分运营商会被 QoS/封禁;Brutal 对同链路其他流量不友好;
|
||||
计量流量的节点(如 jp,15GB/月)注意消耗;LXC 容器内 socket 缓冲上限受宿主机约束。
|
||||
|
||||
---
|
||||
|
||||
## 7. mtg(MTProto 代理)
|
||||
|
||||
Telegram 内置代理,Fake TLS 模式抗封锁。用 [mtg](https://github.com/9seconds/mtg)(Go 单二进制):
|
||||
|
||||
### 7.1 安装
|
||||
|
||||
```bash
|
||||
MTG_VER=2.2.8
|
||||
curl -LO https://github.com/9seconds/mtg/releases/download/v$MTG_VER/mtg-$MTG_VER-linux-amd64.tar.gz
|
||||
tar xzf mtg-*.tar.gz
|
||||
install -m 755 mtg-$MTG_VER-linux-amd64/mtg /usr/local/bin/mtg
|
||||
```
|
||||
|
||||
### 7.2 生成 secret(每台独立)
|
||||
|
||||
```bash
|
||||
mtg generate-secret --hex <伪装SNI域名>
|
||||
# 输出 ee<32hex><域名hex>,以 ee 开头 = Fake TLS 类型
|
||||
```
|
||||
|
||||
### 7.3 运行
|
||||
|
||||
```bash
|
||||
mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <上面生成的secret>
|
||||
```
|
||||
|
||||
> `--prefer-ip=prefer-ipv4`:强制 IPv4 出站,避免容器/双栈环境下 IPv6 不可达导致回退失败。
|
||||
|
||||
### 7.4 Telegram 客户端参数
|
||||
|
||||
- 服务器:`<节点域名>`
|
||||
- 端口:`<mtproto端口>`
|
||||
- Secret:上面生成的 hex(Telegram 自动识别 Fake TLS)
|
||||
|
||||
---
|
||||
|
||||
## 8. 本机公网 IP 自动识别(防自代理回环)
|
||||
|
||||
正向代理转发时,若目标 Host 是本机公网 IP,会被识别为"本机直连"返回伪装页(而非经代理转发本机,造成回环)。公网 IP 可能变动,用 cron 脚本每 5 分钟自动维护。
|
||||
|
||||
脚本见 [scripts/sync-nginx-local-ips.sh](scripts/sync-nginx-local-ips.sh),cron 见 [cron/sync-nginx-local-ips](cron/sync-nginx-local-ips):
|
||||
|
||||
```cron
|
||||
*/5 * * * * root /usr/local/sbin/sync-nginx-local-ips.sh >/dev/null 2>&1
|
||||
```
|
||||
|
||||
脚本逻辑:取 `api.ipify.org` 出站 IP + 节点域名 A 记录,比对重写 `/etc/nginx/local_ip_rules.conf`,**仅变更时** `nginx -t && systemctl reload nginx`。
|
||||
|
||||
---
|
||||
|
||||
## 9. systemd 服务文件
|
||||
|
||||
| 服务 | 文件 |
|
||||
|---|---|
|
||||
| nginx | [systemd/nginx.service](systemd/nginx.service) |
|
||||
| sing-box | [systemd/sing-box.service](systemd/sing-box.service) |
|
||||
| mtg | [systemd/mtg.service](systemd/mtg.service) |
|
||||
| uot-relay | [uot-relay/uot-relay.service](uot-relay/uot-relay.service) |
|
||||
| rrpp-qdisc(fq 队列,见 2.6) | [systemd/rrpp-qdisc.service](systemd/rrpp-qdisc.service) |
|
||||
|
||||
```bash
|
||||
cp systemd/*.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now nginx sing-box mtg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 启动顺序与验证清单
|
||||
|
||||
### 启动顺序
|
||||
|
||||
```bash
|
||||
sysctl --system # 1. BBR
|
||||
systemctl start sing-box # 2. VLESS/SS
|
||||
systemctl start mtg # 3. MTProto
|
||||
systemctl start nginx # 4. nginx(最后,依赖证书/伪装页/规则文件)
|
||||
systemctl start uot-relay # 5. UoT relay(须在 nginx 前可用)
|
||||
```
|
||||
|
||||
### 验证清单
|
||||
|
||||
```bash
|
||||
# BBR
|
||||
sysctl net.ipv4.tcp_congestion_control # => bbr
|
||||
sysctl net.ipv4.tcp_mtu_probing # => 1(PMTUD 黑洞缓解,见 2.4)
|
||||
|
||||
# 系统级网络优化(见 2.6):确认 sysctl.d 的值真的生效(注意 /etc/sysctl.conf 覆盖坑)
|
||||
sysctl -n net.core.somaxconn net.ipv4.tcp_max_syn_backlog net.ipv4.tcp_fastopen \
|
||||
net.ipv4.tcp_slow_start_after_idle net.ipv4.ip_local_port_range
|
||||
# KVM/完整 VM 追加:net.core.netdev_max_backlog / rmem_max / wmem_max
|
||||
tc qdisc show | grep -v noqueue # 期望默认路由网卡为 fq(容器内为 noqueue,属正常)
|
||||
systemctl is-active rrpp-qdisc # 期望 active(容器内该单元会自行跳过)
|
||||
|
||||
# fail2ban
|
||||
fail2ban-client status sshd # => jail 存在(见 2.5)
|
||||
|
||||
# sing-box
|
||||
sing-box check -c /etc/sing-box/config.json
|
||||
systemctl is-active sing-box
|
||||
|
||||
# mtg(Fake TLS:非法 ClientHello 应返回 TLS alert 0x15)
|
||||
timeout 8 python3 -c "
|
||||
import socket,os,struct
|
||||
s=socket.connect(('<节点域名>',<mtproto端口>)) if False else socket.create_connection(('<节点域名>',<mtproto端口>),timeout=6)
|
||||
s.sendall(b'\x16\x03\x01'+struct.pack('>H',100)+os.urandom(100))
|
||||
print('resp:',s.recv(8).hex())" # 期望首字节 15 (TLS alert)
|
||||
|
||||
# UoT relay(127.0.0.1:15201 监听 + CONNECT magic 地址)
|
||||
ss -tlnp | grep 15201 # => uot_relay.py 监听 127.0.0.1:15201
|
||||
|
||||
# UDP 传输(Hysteria2/TUIC)——已于 2026-09-20 撤除;以下仅当重新启用时才适用
|
||||
# 必须端到端验证,不能只看监听
|
||||
ss -lunp | grep -E ':<hy2端口>|:<tuic端口>' # 仅确认服务在听
|
||||
# 再用 sing-box 客户端 + curl 经由 socks 实测(见 6.4「验证方法」)
|
||||
ethtool -k <网卡> | grep -E 'rx-udp-gro-forwarding|rx-gro-list' # KVM 期望 on;LXC 容器不可设
|
||||
|
||||
# nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>'))
|
||||
B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0)
|
||||
curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \
|
||||
--proxy-header "Authorization: Basic $B64" \
|
||||
-o /dev/null -w '%{http_code}\n' http://example.com/ # => 200
|
||||
|
||||
# 伪装页(直连 IP/域名,无认证)
|
||||
curl -sk https://<节点域名>:<nginx端口>/ -o /dev/null -w '%{http_code}\n' # => 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 目录结构与文件索引
|
||||
|
||||
```
|
||||
server_config/
|
||||
├── README.md ← 本文档
|
||||
├── sysctl/
|
||||
│ ├── 99-zz-rrpp-network.kvm.conf ← 系统级网络优化(KVM/完整 VM,见 §2.6)
|
||||
│ └── 99-zz-rrpp-network.lxc.conf ← 系统级网络优化(LXC/OpenVZ 容器,见 §2.6)
|
||||
├── nginx/
|
||||
│ ├── nginx.conf
|
||||
│ ├── conf.d/
|
||||
│ │ ├── api.aeolianchi.me.conf
|
||||
│ │ └── autodiscover.aeolianchi.me.conf
|
||||
│ └── local_ip_rules.conf.example
|
||||
│ ├── nginx.conf ← 完整 nginx 配置(占位符版)
|
||||
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
|
||||
│ └── conf.d/ ← 预留:额外站点配置
|
||||
├── uot-relay/
|
||||
│ ├── uot_relay.py ← UoT v2 relay(UDP over TCP 解封装)
|
||||
│ └── uot-relay.service ← systemd unit
|
||||
├── sing-box/
|
||||
│ └── config.json
|
||||
├── traffic-monitor/
|
||||
│ ├── config.json
|
||||
│ ├── traffic_monitor.py
|
||||
│ └── traffic-monitor.service
|
||||
│ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
|
||||
├── systemd/
|
||||
│ ├── nginx.service
|
||||
│ ├── sing-box.service
|
||||
│ ├── mtg.service
|
||||
│ └── rrpp-qdisc.service ← 开机把默认网卡 qdisc 设为 fq(见 §2.6)
|
||||
├── scripts/
|
||||
│ ├── sync-nginx-local-ips.sh
|
||||
│ └── rrpp-stats
|
||||
├── pac/
|
||||
│ ├── proxy.pac
|
||||
│ └── proxy_wg.pac
|
||||
└── cron/
|
||||
├── crontab.txt
|
||||
└── sync-nginx-local-ips
|
||||
│ ├── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
|
||||
│ └── rrpp-qdisc.sh ← fq 队列设置脚本(rrpp-qdisc.service 调用)
|
||||
├── cron/
|
||||
│ └── sync-nginx-local-ips ← cron 定义(每 5 分钟)
|
||||
├── rrpp-subscription/ ← RRPP 订阅后端(bj,见 §12)
|
||||
│ ├── API.md ← 订阅端点 / method / 分层 / 规则集文档
|
||||
│ └── gen_rulesets.py ← PAC → 客户端规则集转换脚本
|
||||
└── fake-site/
|
||||
└── index.html ← 伪装站点页面(所有节点共用同一份)
|
||||
```
|
||||
|
||||
> 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。
|
||||
|
||||
---
|
||||
|
||||
## 12. RRPP 订阅后端(PAC → 规则集下发)
|
||||
|
||||
订阅服务运行在 **bj**(`C:/BtSoft/nginx/others/backEnd/api-rrrosmontis-icu/RRPP/`,nssm 服务 `RRPPSubBackend`,监听 `127.0.0.1:5010`,由 nginx `api.rrrosmontis.icu` 反代)。
|
||||
完整端点说明见 `rrpp-subscription/API.md`(bj 上同名文件为主副本,两处内容保持一致)。
|
||||
|
||||
### 12.1 PAC 为什么不能直接下发
|
||||
|
||||
PAC 是浏览器/系统用的 JS 自动配置,**mihomo/Clash 与 Shadowrocket 都读不了**。两者唯一共同能吃的格式是
|
||||
「每行自带规则类型的规则行文本」(Clash 的 `behavior: classical`):
|
||||
|
||||
- mihomo → `rule-providers`(`behavior: classical` + `format: text`)
|
||||
- Shadowrocket → `RULE-SET,<url>,<策略>`(官方手册:规则集内容需包含规则类型)
|
||||
|
||||
因此由后端 `gen_rulesets.py` 把 PAC 数据转成三个 `.list`,两种客户端共用同一份文件。
|
||||
|
||||
### 12.2 规则集产物
|
||||
|
||||
| 规则集 | 文件(`https://cdn.rrrosmontis.icu/RRPP/PAC/`) | 规则数 | 策略 |
|
||||
|---|---|---|---|
|
||||
| `rrpp-proxy` | `rrpp-proxy.list` | 3943(`DOMAIN` 539 + `DOMAIN-SUFFIX` 3404) | `RRPP` / `PROXY` |
|
||||
| `rrpp-direct` | `rrpp-direct.list` | 16(`DOMAIN-SUFFIX`) | `DIRECT` |
|
||||
| `rrpp-cn-ip` | `rrpp-cn-ip.list` | 7456(`IP-CIDR`) | `DIRECT` |
|
||||
|
||||
顺序(与 PAC 语义一致):强制代理 → 直连种子 → 中国 IP 直连 → `MATCH` 全代理。
|
||||
|
||||
### 12.3 维护:PAC 更新后重新生成
|
||||
|
||||
```bash
|
||||
# bj 上执行(Windows:C:/BtSoft/nginx/others/htmlResources/cdn/RRPP/PAC/ 为生成目录,同时即 CDN 根)
|
||||
python gen_rulesets.py # 幂等:内容无变化则不写盘;原子替换
|
||||
python gen_rulesets.py --check # 只预览差异,不写盘
|
||||
```
|
||||
|
||||
产物文件名与 URL 固定,**订阅端与客户端均无需改动**:mihomo 按 `interval: 86400` 自动更新,
|
||||
Shadowrocket 在「配置 → 规则集URL」中自动更新。
|
||||
|
||||
### 12.4 客户端接入
|
||||
|
||||
```bash
|
||||
# mihomo / Clash Verge:规则走 rule-providers(YAML 约 2.4KB)
|
||||
...&method=clash
|
||||
# 不认 rule-providers,或要「导入一次到位」:规则内联(约 440KB)
|
||||
...&method=clash&rules=inline
|
||||
# Shadowrocket:只下发规则([General] + 远程 RULE-SET + FINAL,不含节点)
|
||||
...&method=shadowrocket
|
||||
# 兜底:全流量走代理(旧行为)
|
||||
...&method=clash&rules=global
|
||||
```
|
||||
|
||||
> `method=shadowrocket` 的 `.conf` 不写 `[Proxy]` 段——Shadowrocket 的 REALITY 参数写法官方未文档化,不猜测;
|
||||
> 策略 `PROXY` 指向首页当前选中的节点,节点用 `clash`/`v2ray` 订阅导入。
|
||||
> 想在小火箭里「一次导入节点+规则」,用 `method=clash&rules=inline`。
|
||||
|
||||
### 12.5 验证口径
|
||||
|
||||
- 生成:11415 条规则(`regexp:` 跳过 7、非法 1)
|
||||
- CDN:三个 URL HTTP 200,md5 与本地一致(`806808b8` / `8f89397e` / `3e75093d`)
|
||||
- mihomo v1.19.31 真实内核:`-t` 通过;运行时 `GET /providers/rules` 报 `3943 / 16 / 7456`(`behavior=Classical`),
|
||||
落地文件字节数与生成一致;`rules=inline` 亦通过 `-t`
|
||||
- Shadowrocket 为 iOS 应用,未做实机验证(依官方手册 `RULE-SET` 与 Clash YAML 兼容说明实现)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
* * * * * /usr/local/bin/rrpp-stats | sudo tee /root/backups/stats.json > /dev/null
|
||||
27 0,6,12,18 * * * "/home/RRRosmontis/.acme.sh"/acme.sh --cron --home "/home/RRRosmontis/.acme.sh" > /dev/null
|
||||
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name api.aeolianchi.me;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/api.aeolianchi.me/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/api.aeolianchi.me/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
location /vpn/status/traffic {
|
||||
proxy_pass http://127.0.0.1:18080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# autodiscover.aeolianchi.me -> jp 反代 -> bj autodiscover.rrrosmontis.icu
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name autodiscover.aeolianchi.me;
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/acme;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# autodiscover.aeolianchi.me 443 -> 反代 -> bj autodiscover.rrrosmontis.icu
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name autodiscover.aeolianchi.me;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/aeolianchi.me/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/aeolianchi.me/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
|
||||
location / {
|
||||
proxy_pass https://autodiscover.rrrosmontis.icu;
|
||||
proxy_set_header Host autodiscover.rrrosmontis.icu;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name autodiscover.rrrosmontis.icu;
|
||||
}
|
||||
|
||||
access_log /var/log/nginx/autodiscover.aeolianchi.me.log;
|
||||
error_log /var/log/nginx/autodiscover.aeolianchi.me.error.log;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
# nginx 本机公网 IP 直连规则(自动生成,勿手改;由 sync-nginx-local-ips.sh 维护)
|
||||
# 公网 IP 变更后本文件会被重写并 reload nginx
|
||||
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
|
||||
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
|
||||
# 每条规则对应一个本机公网 IP(出站 IP + 节点域名 A 记录)
|
||||
if ($host = "<PUBLIC_IP_1>") { rewrite ^(.*)$ /__local$1 last; }
|
||||
if ($host = "<PUBLIC_IP_2>") { rewrite ^(.*)$ /__local$1 last; }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
load_module /usr/lib/nginx/modules/ngx_http_proxy_connect_module.so;
|
||||
worker_processes auto;
|
||||
worker_rlimit_nofile 65535;
|
||||
events {
|
||||
@@ -17,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;
|
||||
@@ -25,46 +31,15 @@ http {
|
||||
keepalive_timeout 65;
|
||||
keepalive_requests 100;
|
||||
|
||||
# 80 端口:iam.aeolianchi.me 强制跳转到 HTTPS(保留 ACME 验证路径)
|
||||
# <nginx端口>:主域名 HTTPS 站点 + 认证 CONNECT 代理
|
||||
server {
|
||||
listen 80;
|
||||
server_name iam.aeolianchi.me;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/acme;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# 80 端口:默认站点只做静态伪装,不提供代理、不弹认证
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /var/www/html;
|
||||
index index.html;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/acme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
|
||||
# 443 端口:iam.aeolianchi.me HTTPS 站点 + 认证 CONNECT 代理
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen <nginx端口> ssl;
|
||||
http2 on;
|
||||
server_name iam.aeolianchi.me;
|
||||
server_name <节点域名>;
|
||||
root /var/www/html;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/iam.aeolianchi.me/privkey.pem;
|
||||
ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
@@ -75,14 +50,19 @@ http {
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
|
||||
proxy_connect;
|
||||
proxy_connect_allow 443 563;
|
||||
proxy_connect_connect_timeout 60s;
|
||||
proxy_connect_read_timeout 120s;
|
||||
proxy_connect_send_timeout 120s;
|
||||
proxy_connect_allow all;
|
||||
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;
|
||||
if ($host != "iam.aeolianchi.me") {
|
||||
if ($host != "<节点域名>") {
|
||||
rewrite ^ /__proxy last;
|
||||
}
|
||||
index index.html;
|
||||
@@ -105,14 +85,14 @@ http {
|
||||
}
|
||||
}
|
||||
|
||||
# 443 端口 fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
|
||||
# <nginx端口> fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen <nginx端口> ssl default_server;
|
||||
http2 on;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/iam.aeolianchi.me/privkey.pem;
|
||||
ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
@@ -123,10 +103,15 @@ http {
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
|
||||
proxy_connect;
|
||||
proxy_connect_allow 443 563;
|
||||
proxy_connect_connect_timeout 60s;
|
||||
proxy_connect_read_timeout 120s;
|
||||
proxy_connect_send_timeout 120s;
|
||||
proxy_connect_allow all;
|
||||
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;
|
||||
|
||||
@@ -1,647 +0,0 @@
|
||||
function FindProxyForURL(url, host) {
|
||||
// RRPP 规则代理列表 — 仅以下域名走加密隧道
|
||||
var proxy = "PROXY 127.0.0.1:1080";
|
||||
// Google 全系 + Gemini + CDN
|
||||
if (shExpMatch(host, "google.com") ||
|
||||
shExpMatch(host, "*.google.com") ||
|
||||
shExpMatch(host, "google.com.hk") ||
|
||||
shExpMatch(host, "*.google.com.hk") ||
|
||||
shExpMatch(host, "google.co.jp") ||
|
||||
shExpMatch(host, "*.google.co.jp") ||
|
||||
shExpMatch(host, "googleapis.com") ||
|
||||
shExpMatch(host, "*.googleapis.com") ||
|
||||
shExpMatch(host, "googleusercontent.com") ||
|
||||
shExpMatch(host, "*.googleusercontent.com") ||
|
||||
shExpMatch(host, "googletagmanager.com") ||
|
||||
shExpMatch(host, "*.googletagmanager.com") ||
|
||||
shExpMatch(host, "google-analytics.com") ||
|
||||
shExpMatch(host, "*.google-analytics.com") ||
|
||||
shExpMatch(host, "gstatic.com") ||
|
||||
shExpMatch(host, "*.gstatic.com") ||
|
||||
shExpMatch(host, "ggpht.com") ||
|
||||
shExpMatch(host, "*.ggpht.com") ||
|
||||
shExpMatch(host, "googlehosted.com") ||
|
||||
shExpMatch(host, "*.googlehosted.com") ||
|
||||
shExpMatch(host, "1e100.net") ||
|
||||
shExpMatch(host, "*.1e100.net") ||
|
||||
shExpMatch(host, "youtube.com") ||
|
||||
shExpMatch(host, "*.youtube.com") ||
|
||||
shExpMatch(host, "ytimg.com") ||
|
||||
shExpMatch(host, "*.ytimg.com") ||
|
||||
shExpMatch(host, "googlevideo.com") ||
|
||||
shExpMatch(host, "*.googlevideo.com") ||
|
||||
shExpMatch(host, "gemini.google.com") ||
|
||||
shExpMatch(host, "*.gemini.google.com") ||
|
||||
shExpMatch(host, "generativeai.googleapis.com") ||
|
||||
shExpMatch(host, "*.generativeai.googleapis.com") ||
|
||||
shExpMatch(host, "aistudio.google.com") ||
|
||||
shExpMatch(host, "*.aistudio.google.com") ||
|
||||
shExpMatch(host, "deepmind.google.com") ||
|
||||
shExpMatch(host, "*.deepmind.google.com") ||
|
||||
shExpMatch(host, "deepmind.com") ||
|
||||
shExpMatch(host, "*.deepmind.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// OpenAI / ChatGPT 全系 + CDN
|
||||
if (shExpMatch(host, "openai.com") ||
|
||||
shExpMatch(host, "*.openai.com") ||
|
||||
shExpMatch(host, "chatgpt.com") ||
|
||||
shExpMatch(host, "*.chatgpt.com") ||
|
||||
shExpMatch(host, "oaistatic.com") ||
|
||||
shExpMatch(host, "*.oaistatic.com") ||
|
||||
shExpMatch(host, "oaiusercontent.com") ||
|
||||
shExpMatch(host, "*.oaiusercontent.com") ||
|
||||
shExpMatch(host, "openaiusercontent.com") ||
|
||||
shExpMatch(host, "*.openaiusercontent.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Grok / xAI 全系 + CDN
|
||||
if (shExpMatch(host, "grok.com") ||
|
||||
shExpMatch(host, "*.grok.com") ||
|
||||
shExpMatch(host, "x.com") ||
|
||||
shExpMatch(host, "*.x.com") ||
|
||||
shExpMatch(host, "twitter.com") ||
|
||||
shExpMatch(host, "*.twitter.com") ||
|
||||
shExpMatch(host, "twimg.com") ||
|
||||
shExpMatch(host, "*.twimg.com") ||
|
||||
shExpMatch(host, "x.ai") ||
|
||||
shExpMatch(host, "*.x.ai") ||
|
||||
shExpMatch(host, "xai.com") ||
|
||||
shExpMatch(host, "*.xai.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Meta 全系 + CDN
|
||||
if (shExpMatch(host, "meta.com") ||
|
||||
shExpMatch(host, "*.meta.com") ||
|
||||
shExpMatch(host, "meta.ai") ||
|
||||
shExpMatch(host, "*.meta.ai") ||
|
||||
shExpMatch(host, "facebook.com") ||
|
||||
shExpMatch(host, "*.facebook.com") ||
|
||||
shExpMatch(host, "fb.com") ||
|
||||
shExpMatch(host, "*.fb.com") ||
|
||||
shExpMatch(host, "fbcdn.net") ||
|
||||
shExpMatch(host, "*.fbcdn.net") ||
|
||||
shExpMatch(host, "fbsbx.com") ||
|
||||
shExpMatch(host, "*.fbsbx.com") ||
|
||||
shExpMatch(host, "instagram.com") ||
|
||||
shExpMatch(host, "*.instagram.com") ||
|
||||
shExpMatch(host, "cdninstagram.com") ||
|
||||
shExpMatch(host, "*.cdninstagram.com") ||
|
||||
shExpMatch(host, "threads.net") ||
|
||||
shExpMatch(host, "*.threads.net") ||
|
||||
shExpMatch(host, "whatsapp.com") ||
|
||||
shExpMatch(host, "*.whatsapp.com") ||
|
||||
shExpMatch(host, "whatsapp.net") ||
|
||||
shExpMatch(host, "*.whatsapp.net") ||
|
||||
shExpMatch(host, "messenger.com") ||
|
||||
shExpMatch(host, "*.messenger.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Claude / Anthropic 全系 + CDN
|
||||
if (shExpMatch(host, "anthropic.com") ||
|
||||
shExpMatch(host, "*.anthropic.com") ||
|
||||
shExpMatch(host, "claude.ai") ||
|
||||
shExpMatch(host, "*.claude.ai") ||
|
||||
shExpMatch(host, "claudeusercontent.com") ||
|
||||
shExpMatch(host, "*.claudeusercontent.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Dify 全系
|
||||
if (shExpMatch(host, "dify.ai") ||
|
||||
shExpMatch(host, "*.dify.ai") ||
|
||||
shExpMatch(host, "udify.app") ||
|
||||
shExpMatch(host, "*.udify.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Cloudflare 全系
|
||||
if (shExpMatch(host, "cloudflare.com") ||
|
||||
shExpMatch(host, "*.cloudflare.com") ||
|
||||
shExpMatch(host, "cloudflare.net") ||
|
||||
shExpMatch(host, "*.cloudflare.net") ||
|
||||
shExpMatch(host, "cloudflareinsights.com") ||
|
||||
shExpMatch(host, "*.cloudflareinsights.com") ||
|
||||
shExpMatch(host, "cloudflarestatus.com") ||
|
||||
shExpMatch(host, "*.cloudflarestatus.com") ||
|
||||
shExpMatch(host, "cloudflarecdn.com") ||
|
||||
shExpMatch(host, "*.cloudflarecdn.com") ||
|
||||
shExpMatch(host, "cf-cdn.com") ||
|
||||
shExpMatch(host, "*.cf-cdn.com") ||
|
||||
shExpMatch(host, "workers.dev") ||
|
||||
shExpMatch(host, "*.workers.dev") ||
|
||||
shExpMatch(host, "pages.dev") ||
|
||||
shExpMatch(host, "*.pages.dev")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GitHub 全系
|
||||
if (shExpMatch(host, "github.com") ||
|
||||
shExpMatch(host, "*.github.com") ||
|
||||
shExpMatch(host, "githubusercontent.com") ||
|
||||
shExpMatch(host, "*.githubusercontent.com") ||
|
||||
shExpMatch(host, "githubassets.com") ||
|
||||
shExpMatch(host, "*.githubassets.com") ||
|
||||
shExpMatch(host, "github.io") ||
|
||||
shExpMatch(host, "*.github.io") ||
|
||||
shExpMatch(host, "githubcopilot.com") ||
|
||||
shExpMatch(host, "*.githubcopilot.com") ||
|
||||
shExpMatch(host, "ghcr.io") ||
|
||||
shExpMatch(host, "*.ghcr.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Hugging Face 全系 + CDN
|
||||
if (shExpMatch(host, "huggingface.co") ||
|
||||
shExpMatch(host, "*.huggingface.co") ||
|
||||
shExpMatch(host, "hf.co") ||
|
||||
shExpMatch(host, "*.hf.co") ||
|
||||
shExpMatch(host, "huggingfacecdn.com") ||
|
||||
shExpMatch(host, "*.huggingfacecdn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Microsoft Copilot 全系 + Bing + Azure CDN
|
||||
if (shExpMatch(host, "microsoft.com") ||
|
||||
shExpMatch(host, "*.microsoft.com") ||
|
||||
shExpMatch(host, "bing.com") ||
|
||||
shExpMatch(host, "*.bing.com") ||
|
||||
shExpMatch(host, "bingapis.com") ||
|
||||
shExpMatch(host, "*.bingapis.com") ||
|
||||
shExpMatch(host, "bing.net") ||
|
||||
shExpMatch(host, "*.bing.net") ||
|
||||
shExpMatch(host, "live.com") ||
|
||||
shExpMatch(host, "*.live.com") ||
|
||||
shExpMatch(host, "microsoftonline.com") ||
|
||||
shExpMatch(host, "*.microsoftonline.com") ||
|
||||
shExpMatch(host, "microsoft365.com") ||
|
||||
shExpMatch(host, "*.microsoft365.com") ||
|
||||
shExpMatch(host, "office.com") ||
|
||||
shExpMatch(host, "*.office.com") ||
|
||||
shExpMatch(host, "office.net") ||
|
||||
shExpMatch(host, "*.office.net") ||
|
||||
shExpMatch(host, "azure.com") ||
|
||||
shExpMatch(host, "*.azure.com") ||
|
||||
shExpMatch(host, "azureedge.net") ||
|
||||
shExpMatch(host, "*.azureedge.net") ||
|
||||
shExpMatch(host, "azurefd.net") ||
|
||||
shExpMatch(host, "*.azurefd.net") ||
|
||||
shExpMatch(host, "msn.com") ||
|
||||
shExpMatch(host, "*.msn.com") ||
|
||||
shExpMatch(host, "msecnd.net") ||
|
||||
shExpMatch(host, "*.msecnd.net") ||
|
||||
shExpMatch(host, "msftncsi.com") ||
|
||||
shExpMatch(host, "*.msftncsi.com") ||
|
||||
shExpMatch(host, "s-microsoft.com") ||
|
||||
shExpMatch(host, "*.s-microsoft.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Steam 全系 + CDN / 社区 / 下载
|
||||
if (shExpMatch(host, "steampowered.com") ||
|
||||
shExpMatch(host, "*.steampowered.com") ||
|
||||
shExpMatch(host, "steamcommunity.com") ||
|
||||
shExpMatch(host, "*.steamcommunity.com") ||
|
||||
shExpMatch(host, "steam.com") ||
|
||||
shExpMatch(host, "*.steam.com") ||
|
||||
shExpMatch(host, "steamgames.com") ||
|
||||
shExpMatch(host, "*.steamgames.com") ||
|
||||
shExpMatch(host, "steamusercontent.com") ||
|
||||
shExpMatch(host, "*.steamusercontent.com") ||
|
||||
shExpMatch(host, "steamcontent.com") ||
|
||||
shExpMatch(host, "*.steamcontent.com") ||
|
||||
shExpMatch(host, "steamstatic.com") ||
|
||||
shExpMatch(host, "*.steamstatic.com") ||
|
||||
shExpMatch(host, "steam-chat.com") ||
|
||||
shExpMatch(host, "*.steam-chat.com") ||
|
||||
shExpMatch(host, "steam-api.com") ||
|
||||
shExpMatch(host, "*.steam-api.com") ||
|
||||
shExpMatch(host, "steamserver.net") ||
|
||||
shExpMatch(host, "*.steamserver.net") ||
|
||||
shExpMatch(host, "steamcdn-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamcdn-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamcommunity-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamcommunity-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamstore-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamstore-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamuserimages-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamuserimages-a.akamaihd.net") ||
|
||||
shExpMatch(host, "akamaihd.net") ||
|
||||
shExpMatch(host, "*.akamaihd.net") ||
|
||||
shExpMatch(host, "valvesoftware.com") ||
|
||||
shExpMatch(host, "*.valvesoftware.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Discord 全系
|
||||
if (shExpMatch(host, "discord.com") ||
|
||||
shExpMatch(host, "*.discord.com") ||
|
||||
shExpMatch(host, "discordapp.com") ||
|
||||
shExpMatch(host, "*.discordapp.com") ||
|
||||
shExpMatch(host, "discord.gg") ||
|
||||
shExpMatch(host, "*.discord.gg") ||
|
||||
shExpMatch(host, "discord.media") ||
|
||||
shExpMatch(host, "*.discord.media") ||
|
||||
shExpMatch(host, "discordapp.net") ||
|
||||
shExpMatch(host, "*.discordapp.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Telegram 全系
|
||||
if (shExpMatch(host, "telegram.org") ||
|
||||
shExpMatch(host, "*.telegram.org") ||
|
||||
shExpMatch(host, "t.me") ||
|
||||
shExpMatch(host, "*.t.me") ||
|
||||
shExpMatch(host, "telega.one") ||
|
||||
shExpMatch(host, "*.telega.one") ||
|
||||
shExpMatch(host, "telegram-cdn.org") ||
|
||||
shExpMatch(host, "*.telegram-cdn.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Reddit 全系
|
||||
if (shExpMatch(host, "reddit.com") ||
|
||||
shExpMatch(host, "*.reddit.com") ||
|
||||
shExpMatch(host, "redd.it") ||
|
||||
shExpMatch(host, "*.redd.it") ||
|
||||
shExpMatch(host, "redditmedia.com") ||
|
||||
shExpMatch(host, "*.redditmedia.com") ||
|
||||
shExpMatch(host, "redditstatic.com") ||
|
||||
shExpMatch(host, "*.redditstatic.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// LINE 全系
|
||||
if (shExpMatch(host, "line.me") ||
|
||||
shExpMatch(host, "*.line.me") ||
|
||||
shExpMatch(host, "line-apps.com") ||
|
||||
shExpMatch(host, "*.line-apps.com") ||
|
||||
shExpMatch(host, "line-scdn.net") ||
|
||||
shExpMatch(host, "*.line-scdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Spotify 全系
|
||||
if (shExpMatch(host, "spotify.com") ||
|
||||
shExpMatch(host, "*.spotify.com") ||
|
||||
shExpMatch(host, "spotifycdn.com") ||
|
||||
shExpMatch(host, "*.spotifycdn.com") ||
|
||||
shExpMatch(host, "scdn.co") ||
|
||||
shExpMatch(host, "*.scdn.co")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Netflix 全系
|
||||
if (shExpMatch(host, "netflix.com") ||
|
||||
shExpMatch(host, "*.netflix.com") ||
|
||||
shExpMatch(host, "nflxvideo.net") ||
|
||||
shExpMatch(host, "*.nflxvideo.net") ||
|
||||
shExpMatch(host, "nflximg.net") ||
|
||||
shExpMatch(host, "*.nflximg.net") ||
|
||||
shExpMatch(host, "nflxext.com") ||
|
||||
shExpMatch(host, "*.nflxext.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Disney+ 全系
|
||||
if (shExpMatch(host, "disneyplus.com") ||
|
||||
shExpMatch(host, "*.disneyplus.com") ||
|
||||
shExpMatch(host, "disney-plus.net") ||
|
||||
shExpMatch(host, "*.disney-plus.net") ||
|
||||
shExpMatch(host, "dssott.com") ||
|
||||
shExpMatch(host, "*.dssott.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// TikTok 全系
|
||||
if (shExpMatch(host, "tiktok.com") ||
|
||||
shExpMatch(host, "*.tiktok.com") ||
|
||||
shExpMatch(host, "tiktokcdn.com") ||
|
||||
shExpMatch(host, "*.tiktokcdn.com") ||
|
||||
shExpMatch(host, "tiktokv.com") ||
|
||||
shExpMatch(host, "*.tiktokv.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// PlayStation 全系
|
||||
if (shExpMatch(host, "playstation.com") ||
|
||||
shExpMatch(host, "*.playstation.com") ||
|
||||
shExpMatch(host, "sonyentertainmentnetwork.com") ||
|
||||
shExpMatch(host, "*.sonyentertainmentnetwork.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Nintendo 全系
|
||||
if (shExpMatch(host, "nintendo.com") ||
|
||||
shExpMatch(host, "*.nintendo.com") ||
|
||||
shExpMatch(host, "nintendowifi.net") ||
|
||||
shExpMatch(host, "*.nintendowifi.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Epic Games 全系
|
||||
if (shExpMatch(host, "epicgames.com") ||
|
||||
shExpMatch(host, "*.epicgames.com") ||
|
||||
shExpMatch(host, "unrealengine.com") ||
|
||||
shExpMatch(host, "*.unrealengine.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// DuckDuckGo / Brave Search
|
||||
if (shExpMatch(host, "duckduckgo.com") ||
|
||||
shExpMatch(host, "*.duckduckgo.com") ||
|
||||
shExpMatch(host, "search.brave.com") ||
|
||||
shExpMatch(host, "*.search.brave.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Proton 全系
|
||||
if (shExpMatch(host, "proton.me") ||
|
||||
shExpMatch(host, "*.proton.me") ||
|
||||
shExpMatch(host, "protonmail.com") ||
|
||||
shExpMatch(host, "*.protonmail.com") ||
|
||||
shExpMatch(host, "protonvpn.com") ||
|
||||
shExpMatch(host, "*.protonvpn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Stack Overflow / Stack Exchange 全系
|
||||
if (shExpMatch(host, "stackoverflow.com") ||
|
||||
shExpMatch(host, "*.stackoverflow.com") ||
|
||||
shExpMatch(host, "stackexchange.com") ||
|
||||
shExpMatch(host, "*.stackexchange.com") ||
|
||||
shExpMatch(host, "superuser.com") ||
|
||||
shExpMatch(host, "*.superuser.com") ||
|
||||
shExpMatch(host, "serverfault.com") ||
|
||||
shExpMatch(host, "*.serverfault.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Quora / Medium
|
||||
if (shExpMatch(host, "quora.com") ||
|
||||
shExpMatch(host, "*.quora.com") ||
|
||||
shExpMatch(host, "medium.com") ||
|
||||
shExpMatch(host, "*.medium.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Pixiv 全系
|
||||
if (shExpMatch(host, "pixiv.net") ||
|
||||
shExpMatch(host, "*.pixiv.net") ||
|
||||
shExpMatch(host, "pximg.net") ||
|
||||
shExpMatch(host, "*.pximg.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// ニコニコ 全系
|
||||
if (shExpMatch(host, "nicovideo.jp") ||
|
||||
shExpMatch(host, "*.nicovideo.jp") ||
|
||||
shExpMatch(host, "nico.ms") ||
|
||||
shExpMatch(host, "*.nico.ms")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// EA / Origin / Apex 全系
|
||||
if (shExpMatch(host, "ea.com") ||
|
||||
shExpMatch(host, "*.ea.com") ||
|
||||
shExpMatch(host, "origin.com") ||
|
||||
shExpMatch(host, "*.origin.com") ||
|
||||
shExpMatch(host, "eaassets.com") ||
|
||||
shExpMatch(host, "*.eaassets.com") ||
|
||||
shExpMatch(host, "apexlegends.com") ||
|
||||
shExpMatch(host, "*.apexlegends.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Battle.net / Blizzard 全系
|
||||
if (shExpMatch(host, "battle.net") ||
|
||||
shExpMatch(host, "*.battle.net") ||
|
||||
shExpMatch(host, "blizzard.com") ||
|
||||
shExpMatch(host, "*.blizzard.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Ubisoft 全系
|
||||
if (shExpMatch(host, "ubisoft.com") ||
|
||||
shExpMatch(host, "*.ubisoft.com") ||
|
||||
shExpMatch(host, "ubi.com") ||
|
||||
shExpMatch(host, "*.ubi.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Riot Games / 英雄联盟 / Valorant 全系
|
||||
if (shExpMatch(host, "riotgames.com") ||
|
||||
shExpMatch(host, "*.riotgames.com") ||
|
||||
shExpMatch(host, "leagueoflegends.com") ||
|
||||
shExpMatch(host, "*.leagueoflegends.com") ||
|
||||
shExpMatch(host, "valorant.com") ||
|
||||
shExpMatch(host, "*.valorant.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GOG / itch.io 全系
|
||||
if (shExpMatch(host, "gog.com") ||
|
||||
shExpMatch(host, "*.gog.com") ||
|
||||
shExpMatch(host, "itch.io") ||
|
||||
shExpMatch(host, "*.itch.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Twitch 全系
|
||||
if (shExpMatch(host, "twitch.tv") ||
|
||||
shExpMatch(host, "*.twitch.tv") ||
|
||||
shExpMatch(host, "ttvnw.net") ||
|
||||
shExpMatch(host, "*.ttvnw.net") ||
|
||||
shExpMatch(host, "twitchcdn.net") ||
|
||||
shExpMatch(host, "*.twitchcdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Crunchyroll 全系
|
||||
if (shExpMatch(host, "crunchyroll.com") ||
|
||||
shExpMatch(host, "*.crunchyroll.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Amazon Prime Video 全系
|
||||
if (shExpMatch(host, "primevideo.com") ||
|
||||
shExpMatch(host, "*.primevideo.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Apple TV+ 全系
|
||||
if (shExpMatch(host, "tv.apple.com") ||
|
||||
shExpMatch(host, "tvplus.apple.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// HBO Max / Hulu / Paramount+ 全系
|
||||
if (shExpMatch(host, "max.com") ||
|
||||
shExpMatch(host, "*.max.com") ||
|
||||
shExpMatch(host, "hulu.com") ||
|
||||
shExpMatch(host, "*.hulu.com") ||
|
||||
shExpMatch(host, "paramountplus.com") ||
|
||||
shExpMatch(host, "*.paramountplus.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Slack 全系
|
||||
if (shExpMatch(host, "slack.com") ||
|
||||
shExpMatch(host, "*.slack.com") ||
|
||||
shExpMatch(host, "slack-edge.com") ||
|
||||
shExpMatch(host, "*.slack-edge.com") ||
|
||||
shExpMatch(host, "slack-imgs.com") ||
|
||||
shExpMatch(host, "*.slack-imgs.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Zoom 全系
|
||||
if (shExpMatch(host, "zoom.us") ||
|
||||
shExpMatch(host, "*.zoom.us") ||
|
||||
shExpMatch(host, "zoom.com") ||
|
||||
shExpMatch(host, "*.zoom.com") ||
|
||||
shExpMatch(host, "zoomgov.com") ||
|
||||
shExpMatch(host, "*.zoomgov.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Signal 全系
|
||||
if (shExpMatch(host, "signal.org") ||
|
||||
shExpMatch(host, "*.signal.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Docker 全系
|
||||
if (shExpMatch(host, "docker.com") ||
|
||||
shExpMatch(host, "*.docker.com") ||
|
||||
shExpMatch(host, "docker.io") ||
|
||||
shExpMatch(host, "*.docker.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GitLab 全系
|
||||
if (shExpMatch(host, "gitlab.com") ||
|
||||
shExpMatch(host, "*.gitlab.com") ||
|
||||
shExpMatch(host, "gitlab.io") ||
|
||||
shExpMatch(host, "*.gitlab.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Vercel 全系
|
||||
if (shExpMatch(host, "vercel.com") ||
|
||||
shExpMatch(host, "*.vercel.com") ||
|
||||
shExpMatch(host, "vercel.app") ||
|
||||
shExpMatch(host, "*.vercel.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Netlify 全系
|
||||
if (shExpMatch(host, "netlify.com") ||
|
||||
shExpMatch(host, "*.netlify.com") ||
|
||||
shExpMatch(host, "netlify.app") ||
|
||||
shExpMatch(host, "*.netlify.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Perplexity / Poe / Midjourney 全系
|
||||
if (shExpMatch(host, "perplexity.ai") ||
|
||||
shExpMatch(host, "*.perplexity.ai") ||
|
||||
shExpMatch(host, "poe.com") ||
|
||||
shExpMatch(host, "*.poe.com") ||
|
||||
shExpMatch(host, "midjourney.com") ||
|
||||
shExpMatch(host, "*.midjourney.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Figma / Canva 全系
|
||||
if (shExpMatch(host, "figma.com") ||
|
||||
shExpMatch(host, "*.figma.com") ||
|
||||
shExpMatch(host, "canva.com") ||
|
||||
shExpMatch(host, "*.canva.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// LinkedIn 全系
|
||||
if (shExpMatch(host, "linkedin.com") ||
|
||||
shExpMatch(host, "*.linkedin.com") ||
|
||||
shExpMatch(host, "licdn.com") ||
|
||||
shExpMatch(host, "*.licdn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Pinterest 全系
|
||||
if (shExpMatch(host, "pinterest.com") ||
|
||||
shExpMatch(host, "*.pinterest.com") ||
|
||||
shExpMatch(host, "pinimg.com") ||
|
||||
shExpMatch(host, "*.pinimg.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Tumblr 全系
|
||||
if (shExpMatch(host, "tumblr.com") ||
|
||||
shExpMatch(host, "*.tumblr.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Snapchat 全系
|
||||
if (shExpMatch(host, "snapchat.com") ||
|
||||
shExpMatch(host, "*.snapchat.com") ||
|
||||
shExpMatch(host, "sc-cdn.net") ||
|
||||
shExpMatch(host, "*.sc-cdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Wikipedia / Wikimedia 全系
|
||||
if (shExpMatch(host, "wikipedia.org") ||
|
||||
shExpMatch(host, "*.wikipedia.org") ||
|
||||
shExpMatch(host, "wikimedia.org") ||
|
||||
shExpMatch(host, "*.wikimedia.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// PayPal 全系
|
||||
if (shExpMatch(host, "paypal.com") ||
|
||||
shExpMatch(host, "*.paypal.com") ||
|
||||
shExpMatch(host, "paypalobjects.com") ||
|
||||
shExpMatch(host, "*.paypalobjects.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Patreon 全系
|
||||
if (shExpMatch(host, "patreon.com") ||
|
||||
shExpMatch(host, "*.patreon.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// 通用 CDN
|
||||
if (shExpMatch(host, "cloudfront.net") ||
|
||||
shExpMatch(host, "*.cloudfront.net") ||
|
||||
shExpMatch(host, "fastly.net") ||
|
||||
shExpMatch(host, "*.fastly.net") ||
|
||||
shExpMatch(host, "fastlylb.net") ||
|
||||
shExpMatch(host, "*.fastlylb.net") ||
|
||||
shExpMatch(host, "jsdelivr.net") ||
|
||||
shExpMatch(host, "*.jsdelivr.net") ||
|
||||
shExpMatch(host, "unpkg.com") ||
|
||||
shExpMatch(host, "*.unpkg.com") ||
|
||||
shExpMatch(host, "statically.io") ||
|
||||
shExpMatch(host, "*.statically.io") ||
|
||||
shExpMatch(host, "cloudinary.com") ||
|
||||
shExpMatch(host, "*.cloudinary.com") ||
|
||||
shExpMatch(host, "imgur.com") ||
|
||||
shExpMatch(host, "*.imgur.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// linux.do
|
||||
if (shExpMatch(host, "linux.do") ||
|
||||
shExpMatch(host, "*.linux.do")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
return "DIRECT";
|
||||
}
|
||||
@@ -1,648 +0,0 @@
|
||||
function FindProxyForURL(url, host) {
|
||||
// RRPP 规则代理列表 — 仅以下域名走加密隧道
|
||||
var proxy = "PROXY 127.0.0.1:1080";
|
||||
// Google 全系 + Gemini + CDN
|
||||
if (shExpMatch(host, "google.com") ||
|
||||
shExpMatch(host, "*.google.com") ||
|
||||
shExpMatch(host, "google.com.hk") ||
|
||||
shExpMatch(host, "*.google.com.hk") ||
|
||||
shExpMatch(host, "google.co.jp") ||
|
||||
shExpMatch(host, "*.google.co.jp") ||
|
||||
shExpMatch(host, "googleapis.com") ||
|
||||
shExpMatch(host, "*.googleapis.com") ||
|
||||
shExpMatch(host, "googleusercontent.com") ||
|
||||
shExpMatch(host, "*.googleusercontent.com") ||
|
||||
shExpMatch(host, "googletagmanager.com") ||
|
||||
shExpMatch(host, "*.googletagmanager.com") ||
|
||||
shExpMatch(host, "google-analytics.com") ||
|
||||
shExpMatch(host, "*.google-analytics.com") ||
|
||||
shExpMatch(host, "gstatic.com") ||
|
||||
shExpMatch(host, "*.gstatic.com") ||
|
||||
shExpMatch(host, "ggpht.com") ||
|
||||
shExpMatch(host, "*.ggpht.com") ||
|
||||
shExpMatch(host, "googlehosted.com") ||
|
||||
shExpMatch(host, "*.googlehosted.com") ||
|
||||
shExpMatch(host, "1e100.net") ||
|
||||
shExpMatch(host, "*.1e100.net") ||
|
||||
shExpMatch(host, "youtube.com") ||
|
||||
shExpMatch(host, "*.youtube.com") ||
|
||||
shExpMatch(host, "ytimg.com") ||
|
||||
shExpMatch(host, "*.ytimg.com") ||
|
||||
shExpMatch(host, "googlevideo.com") ||
|
||||
shExpMatch(host, "*.googlevideo.com") ||
|
||||
shExpMatch(host, "gemini.google.com") ||
|
||||
shExpMatch(host, "*.gemini.google.com") ||
|
||||
shExpMatch(host, "generativeai.googleapis.com") ||
|
||||
shExpMatch(host, "*.generativeai.googleapis.com") ||
|
||||
shExpMatch(host, "aistudio.google.com") ||
|
||||
shExpMatch(host, "*.aistudio.google.com") ||
|
||||
shExpMatch(host, "deepmind.google.com") ||
|
||||
shExpMatch(host, "*.deepmind.google.com") ||
|
||||
shExpMatch(host, "deepmind.com") ||
|
||||
shExpMatch(host, "*.deepmind.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// OpenAI / ChatGPT 全系 + CDN
|
||||
if (shExpMatch(host, "openai.com") ||
|
||||
shExpMatch(host, "*.openai.com") ||
|
||||
shExpMatch(host, "chatgpt.com") ||
|
||||
shExpMatch(host, "*.chatgpt.com") ||
|
||||
shExpMatch(host, "oaistatic.com") ||
|
||||
shExpMatch(host, "*.oaistatic.com") ||
|
||||
shExpMatch(host, "oaiusercontent.com") ||
|
||||
shExpMatch(host, "*.oaiusercontent.com") ||
|
||||
shExpMatch(host, "openaiusercontent.com") ||
|
||||
shExpMatch(host, "*.openaiusercontent.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Grok / xAI 全系 + CDN
|
||||
if (shExpMatch(host, "grok.com") ||
|
||||
shExpMatch(host, "*.grok.com") ||
|
||||
shExpMatch(host, "x.com") ||
|
||||
shExpMatch(host, "*.x.com") ||
|
||||
shExpMatch(host, "twitter.com") ||
|
||||
shExpMatch(host, "*.twitter.com") ||
|
||||
shExpMatch(host, "twimg.com") ||
|
||||
shExpMatch(host, "*.twimg.com") ||
|
||||
shExpMatch(host, "x.ai") ||
|
||||
shExpMatch(host, "*.x.ai") ||
|
||||
shExpMatch(host, "xai.com") ||
|
||||
shExpMatch(host, "*.xai.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Meta 全系 + CDN
|
||||
if (shExpMatch(host, "meta.com") ||
|
||||
shExpMatch(host, "*.meta.com") ||
|
||||
shExpMatch(host, "meta.ai") ||
|
||||
shExpMatch(host, "*.meta.ai") ||
|
||||
shExpMatch(host, "facebook.com") ||
|
||||
shExpMatch(host, "*.facebook.com") ||
|
||||
shExpMatch(host, "fb.com") ||
|
||||
shExpMatch(host, "*.fb.com") ||
|
||||
shExpMatch(host, "fbcdn.net") ||
|
||||
shExpMatch(host, "*.fbcdn.net") ||
|
||||
shExpMatch(host, "fbsbx.com") ||
|
||||
shExpMatch(host, "*.fbsbx.com") ||
|
||||
shExpMatch(host, "instagram.com") ||
|
||||
shExpMatch(host, "*.instagram.com") ||
|
||||
shExpMatch(host, "cdninstagram.com") ||
|
||||
shExpMatch(host, "*.cdninstagram.com") ||
|
||||
shExpMatch(host, "threads.net") ||
|
||||
shExpMatch(host, "*.threads.net") ||
|
||||
shExpMatch(host, "whatsapp.com") ||
|
||||
shExpMatch(host, "*.whatsapp.com") ||
|
||||
shExpMatch(host, "whatsapp.net") ||
|
||||
shExpMatch(host, "*.whatsapp.net") ||
|
||||
shExpMatch(host, "messenger.com") ||
|
||||
shExpMatch(host, "*.messenger.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Claude / Anthropic 全系 + CDN
|
||||
if (shExpMatch(host, "anthropic.com") ||
|
||||
shExpMatch(host, "*.anthropic.com") ||
|
||||
shExpMatch(host, "claude.ai") ||
|
||||
shExpMatch(host, "*.claude.ai") ||
|
||||
shExpMatch(host, "claudeusercontent.com") ||
|
||||
shExpMatch(host, "*.claudeusercontent.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Dify 全系
|
||||
if (shExpMatch(host, "dify.ai") ||
|
||||
shExpMatch(host, "*.dify.ai") ||
|
||||
shExpMatch(host, "udify.app") ||
|
||||
shExpMatch(host, "*.udify.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Cloudflare 全系
|
||||
if (shExpMatch(host, "cloudflare.com") ||
|
||||
shExpMatch(host, "*.cloudflare.com") ||
|
||||
shExpMatch(host, "cloudflare.net") ||
|
||||
shExpMatch(host, "*.cloudflare.net") ||
|
||||
shExpMatch(host, "cloudflareinsights.com") ||
|
||||
shExpMatch(host, "*.cloudflareinsights.com") ||
|
||||
shExpMatch(host, "cloudflarestatus.com") ||
|
||||
shExpMatch(host, "*.cloudflarestatus.com") ||
|
||||
shExpMatch(host, "cloudflarecdn.com") ||
|
||||
shExpMatch(host, "*.cloudflarecdn.com") ||
|
||||
shExpMatch(host, "cf-cdn.com") ||
|
||||
shExpMatch(host, "*.cf-cdn.com") ||
|
||||
shExpMatch(host, "workers.dev") ||
|
||||
shExpMatch(host, "*.workers.dev") ||
|
||||
shExpMatch(host, "pages.dev") ||
|
||||
shExpMatch(host, "*.pages.dev")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GitHub 全系
|
||||
if (shExpMatch(host, "github.com") ||
|
||||
shExpMatch(host, "*.github.com") ||
|
||||
shExpMatch(host, "githubusercontent.com") ||
|
||||
shExpMatch(host, "*.githubusercontent.com") ||
|
||||
shExpMatch(host, "githubassets.com") ||
|
||||
shExpMatch(host, "*.githubassets.com") ||
|
||||
shExpMatch(host, "github.io") ||
|
||||
shExpMatch(host, "*.github.io") ||
|
||||
shExpMatch(host, "githubcopilot.com") ||
|
||||
shExpMatch(host, "*.githubcopilot.com") ||
|
||||
shExpMatch(host, "ghcr.io") ||
|
||||
shExpMatch(host, "*.ghcr.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Hugging Face 全系 + CDN
|
||||
if (shExpMatch(host, "huggingface.co") ||
|
||||
shExpMatch(host, "*.huggingface.co") ||
|
||||
shExpMatch(host, "hf.co") ||
|
||||
shExpMatch(host, "*.hf.co") ||
|
||||
shExpMatch(host, "huggingfacecdn.com") ||
|
||||
shExpMatch(host, "*.huggingfacecdn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Microsoft Copilot 全系 + Bing + Azure CDN
|
||||
if (shExpMatch(host, "microsoft.com") ||
|
||||
shExpMatch(host, "*.microsoft.com") ||
|
||||
shExpMatch(host, "bing.com") ||
|
||||
shExpMatch(host, "*.bing.com") ||
|
||||
shExpMatch(host, "bingapis.com") ||
|
||||
shExpMatch(host, "*.bingapis.com") ||
|
||||
shExpMatch(host, "bing.net") ||
|
||||
shExpMatch(host, "*.bing.net") ||
|
||||
shExpMatch(host, "live.com") ||
|
||||
shExpMatch(host, "*.live.com") ||
|
||||
shExpMatch(host, "microsoftonline.com") ||
|
||||
shExpMatch(host, "*.microsoftonline.com") ||
|
||||
shExpMatch(host, "microsoft365.com") ||
|
||||
shExpMatch(host, "*.microsoft365.com") ||
|
||||
shExpMatch(host, "office.com") ||
|
||||
shExpMatch(host, "*.office.com") ||
|
||||
shExpMatch(host, "office.net") ||
|
||||
shExpMatch(host, "*.office.net") ||
|
||||
shExpMatch(host, "azure.com") ||
|
||||
shExpMatch(host, "*.azure.com") ||
|
||||
shExpMatch(host, "azureedge.net") ||
|
||||
shExpMatch(host, "*.azureedge.net") ||
|
||||
shExpMatch(host, "azurefd.net") ||
|
||||
shExpMatch(host, "*.azurefd.net") ||
|
||||
shExpMatch(host, "msn.com") ||
|
||||
shExpMatch(host, "*.msn.com") ||
|
||||
shExpMatch(host, "msecnd.net") ||
|
||||
shExpMatch(host, "*.msecnd.net") ||
|
||||
shExpMatch(host, "msftncsi.com") ||
|
||||
shExpMatch(host, "*.msftncsi.com") ||
|
||||
shExpMatch(host, "s-microsoft.com") ||
|
||||
shExpMatch(host, "*.s-microsoft.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Steam 全系 + CDN / 社区 / 下载
|
||||
if (shExpMatch(host, "steampowered.com") ||
|
||||
shExpMatch(host, "*.steampowered.com") ||
|
||||
shExpMatch(host, "steamcommunity.com") ||
|
||||
shExpMatch(host, "*.steamcommunity.com") ||
|
||||
shExpMatch(host, "steam.com") ||
|
||||
shExpMatch(host, "*.steam.com") ||
|
||||
shExpMatch(host, "steamgames.com") ||
|
||||
shExpMatch(host, "*.steamgames.com") ||
|
||||
shExpMatch(host, "steamusercontent.com") ||
|
||||
shExpMatch(host, "*.steamusercontent.com") ||
|
||||
shExpMatch(host, "steamcontent.com") ||
|
||||
shExpMatch(host, "*.steamcontent.com") ||
|
||||
shExpMatch(host, "steamstatic.com") ||
|
||||
shExpMatch(host, "*.steamstatic.com") ||
|
||||
shExpMatch(host, "steam-chat.com") ||
|
||||
shExpMatch(host, "*.steam-chat.com") ||
|
||||
shExpMatch(host, "steam-api.com") ||
|
||||
shExpMatch(host, "*.steam-api.com") ||
|
||||
shExpMatch(host, "steamserver.net") ||
|
||||
shExpMatch(host, "*.steamserver.net") ||
|
||||
shExpMatch(host, "steamcdn-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamcdn-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamcommunity-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamcommunity-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamstore-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamstore-a.akamaihd.net") ||
|
||||
shExpMatch(host, "steamuserimages-a.akamaihd.net") ||
|
||||
shExpMatch(host, "*.steamuserimages-a.akamaihd.net") ||
|
||||
shExpMatch(host, "akamaihd.net") ||
|
||||
shExpMatch(host, "*.akamaihd.net") ||
|
||||
shExpMatch(host, "valvesoftware.com") ||
|
||||
shExpMatch(host, "*.valvesoftware.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Discord 全系
|
||||
if (shExpMatch(host, "discord.com") ||
|
||||
shExpMatch(host, "*.discord.com") ||
|
||||
shExpMatch(host, "discordapp.com") ||
|
||||
shExpMatch(host, "*.discordapp.com") ||
|
||||
shExpMatch(host, "discord.gg") ||
|
||||
shExpMatch(host, "*.discord.gg") ||
|
||||
shExpMatch(host, "discord.media") ||
|
||||
shExpMatch(host, "*.discord.media") ||
|
||||
shExpMatch(host, "discordapp.net") ||
|
||||
shExpMatch(host, "*.discordapp.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Telegram 全系
|
||||
if (shExpMatch(host, "telegram.org") ||
|
||||
shExpMatch(host, "*.telegram.org") ||
|
||||
shExpMatch(host, "t.me") ||
|
||||
shExpMatch(host, "*.t.me") ||
|
||||
shExpMatch(host, "telega.one") ||
|
||||
shExpMatch(host, "*.telega.one") ||
|
||||
shExpMatch(host, "telegram-cdn.org") ||
|
||||
shExpMatch(host, "*.telegram-cdn.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Reddit 全系
|
||||
if (shExpMatch(host, "reddit.com") ||
|
||||
shExpMatch(host, "*.reddit.com") ||
|
||||
shExpMatch(host, "redd.it") ||
|
||||
shExpMatch(host, "*.redd.it") ||
|
||||
shExpMatch(host, "redditmedia.com") ||
|
||||
shExpMatch(host, "*.redditmedia.com") ||
|
||||
shExpMatch(host, "redditstatic.com") ||
|
||||
shExpMatch(host, "*.redditstatic.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// LINE 全系
|
||||
if (shExpMatch(host, "line.me") ||
|
||||
shExpMatch(host, "*.line.me") ||
|
||||
shExpMatch(host, "line-apps.com") ||
|
||||
shExpMatch(host, "*.line-apps.com") ||
|
||||
shExpMatch(host, "line-scdn.net") ||
|
||||
shExpMatch(host, "*.line-scdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Spotify 全系
|
||||
if (shExpMatch(host, "spotify.com") ||
|
||||
shExpMatch(host, "*.spotify.com") ||
|
||||
shExpMatch(host, "spotifycdn.com") ||
|
||||
shExpMatch(host, "*.spotifycdn.com") ||
|
||||
shExpMatch(host, "scdn.co") ||
|
||||
shExpMatch(host, "*.scdn.co")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Netflix 全系
|
||||
if (shExpMatch(host, "netflix.com") ||
|
||||
shExpMatch(host, "*.netflix.com") ||
|
||||
shExpMatch(host, "nflxvideo.net") ||
|
||||
shExpMatch(host, "*.nflxvideo.net") ||
|
||||
shExpMatch(host, "nflximg.net") ||
|
||||
shExpMatch(host, "*.nflximg.net") ||
|
||||
shExpMatch(host, "nflxext.com") ||
|
||||
shExpMatch(host, "*.nflxext.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Disney+ 全系
|
||||
if (shExpMatch(host, "disneyplus.com") ||
|
||||
shExpMatch(host, "*.disneyplus.com") ||
|
||||
shExpMatch(host, "disney-plus.net") ||
|
||||
shExpMatch(host, "*.disney-plus.net") ||
|
||||
shExpMatch(host, "dssott.com") ||
|
||||
shExpMatch(host, "*.dssott.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// TikTok 全系
|
||||
if (shExpMatch(host, "tiktok.com") ||
|
||||
shExpMatch(host, "*.tiktok.com") ||
|
||||
shExpMatch(host, "tiktokcdn.com") ||
|
||||
shExpMatch(host, "*.tiktokcdn.com") ||
|
||||
shExpMatch(host, "tiktokv.com") ||
|
||||
shExpMatch(host, "*.tiktokv.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// PlayStation 全系
|
||||
if (shExpMatch(host, "playstation.com") ||
|
||||
shExpMatch(host, "*.playstation.com") ||
|
||||
shExpMatch(host, "sonyentertainmentnetwork.com") ||
|
||||
shExpMatch(host, "*.sonyentertainmentnetwork.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Nintendo 全系
|
||||
if (shExpMatch(host, "nintendo.com") ||
|
||||
shExpMatch(host, "*.nintendo.com") ||
|
||||
shExpMatch(host, "nintendowifi.net") ||
|
||||
shExpMatch(host, "*.nintendowifi.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Epic Games 全系
|
||||
if (shExpMatch(host, "epicgames.com") ||
|
||||
shExpMatch(host, "*.epicgames.com") ||
|
||||
shExpMatch(host, "unrealengine.com") ||
|
||||
shExpMatch(host, "*.unrealengine.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// DuckDuckGo / Brave Search
|
||||
if (shExpMatch(host, "duckduckgo.com") ||
|
||||
shExpMatch(host, "*.duckduckgo.com") ||
|
||||
shExpMatch(host, "search.brave.com") ||
|
||||
shExpMatch(host, "*.search.brave.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Proton 全系
|
||||
if (shExpMatch(host, "proton.me") ||
|
||||
shExpMatch(host, "*.proton.me") ||
|
||||
shExpMatch(host, "protonmail.com") ||
|
||||
shExpMatch(host, "*.protonmail.com") ||
|
||||
shExpMatch(host, "protonvpn.com") ||
|
||||
shExpMatch(host, "*.protonvpn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Stack Overflow / Stack Exchange 全系
|
||||
if (shExpMatch(host, "stackoverflow.com") ||
|
||||
shExpMatch(host, "*.stackoverflow.com") ||
|
||||
shExpMatch(host, "stackexchange.com") ||
|
||||
shExpMatch(host, "*.stackexchange.com") ||
|
||||
shExpMatch(host, "superuser.com") ||
|
||||
shExpMatch(host, "*.superuser.com") ||
|
||||
shExpMatch(host, "serverfault.com") ||
|
||||
shExpMatch(host, "*.serverfault.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Quora / Medium
|
||||
if (shExpMatch(host, "quora.com") ||
|
||||
shExpMatch(host, "*.quora.com") ||
|
||||
shExpMatch(host, "medium.com") ||
|
||||
shExpMatch(host, "*.medium.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Pixiv 全系
|
||||
if (shExpMatch(host, "pixiv.net") ||
|
||||
shExpMatch(host, "*.pixiv.net") ||
|
||||
shExpMatch(host, "pximg.net") ||
|
||||
shExpMatch(host, "*.pximg.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// ニコニコ 全系
|
||||
if (shExpMatch(host, "nicovideo.jp") ||
|
||||
shExpMatch(host, "*.nicovideo.jp") ||
|
||||
shExpMatch(host, "nico.ms") ||
|
||||
shExpMatch(host, "*.nico.ms")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// EA / Origin / Apex 全系
|
||||
if (shExpMatch(host, "ea.com") ||
|
||||
shExpMatch(host, "*.ea.com") ||
|
||||
shExpMatch(host, "origin.com") ||
|
||||
shExpMatch(host, "*.origin.com") ||
|
||||
shExpMatch(host, "eaassets.com") ||
|
||||
shExpMatch(host, "*.eaassets.com") ||
|
||||
shExpMatch(host, "apexlegends.com") ||
|
||||
shExpMatch(host, "*.apexlegends.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Battle.net / Blizzard 全系
|
||||
if (shExpMatch(host, "battle.net") ||
|
||||
shExpMatch(host, "*.battle.net") ||
|
||||
shExpMatch(host, "blizzard.com") ||
|
||||
shExpMatch(host, "*.blizzard.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Ubisoft 全系
|
||||
if (shExpMatch(host, "ubisoft.com") ||
|
||||
shExpMatch(host, "*.ubisoft.com") ||
|
||||
shExpMatch(host, "ubi.com") ||
|
||||
shExpMatch(host, "*.ubi.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Riot Games / 英雄联盟 / Valorant 全系
|
||||
if (shExpMatch(host, "riotgames.com") ||
|
||||
shExpMatch(host, "*.riotgames.com") ||
|
||||
shExpMatch(host, "leagueoflegends.com") ||
|
||||
shExpMatch(host, "*.leagueoflegends.com") ||
|
||||
shExpMatch(host, "valorant.com") ||
|
||||
shExpMatch(host, "*.valorant.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GOG / itch.io 全系
|
||||
if (shExpMatch(host, "gog.com") ||
|
||||
shExpMatch(host, "*.gog.com") ||
|
||||
shExpMatch(host, "itch.io") ||
|
||||
shExpMatch(host, "*.itch.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Twitch 全系
|
||||
if (shExpMatch(host, "twitch.tv") ||
|
||||
shExpMatch(host, "*.twitch.tv") ||
|
||||
shExpMatch(host, "ttvnw.net") ||
|
||||
shExpMatch(host, "*.ttvnw.net") ||
|
||||
shExpMatch(host, "twitchcdn.net") ||
|
||||
shExpMatch(host, "*.twitchcdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Crunchyroll 全系
|
||||
if (shExpMatch(host, "crunchyroll.com") ||
|
||||
shExpMatch(host, "*.crunchyroll.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Amazon Prime Video 全系
|
||||
if (shExpMatch(host, "primevideo.com") ||
|
||||
shExpMatch(host, "*.primevideo.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Apple TV+ 全系
|
||||
if (shExpMatch(host, "tv.apple.com") ||
|
||||
shExpMatch(host, "tvplus.apple.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// HBO Max / Hulu / Paramount+ 全系
|
||||
if (shExpMatch(host, "max.com") ||
|
||||
shExpMatch(host, "*.max.com") ||
|
||||
shExpMatch(host, "hulu.com") ||
|
||||
shExpMatch(host, "*.hulu.com") ||
|
||||
shExpMatch(host, "paramountplus.com") ||
|
||||
shExpMatch(host, "*.paramountplus.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Slack 全系
|
||||
if (shExpMatch(host, "slack.com") ||
|
||||
shExpMatch(host, "*.slack.com") ||
|
||||
shExpMatch(host, "slack-edge.com") ||
|
||||
shExpMatch(host, "*.slack-edge.com") ||
|
||||
shExpMatch(host, "slack-imgs.com") ||
|
||||
shExpMatch(host, "*.slack-imgs.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Zoom 全系
|
||||
if (shExpMatch(host, "zoom.us") ||
|
||||
shExpMatch(host, "*.zoom.us") ||
|
||||
shExpMatch(host, "zoom.com") ||
|
||||
shExpMatch(host, "*.zoom.com") ||
|
||||
shExpMatch(host, "zoomgov.com") ||
|
||||
shExpMatch(host, "*.zoomgov.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Signal 全系
|
||||
if (shExpMatch(host, "signal.org") ||
|
||||
shExpMatch(host, "*.signal.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Docker 全系
|
||||
if (shExpMatch(host, "docker.com") ||
|
||||
shExpMatch(host, "*.docker.com") ||
|
||||
shExpMatch(host, "docker.io") ||
|
||||
shExpMatch(host, "*.docker.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// GitLab 全系
|
||||
if (shExpMatch(host, "gitlab.com") ||
|
||||
shExpMatch(host, "*.gitlab.com") ||
|
||||
shExpMatch(host, "gitlab.io") ||
|
||||
shExpMatch(host, "*.gitlab.io")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Vercel 全系
|
||||
if (shExpMatch(host, "vercel.com") ||
|
||||
shExpMatch(host, "*.vercel.com") ||
|
||||
shExpMatch(host, "vercel.app") ||
|
||||
shExpMatch(host, "*.vercel.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Netlify 全系
|
||||
if (shExpMatch(host, "netlify.com") ||
|
||||
shExpMatch(host, "*.netlify.com") ||
|
||||
shExpMatch(host, "netlify.app") ||
|
||||
shExpMatch(host, "*.netlify.app")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Perplexity / Poe / Midjourney 全系
|
||||
if (shExpMatch(host, "perplexity.ai") ||
|
||||
shExpMatch(host, "*.perplexity.ai") ||
|
||||
shExpMatch(host, "poe.com") ||
|
||||
shExpMatch(host, "*.poe.com") ||
|
||||
shExpMatch(host, "midjourney.com") ||
|
||||
shExpMatch(host, "*.midjourney.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Figma / Canva 全系
|
||||
if (shExpMatch(host, "figma.com") ||
|
||||
shExpMatch(host, "*.figma.com") ||
|
||||
shExpMatch(host, "canva.com") ||
|
||||
shExpMatch(host, "*.canva.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// LinkedIn 全系
|
||||
if (shExpMatch(host, "linkedin.com") ||
|
||||
shExpMatch(host, "*.linkedin.com") ||
|
||||
shExpMatch(host, "licdn.com") ||
|
||||
shExpMatch(host, "*.licdn.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Pinterest 全系
|
||||
if (shExpMatch(host, "pinterest.com") ||
|
||||
shExpMatch(host, "*.pinterest.com") ||
|
||||
shExpMatch(host, "pinimg.com") ||
|
||||
shExpMatch(host, "*.pinimg.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Tumblr 全系
|
||||
if (shExpMatch(host, "tumblr.com") ||
|
||||
shExpMatch(host, "*.tumblr.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Snapchat 全系
|
||||
if (shExpMatch(host, "snapchat.com") ||
|
||||
shExpMatch(host, "*.snapchat.com") ||
|
||||
shExpMatch(host, "sc-cdn.net") ||
|
||||
shExpMatch(host, "*.sc-cdn.net")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Wikipedia / Wikimedia 全系
|
||||
if (shExpMatch(host, "wikipedia.org") ||
|
||||
shExpMatch(host, "*.wikipedia.org") ||
|
||||
shExpMatch(host, "wikimedia.org") ||
|
||||
shExpMatch(host, "*.wikimedia.org")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// PayPal 全系
|
||||
if (shExpMatch(host, "paypal.com") ||
|
||||
shExpMatch(host, "*.paypal.com") ||
|
||||
shExpMatch(host, "paypalobjects.com") ||
|
||||
shExpMatch(host, "*.paypalobjects.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Patreon 全系
|
||||
if (shExpMatch(host, "patreon.com") ||
|
||||
shExpMatch(host, "*.patreon.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// 通用 CDN
|
||||
if (shExpMatch(host, "cloudfront.net") ||
|
||||
shExpMatch(host, "*.cloudfront.net") ||
|
||||
shExpMatch(host, "fastly.net") ||
|
||||
shExpMatch(host, "*.fastly.net") ||
|
||||
shExpMatch(host, "fastlylb.net") ||
|
||||
shExpMatch(host, "*.fastlylb.net") ||
|
||||
shExpMatch(host, "jsdelivr.net") ||
|
||||
shExpMatch(host, "*.jsdelivr.net") ||
|
||||
shExpMatch(host, "unpkg.com") ||
|
||||
shExpMatch(host, "*.unpkg.com") ||
|
||||
shExpMatch(host, "statically.io") ||
|
||||
shExpMatch(host, "*.statically.io") ||
|
||||
shExpMatch(host, "cloudinary.com") ||
|
||||
shExpMatch(host, "*.cloudinary.com") ||
|
||||
shExpMatch(host, "imgur.com") ||
|
||||
shExpMatch(host, "*.imgur.com")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// linux.do
|
||||
if (shExpMatch(host, "linux.do") ||
|
||||
shExpMatch(host, "*.linux.do")) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
return "DIRECT";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# RRPP 订阅服务 · SSO 认证接入文档
|
||||
|
||||
架构:`客户端 → nginx(api.rrrosmontis.icu) → RRPP 后端(127.0.0.1:5010) → SSO(127.0.0.1:4550) → nodes.json(国家分层)`
|
||||
|
||||
## 1. 客户端公开端点
|
||||
|
||||
### 统一订阅端点
|
||||
```
|
||||
GET https://api.rrrosmontis.icu/v1/rrpp/subscription?method=<类型>&user=<b64url邮箱>&password=<b64url密码>[&prefer=<协议>][&rules=<模式>]
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `method` | 是 | `clash` / `v2ray` / `shadowrocket` / `rrpp_client` |
|
||||
| `user` | 是 | URL-safe base64 编码的邮箱 |
|
||||
| `password` | 是 | URL-safe base64 编码的密码 |
|
||||
| `prefer` | 否 | `vless` / `ss` / `http`,指定优先协议;缺省 `vless`,无则回退 vless→ss→http |
|
||||
| `rules` | 否 | 仅 `method=clash`:`provider`(默认) / `inline` / `global`,见 §7 |
|
||||
| `token` | 否 | SSO 会话 token(免密,代登录态);与 `user`/`password` 二选一 |
|
||||
|
||||
### 旧端点(兼容)
|
||||
```
|
||||
GET /v1/rrpp/nodes_update?user=<b64>&password=<b64>
|
||||
```
|
||||
等价于 `method=rrpp_client`。
|
||||
|
||||
## 2. method 说明
|
||||
|
||||
| method | Content-Type | 内容 | 适用客户端 |
|
||||
|---|---|---|---|
|
||||
| `clash` | `text/yaml` | Clash YAML:`proxies` + `proxy-groups(RRPP)` + `rule-providers` + `rules`(备注 `R.R.P.P. - 国家代码`) | mihomo / Clash Verge / ClashX Meta |
|
||||
| `v2ray` | `text/plain` | base64 节点串(`vless://` / `ss://` / `http://`) | v2rayN / Shadowrocket / Quantumult |
|
||||
| `shadowrocket` | `text/plain` | Shadowrocket `.conf`:`[General]` + 远程 `RULE-SET` + `FINAL`(**不含节点**) | Shadowrocket |
|
||||
| `rrpp_client` | `application/json` | 旧格式节点 JSON(仅 http,remark = `服务商 流量`) | RRPP 自建客户端 |
|
||||
|
||||
## 3. 返回码
|
||||
|
||||
| 码 | 场景 |
|
||||
|---|---|
|
||||
| 200 | 成功 |
|
||||
| 401 | 凭据缺失/SSO 认证失败 |
|
||||
| 403 | tier=0(RRPP 未开通) |
|
||||
| 404 | method 未知,或 `rules` 取值非法 |
|
||||
| 502 | SSO 不可达 |
|
||||
| 503 | nodes.json 缺失/损坏,或该 tier 无可用节点 |
|
||||
|
||||
## 4. nodes.json 格式(国家分层)
|
||||
|
||||
`C:/BtSoft/nginx/others/backEnd/api-rrrosmontis-icu/RRPP/nodes.json`,顶层为国家数组:
|
||||
|
||||
```jsonc
|
||||
[
|
||||
{
|
||||
"country": "JP", // 国家代码
|
||||
"available_level": [1, 2, 3], // 可见等级
|
||||
"provider": "Azure", // 服务商
|
||||
"traffic": "15G", // 流量限额
|
||||
"domain": "iam.aeolianchi.me", // 域名
|
||||
"ipv6_only": false, // 可选,true 时备注加 (IPv6)
|
||||
"methods": { // 可用协议
|
||||
"http": { "port": ..., "username": ..., "password": ... },
|
||||
"vless": { "port": ..., "uuid": ..., "flow": ..., "sni": ..., "public_key": ..., "short_id": ... },
|
||||
"ss": { "port": ..., "method": ..., "password": ... }
|
||||
}
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
## 5. 协议选择逻辑
|
||||
|
||||
- `prefer` 指定则优先用该协议
|
||||
- 缺省默认 `vless`(最好用)
|
||||
- 该国家无 `prefer` 协议时按 `vless → ss → http` 回退
|
||||
- `rrpp_client` 固定只用 `http`(兼容性),备注 = `provider + traffic`(IPv6 加 `(IPv6)`)
|
||||
|
||||
## 6. SSO 认证
|
||||
|
||||
- 后端调 SSO `POST http://127.0.0.1:4550/v1/sso/service/rrpp/nodes`(带 `X-SSO-Service-Token` + `X-Real-IP`)
|
||||
- SSO 返回 `{tier: 0|1|2|3}`,0=未开通
|
||||
- 服务密钥读 `sso-rrrosmontis-icu/secrets.json` 的 `service_tokens.rrpp`
|
||||
|
||||
## 7. 分流规则(PAC → 规则集,随订阅下发)
|
||||
|
||||
### 7.1 为什么不是直接下发 PAC
|
||||
|
||||
PAC 是给浏览器/操作系统用的 JS 自动配置;**mihomo/Clash 与 Shadowrocket 都不能读 PAC**。
|
||||
两者唯一共同能吃的格式是「每行自带规则类型的规则行文本」(Clash 术语 `behavior: classical`):
|
||||
|
||||
- mihomo:`rule-providers`(`behavior: classical` + `format: text`)
|
||||
- Shadowrocket:`RULE-SET,<url>,<策略>`(官方手册要求「规则集的内容需包含规则类型」)
|
||||
|
||||
因此把 PAC 数据**在后端一次性转换成三个规则集文件**,两种客户端共用同一份文件。
|
||||
|
||||
### 7.2 规则集文件(CDN 公开)
|
||||
|
||||
生成目录即 CDN 根下的 `RRPP/PAC/`,公开可访问、无鉴权(规则集本身不含节点信息):
|
||||
|
||||
| 规则集 | 文件 | 规则数 | 组成 | 策略 |
|
||||
|---|---|---|---|---|
|
||||
| `rrpp-proxy` | `rrpp-proxy.list` | 3943 | 539 `DOMAIN` + 3404 `DOMAIN-SUFFIX`(强制代理域名) | `RRPP` / `PROXY` |
|
||||
| `rrpp-direct` | `rrpp-direct.list` | 16 | 16 `DOMAIN-SUFFIX`(保守直连种子) | `DIRECT` |
|
||||
| `rrpp-cn-ip` | `rrpp-cn-ip.list` | 7456 | 7456 `IP-CIDR`(中国 IP 段) | `DIRECT` |
|
||||
|
||||
URL:`https://cdn.rrrosmontis.icu/RRPP/PAC/<文件名>`
|
||||
体积:120366 / 386 / 177443 字节,合计 11415 条规则。
|
||||
|
||||
### 7.3 规则顺序(与 PAC 语义一致)
|
||||
|
||||
```
|
||||
RULE-SET,rrpp-proxy → RRPP # 强制代理优先(PAC 中强制代理规则优先级更高)
|
||||
RULE-SET,rrpp-direct → DIRECT # 直连种子
|
||||
RULE-SET,rrpp-cn-ip → DIRECT # 中国 IP 直连
|
||||
MATCH → RRPP # 其余全走代理
|
||||
```
|
||||
|
||||
### 7.4 维护流程(PAC 更新后)
|
||||
|
||||
转换脚本 `gen_rulesets.py`(与本文件同目录,另在仓库 `server_config/rrpp-subscription/` 存有副本):
|
||||
|
||||
```bash
|
||||
python gen_rulesets.py # 读 PAC 目录 → 原子覆写三个 .list(幂等,内容不变则不写)
|
||||
python gen_rulesets.py --check # 只检查将有哪些变化,不写盘
|
||||
python gen_rulesets.py --pac-dir <目录> --out-dir <目录> --quiet
|
||||
```
|
||||
|
||||
- 输入:`pac_domains.json` / `pac_direct_domains.json` / `pac_ip_cn.json`
|
||||
- 转换规则:`full:x`→`DOMAIN,x`;`keyword:x`→`DOMAIN-KEYWORD,x`;`regexp:`/`include:`→跳过并计数;行内 `#` 注释剥离;`*.x`/普通域名→`DOMAIN-SUFFIX,x`
|
||||
- 输出排序去重、LF 换行、无注释,写盘用临时文件 + `os.replace` 原子替换
|
||||
- **订阅端无需任何改动**:文件名与 URL 固定,客户端按 `interval` 自动拉新规则
|
||||
|
||||
### 7.5 客户端接入
|
||||
|
||||
| 场景 | 用法 |
|
||||
|---|---|
|
||||
| mihomo / Clash Verge(推荐) | `&method=clash` → 规则走 `rule-providers`,YAML 约 2.4KB,规则每 24h 自动更新 |
|
||||
| 客户端不认 `rule-providers`,或想「导入一次到位」 | `&method=clash&rules=inline` → 规则内联进 YAML(约 440KB),导入即含节点+全部规则 |
|
||||
| Shadowrocket(只想要规则,节点另行导入) | `&method=shadowrocket` → `.conf`,规则走远程 `RULE-SET`(在「配置 → 规则集URL」中可见并自动更新) |
|
||||
| Shadowrocket(想一次导入节点+规则) | 用 `&method=clash&rules=inline`(小火箭兼容 Clash YAML,含节点信息的标准 Clash 链接可同时导入配置与节点) |
|
||||
| 兜底 / 排障 | `&rules=global` → 只有 `MATCH,RRPP`,全流量走代理(等价旧行为) |
|
||||
|
||||
> `method=shadowrocket` 的 `.conf` **不写 `[Proxy]` 段**:Shadowrocket 的 REALITY 参数写法官方未文档化,不猜测;
|
||||
> 该配置的策略 `PROXY` 指向首页当前选中的节点,节点请用 `clash`/`v2ray` 订阅导入。
|
||||
|
||||
### 7.6 验证记录(2026-09-19)
|
||||
|
||||
- 生成:11415 条规则;`regexp:` 跳过 7 条、非法 1 条
|
||||
- CDN:三个 URL 均 HTTP 200,`md5` 与本地生成一致(`806808b8` / `8f89397e` / `3e75093d`)
|
||||
- **mihomo v1.19.31 真实内核运行验证**:`-t` 校验通过;运行时 `GET /providers/rules` 显示
|
||||
`rrpp-proxy=3943`、`rrpp-direct=16`、`rrpp-cn-ip=7456`(`behavior=Classical`),落地文件字节数与生成完全一致;
|
||||
`&rules=inline` 配置亦通过 `-t` 校验
|
||||
- Shadowrocket 为 iOS 应用,**未做实机验证**,仅依据官方手册的 `RULE-SET` / Clash YAML 兼容说明实现
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
RRPP PAC → 规则集转换器
|
||||
|
||||
把 PAC 数据文件(JSON)转换成 **mihomo(Clash) 与 Shadowrocket 通用**的
|
||||
"classical 规则行"文本规则集——每行都含规则类型:
|
||||
|
||||
pac_direct_domains.json -> rrpp-direct.list (DOMAIN-SUFFIX,<域名>)
|
||||
pac_domains.json -> rrpp-proxy.list (DOMAIN-SUFFIX,<域名>)
|
||||
pac_ip_cn.json -> rrpp-cn-ip.list (IP-CIDR,<网段>)
|
||||
|
||||
为什么用这个格式(两端通吃):
|
||||
* mihomo : rule-providers { behavior: classical, format: text }
|
||||
* Shadowrocket: RULE-SET,<url>,<策略>
|
||||
(官方要求"规则集的内容需包含规则类型")
|
||||
|
||||
三种规则集由客户端按各自策略引用:
|
||||
rrpp-proxy.list -> 代理(强制代理域名)
|
||||
rrpp-direct.list -> 直连(保守直连种子)
|
||||
rrpp-cn-ip.list -> 直连(中国 IP 段)
|
||||
|
||||
用法
|
||||
----
|
||||
python gen_rulesets.py # 就地生成(内容有变化才写盘)
|
||||
python gen_rulesets.py --check # 只检查/报告,不写盘
|
||||
python gen_rulesets.py --pac-dir DIR --out-dir DIR
|
||||
python gen_rulesets.py --quiet
|
||||
|
||||
维护
|
||||
----
|
||||
PAC 更新后重新执行本脚本即可。脚本是**幂等**的:内容与现有文件一致时不会重写;
|
||||
写入采用 临时文件 + 原子替换,失败不会损坏已有文件。输出不含注释,只有规则行。
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ---------- 默认路径(bj 生产环境) ----------
|
||||
DEFAULT_PAC_DIR = r'C:/BtSoft/nginx/others/htmlResources/cdn/RRPP/PAC'
|
||||
DEFAULT_OUT_DIR = DEFAULT_PAC_DIR # 与 PAC 同目录,由 CDN 直接对外提供
|
||||
|
||||
# 映射:PAC 文件 -> (输出文件, JSON 中的列表字段, 类型)
|
||||
# 类型 domains = 域名表(含 v2fly 前缀:full:/keyword:/regexp:/行内注释)
|
||||
# 类型 cidrs = IP 网段表
|
||||
RULESETS = [
|
||||
('pac_domains.json', 'rrpp-proxy.list', 'domains'),
|
||||
('pac_direct_domains.json', 'rrpp-direct.list', 'domains'),
|
||||
('pac_ip_cn.json', 'rrpp-cn-ip.list', 'cidrs'),
|
||||
]
|
||||
|
||||
|
||||
def log(msg, quiet=False):
|
||||
if not quiet:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def read_json(path):
|
||||
"""读取 JSON;容忍 BOM。"""
|
||||
with open(path, 'r', encoding='utf-8-sig') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _valid_domain(s):
|
||||
if not s or len(s) > 253 or '.' not in s:
|
||||
return False
|
||||
if any(c in s for c in ' /\\:@?#[]'):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def convert_domain_entry(v):
|
||||
"""把一条域名表条目转成规则行。
|
||||
|
||||
支持 v2fly geosite 前缀与行内注释(PAC 数据实测存在):
|
||||
full:<域名> -> DOMAIN,<域名> (精确匹配)
|
||||
keyword:<关键词> -> DOMAIN-KEYWORD,<关键词>
|
||||
regexp:<正则> -> 跳过(两端都无法表达域名正则)
|
||||
<域名> # 注释 -> DOMAIN-SUFFIX,<域名>
|
||||
返回 (规则行 或 None, 跳过原因 或 None)。
|
||||
"""
|
||||
s = str(v or '')
|
||||
if '#' in s: # 行内注释(如 "apple.xn--czr694b # apple.商标")
|
||||
s = s.split('#', 1)[0]
|
||||
s = s.strip().lower()
|
||||
if not s:
|
||||
return None, 'empty'
|
||||
|
||||
if s.startswith('regexp:'):
|
||||
return None, 'regexp'
|
||||
if s.startswith('include:'):
|
||||
return None, 'include'
|
||||
|
||||
if s.startswith('full:'):
|
||||
dom = s[5:].strip().lstrip('*.').rstrip('.')
|
||||
return (f'DOMAIN,{dom}', None) if _valid_domain(dom) else (None, 'invalid')
|
||||
|
||||
if s.startswith('keyword:'):
|
||||
kw = s[8:].strip()
|
||||
return (f'DOMAIN-KEYWORD,{kw}', None) if kw else (None, 'invalid')
|
||||
|
||||
if s.startswith('domain:'):
|
||||
s = s[7:].strip()
|
||||
|
||||
dom = s.lstrip('*.').rstrip('.') # 通配前缀统一按后缀匹配
|
||||
return (f'DOMAIN-SUFFIX,{dom}', None) if _valid_domain(dom) else (None, 'invalid')
|
||||
|
||||
|
||||
def norm_cidr(v):
|
||||
"""CIDR 规范化/校验:返回规范化网段,非法返回 None。"""
|
||||
s = str(v or '').strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
net = ipaddress.ip_network(s, strict=False)
|
||||
except ValueError:
|
||||
return None
|
||||
return str(net.network_address) + '/' + str(net.prefixlen)
|
||||
|
||||
|
||||
def build_lines(kind, data, quiet=False):
|
||||
"""由 PAC JSON 生成规则行(排序去重),返回 (lines, stats)。"""
|
||||
field = 'domains' if kind == 'domains' else 'cidrs'
|
||||
raw = data.get(field) if isinstance(data, dict) else None
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(f'字段 {field} 不是列表')
|
||||
seen, stats, out = set(), {}, []
|
||||
for item in raw:
|
||||
if kind == 'domains':
|
||||
line, why = convert_domain_entry(item)
|
||||
else:
|
||||
c = norm_cidr(item)
|
||||
line, why = (f'IP-CIDR,{c}', None) if c else (None, 'invalid')
|
||||
if line is None:
|
||||
stats[why] = stats.get(why, 0) + 1
|
||||
continue
|
||||
if line not in seen:
|
||||
seen.add(line)
|
||||
out.append(line)
|
||||
out.sort()
|
||||
if stats:
|
||||
detail = ' '.join(f'{k}={v}' for k, v in sorted(stats.items()))
|
||||
log(f' ! 跳过条目: {detail}', quiet)
|
||||
return out, stats
|
||||
|
||||
|
||||
|
||||
def write_if_changed(path, text, check_only=False):
|
||||
"""内容有变化才原子写入。返回 (changed, md5)。"""
|
||||
data = text.encode('utf-8')
|
||||
md5 = hashlib.md5(data).hexdigest()
|
||||
old = None
|
||||
if os.path.exists(path):
|
||||
with open(path, 'rb') as f:
|
||||
old = f.read()
|
||||
if old == data:
|
||||
return False, md5
|
||||
if check_only:
|
||||
return True, md5
|
||||
tmp = path + '.tmp'
|
||||
with open(tmp, 'wb') as f: # 二进制写:确保 LF、无 BOM
|
||||
f.write(data)
|
||||
os.replace(tmp, path)
|
||||
return True, md5
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='RRPP PAC -> 规则集转换器')
|
||||
ap.add_argument('--pac-dir', default=DEFAULT_PAC_DIR, help='PAC JSON 所在目录')
|
||||
ap.add_argument('--out-dir', default=DEFAULT_OUT_DIR, help='规则集输出目录')
|
||||
ap.add_argument('--check', action='store_true', help='只检查,不写盘')
|
||||
ap.add_argument('--quiet', action='store_true')
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isdir(args.pac_dir):
|
||||
print(f'错误:PAC 目录不存在: {args.pac_dir}', file=sys.stderr)
|
||||
return 2
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
|
||||
total, changed_cnt, failed = 0, 0, 0
|
||||
log(f'PAC 目录 : {args.pac_dir}')
|
||||
log(f'输出目录 : {args.out_dir}{" (--check 模式,不写盘)" if args.check else ""}')
|
||||
log('')
|
||||
|
||||
for src_name, out_name, kind in RULESETS:
|
||||
src_path = os.path.join(args.pac_dir, src_name)
|
||||
out_path = os.path.join(args.out_dir, out_name)
|
||||
if not os.path.exists(src_path):
|
||||
print(f' [跳过] {src_name} 不存在', file=sys.stderr)
|
||||
failed += 1
|
||||
continue
|
||||
try:
|
||||
data = read_json(src_path)
|
||||
lines, _stats = build_lines(kind, data, args.quiet)
|
||||
text = '\n'.join(lines) + '\n'
|
||||
changed, md5 = write_if_changed(out_path, text, args.check)
|
||||
size = len(text.encode('utf-8'))
|
||||
ver = data.get('version', '-') if isinstance(data, dict) else '-'
|
||||
state = ('已更新' if changed else '无变化') if not args.check else ('需更新' if changed else '无变化')
|
||||
log(f' {src_name:26s} -> {out_name:18s} {len(lines):5d} 行 {size/1024:7.1f} KB '
|
||||
f'ver={ver:12s} md5={md5[:8]} [{state}]')
|
||||
total += len(lines)
|
||||
changed_cnt += 1 if changed else 0
|
||||
except Exception as e:
|
||||
print(f' [失败] {src_name}: {type(e).__name__}: {e}', file=sys.stderr)
|
||||
failed += 1
|
||||
|
||||
log('')
|
||||
log(f'合计 {total} 条规则,{changed_cnt} 个文件{"需更新" if args.check else "已更新"},{failed} 个失败')
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# RRPP 网络接口调优脚本(开机执行)
|
||||
# 1) 把默认路由网卡的根 qdisc 设为 fq —— BBR 的最佳搭档
|
||||
# (net.core.default_qdisc=fq 只对"新创建"网卡生效,已存在网卡需显式 replace)
|
||||
# 2) 开启 UDP GRO 转发 —— QUIC 系协议(Hysteria2 / TUIC)转发性能
|
||||
# 容器(LXC/OpenVZ)内网卡为 noqueue、且无 netdev 权限,本脚本自动跳过。
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
IFACE=$(ip -4 route show default | awk '{print $5; exit}')
|
||||
[ -n "${IFACE:-}" ] || { echo "未找到默认路由网卡,跳过"; exit 0; }
|
||||
if tc qdisc show dev "$IFACE" 2>/dev/null | grep -q " noqueue"; then
|
||||
echo "$IFACE 为 noqueue(容器环境),跳过 qdisc 与网卡 offload 设置"
|
||||
exit 0
|
||||
fi
|
||||
tc qdisc replace dev "$IFACE" root fq 2>/dev/null && echo "已设置 $IFACE root qdisc = fq" || echo "设置 qdisc 失败(可忽略)"
|
||||
ethtool -K "$IFACE" rx-udp-gro-forwarding on 2>/dev/null && echo "已开启 $IFACE rx-udp-gro-forwarding" || echo "$IFACE 不支持 rx-udp-gro-forwarding"
|
||||
ethtool -K "$IFACE" rx-gro-list on 2>/dev/null && echo "已开启 $IFACE rx-gro-list" || echo "$IFACE 不支持 rx-gro-list"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# /usr/local/bin/rrpp-stats — returns traffic stats as JSON
|
||||
|
||||
vnstat --json 2>/dev/null || echo '{"error":"vnstat not available"}'
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/bin/bash
|
||||
# sync-nginx-local-ips.sh
|
||||
# 自动维护 nginx 本机公网 IP 直连规则(/etc/nginx/local_ip_rules.conf):
|
||||
# 出站公网 IP(ipify)+ iam.aeolianchi.me 的 A 记录 = 本机当前公网 IP 集合。
|
||||
# 出站公网 IP(ipify)+ <节点域名> 的 A 记录 = 本机当前公网 IP 集合。
|
||||
# 公网 IP 变更时重写规则文件并 reload nginx(仅变更时 reload)。
|
||||
# 由 /etc/cron.d/sync-nginx-local-ips 每 5 分钟调用一次。
|
||||
#
|
||||
# 部署时把 DOMAIN 改为本节点域名。
|
||||
set -u
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
RULES=/etc/nginx/local_ip_rules.conf
|
||||
DOMAIN=iam.aeolianchi.me
|
||||
DOMAIN=<节点域名>
|
||||
|
||||
ips=""
|
||||
# 出站公网 IP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"log": {
|
||||
"level": "warn",
|
||||
"level": "info",
|
||||
"timestamp": true
|
||||
},
|
||||
"inbounds": [
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "vless",
|
||||
"tag": "vless-reality-in",
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": 8443,
|
||||
"listen_port": "<vless端口>",
|
||||
"users": [
|
||||
{
|
||||
"uuid": "<VLESS_UUID>",
|
||||
@@ -17,11 +17,11 @@
|
||||
],
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "www.apple.com",
|
||||
"server_name": "<伪装SNI域名>",
|
||||
"reality": {
|
||||
"enabled": true,
|
||||
"handshake": {
|
||||
"server": "www.apple.com",
|
||||
"server": "<伪装SNI域名>",
|
||||
"server_port": 443
|
||||
},
|
||||
"private_key": "<REALITY_PRIVATE_KEY>",
|
||||
@@ -30,6 +30,14 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shadowsocks",
|
||||
"tag": "ss-in",
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": "<ss端口>",
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<SS_PSK_BASE64_32字节>"
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# ============================================================
|
||||
# RRPP 节点系统级网络优化(KVM / 完整虚拟机版)
|
||||
# 部署位置:/etc/sysctl.d/99-zz-rrpp-network.conf
|
||||
# 配套:systemd/rrpp-qdisc.service + scripts/rrpp-qdisc.sh(fq 队列 + UDP GRO)
|
||||
# 文件名 99-zz- 前缀保证最后生效;注意 /etc/sysctl.conf 会更晚加载(见 README §2.6)
|
||||
# ============================================================
|
||||
|
||||
# --- 拥塞控制与队列 ---
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
net.core.default_qdisc = fq
|
||||
|
||||
# --- PMTUD 黑洞缓解 ---
|
||||
net.ipv4.tcp_mtu_probing = 1
|
||||
|
||||
# --- 握手 / 时延 ---
|
||||
net.ipv4.tcp_fastopen = 3
|
||||
net.ipv4.tcp_slow_start_after_idle = 0
|
||||
|
||||
# --- 连接生命周期 ---
|
||||
net.ipv4.tcp_tw_reuse = 2
|
||||
net.ipv4.tcp_fin_timeout = 15
|
||||
net.ipv4.tcp_keepalive_time = 300
|
||||
net.ipv4.tcp_keepalive_intvl = 30
|
||||
net.ipv4.tcp_keepalive_probes = 5
|
||||
net.ipv4.ip_local_port_range = 10240 65000
|
||||
|
||||
# --- 队列深度与缓冲(TCP)---
|
||||
net.core.somaxconn = 32768
|
||||
net.ipv4.tcp_max_syn_backlog = 32768
|
||||
net.core.netdev_max_backlog = 32768
|
||||
net.core.rmem_max = 33554432
|
||||
net.core.wmem_max = 33554432
|
||||
net.ipv4.tcp_rmem = 4096 131072 33554432
|
||||
net.ipv4.tcp_wmem = 4096 16384 33554432
|
||||
|
||||
# --- UDP(Hysteria2 / TUIC / UoT 的 socket 缓冲下限)---
|
||||
net.ipv4.udp_rmem_min = 8192
|
||||
net.ipv4.udp_wmem_min = 8192
|
||||
@@ -0,0 +1,22 @@
|
||||
# ============================================================
|
||||
# RRPP 节点系统级网络优化(LXC / OpenVZ 容器版)
|
||||
# 部署位置:/etc/sysctl.d/99-zz-rrpp-network.conf
|
||||
# 容器差异:net.core.rmem_max / wmem_max / default_qdisc / netdev_max_backlog
|
||||
# 缺失或只读;eth0 为 noqueue,qdisc/网卡 offload 不可设(由宿主机控制)。
|
||||
# net.ipv4.udp_rmem_min / udp_wmem_min 在部分容器内只读,若报错可忽略。
|
||||
# ============================================================
|
||||
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
net.ipv4.tcp_mtu_probing = 1
|
||||
net.ipv4.tcp_fastopen = 3
|
||||
net.ipv4.tcp_slow_start_after_idle = 0
|
||||
net.ipv4.tcp_tw_reuse = 2
|
||||
net.ipv4.tcp_fin_timeout = 15
|
||||
net.ipv4.tcp_keepalive_time = 300
|
||||
net.ipv4.tcp_keepalive_intvl = 30
|
||||
net.ipv4.tcp_keepalive_probes = 5
|
||||
net.ipv4.ip_local_port_range = 10240 65000
|
||||
net.core.somaxconn = 32768
|
||||
net.ipv4.tcp_max_syn_backlog = 32768
|
||||
net.ipv4.udp_rmem_min = 8192
|
||||
net.ipv4.udp_wmem_min = 8192
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=MTProto proxy (mtg) for Telegram
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <MTPROTO_SECRET>
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=nginx (RRPP proxy, compiled with ngx_http_proxy_connect_module)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
PIDFile=/var/run/nginx.pid
|
||||
ExecStartPre=/usr/local/nginx/sbin/nginx -t
|
||||
ExecStart=/usr/local/nginx/sbin/nginx
|
||||
ExecReload=/usr/local/nginx/sbin/nginx -s reload
|
||||
ExecStop=/usr/local/nginx/sbin/nginx -s quit
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=RRPP: set fq qdisc on default-route interface (BBR companion)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/sbin/rrpp-qdisc.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=RRPP bare UDP over TCP v2 relay
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
User=rrpp
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/rrpp
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=sing-box (VLESS+REALITY / Shadowsocks)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/sing-box -D /var/lib/sing-box -C /etc/sing-box run
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -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
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"vnstat_bin": "vnstat",
|
||||
"interface": "eth0",
|
||||
"data_dir": "/home/agent/data",
|
||||
"alert_state_path": "/home/agent/alert_state.json",
|
||||
"poll_interval_sec": 60,
|
||||
"schedule_interval_sec": 30,
|
||||
"http_host": "127.0.0.1",
|
||||
"http_port": 18080,
|
||||
"quota_bytes": 15000000000,
|
||||
"alert_thresholds_pct": [
|
||||
50,
|
||||
80,
|
||||
90,
|
||||
95,
|
||||
99,
|
||||
100
|
||||
],
|
||||
"effective_domain": "iam.aeolianchi.me",
|
||||
"log_level": "INFO",
|
||||
"smtp": {
|
||||
"host": "mail.rrrosmontis.icu",
|
||||
"port": 465,
|
||||
"user": "<SMTP_USER>",
|
||||
"password": "<SMTP_PASSWORD>",
|
||||
"from_name": "Royirin Rosmontis Private Proxy JP 流量监控",
|
||||
"from_addr": "<SMTP_FROM>",
|
||||
"to_addr": "<SMTP_TO>",
|
||||
"starttls": false,
|
||||
"skip_cert_verify": false,
|
||||
"timeout_sec": 30,
|
||||
"ssl": true
|
||||
},
|
||||
"current_ip": "<PUBLIC_IP>",
|
||||
"billing_cycle_start_day": 1,
|
||||
"azure_usage_enabled": true,
|
||||
"azure_usage_fetch_interval_sec": 21600,
|
||||
"azure_usage_cache_path": "/home/agent/data/azure_usage.json",
|
||||
"azure_meter_name_match": "Data Transfer Out",
|
||||
"azure_free_limit_gb": 15
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=Royirin Rosmontis Private Proxy JP Traffic Monitor
|
||||
After=network-online.target nginx.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/agent
|
||||
ExecStart=/usr/bin/python3 /home/agent/traffic_monitor.py --config /home/agent/config.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,666 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Royirin Rosmontis Private Proxy JP 流量监控(Azure 门户口径版)
|
||||
- 主计量口径:Azure 门户免费服务页 "Data Transfer Out (GB)"(Standard Data Transfer Out - Free,MGN 路由)
|
||||
—— 通过 VM 托管身份调用 Consumption Usage Details API 拉取,按自然月聚合,缓存至 data/azure_usage.json
|
||||
- 参考口径:vnstat -i eth0(实时原始出站,仅作参考展示)
|
||||
- 数据源(参考):vnstat -i <interface> --json m/d(eth0 为 bond 主接口,覆盖 nginx 443 与 sing-box 8443 全部流量)
|
||||
- 按 年/月 聚合成 JSON 存入 data/YYYY/YYYY-MM.json(弃用 SQLite)
|
||||
- HTTP 服务: GET /vpn/status/traffic 返回当月用量 JSON(Azure 口径为主)
|
||||
- 阈值告警: 达到配额 50/80/90/95/99/100% 时发邮件(每档每月只发一次,跳档补发;基于 Azure 口径)
|
||||
- 月报: 每月 1 日 00:05 起发送上个月报告(失败重试;基于 Azure 口径)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import smtplib
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
log = logging.getLogger('traffic_monitor')
|
||||
|
||||
GB = 1_000_000_000 # Azure 数据转移按十进制 GB 计费
|
||||
|
||||
|
||||
def utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def load_json(path, default):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
log.warning('read %s failed: %s; using default', path, e)
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path, obj):
|
||||
tmp = path + '.tmp'
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump(obj, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def gb_str(n_bytes):
|
||||
return f'{n_bytes / GB:.2f} GB'
|
||||
|
||||
|
||||
def month_struct(year, month):
|
||||
return {
|
||||
'year': int(year),
|
||||
'month': int(month),
|
||||
'rx_bytes': 0,
|
||||
'tx_bytes': 0,
|
||||
'days': [],
|
||||
'updated_at': utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class Monitor:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.lock = threading.RLock()
|
||||
self.months = {}
|
||||
self.alert_state = None
|
||||
|
||||
os.makedirs(self.config['data_dir'], exist_ok=True)
|
||||
self._load_all_months()
|
||||
self._load_alert_state()
|
||||
# Azure 计费用量(Usage Details API,VM 托管身份鉴权)
|
||||
self.azure_usage = None
|
||||
self._azure_last_fetch = 0.0
|
||||
cache_path = self.config.get('azure_usage_cache_path')
|
||||
if cache_path and os.path.exists(cache_path):
|
||||
self.azure_usage = load_json(cache_path, None)
|
||||
log.info('azure usage cache loaded: %s', self.azure_usage)
|
||||
|
||||
# ---------- 基础 ----------
|
||||
@property
|
||||
def _config_path(self):
|
||||
return os.environ.get('TRAFFIC_CONFIG', '/home/agent/config.json')
|
||||
|
||||
def _load_all_months(self):
|
||||
root = self.config['data_dir']
|
||||
if not os.path.isdir(root):
|
||||
return
|
||||
for year_dir in os.listdir(root):
|
||||
ypath = os.path.join(root, year_dir)
|
||||
if not os.path.isdir(ypath):
|
||||
continue
|
||||
for fn in os.listdir(ypath):
|
||||
if not fn.endswith('.json'):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(ypath, fn), 'r', encoding='utf-8') as f:
|
||||
m = json.load(f)
|
||||
mk = f"{m['year']:04d}-{m['month']:02d}"
|
||||
self.months[mk] = m
|
||||
except Exception as e:
|
||||
log.warning('load %s/%s failed: %s', ypath, fn, e)
|
||||
|
||||
def _load_alert_state(self):
|
||||
self.alert_state = load_json(
|
||||
self.config['alert_state_path'],
|
||||
{'alert_thresholds_sent': {}, 'monthly_reports_sent': []})
|
||||
self.alert_state.setdefault('alert_thresholds_sent', {})
|
||||
self.alert_state.setdefault('monthly_reports_sent', [])
|
||||
|
||||
def _save_alert_state(self):
|
||||
save_json(self.config['alert_state_path'], self.alert_state)
|
||||
|
||||
def _month_path(self, year, month):
|
||||
return os.path.join(self.config['data_dir'], str(year), f'{year:04d}-{month:02d}.json')
|
||||
|
||||
def _persist_month(self, mk):
|
||||
m = self.months.get(mk)
|
||||
if m is None:
|
||||
return
|
||||
path = self._month_path(m['year'], m['month'])
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
save_json(path, m)
|
||||
|
||||
# ---------- vnstat 读取 ----------
|
||||
def _vnstat(self, what):
|
||||
bin_path = self.config.get('vnstat_bin', 'vnstat')
|
||||
iface = self.config.get('interface', 'eth0')
|
||||
cmd = [bin_path, '-i', iface, '--json', what]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, timeout=20, check=True)
|
||||
return json.loads(out.stdout.decode('utf-8'))
|
||||
except FileNotFoundError:
|
||||
log.error('vnstat binary not found: %s', bin_path)
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error('vnstat --json %s timed out', what)
|
||||
return None
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError, OSError) as e:
|
||||
log.error('vnstat --json %s failed: %s', what, e)
|
||||
return None
|
||||
|
||||
def poll_vnstat(self):
|
||||
"""拉取 vnstat 月/日数据,更新并落盘。返回更新的月数。"""
|
||||
monthly = self._vnstat('m')
|
||||
daily = self._vnstat('d')
|
||||
touched = set()
|
||||
|
||||
with self.lock:
|
||||
if monthly:
|
||||
try:
|
||||
for itf in monthly.get('interfaces', []):
|
||||
for item in itf.get('traffic', {}).get('month', []):
|
||||
d = item.get('date', {})
|
||||
mk = f"{int(d['year']):04d}-{int(d['month']):02d}"
|
||||
m = self.months.get(mk)
|
||||
if m is None:
|
||||
m = month_struct(d['year'], d['month'])
|
||||
self.months[mk] = m
|
||||
m['rx_bytes'] = int(item.get('rx', 0))
|
||||
m['tx_bytes'] = int(item.get('tx', 0))
|
||||
m['updated_at'] = utcnow().isoformat()
|
||||
touched.add(mk)
|
||||
except Exception as e:
|
||||
log.exception('parse vnstat monthly failed: %s', e)
|
||||
|
||||
if daily:
|
||||
try:
|
||||
by_month = {}
|
||||
for itf in daily.get('interfaces', []):
|
||||
for item in itf.get('traffic', {}).get('day', []):
|
||||
d = item.get('date', {})
|
||||
mk = f"{int(d['year']):04d}-{int(d['month']):02d}"
|
||||
by_month.setdefault(mk, []).append({
|
||||
'day': int(d['day']),
|
||||
'rx_bytes': int(item.get('rx', 0)),
|
||||
'tx_bytes': int(item.get('tx', 0)),
|
||||
})
|
||||
for mk, days in by_month.items():
|
||||
m = self.months.get(mk)
|
||||
if m is None:
|
||||
y, mon = mk.split('-')
|
||||
m = month_struct(int(y), int(mon))
|
||||
self.months[mk] = m
|
||||
days.sort(key=lambda x: x['day'])
|
||||
m['days'] = days
|
||||
m['updated_at'] = utcnow().isoformat()
|
||||
touched.add(mk)
|
||||
except Exception as e:
|
||||
log.exception('parse vnstat daily failed: %s', e)
|
||||
|
||||
for mk in touched:
|
||||
self._persist_month(mk)
|
||||
return len(touched)
|
||||
|
||||
# ---------- 用量(按 Azure 计费周期) ----------
|
||||
def current_month_key(self):
|
||||
return utcnow().strftime('%Y-%m')
|
||||
|
||||
def cycle_start(self, now):
|
||||
"""返回当前计费周期的起始 datetime(每月 billing_cycle_start_day 00:00 UTC)。"""
|
||||
day = int(self.config.get('billing_cycle_start_day', 1))
|
||||
day = max(1, min(28, day))
|
||||
if now.day >= day:
|
||||
return now.replace(day=day, hour=0, minute=0, second=0, microsecond=0)
|
||||
prev = (now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
- timedelta(days=1))
|
||||
return prev.replace(day=day, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
def cycle_end(self, start):
|
||||
"""周期结束 = 下个月同一天的 00:00(不含)。"""
|
||||
nxt = (start.replace(day=1) + timedelta(days=32)).replace(day=1)
|
||||
return nxt.replace(day=start.day, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
def _day_totals(self):
|
||||
"""返回 [{date, rx, tx}],date 为 UTC date。"""
|
||||
out = []
|
||||
for m in self.months.values():
|
||||
for d in m.get('days', []):
|
||||
out.append((date(m['year'], m['month'], d['day']),
|
||||
int(d.get('rx_bytes', 0)), int(d.get('tx_bytes', 0))))
|
||||
return out
|
||||
|
||||
def cycle_usage(self):
|
||||
"""返回当前计费周期 {rx_bytes, tx_bytes, start, end}。"""
|
||||
now = utcnow()
|
||||
start = self.cycle_start(now)
|
||||
end = self.cycle_end(start)
|
||||
rx = tx = 0
|
||||
with self.lock:
|
||||
for dt, r, t in self._day_totals():
|
||||
if start.date() <= dt <= now.date():
|
||||
rx += r
|
||||
tx += t
|
||||
return {'rx_bytes': rx, 'tx_bytes': tx, 'start': start, 'end': end}
|
||||
|
||||
def tx_bytes_for(self, mk):
|
||||
with self.lock:
|
||||
m = self.months.get(mk, {})
|
||||
return int(m.get('tx_bytes', 0))
|
||||
|
||||
def current_status(self):
|
||||
"""当前状态:以 Azure 门户口径(Standard Data Transfer Out - Free)为主计量。"""
|
||||
now = utcnow()
|
||||
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
|
||||
cu = self.cycle_usage()
|
||||
az = self.azure_usage or {}
|
||||
used_gb = az.get('portal_data_transfer_out_gb')
|
||||
used_pct = round(used_gb / quota_gb * 100, 4) if (used_gb is not None and quota_gb) else None
|
||||
with self.lock:
|
||||
cal_mk = now.strftime('%Y-%m')
|
||||
m = self.months.get(cal_mk)
|
||||
cal_tx = int(m.get('tx_bytes', 0)) if m else 0
|
||||
cal_rx = int(m.get('rx_bytes', 0)) if m else 0
|
||||
vnstat_updated = (m or {}).get('updated_at')
|
||||
return {
|
||||
'year': now.year,
|
||||
'month': now.month,
|
||||
'meter': 'azure_portal',
|
||||
'used_gb': used_gb,
|
||||
'quota_gb': quota_gb,
|
||||
'quota_used_pct': used_pct,
|
||||
'billing_cycle_start_day': int(self.config.get('billing_cycle_start_day', 1)),
|
||||
'cycle_start': cu['start'].strftime('%Y-%m-%d'),
|
||||
'cycle_end': cu['end'].strftime('%Y-%m-%d'),
|
||||
'azure_portal_data_transfer_out_gb': az.get('portal_data_transfer_out_gb'),
|
||||
'azure_bandwidth_out_gb': az.get('bandwidth_out_gb'),
|
||||
'azure_usage_cost_cny': az.get('cost_cny'),
|
||||
'azure_usage_month': az.get('month'),
|
||||
'azure_usage_updated_at': az.get('updated_at'),
|
||||
'vnstat_calendar_month_tx_bytes': cal_tx,
|
||||
'vnstat_calendar_month_rx_bytes': cal_rx,
|
||||
'vnstat_updated_at': vnstat_updated,
|
||||
'updated_at': now.isoformat(),
|
||||
}
|
||||
|
||||
def _vnstat_calendar_tx_gb(self):
|
||||
"""参考值:vnstat 自然月出站(GB,十进制)。"""
|
||||
now = utcnow()
|
||||
with self.lock:
|
||||
m = self.months.get(now.strftime('%Y-%m'), {})
|
||||
return int(m.get('tx_bytes', 0)) / GB
|
||||
|
||||
# ---------- Azure 计费用量(Usage Details API) ----------
|
||||
def _azure_token(self):
|
||||
"""通过 VM 托管身份从 IMDS 获取 management.azure.com 的访问令牌。"""
|
||||
url = ('http://169.254.169.254/metadata/identity/oauth2/token'
|
||||
'?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F')
|
||||
req = urllib.request.Request(url, headers={'Metadata': 'true'})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode('utf-8'))['access_token']
|
||||
|
||||
def _azure_subscription_id(self):
|
||||
url = 'http://169.254.169.254/metadata/instance/compute?api-version=2021-02-01'
|
||||
req = urllib.request.Request(url, headers={'Metadata': 'true'})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode('utf-8')).get('subscriptionId')
|
||||
|
||||
def fetch_azure_usage(self, month_offset=0):
|
||||
"""拉取指定月份 usageDetails,聚合与门户免费服务页同源的 Data Transfer Out 用量。
|
||||
month_offset: 0=当月(写入缓存),-1=上月(用于月报,不覆盖缓存)。"""
|
||||
sub = self._azure_subscription_id()
|
||||
if not sub:
|
||||
raise RuntimeError('cannot read subscriptionId from IMDS')
|
||||
token = self._azure_token()
|
||||
now = utcnow()
|
||||
month_start = (now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
+ timedelta(days=month_offset * 32)).replace(day=1)
|
||||
nxt = (month_start.replace(day=1) + timedelta(days=32)).replace(day=1)
|
||||
flt = ("properties/usageStart ge '{0}T00:00:00Z' and "
|
||||
"properties/usageStart lt '{1}T00:00:00Z'").format(
|
||||
month_start.strftime('%Y-%m-%d'), nxt.strftime('%Y-%m-%d'))
|
||||
base = ('https://management.azure.com/subscriptions/{sub}/providers/'
|
||||
'Microsoft.Consumption/usageDetails?api-version=2023-11-01'
|
||||
'&metric=actualcost&$filter={f}')
|
||||
url = base.format(sub=sub, f=urllib.parse.quote(flt, safe=''))
|
||||
portal_gb = 0.0
|
||||
bw_out_gb = 0.0
|
||||
cost_cny = 0.0
|
||||
rows = 0
|
||||
pages = 0
|
||||
while url:
|
||||
req = urllib.request.Request(url, headers={'Authorization': 'Bearer ' + token})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
d = json.loads(resp.read().decode('utf-8'))
|
||||
for item in d.get('value', []):
|
||||
p = item.get('properties', {}) or {}
|
||||
qty = float(p.get('quantity') or 0)
|
||||
rows += 1
|
||||
name = str(p.get('meterName') or '')
|
||||
subcat = str(p.get('meterSubCategory') or '')
|
||||
if str(p.get('meterCategory') or '') == 'Bandwidth' and 'Data Transfer Out' in name:
|
||||
bw_out_gb += qty
|
||||
# 门户免费服务页 "Data Transfer Out (GB)" = MGN 路由的出站免费计费表
|
||||
if 'MGN' in subcat or '- Free' in name:
|
||||
portal_gb += qty
|
||||
cost_cny += float(p.get('costInBillingCurrency') or 0)
|
||||
pages += 1
|
||||
url = d.get('nextLink')
|
||||
if pages > 30:
|
||||
log.warning('azure usage pagination limit reached')
|
||||
break
|
||||
result = {
|
||||
'month': month_start.strftime('%Y-%m'),
|
||||
'portal_data_transfer_out_gb': round(portal_gb, 4),
|
||||
'bandwidth_out_gb': round(bw_out_gb, 4),
|
||||
'cost_cny': round(cost_cny, 4),
|
||||
'rows': rows,
|
||||
'updated_at': utcnow().isoformat(),
|
||||
}
|
||||
self.azure_usage = result
|
||||
if month_offset == 0:
|
||||
path = self.config.get('azure_usage_cache_path')
|
||||
if path:
|
||||
save_json(path, result)
|
||||
log.info('azure usage fetched (offset=%d): %s', month_offset, result)
|
||||
return result
|
||||
|
||||
def check_azure_usage(self):
|
||||
if not self.config.get('azure_usage_enabled', False):
|
||||
return
|
||||
interval = float(self.config.get('azure_usage_fetch_interval_sec', 21600))
|
||||
now = time.time()
|
||||
if now - self._azure_last_fetch < interval:
|
||||
return
|
||||
self._azure_last_fetch = now
|
||||
try:
|
||||
self.fetch_azure_usage()
|
||||
except Exception as e:
|
||||
log.warning('azure usage fetch failed: %s', e)
|
||||
|
||||
# ---------- IP / 邮件 ----------
|
||||
def get_current_ip(self):
|
||||
configured = self.config.get('current_ip')
|
||||
if configured:
|
||||
return str(configured)
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(('8.8.8.8', 80))
|
||||
return s.getsockname()[0]
|
||||
finally:
|
||||
s.close()
|
||||
except OSError:
|
||||
return '0.0.0.0'
|
||||
|
||||
def send_email(self, subject, body):
|
||||
cfg = self.config['smtp']
|
||||
msg = EmailMessage()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = formataddr((cfg.get('from_name', ''), cfg.get('from_addr')))
|
||||
msg['To'] = cfg.get('to_addr')
|
||||
msg.set_content(body)
|
||||
|
||||
context = None
|
||||
if cfg.get('skip_cert_verify'):
|
||||
context = ssl._create_unverified_context()
|
||||
host = cfg['host']
|
||||
port = int(cfg.get('port', 465))
|
||||
timeout = int(cfg.get('timeout_sec', 30))
|
||||
if cfg.get('ssl', False):
|
||||
smtp_cls = smtplib.SMTP_SSL
|
||||
kwargs = {'context': context}
|
||||
else:
|
||||
smtp_cls = smtplib.SMTP
|
||||
kwargs = {}
|
||||
with smtp_cls(host, port, timeout=timeout, **kwargs) as smtp:
|
||||
if not cfg.get('ssl', False):
|
||||
smtp.ehlo()
|
||||
if cfg.get('starttls', True):
|
||||
smtp.starttls(context=context)
|
||||
smtp.ehlo()
|
||||
smtp.login(cfg['user'], cfg['password'])
|
||||
smtp.send_message(msg)
|
||||
log.info('email sent: %s', subject)
|
||||
|
||||
def build_alert_mail(self, mk, used_gb, quota_gb, pct, az):
|
||||
ip = self.get_current_ip()
|
||||
month = (az or {}).get('month', mk.replace('azure:', ''))
|
||||
az_updated = (az or {}).get('updated_at', '')
|
||||
vnstat_tx = self._vnstat_calendar_tx_gb()
|
||||
body = (
|
||||
'Royirin Rosmontis Private Proxy JP 流量监控\n'
|
||||
'==========\n'
|
||||
'计量口径:Azure 门户(Standard Data Transfer Out - Free)\n'
|
||||
f'统计月份:{month}(自然月)\n'
|
||||
f'当前用量:{used_gb:.2f} GB / {quota_gb:.0f} GB({pct:.2f}%)\n'
|
||||
f'AZURE 数据更新:{az_updated}\n'
|
||||
f'参考(vnstat 自然月出站):{vnstat_tx:.2f} GB\n'
|
||||
f'当前 IP:{ip}\n'
|
||||
f'生效域名:{self.config.get("effective_domain", "")}\n'
|
||||
'==========\n'
|
||||
f'已达阈值 {pct:.2f}% (Azure 门户口径),请注意使用情况'
|
||||
)
|
||||
subject = f'[流量告警] {month} 已用 {pct:.2f}% ({used_gb:.2f}/{quota_gb:.0f} GB)'
|
||||
return subject, body
|
||||
|
||||
def build_monthly_mail(self, month, used_gb, quota_gb, pct, az):
|
||||
ip = self.get_current_ip()
|
||||
over = '是' if used_gb > quota_gb else '否'
|
||||
az_updated = (az or {}).get('updated_at', '')
|
||||
vnstat_tx = self._vnstat_calendar_tx_gb()
|
||||
body = (
|
||||
'Royirin Rosmontis Private Proxy JP 流量监控\n'
|
||||
'月度报告\n'
|
||||
'==========\n'
|
||||
'计量口径:Azure 门户(Standard Data Transfer Out - Free)\n'
|
||||
f'报告月份:{month}(自然月)\n'
|
||||
f'当月用量:{used_gb:.2f} GB / {quota_gb:.0f} GB({pct:.2f}%)\n'
|
||||
f'AZURE 数据更新:{az_updated}\n'
|
||||
f'参考(vnstat 自然月出站):{vnstat_tx:.2f} GB\n'
|
||||
f'当前 IP:{ip}\n'
|
||||
f'生效域名:{self.config.get("effective_domain", "")}\n'
|
||||
'==========\n'
|
||||
f'上月使用 {over} 超限, 使用量为限额的 {pct:.2f}%'
|
||||
)
|
||||
subject = f'[流量月报] {month} 用量 {used_gb:.2f} GB ({pct:.2f}%)'
|
||||
return subject, body
|
||||
|
||||
# ---------- 定时任务 ----------
|
||||
def check_alerts(self):
|
||||
"""阈值告警:以 Azure 门户口径为准(无 Azure 数据时跳过,避免误报)。"""
|
||||
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
|
||||
if quota_gb <= 0:
|
||||
return
|
||||
az = self.azure_usage or {}
|
||||
used_gb = az.get('portal_data_transfer_out_gb')
|
||||
if used_gb is None:
|
||||
log.warning('alerts skipped: no azure usage data yet')
|
||||
return
|
||||
mk = 'azure:' + str(az.get('month') or utcnow().strftime('%Y-%m'))
|
||||
pct = used_gb / quota_gb * 100
|
||||
sent = self.alert_state['alert_thresholds_sent'].setdefault(mk, [])
|
||||
if not sent:
|
||||
# 新周期/口径切换:已越过的档位直接标记,避免切换瞬间补发一堆邮件
|
||||
for t in sorted(self.config.get('alert_thresholds_pct', [])):
|
||||
if pct >= t:
|
||||
sent.append(t)
|
||||
self._save_alert_state()
|
||||
log.info('seeded thresholds for %s at %.2f%%: %s', mk, pct, sent)
|
||||
for t in sorted(self.config.get('alert_thresholds_pct', [])):
|
||||
if pct >= t and t not in sent:
|
||||
subject, body = self.build_alert_mail(mk, used_gb, quota_gb, pct, az)
|
||||
try:
|
||||
self.send_email(subject, body)
|
||||
sent.append(t)
|
||||
self._save_alert_state()
|
||||
log.info('threshold %s%% alert sent for %s (used %.2f%%)', t, mk, pct)
|
||||
except Exception as e:
|
||||
log.error('send threshold %s%% alert failed for %s: %s; will retry',
|
||||
t, mk, e)
|
||||
break
|
||||
|
||||
def check_monthly_report(self):
|
||||
"""月度报告:以 Azure 门户口径为准(上月数据,当月 1 号后发送)。"""
|
||||
now = utcnow()
|
||||
cur_start = self.cycle_start(now)
|
||||
prev_start = (cur_start.replace(day=1) - timedelta(days=1)).replace(day=1)
|
||||
prev_mk = 'azure:' + prev_start.strftime('%Y-%m')
|
||||
if prev_mk in self.alert_state['monthly_reports_sent']:
|
||||
return
|
||||
# 口径切换迁移:旧键(cycle:)已发过则直接标记,避免重复发信
|
||||
old_mk = 'cycle:' + prev_start.strftime('%Y-%m')
|
||||
if old_mk in self.alert_state['monthly_reports_sent']:
|
||||
self.alert_state['monthly_reports_sent'].append(prev_mk)
|
||||
self._save_alert_state()
|
||||
log.info('monthly report key migrated: %s already sent as %s', prev_mk, old_mk)
|
||||
return
|
||||
try:
|
||||
az_prev = self.fetch_azure_usage(month_offset=-1)
|
||||
except Exception as e:
|
||||
log.error('fetch prev month azure usage failed: %s; will retry', e)
|
||||
return
|
||||
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
|
||||
used_gb = az_prev.get('portal_data_transfer_out_gb') or 0.0
|
||||
pct = used_gb / quota_gb * 100 if quota_gb else 0.0
|
||||
subject, body = self.build_monthly_mail(
|
||||
az_prev.get('month', prev_start.strftime('%Y-%m')), used_gb, quota_gb, pct, az_prev)
|
||||
try:
|
||||
self.send_email(subject, body)
|
||||
self.alert_state['monthly_reports_sent'].append(prev_mk)
|
||||
self._save_alert_state()
|
||||
log.info('monthly report sent for %s (used %.2f GB, %.2f%%)',
|
||||
prev_mk, used_gb, pct)
|
||||
except Exception as e:
|
||||
log.error('send monthly report for %s failed: %s; will retry', prev_mk, e)
|
||||
|
||||
|
||||
def build_http_handler(monitor):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == '/vpn/status/traffic':
|
||||
try:
|
||||
payload = json.dumps(monitor.current_status(), ensure_ascii=False)
|
||||
body = payload.encode('utf-8')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except Exception as e:
|
||||
log.exception('status endpoint error')
|
||||
body = b'{"error":"internal"}'
|
||||
self.send_response(500)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
elif self.path in ('/', '/healthz'):
|
||||
body = b'ok\n'
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/plain')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
body = b'{"error":"not found"}\n'
|
||||
self.send_response(404)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
log.debug('http: ' + fmt, *args)
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def run_service(config):
|
||||
monitor = Monitor(config)
|
||||
|
||||
def poll_loop():
|
||||
while True:
|
||||
try:
|
||||
n = monitor.poll_vnstat()
|
||||
log.debug('vnstat poll updated %d month(s)', n)
|
||||
except Exception:
|
||||
log.exception('poll loop error')
|
||||
time.sleep(int(config.get('poll_interval_sec', 60)))
|
||||
|
||||
def schedule_loop():
|
||||
while True:
|
||||
try:
|
||||
monitor.check_azure_usage() # 先刷新 Azure 口径数据,再评估告警
|
||||
monitor.check_alerts()
|
||||
monitor.check_monthly_report()
|
||||
except Exception:
|
||||
log.exception('schedule loop error')
|
||||
time.sleep(int(config.get('schedule_interval_sec', 30)))
|
||||
|
||||
threading.Thread(target=poll_loop, daemon=True, name='vnstat-poll').start()
|
||||
threading.Thread(target=schedule_loop, daemon=True, name='scheduler').start()
|
||||
|
||||
host = config.get('http_host', '127.0.0.1')
|
||||
port = int(config.get('http_port', 18080))
|
||||
server = ThreadingHTTPServer((host, port), build_http_handler(monitor))
|
||||
log.info('HTTP server listening on http://%s:%d/vpn/status/traffic', host, port)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='vnstat traffic monitor')
|
||||
ap.add_argument('--config', default=os.environ.get('TRAFFIC_CONFIG', '/home/agent/config.json'))
|
||||
ap.add_argument('--once', action='store_true', help='拉取一次 vnstat 后退出')
|
||||
ap.add_argument('--status', action='store_true', help='打印当前状态 JSON 后退出')
|
||||
ap.add_argument('--test-email', action='store_true', help='发送一封测试邮件后退出')
|
||||
args = ap.parse_args()
|
||||
|
||||
os.environ['TRAFFIC_CONFIG'] = args.config
|
||||
with open(args.config, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, config.get('log_level', 'INFO').upper(), logging.INFO),
|
||||
format='%(asctime)s %(levelname)s %(name)s: %(message)s')
|
||||
|
||||
if args.once:
|
||||
m = Monitor(config)
|
||||
n = m.poll_vnstat()
|
||||
print(f'updated {n} month(s)')
|
||||
return 0
|
||||
if args.status:
|
||||
m = Monitor(config)
|
||||
m.poll_vnstat()
|
||||
print(json.dumps(m.current_status(), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.test_email:
|
||||
m = Monitor(config)
|
||||
m.poll_vnstat()
|
||||
quota_gb = float(config.get('azure_free_limit_gb', 15))
|
||||
az = m.azure_usage or {}
|
||||
used_gb = az.get('portal_data_transfer_out_gb')
|
||||
if used_gb is None:
|
||||
try:
|
||||
m.fetch_azure_usage()
|
||||
az = m.azure_usage or {}
|
||||
used_gb = az.get('portal_data_transfer_out_gb')
|
||||
except Exception as e:
|
||||
print('azure usage fetch failed:', e)
|
||||
mk = 'azure:' + str(az.get('month') or utcnow().strftime('%Y-%m'))
|
||||
pct = used_gb / quota_gb * 100 if (used_gb is not None and quota_gb) else 0.0
|
||||
subject, body = m.build_alert_mail(mk, used_gb or 0.0, quota_gb, pct, az)
|
||||
print('sending test email:')
|
||||
print('Subject:', subject)
|
||||
print(body)
|
||||
m.send_email(subject, body)
|
||||
print('sent')
|
||||
return 0
|
||||
run_service(config)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
RRPP UoT v2 relay — UDP over TCP 解封装服务(sing-box UoT v2 协议)
|
||||
|
||||
协议(SagerNet 专有,见 sing-box 文档 configuration/shared/udp-over-tcp):
|
||||
|
||||
请求头: | isConnect u8 | ATYP u8 | address | port u16be |
|
||||
isConnect=1: 固定目标模式,后续帧全部是 length+data,发往请求头里的目标
|
||||
isConnect=0: 每帧自带目标,帧格式同 v1: | ATYP | address | port | length u16be | data |
|
||||
|
||||
connect 帧: | length u16be | data |
|
||||
non-connect 帧: | ATYP | address | port | length u16be | data |
|
||||
|
||||
监听 127.0.0.1,仅供本机 nginx proxy_connect 转发,不对公网开放。
|
||||
|
||||
健壮性设计(2026-09 修复,实测复现过隧道被误杀):
|
||||
* 一律使用「未连接」UDP socket(sendto/recvfrom)——连接式 socket 会把目标侧
|
||||
ICMP(port unreachable 等)转成 ConnectionRefusedError,在 send/recv 上抛异常,
|
||||
旧实现因此把整条 UoT 隧道关掉(客户端表现为 "UoT TCP 流已关闭")。
|
||||
* 数据报级错误(BlockingIOError/EAGAIN、ENETUNREACH、EHOSTUNREACH、EMSGSIZE
|
||||
等)只丢当前包并继续;EAGAIN 先等可写再重试(有总时限)。
|
||||
* 只有 TCP 侧 EOF、UoT 协议错误或空闲超时才关闭隧道。
|
||||
* 按地址族惰性建 socket,non-connect 模式可混用 IPv4/IPv6 目标。
|
||||
"""
|
||||
import errno
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
LISTEN_HOST = "127.0.0.1"
|
||||
LISTEN_PORT = 15201
|
||||
IDLE_TIMEOUT = 540 # 9 分钟无流量断开(须 < nginx proxy_connect 隧道超时 10m)
|
||||
MAX_DGRAM = 65507 # UDP over IPv4 单包上限
|
||||
SEND_RETRY_TOTAL = 1.0 # EAGAIN 时最多等待 1 秒可写,超时则丢弃该包
|
||||
RECV_ERROR_STREAK_LIMIT = 200 # 连续 recv 错误上限(防病态自旋),超过视为隧道异常
|
||||
LOG_PREFIX = "[uot-relay]"
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"{LOG_PREFIX} {time.strftime('%Y-%m-%d %H:%M:%S')} {msg}", flush=True)
|
||||
|
||||
|
||||
def read_exact(f, n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = f.read(n - len(buf))
|
||||
if not chunk:
|
||||
raise EOFError("connection closed")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def parse_addr(f):
|
||||
"""从流中读取 SOCKS 格式地址(ATYP + address + port)。"""
|
||||
atyp = read_exact(f, 1)[0]
|
||||
if atyp == 0x00:
|
||||
addr = socket.inet_ntop(socket.AF_INET, read_exact(f, 4))
|
||||
elif atyp == 0x01:
|
||||
addr = socket.inet_ntop(socket.AF_INET6, read_exact(f, 16))
|
||||
elif atyp == 0x02:
|
||||
ln = read_exact(f, 1)[0]
|
||||
addr = read_exact(f, ln).decode("utf-8", "replace")
|
||||
else:
|
||||
raise ValueError(f"bad ATYP {atyp:#x}")
|
||||
port = struct.unpack(">H", read_exact(f, 2))[0]
|
||||
return addr, port
|
||||
|
||||
|
||||
def encode_addr(addr, port):
|
||||
"""SOCKS 格式地址编码(用于 non-connect 模式的回包)。"""
|
||||
try:
|
||||
return b"\x00" + socket.inet_pton(socket.AF_INET, addr) + struct.pack(">H", port)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
return b"\x01" + socket.inet_pton(socket.AF_INET6, addr) + struct.pack(">H", port)
|
||||
except OSError:
|
||||
pass
|
||||
d = addr.encode("utf-8")
|
||||
if len(d) > 255:
|
||||
raise ValueError("domain too long")
|
||||
return b"\x02" + bytes([len(d)]) + d + struct.pack(">H", port)
|
||||
|
||||
|
||||
def norm_src(src):
|
||||
"""recvfrom 地址归一化:IPv4 为 (host, port),IPv6 为 4 元组。"""
|
||||
return src[0], src[1]
|
||||
|
||||
|
||||
class UdpPool:
|
||||
"""按地址族惰性创建「未连接」UDP socket(v4/v6 可混用)。"""
|
||||
|
||||
def __init__(self):
|
||||
self.socks = {}
|
||||
|
||||
def get(self, addr):
|
||||
fam = socket.AF_INET6 if ":" in addr else socket.AF_INET
|
||||
s = self.socks.get(fam)
|
||||
if s is None:
|
||||
s = socket.socket(fam, socket.SOCK_DGRAM)
|
||||
s.setblocking(False)
|
||||
self.socks[fam] = s
|
||||
return s
|
||||
|
||||
def all(self):
|
||||
return list(self.socks.values())
|
||||
|
||||
def close(self):
|
||||
for s in self.socks.values():
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.socks.clear()
|
||||
|
||||
|
||||
def send_dgram(sock, data, dst):
|
||||
"""发送一个 UDP 数据报。
|
||||
|
||||
返回 True=已发出,False=本包丢弃(**不是**连接级错误,调用方继续跑)。
|
||||
EAGAIN 时等可写重试(有总时限);其余 OSError 一律丢弃本包。
|
||||
"""
|
||||
deadline = time.time() + SEND_RETRY_TOTAL
|
||||
while True:
|
||||
try:
|
||||
sock.sendto(data, dst)
|
||||
return True
|
||||
except BlockingIOError:
|
||||
if time.time() >= deadline:
|
||||
return False
|
||||
try:
|
||||
select.select([], [sock], [], 0.1)
|
||||
except Exception:
|
||||
return False
|
||||
except InterruptedError:
|
||||
continue
|
||||
except OSError:
|
||||
# ENETUNREACH / EHOSTUNREACH / EMSGSIZE / EACCES ...
|
||||
return False
|
||||
|
||||
|
||||
def handle(conn, peer):
|
||||
conn.settimeout(IDLE_TIMEOUT)
|
||||
f = conn.makefile("rb")
|
||||
pool = UdpPool()
|
||||
stop = threading.Event()
|
||||
fixed_dst = None
|
||||
try:
|
||||
is_connect = read_exact(f, 1)[0]
|
||||
req_dst = parse_addr(f)
|
||||
log(f"conn from nginx: isConnect={is_connect} dst={req_dst}")
|
||||
if is_connect:
|
||||
fixed_dst = req_dst
|
||||
pool.get(fixed_dst[0]) # 预建 socket:主循环首轮才有可 select 的对象
|
||||
|
||||
write_lock = threading.Lock()
|
||||
|
||||
def tcp_write(data):
|
||||
with write_lock:
|
||||
conn.sendall(data)
|
||||
|
||||
def uplink():
|
||||
"""TCP -> UDP:逐帧解析并发送;单包错误只丢包,不关隧道。"""
|
||||
try:
|
||||
while not stop.is_set():
|
||||
if is_connect:
|
||||
ln = struct.unpack(">H", read_exact(f, 2))[0]
|
||||
if ln > MAX_DGRAM:
|
||||
raise ValueError(f"datagram too large: {ln}")
|
||||
data = read_exact(f, ln)
|
||||
send_dgram(pool.get(fixed_dst[0]), data, fixed_dst)
|
||||
else:
|
||||
dst = parse_addr(f)
|
||||
ln = struct.unpack(">H", read_exact(f, 2))[0]
|
||||
if ln > MAX_DGRAM:
|
||||
raise ValueError(f"datagram too large: {ln}")
|
||||
data = read_exact(f, ln)
|
||||
send_dgram(pool.get(dst[0]), data, dst)
|
||||
except Exception as e:
|
||||
if not stop.is_set():
|
||||
log(f"uplink end: {type(e).__name__}: {e}")
|
||||
finally:
|
||||
stop.set()
|
||||
|
||||
threading.Thread(target=uplink, daemon=True).start()
|
||||
|
||||
# 主循环:UDP -> TCP(带空闲超时)
|
||||
last = time.time()
|
||||
err_streak = 0
|
||||
while not stop.is_set():
|
||||
socks = pool.all()
|
||||
if not socks:
|
||||
# non-connect 模式:socket 由第一帧触发创建,空池时等待而非关连接
|
||||
if time.time() - last > IDLE_TIMEOUT:
|
||||
log("idle timeout (no datagram), closing")
|
||||
break
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
try:
|
||||
r, _, _ = select.select(socks, [], [], 2.0)
|
||||
except OSError:
|
||||
break
|
||||
got = False
|
||||
for s in r:
|
||||
try:
|
||||
data, src = s.recvfrom(MAX_DGRAM)
|
||||
except (BlockingIOError, InterruptedError):
|
||||
continue
|
||||
except OSError as e:
|
||||
# 单包级错误(含 ICMP 触发的错误):丢包继续,不关隧道
|
||||
err_streak += 1
|
||||
if err_streak % 50 == 1:
|
||||
log(f"recv error (datagram dropped, streak={err_streak}): "
|
||||
f"{errno.errorcode.get(e.errno, e.errno)} {e}")
|
||||
if err_streak >= RECV_ERROR_STREAK_LIMIT:
|
||||
log("recv error streak limit reached, closing tunnel")
|
||||
stop.set()
|
||||
break
|
||||
continue
|
||||
err_streak = 0
|
||||
a, p = norm_src(src)
|
||||
if is_connect:
|
||||
if (a, p) != fixed_dst:
|
||||
continue # 固定目标模式:只回传目标自身的包
|
||||
tcp_write(struct.pack(">H", len(data)) + data)
|
||||
else:
|
||||
tcp_write(encode_addr(a, p) + struct.pack(">H", len(data)) + data)
|
||||
got = True
|
||||
if got:
|
||||
last = time.time()
|
||||
elif time.time() - last > IDLE_TIMEOUT:
|
||||
log("idle timeout, closing")
|
||||
break
|
||||
except Exception as e:
|
||||
log(f"conn error: {type(e).__name__}: {e}")
|
||||
finally:
|
||||
stop.set()
|
||||
try:
|
||||
f.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
pool.close()
|
||||
|
||||
|
||||
def main():
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind((LISTEN_HOST, LISTEN_PORT))
|
||||
srv.listen(128)
|
||||
log(f"listening on {LISTEN_HOST}:{LISTEN_PORT}")
|
||||
while True:
|
||||
conn, peer = srv.accept()
|
||||
threading.Thread(target=handle, args=(conn, peer), daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user