Compare commits

..
Author SHA1 Message Date
RoyirinRosmontis 41204df78a release: publish 9999091904 2026-09-19 04:43:17 +08:00
RoyirinRosmontis d0e739eff1 fix(lite): hide tun network command windows 2026-09-19 04:36:54 +08:00
RoyirinRosmontis e406d614b7 refactor(lite): keep tunnel startup details out of main log 2026-09-19 04:29:24 +08:00
RoyirinRosmontis 357fd26a49 fix(lite): bundle tun runtime and quiet connection logs 2026-09-19 04:27:20 +08:00
RoyirinRosmontis 9d5bc527d3 feat(lite): add opt-in tun ipv6 mode 2026-09-19 04:20:34 +08:00
RoyirinRosmontis 2315735979 fix(lite): report tun worker startup errors 2026-09-19 04:07:27 +08:00
RoyirinRosmontis 889dcb88e5 style(lite): restore intended mode hover contrast 2026-09-19 04:01:02 +08:00
RoyirinRosmontis 9224ad33fe style(lite): swap mode card hover colors 2026-09-19 03:54:59 +08:00
RoyirinRosmontis 50364dc17a feat(lite): merge TUN mode with IPv6 routing 2026-09-19 03:41:41 +08:00
RRRosmontis 3e29012d9c server_config: CONNECT 出站超时 60s→10s,并补充排障口径
实测(2026-09):目标为黑洞 IP 时,nginx 出站超时 60s > lite 客户端 15s,
表现为客户端 'read operation timed out' 而非 502,形似代理卡死,实为超时错配。
改为 10s 后黑洞目标 10.2s 返回、正常目标 0.4-0.7s 不受影响(五节点已生效)。

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

- nginx 模板:map $request 识别 sing-box UoT v2 magic 地址
  (sp.v2.udp-over-tcp.arpa)→ 127.0.0.1:15201;default_server 块加
  proxy_connect_address;read/send timeout 10m
- 新增 uot-relay/:Python 标准库 UoT v2 relay(connect/non-connect 双模式,
  仅监听 127.0.0.1,空闲 9 分钟断开)
- README 5.5 节:部署方法 + 客户端协议 + 四个实测坑
  ($connect_host 未注册变量 / data_timeout 与 read_timeout 同槽 /
  CONNECT 恒落 default_server / 拒绝端口 0)
2026-09-19 00:31:41 +08:00
RoyirinRosmontis e16ef58182 style(lite): unify compact settings buttons 2026-09-18 22:29:28 +08:00
RoyirinRosmontis f4d777113c feat(lite): add tray settings and reset controls 2026-09-18 21:43:09 +08:00
RoyirinRosmontis 26926f6469 feat(lite): enable built-in PAC by default and publish 9999091820 2026-09-18 20:39:34 +08:00
RoyirinRosmontis a0e2600399 feat(lite): add built-in PAC mode and publish 9999091820 2026-09-18 20:34:18 +08:00
RoyirinRosmontis 01de67610d feat(lite): add detailed runtime logs and publish 9999091817 2026-09-18 17:10:03 +08:00
RoyirinRosmontis 384a6107a8 style(lite): unify settings action labels 2026-09-18 16:46:33 +08:00
RoyirinRosmontis fb47f9e1d2 style(lite): compact settings dialog buttons 2026-09-18 16:43:05 +08:00
RoyirinRosmontis 71a67d7dca feat(lite): 持久化代理模式配置并发布 9999091816 2026-09-18 16:23:40 +08:00
26 changed files with 4300 additions and 279 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
# Royirin Rosmontis Private Proxy # Royirin Rosmontis Private Proxy
Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu
## 警告 ## 警告
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本 - 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本。common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows 应用 (.exe) - 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows 应用 (.exe)
- 因为 Windows 系统的安全机制,Release 发布的所有程序都为 Pyinstaller 打包版本,故启动速度可能较慢,尤其是首次启动。相对而言,Nuitka 打包的版本启动较快,但可能仅能在自己电脑上运行。如果需要 Nuitka 版本,仓库提供了对应的打包程序,请自备虚拟环境
### 如何区分 common 版本与 Lite 版本 ### 如何区分 common 版本与 Lite 版本
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳 - Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳
+7 -2
View File
@@ -9,6 +9,11 @@ qt_datas, qt_binaries, qt_hiddenimports = collect_all('PyQt5')
# 只内置 config.json(普通设置)。nodes.json / pac_*.json 属用户数据: # 只内置 config.json(普通设置)。nodes.json / pac_*.json 属用户数据:
# 运行时读 %LOCALAPPDATA%\RRPP(首启自动创建模板 / 从 CDN 拉取),不进 EXE。 # 运行时读 %LOCALAPPDATA%\RRPP(首启自动创建模板 / 从 CDN 拉取),不进 EXE。
_JSON_DATAS = [(str(Path(SPECPATH) / 'config.json'), '.')] _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]}") print(f"[spec] 打包 json 数据文件: {[Path(src).name for src, _ in _JSON_DATAS]}")
# 自定义 manifest:普通权限运行 + 声明 PerMonitorV2 DPI 感知, # 自定义 manifest:普通权限运行 + 声明 PerMonitorV2 DPI 感知,
@@ -46,9 +51,9 @@ _MANIFEST = r'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
a = Analysis( a = Analysis(
['main.py'], ['main.py'],
pathex=['..\\.venv\\Lib\\site-packages'], pathex=[],
binaries=qt_binaries, binaries=qt_binaries,
datas=[('ico.ico', '.')] + _JSON_DATAS + qt_datas, datas=[('ico.ico', '.')] + _JSON_DATAS + _TUN_RUNTIME + qt_datas,
hiddenimports=qt_hiddenimports, hiddenimports=qt_hiddenimports,
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
+6
View File
@@ -21,7 +21,13 @@ for /f %%v in ('..\.venv\Scripts\python.exe -c "import json;v=json.load(open('co
call ..\.venv\Scripts\python.exe -m nuitka ^ call ..\.venv\Scripts\python.exe -m nuitka ^
--standalone --onefile ^ --standalone --onefile ^
--enable-plugin=pyqt5 ^ --enable-plugin=pyqt5 ^
--user-package-configuration-file=rrpp-tun.nuitka-package.config.yml ^
--include-data-files=config.json=config.json ^ --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-icon-from-ico=ico.ico ^
--windows-console-mode=disable ^ --windows-console-mode=disable ^
--company-name="Global Rosmanka Cloud Nexus Inc" ^ --company-name="Global Rosmanka Cloud Nexus Inc" ^
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "999909181435", "version": "999909190441",
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/", "pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client", "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" "update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
+805 -226
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
---
- module-name: 'main'
dlls:
- from_filenames:
relative_path: 'runtime'
prefixes:
- 'tun2socks'
suffixes:
- 'exe'
executable: 'yes'
dest_path: '.'
when: 'win32'
- from_filenames:
relative_path: 'runtime'
prefixes:
- 'wintun'
suffixes:
- 'dll'
dest_path: '.'
when: 'win32'
+152
View File
@@ -0,0 +1,152 @@
"""轻量 DNS 观察器,用于把 TUN 里的目标 IP 关联回解析域名。
TUN 送到 SOCKS5 的请求通常只带目标 IP。这里观察经 UoT 或直连的
UDP DNS 回包,保存短期的 ``IP -> 域名`` 映射供分流和日志使用。
"""
from __future__ import annotations
import ipaddress
import struct
import threading
import time
from typing import Callable
def _read_name(packet: bytes, offset: int) -> tuple[str, int]:
labels: list[str] = []
cursor = offset
next_offset = None
jumps = 0
while True:
if cursor >= len(packet):
raise ValueError("DNS 名称超出数据包")
length = packet[cursor]
if length == 0:
cursor += 1
if next_offset is None:
next_offset = cursor
break
if length & 0xC0 == 0xC0:
if cursor + 1 >= len(packet):
raise ValueError("DNS 压缩指针不完整")
pointer = ((length & 0x3F) << 8) | packet[cursor + 1]
if next_offset is None:
next_offset = cursor + 2
cursor = pointer
jumps += 1
if jumps > 16:
raise ValueError("DNS 名称压缩指针循环")
continue
if length > 63 or cursor + 1 + length > len(packet):
raise ValueError("DNS 标签不完整")
labels.append(packet[cursor + 1:cursor + 1 + length].decode("idna"))
cursor += 1 + length
return ".".join(labels).rstrip(".").lower(), int(next_offset)
def _question(packet: bytes) -> tuple[int, str, int]:
if len(packet) < 12:
raise ValueError("DNS 头不完整")
txid, flags, questions, _answers, _authority, _additional = struct.unpack(
"!HHHHHH", packet[:12]
)
if flags & 0x8000 or questions < 1:
raise ValueError("不是 DNS 查询")
name, offset = _read_name(packet, 12)
if offset + 4 > len(packet):
raise ValueError("DNS 问题段不完整")
return txid, name, offset + 4
class DnsObserver:
def __init__(self, log: Callable[[str], None] | None = None,
max_ttl: int = 300):
self.log = log or (lambda _message: None)
self.max_ttl = max(1, int(max_ttl))
self._lock = threading.RLock()
self._domain_to_ips: dict[str, tuple[float, tuple[str, ...]]] = {}
# 一个 CDN IP 可能同时服务多个域名;不能把最后一次 DNS 回包当成唯一身份。
self._ip_to_domains: dict[str, tuple[float, set[str]]] = {}
def observe_query(self, packet: bytes) -> str | None:
try:
_txid, domain, _ = _question(packet)
return domain or None
except (ValueError, UnicodeError):
return None
def observe_response(self, packet: bytes) -> tuple[str, tuple[str, ...]] | None:
try:
if len(packet) < 12:
raise ValueError("DNS 头不完整")
_txid, flags, questions, answers, _authority, _additional = struct.unpack(
"!HHHHHH", packet[:12]
)
if not (flags & 0x8000) or questions < 1:
raise ValueError("不是 DNS 回包")
domain, offset = _read_name(packet, 12)
if offset + 4 > len(packet):
raise ValueError("DNS 问题段不完整")
offset += 4
ips: list[str] = []
ttl = self.max_ttl
for _ in range(answers):
_owner, offset = _read_name(packet, offset)
if offset + 10 > len(packet):
raise ValueError("DNS 答案头不完整")
rtype, rclass, record_ttl, size = struct.unpack(
"!HHIH", packet[offset:offset + 10]
)
offset += 10
if offset + size > len(packet):
raise ValueError("DNS 答案数据不完整")
raw = packet[offset:offset + size]
offset += size
if rclass != 1 or rtype not in (1, 28):
continue
if (rtype == 1 and size != 4) or (rtype == 28 and size != 16):
continue
ips.append(str(ipaddress.ip_address(raw)))
ttl = min(ttl, max(1, int(record_ttl)))
if not domain or not ips:
return None
expires = time.monotonic() + min(ttl, self.max_ttl)
unique = tuple(dict.fromkeys(ips))
with self._lock:
self._domain_to_ips[domain] = (expires, unique)
for ip in unique:
previous = self._ip_to_domains.get(ip)
domains = set(previous[1]) if previous and previous[0] > time.monotonic() else set()
domains.add(domain)
self._ip_to_domains[ip] = (expires, domains)
return domain, unique
except (ValueError, UnicodeError, OSError):
return None
def lookup_ip(self, ip: str) -> str | None:
try:
normalized = str(ipaddress.ip_address(ip))
except ValueError:
return None
now = time.monotonic()
with self._lock:
item = self._ip_to_domains.get(normalized)
if not item:
return None
if item[0] <= now:
self._ip_to_domains.pop(normalized, None)
return None
return next(iter(item[1])) if len(item[1]) == 1 else None
def lookup_domain(self, domain: str) -> tuple[str, ...]:
key = str(domain).rstrip(".").lower()
now = time.monotonic()
with self._lock:
item = self._domain_to_ips.get(key)
if not item:
return ()
if item[0] <= now:
self._domain_to_ips.pop(key, None)
return ()
return item[1]
+857
View File
@@ -0,0 +1,857 @@
"""本地 SOCKS5 → HTTP CONNECT/UoT v2 桥接。
tun2socks 只需要一个标准 SOCKS5 出口;TCP 连接转成当前节点的 HTTP
CONNECT,UDP 连接转成 magic address 上的 UoT v2 流。系统代理设置完全不参与。
"""
from __future__ import annotations
import base64
import ipaddress
import select
import socket
import ssl
import struct
import threading
from typing import Any, Callable
from .dns_observer import DnsObserver
from .uot_protocol import (
Address, MAGIC_ADDRESS, encode_datagram_frame, encode_request,
read_datagram_frame,
)
# relay 的内网监听端口与外层 CONNECT 端口是两个概念:nginx 接收
# CONNECT magic-host:443 后,将字节流转发到 127.0.0.1:15201。
DEFAULT_UOT_RELAY_PORT = 15201
DEFAULT_UOT_CONNECT_PORT = 443
def _recv_exact(stream: socket.socket, size: int) -> bytes:
"""按 SOCKS5/TCP 协议长度读取,不能假设一次 recv 就拿全。"""
data = bytearray()
while len(data) < size:
chunk = stream.recv(size - len(data))
if not chunk:
raise ConnectionError("SOCKS5 连接在握手中断开")
data.extend(chunk)
return bytes(data)
def _node_http(node: dict[str, Any]) -> tuple[str, int, str, str]:
methods = (node.get("methods") or {}).get("http") or {}
host = str(node.get("domain") or "").strip()
port = methods.get("port") or node.get("port") or 443
username = methods.get("username") or node.get("username") or ""
password = methods.get("password") or node.get("password") or ""
if not host:
raise ValueError("节点缺少 HTTP CONNECT 地址")
return host, int(port), str(username), str(password)
def _node_uot_connect_port(node: dict[str, Any]) -> int:
"""返回 magic CONNECT 的对外端口,不能使用 0。"""
chain = node.get("_jump_chain") if isinstance(node, dict) else None
if isinstance(chain, list) and chain:
return _node_uot_connect_port(chain[-1])
methods = (node.get("methods") or {}).get("http") or {}
raw = methods.get("uot_connect_port")
if raw is None:
raw = node.get("uot_connect_port")
# 兼容早期把 uot_port 当作 CONNECT 端口的本地配置;服务端 nginx
# 会按 magic host 映射到隐藏的 15201 relay 端口。
if raw is None:
raw = methods.get("uot_port")
if raw is None:
raw = node.get("uot_port")
if raw is None:
raw = DEFAULT_UOT_CONNECT_PORT
port = int(raw)
if not 1 <= port <= 65535:
raise ValueError("UoT CONNECT 端口超出范围")
return port
def _socks_address(stream: socket.socket, kind: int | None = None) -> Address:
# SOCKS5 请求头已经读取过 ATYP 时直接传入;独立解析时才从流中读取。
if kind is None:
kind = _recv_exact(stream, 1)[0]
if kind == 1:
host = socket.inet_ntoa(_recv_exact(stream, 4))
elif kind == 4:
host = socket.inet_ntop(socket.AF_INET6, _recv_exact(stream, 16))
elif kind == 3:
size = _recv_exact(stream, 1)[0]
host = _recv_exact(stream, size).decode("idna")
else:
raise ValueError("SOCKS5 地址类型不支持")
port = struct.unpack("!H", _recv_exact(stream, 2))[0]
return Address(host, port)
def _pack_socks_udp(destination: Address, payload: bytes) -> bytes:
try:
ip = ipaddress.ip_address(destination.host)
except ValueError:
raw = destination.host.encode("idna")
return b"\x00\x00\x00\x03" + bytes([len(raw)]) + raw + struct.pack("!H", destination.port) + payload
atyp = 1 if ip.version == 4 else 4
return b"\x00\x00\x00" + bytes([atyp]) + ip.packed + struct.pack("!H", destination.port) + payload
def _unpack_socks_udp(packet: bytes) -> tuple[Address, bytes]:
if len(packet) < 4 or packet[:2] != b"\x00\x00":
raise ValueError("SOCKS5 UDP 包头无效")
if packet[2] != 0:
# RFC 1928 允许分片,但 tun2socks 当前不会发送分片;静默拼接会把
# 不完整的报文当成 DNS/QUIC 数据,造成更难排查的协议损坏。
raise ValueError("暂不支持 SOCKS5 UDP 分片")
atyp = packet[3]
offset = 4
if atyp == 1:
if len(packet) < offset + 4:
raise ValueError("SOCKS5 UDP IPv4 地址不完整")
host = socket.inet_ntoa(packet[offset:offset + 4]); offset += 4
elif atyp == 4:
if len(packet) < offset + 16:
raise ValueError("SOCKS5 UDP IPv6 地址不完整")
host = socket.inet_ntop(socket.AF_INET6, packet[offset:offset + 16]); offset += 16
elif atyp == 3:
if len(packet) < offset + 1:
raise ValueError("SOCKS5 UDP 域名长度不完整")
size = packet[offset]; offset += 1
if len(packet) < offset + size:
raise ValueError("SOCKS5 UDP 域名地址不完整")
host = packet[offset:offset + size].decode("idna"); offset += size
else:
raise ValueError("SOCKS5 UDP 地址类型不支持")
if len(packet) < offset + 2:
raise ValueError("SOCKS5 UDP 端口不完整")
port = struct.unpack("!H", packet[offset:offset + 2])[0]
return Address(host, port), packet[offset + 2:]
class HttpConnectDialer:
def __init__(self, node: dict[str, Any], timeout: float = 15.0,
source_address: str | None = None,
proxy_addresses: list[tuple] | None = None,
proxy_addresses_by_node: dict[str, list[tuple]] | None = None):
self.node = node
self.timeout = timeout
# TUN 启动后默认路由会指向虚拟网卡。节点本身必须从物理网卡发出,
# 否则上游 CONNECT 会再次进入本地 SOCKS,形成递归回环。
self.source_address = source_address
self.proxy_addresses = proxy_addresses
self.proxy_addresses_by_node = proxy_addresses_by_node or {}
@staticmethod
def _node_key(node: dict[str, Any]) -> str:
methods = (node.get("methods") or {}).get("http") or {}
return "|".join((str(node.get("domain") or ""),
str(methods.get("port") or node.get("port") or 443),
str(methods.get("username") or node.get("username") or "")))
@staticmethod
def _node_credentials(node: dict[str, Any]) -> tuple[str, str]:
methods = (node.get("methods") or {}).get("http") or {}
username = methods.get("username") or node.get("username") or ""
password = methods.get("password") or node.get("password") or ""
return str(username), str(password)
def _open_proxy_socket(self, host: str, port: int,
addresses: list[tuple] | None = None) -> socket.socket:
addresses = addresses or self.proxy_addresses
if not addresses:
addresses = [item[4] for item in socket.getaddrinfo(
host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)]
last_error: OSError | None = None
for sockaddr in addresses:
family = socket.AF_INET6 if len(sockaddr) == 4 else socket.AF_INET
raw = socket.socket(family, socket.SOCK_STREAM)
try:
raw.settimeout(self.timeout)
if self.source_address:
if family == socket.AF_INET and ":" not in self.source_address:
raw.bind((self.source_address, 0))
elif family == socket.AF_INET6 and ":" in self.source_address:
raw.bind((self.source_address, 0, 0, 0))
raw.connect(sockaddr)
return raw
except OSError as exc:
last_error = exc
raw.close()
if last_error:
raise last_error
raise OSError(f"无法解析 HTTP CONNECT 节点: {host}")
def _jump_addresses(self, node: dict[str, Any]) -> list[tuple] | None:
return self.proxy_addresses_by_node.get(self._node_key(node))
def _open_jump(self, target: Address) -> socket.socket:
"""通过 2-3 个 HTTP CONNECT 节点逐层建立 TLS,再连接最终目标。"""
chain = list(self.node.get("_jump_chain") or [])
if not 2 <= len(chain) <= 3:
raise RuntimeError("跳板链路必须包含 2-3 个节点")
ctx = ssl.create_default_context()
first = chain[0]
first_host, first_port, _username, _password = _node_http(first)
stream = self._open_proxy_socket(
first_host, first_port, self._jump_addresses(first))
try:
stream = ctx.wrap_socket(stream, server_hostname=first_host)
for index, next_node in enumerate(chain[1:], 1):
next_host, next_port, _username, _password = _node_http(next_node)
leftover = _http_connect_over(
stream, chain[index - 1], next_host, next_port, resolve_target=True)
stream = _LayeredTlsTransport(stream, ctx, next_host, leftover)
_http_connect_over(
stream, chain[-1], target.host, target.port, resolve_target=False)
return stream
except Exception:
try:
stream.close()
except OSError:
pass
raise
def open(self, target: Address) -> socket.socket:
if self.node.get("_jump_chain"):
return self._open_jump(target)
host, port, username, password = _node_http(self.node)
raw = self._open_proxy_socket(host, port)
ctx = ssl.create_default_context()
stream = ctx.wrap_socket(raw, server_hostname=host)
authority = f"[{target.host}]:{target.port}" if ":" in target.host else f"{target.host}:{target.port}"
auth = ""
if username or password:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
auth = f"Authorization: Basic {token}\r\n"
request = (
f"CONNECT {authority} HTTP/1.1\r\n"
f"Host: {authority}\r\n"
f"{auth}Connection: Keep-Alive\r\n\r\n"
).encode("ascii")
stream.sendall(request)
response = b""
while b"\r\n\r\n" not in response and len(response) < 65536:
chunk = stream.recv(4096)
if not chunk:
break
response += chunk
first = response.split(b"\r\n", 1)[0].decode("latin1", "replace")
if not first.startswith(("HTTP/1.1 200", "HTTP/1.0 200")):
stream.close()
raise ConnectionError(f"HTTP CONNECT 失败: {first[:160]}")
return stream
def _resolve_jump_address(host: str, port: int) -> tuple:
infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
infos.sort(key=lambda item: 0 if item[0] == socket.AF_INET else 1)
if not infos:
raise OSError(f"无法解析跳板地址: {host}")
return infos[0][4]
def _http_connect_over(transport, proxy_node: dict[str, Any], target_host: str,
target_port: int, *, resolve_target: bool) -> bytes:
"""在当前 TLS 层发送 CONNECT;中间跳板使用解析后的 IP,末跳保留目标名。"""
username, password = HttpConnectDialer._node_credentials(proxy_node)
auth_header = ""
if username or password:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
auth_header = f"Authorization: Basic {token}\r\n"
if resolve_target:
address = _resolve_jump_address(target_host, target_port)
authority = f"[{address[0]}]:{address[1]}" if ":" in address[0] else f"{address[0]}:{address[1]}"
else:
authority = f"[{target_host}]:{target_port}" if ":" in target_host else f"{target_host}:{target_port}"
request = (
f"CONNECT {authority} HTTP/1.1\r\n"
f"Host: {authority}\r\n"
f"{auth_header}Connection: Keep-Alive\r\n\r\n"
).encode("ascii")
transport.sendall(request)
response = b""
while b"\r\n\r\n" not in response and len(response) < 65536:
chunk = transport.recv(4096)
if not chunk:
break
response += chunk
marker = b"\r\n\r\n"
end = response.find(marker)
first = response.split(b"\r\n", 1)[0].decode("latin-1", "replace")
if not (first.startswith("HTTP/1.1 200") or first.startswith("HTTP/1.0 200")):
raise ConnectionError(f"跳板 CONNECT 失败: {first[:160]}")
return response[end + len(marker):] if end >= 0 else b""
class _LayeredTlsTransport:
"""在已有 TLS 字节流上承载下一层 TLS。"""
def __init__(self, transport, ctx, server_hostname: str, initial: bytes = b""):
self._transport = transport
self._incoming = ssl.MemoryBIO()
self._outgoing = ssl.MemoryBIO()
self._pending = initial
self._ssl = ctx.wrap_bio(
self._incoming, self._outgoing, server_side=False,
server_hostname=server_hostname)
self._handshake()
def _flush(self):
data = self._outgoing.read()
if data:
self._transport.sendall(data)
def _feed(self):
if self._pending:
data, self._pending = self._pending, b""
else:
data = self._transport.recv(16384)
if not data:
raise ConnectionError("分层 TLS 下层连接已关闭")
self._incoming.write(data)
def _handshake(self):
while True:
try:
self._ssl.do_handshake()
self._flush()
return
except ssl.SSLWantReadError:
self._flush()
self._feed()
except ssl.SSLWantWriteError:
self._flush()
def sendall(self, data):
view = memoryview(data)
while view:
try:
written = self._ssl.write(view)
view = view[written:]
self._flush()
except ssl.SSLWantReadError:
self._flush()
self._feed()
except ssl.SSLWantWriteError:
self._flush()
def recv(self, size):
while True:
try:
return self._ssl.read(size)
except ssl.SSLWantReadError:
self._flush()
self._feed()
except ssl.SSLWantWriteError:
self._flush()
def settimeout(self, value):
self._transport.settimeout(value)
def gettimeout(self):
return self._transport.gettimeout()
def close(self):
self._transport.close()
class Socks5Bridge:
def __init__(self, node: dict[str, Any], bypass_lan: bool = True,
route_rules: list[dict[str, Any]] | None = None,
log: Callable[[str], None] | None = None,
source_address: str | None = None,
proxy_addresses: list[tuple] | None = None,
direct_target_resolver: Callable[[str, str, int], tuple[str, int] | None] | None = None,
route_rule_resolver: Callable[[str], dict[str, Any] | None] | None = None,
route_node_resolver: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
proxy_addresses_by_node: dict[str, list[tuple]] | None = None,
direct_route_ensurer: Callable[[str], None] | None = None):
self.node = node
self.bypass_lan = bypass_lan
self.route_rules = route_rules or []
self.log = log or (lambda _message: None)
self.source_address = source_address
self.proxy_addresses = proxy_addresses
# 由 main.py 注入 Lite 的同一套 PAC/路由判定,TUN 不复制另一份规则语义。
self.direct_target_resolver = direct_target_resolver
self.route_rule_resolver = route_rule_resolver
self.route_node_resolver = route_node_resolver
self.proxy_addresses_by_node = proxy_addresses_by_node or {}
self.direct_route_ensurer = direct_route_ensurer
self.listener: socket.socket | None = None
self._running = threading.Event()
self._threads: list[threading.Thread] = []
self._up = 0
self._down = 0
self._counter_lock = threading.Lock()
self._dns = DnsObserver(log=self.log)
self._udp_resources: list[socket.socket] = []
self._udp_resources_lock = threading.RLock()
def start(self) -> int:
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.listener.bind(("127.0.0.1", 0))
self.listener.listen(32)
self.listener.settimeout(1)
self._running.set()
thread = threading.Thread(target=self._accept_loop, daemon=True)
thread.start(); self._threads.append(thread)
return self.listener.getsockname()[1]
def _accept_loop(self):
while self._running.is_set() and self.listener:
try:
client, _ = self.listener.accept()
except socket.timeout:
continue
except OSError:
break
thread = threading.Thread(target=self._client, args=(client,), daemon=True)
thread.start(); self._threads.append(thread)
def _client(self, client: socket.socket):
try:
client.settimeout(15)
greeting = _recv_exact(client, 2)
if greeting[0] != 5:
return
methods = _recv_exact(client, greeting[1])
if 0 not in methods:
client.sendall(b"\x05\xff")
return
client.sendall(b"\x05\x00")
header = _recv_exact(client, 4)
if header[0] != 5:
return
command = header[1]
destination = _socks_address(client, header[3])
if command == 1:
self.log(f"[桥接] TCP 目标: {self._target_label(destination)}")
self._tcp(client, destination)
elif command == 3:
self._udp(client)
else:
client.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00")
except Exception as exc:
# 停止 TUN 时主动关闭仍在转发的连接,Windows 可能返回
# WSAECONNRESET (10054);这是收尾动作,不应作为用户错误记录。
if self._running.is_set():
self.log(f"[桥接] SOCKS5 请求失败: {exc}")
finally:
try: client.close()
except OSError: pass
def _target_label(self, target: Address) -> str:
host = target.host
try:
ipaddress.ip_address(host)
except ValueError:
ips = self._dns.lookup_domain(host)
resolved = ",".join(ips) if ips else "未知"
return f"domain={host} ip={resolved} port={target.port}"
domain = self._dns.lookup_ip(host) or "未知"
return f"domain={domain} ip={host} port={target.port}"
def _policy_host(self, host: str) -> str:
"""对已观察到的唯一 DNS 映射应用域名规则;实际连接仍使用原目标 IP。"""
try:
ipaddress.ip_address(host)
except ValueError:
return host
return self._dns.lookup_ip(host) or host
def _direct_target(self, host: str, port: int) -> tuple[str, int] | None:
explicit = False
normalized = str(host).rstrip(".").lower()
for rule in self.route_rules:
if not isinstance(rule, dict) or not rule.get("enabled", True):
continue
if str(rule.get("action") or "direct").lower() != "direct":
continue
value = str(rule.get("domain") or rule.get("value") or "").strip().lower().rstrip(".")
if value.startswith("*."):
value = value[2:]
try:
if ipaddress.ip_address(normalized) in ipaddress.ip_network(value, strict=False):
explicit = True
except ValueError:
if normalized == value or normalized.endswith("." + value):
explicit = True
if explicit:
break
if not self.bypass_lan and not explicit:
return None
try:
address = ipaddress.ip_address(host)
return (host, port) if explicit or address.is_private else None
except ValueError:
try:
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
address = item[4][0].split("%", 1)[0]
if explicit or ipaddress.ip_address(address).is_private:
return address, port
except OSError:
pass
return None
def _route_rule(self, host: str, original_host: str | None = None) -> dict[str, Any] | None:
if self.route_rule_resolver is not None:
try:
rule = self.route_rule_resolver(host)
if isinstance(rule, dict):
return rule
if original_host and original_host != host:
rule = self.route_rule_resolver(original_host)
if isinstance(rule, dict):
return rule
except Exception as exc:
self.log(f"[路由] 规则判断失败: {exc}")
for rule in self.route_rules:
if not isinstance(rule, dict) or not rule.get("enabled", True):
continue
value = str(rule.get("domain") or rule.get("value") or "").strip().lower().rstrip(".")
normalized = str(host).rstrip(".").lower()
base = value[2:] if value.startswith("*.") else value
if base and (normalized == base or normalized.endswith("." + base)):
return rule
return None
def _direct_target_for(self, host: str, port: int,
policy_host: str | None = None) -> tuple[str, int] | None:
policy_host = policy_host or self._policy_host(host)
rule = self._route_rule(policy_host, host)
action = str((rule or {}).get("action") or "").lower()
if action == "node":
return None
if self.direct_target_resolver is not None:
try:
return self.direct_target_resolver(policy_host, host, port)
except Exception as exc:
self.log(f"[路由] PAC 直连判断失败: {exc}")
return None
if action == "direct":
return (host, port)
return self._direct_target(host, port)
def _route_node(self, rule: dict[str, Any] | None) -> dict[str, Any] | None:
if not rule or str(rule.get("action") or "").lower() != "node":
return None
if self.route_node_resolver is None:
return None
try:
node = self.route_node_resolver(rule)
return node if isinstance(node, dict) else None
except Exception as exc:
self.log(f"[路由] 固定节点解析失败: {exc}")
return None
@staticmethod
def _node_key(node: dict[str, Any]) -> str:
chain = node.get("_jump_chain")
if isinstance(chain, list) and chain:
return "jump|" + ">".join(
Socks5Bridge._node_key(item) for item in chain if isinstance(item, dict)
)
methods = (node.get("methods") or {}).get("http") or {}
return "|".join((str(node.get("domain") or ""), str(methods.get("port") or node.get("port") or 443),
str(methods.get("username") or node.get("username") or "")))
def _dialer(self, node: dict[str, Any]) -> HttpConnectDialer:
proxy_addresses = self.proxy_addresses
chain = node.get("_jump_chain")
if isinstance(chain, list) and chain:
proxy_addresses = self.proxy_addresses_by_node.get(
self._node_key(chain[0]))
return HttpConnectDialer(
node, source_address=self.source_address,
proxy_addresses=(proxy_addresses if node is self.node else
self.proxy_addresses_by_node.get(self._node_key(node), proxy_addresses)),
proxy_addresses_by_node=self.proxy_addresses_by_node,
)
def _ensure_direct_routes(self, host: str, port: int) -> None:
if self.direct_route_ensurer is None:
return
try:
self.direct_route_ensurer(str(ipaddress.ip_address(host)))
return
except ValueError:
pass
try:
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
self.direct_route_ensurer(str(item[4][0]).split("%", 1)[0])
except OSError:
pass
def _proxy_target(self, target: Address) -> Address:
"""为上游 CONNECT 恢复唯一 DNS 域名,给 IPv6 目标提供 IPv4 回退。"""
try:
ipaddress.ip_address(target.host)
except ValueError:
return target
domain = self._dns.lookup_ip(target.host)
if not domain:
return target
self.log(
f"[桥接] IPv6 目标回退域名解析: {target.host} -> {domain}:{target.port}"
)
return Address(domain, target.port)
def _open_target(self, target: Address) -> socket.socket:
policy_host = self._policy_host(target.host)
rule = self._route_rule(policy_host, target.host)
route_node = self._route_node(rule)
if rule and str(rule.get("action") or "").lower() == "node":
if route_node is None:
raise ConnectionError("域名路由指定的节点不可用")
return self._dialer(route_node).open(self._proxy_target(target))
direct = self._direct_target_for(target.host, target.port, policy_host)
if direct is not None:
self._ensure_direct_routes(direct[0], direct[1])
return socket.create_connection(direct, 15)
return self._dialer(self.node).open(self._proxy_target(target))
def _tcp(self, client: socket.socket, target: Address):
upstream = self._open_target(target)
upstream.settimeout(None); client.settimeout(None)
client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
self._relay(client, upstream)
def _relay(self, left: socket.socket, right: socket.socket):
sockets = [left, right]
try:
while self._running.is_set():
readable, _, _ = select.select(sockets, [], [], 1)
for source in readable:
data = source.recv(65536)
if not data: return
target = right if source is left else left
target.sendall(data)
with self._counter_lock:
if source is left: self._up += len(data)
else: self._down += len(data)
finally:
for item in sockets:
try: item.close()
except OSError: pass
def _udp(self, control: socket.socket):
# tun2socks v2.7 在 Windows 上通过 ListenPacket("udp", "")
# 创建 IPv6 wildcard socket(日志形如 [::]:port)。SOCKS5 返回的
# UDP relay 地址必须与这个地址族一致,否则 Windows 会在
# wsasendto 阶段以 WSAEINVAL 拒绝向 127.0.0.1:port 发包。
# 优先使用 IPv6 loopback;老系统没有 IPv6 时再回退 IPv4。
try:
udp = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
udp.bind(("::1", 0))
relay_address = b"\x04" + socket.inet_pton(socket.AF_INET6, "::1")
relay_bind = f"[::1]:{udp.getsockname()[1]}"
except OSError:
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.bind(("127.0.0.1", 0))
relay_address = b"\x01" + socket.inet_aton("127.0.0.1")
relay_bind = f"127.0.0.1:{udp.getsockname()[1]}"
port = udp.getsockname()[1]
udp.settimeout(1)
with self._udp_resources_lock:
self._udp_resources.append(udp)
control.sendall(b"\x05\x00\x00" + relay_address + struct.pack("!H", port))
self.log(f"[桥接] SOCKS5 UDP 关联已建立: {relay_bind}")
control.settimeout(1)
streams: dict[str, socket.socket] = {}
direct_flows: dict[tuple[str, int], tuple[socket.socket, tuple, Address]] = {}
malformed = 0
try:
while self._running.is_set():
try:
packet, peer = udp.recvfrom(65535)
except socket.timeout:
try:
if not control.recv(1): break
except socket.timeout:
continue
continue
try:
destination, payload = _unpack_socks_udp(packet)
except Exception as exc:
malformed += 1
if malformed <= 5:
self.log(
f"[桥接] SOCKS5 UDP 数据包解析失败: {exc} len={len(packet)}"
)
continue
if malformed:
malformed = 0
if not streams and not direct_flows:
self.log(
f"[桥接] SOCKS5 UDP 数据报: target={destination.host}:{destination.port} "
f"bytes={len(payload)}"
)
if destination.port == 53:
domain = self._dns.observe_query(payload)
if domain:
self.log(
f"[DNS] 查询: domain={domain} server={destination.host}:{destination.port}"
)
policy_host = self._policy_host(destination.host)
rule = self._route_rule(policy_host, destination.host)
route_node = self._route_node(rule)
if rule and str(rule.get("action") or "").lower() == "node" and route_node is None:
raise ConnectionError("UDP 域名路由指定的节点不可用")
direct = None if route_node is not None else self._direct_target_for(
destination.host, destination.port, policy_host)
if direct is not None:
key = (str(direct[0]), int(direct[1]))
flow = direct_flows.get(key)
if flow is None:
flow_socket, sockaddr = self._open_direct_udp(direct)
flow = (flow_socket, sockaddr, destination)
direct_flows[key] = flow
threading.Thread(
target=self._direct_udp_reader,
args=(flow_socket, udp, destination, peer),
daemon=True,
).start()
self.log(f"[UDP] 直连通道: target={destination.host}:{destination.port}")
flow_socket, direct_addr, _flow_destination = flow
flow_socket.sendto(payload, direct_addr)
with self._counter_lock:
self._up += len(payload)
continue
selected_node = route_node or self.node
stream_key = self._node_key(selected_node)
stream = streams.get(stream_key)
if stream is None:
self.log(
f"[UoT] 建立 UDP 通道: target={destination.host}:{destination.port}"
+ ("(固定节点)" if route_node is not None else "")
)
stream = self._dialer(selected_node).open(
Address(MAGIC_ADDRESS, _node_uot_connect_port(selected_node))
)
stream.settimeout(None)
stream.sendall(encode_request(False, destination))
streams[stream_key] = stream
with self._udp_resources_lock:
self._udp_resources.append(stream)
threading.Thread(
target=self._uot_reader, args=(stream, udp, peer), daemon=True
).start()
# UoT v2 non-connect 模式每个数据报都带目标地址。
stream.sendall(encode_datagram_frame(destination, payload))
with self._counter_lock:
self._up += len(payload)
finally:
udp.close()
for stream in streams.values():
try:
stream.close()
except OSError:
pass
for flow_socket, _sockaddr, _destination in direct_flows.values():
try:
flow_socket.close()
except OSError:
pass
with self._udp_resources_lock:
self._udp_resources = [
item for item in self._udp_resources
if item is not udp and item not in streams.values()
]
def _open_direct_udp_target(self, target: tuple[str, int]) -> tuple[socket.socket, tuple]:
host, port = target
last_error = None
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_DGRAM):
family, socktype, proto, _canonname, sockaddr = item
raw = socket.socket(family, socktype, proto)
try:
destination_ip = str(sockaddr[0]).split("%", 1)[0]
if self.direct_route_ensurer is not None:
self.direct_route_ensurer(destination_ip)
is_loopback = False
try:
is_loopback = ipaddress.ip_address(destination_ip).is_loopback
except ValueError:
pass
if (family == socket.AF_INET and self.source_address and ":" not in self.source_address
and not is_loopback):
raw.bind((self.source_address, 0))
elif (family == socket.AF_INET6 and self.source_address and ":" in self.source_address
and not is_loopback):
raw.bind((self.source_address, 0, 0, 0))
if family == socket.AF_INET:
try:
raw.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except OSError:
pass
raw.settimeout(1)
return raw, sockaddr
except OSError as exc:
last_error = exc
raw.close()
raise last_error or OSError(f"无法解析 UDP 目标: {host}")
def _open_direct_udp(self, target: tuple[str, int]) -> tuple[socket.socket, tuple]:
raw, sockaddr = self._open_direct_udp_target(target)
# 解析后的 sockaddr 直接用于 sendto,保留 IPv6 scope id。
return raw, sockaddr
def _direct_udp_reader(self, raw: socket.socket, udp: socket.socket,
destination: Address, peer):
try:
while self._running.is_set():
try:
payload, _source = raw.recvfrom(65535)
except socket.timeout:
continue
if not payload:
continue
if destination.port == 53:
observed = self._dns.observe_response(payload)
if observed:
domain, ips = observed
self.log(f"[DNS] 直连回包: domain={domain} ip={','.join(ips)}")
udp.sendto(_pack_socks_udp(destination, payload), peer)
with self._counter_lock:
self._down += len(payload)
except OSError:
pass
def _uot_reader(self, stream, udp, peer):
try:
while self._running.is_set():
destination, payload = read_datagram_frame(stream)
observed = self._dns.observe_response(payload)
if observed:
domain, ips = observed
self.log(f"[DNS] 回包: domain={domain} ip={','.join(ips)}")
udp.sendto(_pack_socks_udp(destination, payload), peer)
with self._counter_lock: self._down += len(payload)
except Exception as exc:
if self._running.is_set():
self.log(f"[UoT] UDP 回包读取失败: {exc}")
def stop(self):
self._running.clear()
if self.listener:
try: self.listener.close()
except OSError: pass
self.listener = None
with self._udp_resources_lock:
resources = list(self._udp_resources)
self._udp_resources.clear()
for resource in resources:
try:
resource.close()
except OSError:
pass
def traffic(self) -> tuple[int, int, int, int]:
with self._counter_lock:
return self._up, self._down, self._up, self._down
+137
View File
@@ -0,0 +1,137 @@
"""Windows 系统代理模式:注册表切换、原状态保存与异常退出恢复。"""
from __future__ import annotations
import ctypes
import re
from typing import Callable
PROXY_REG = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings"
BACKUP_KEY = "prev_proxy_state"
_VALUE_NAMES = ("ProxyEnable", "ProxyServer", "ProxyOverride", "AutoConfigURL")
def is_our_pac_url(value: str) -> bool:
return bool(re.fullmatch(r"http://127\.0\.0\.1:\d+/proxy\.pac", str(value).strip()))
def notify_proxy_change() -> None:
"""通知 WinINET 和顶层窗口重新读取系统代理。"""
try:
ctypes.windll.Wininet.InternetSetOptionW(None, 39, None, 0)
ctypes.windll.Wininet.InternetSetOptionW(None, 37, None, 0)
result = ctypes.c_ulong()
ctypes.windll.user32.SendMessageTimeoutW(
0xFFFF, 0x001A, 0, ctypes.c_wchar_p("InternetSettings"),
0x0002, 5000, ctypes.byref(result))
except Exception:
pass
class SystemProxyMode:
def __init__(self, *, run: Callable, settings_load: Callable,
settings_save: Callable, log: Callable,
endpoint: Callable[[], tuple[str, int]],
proxy_override: Callable[[bool], str]):
self.run = run
self.settings_load = settings_load
self.settings_save = settings_save
self.log = log
self.endpoint = endpoint
self.proxy_override = proxy_override
self._previous = None
def reg_query(self, name: str):
result = self.run(["reg", "query", PROXY_REG, "/v", name], warn=False)
if result.returncode:
return None
for line in (result.stdout or "").splitlines():
parts = line.strip().split(None, 2)
if len(parts) >= 3 and parts[0].lower() == name.lower():
return parts[1], parts[2]
return None
def _backup(self):
if self._previous is not None:
return
state = {name: list(value) if (value := self.reg_query(name)) else None
for name in _VALUE_NAMES}
self._previous = state
try:
self.settings_save({**self.settings_load(), BACKUP_KEY: state})
except Exception:
pass
def _restore(self):
state = self._previous
if state is None:
state = self.settings_load().get(BACKUP_KEY)
if isinstance(state, dict):
for name in _VALUE_NAMES:
value = state.get(name)
if value:
self.run(["reg", "add", PROXY_REG, "/v", name, "/t", value[0],
"/d", value[1], "/f"], warn=False)
else:
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
self.log("已恢复开启代理前的系统代理设置")
else:
for name in ("ProxyServer", "ProxyOverride", "AutoConfigURL"):
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
"REG_DWORD", "/d", "0", "/f"])
self._previous = None
try:
data = self.settings_load()
if BACKUP_KEY in data:
del data[BACKUP_KEY]
self.settings_save(data)
except Exception:
pass
def enable(self, pac_url=None, bypass_lan=False):
self._backup()
if pac_url:
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
"REG_DWORD", "/d", "0", "/f"])
for name in ("ProxyServer", "ProxyOverride"):
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
self.run(["reg", "add", PROXY_REG, "/v", "AutoConfigURL", "/t",
"REG_SZ", "/d", pac_url, "/f"])
self.log("系统代理已设置 (PAC)")
else:
host, port = self.endpoint()
address = f"{host}:{port}"
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
"REG_DWORD", "/d", "1", "/f"])
self.run(["reg", "add", PROXY_REG, "/v", "ProxyServer", "/t",
"REG_SZ", "/d", address, "/f"])
self.run(["reg", "add", PROXY_REG, "/v", "ProxyOverride", "/t",
"REG_SZ", "/d", self.proxy_override(bypass_lan), "/f"])
self.log(f"系统代理已设置: {address}")
notify_proxy_change()
def disable(self):
self._restore()
notify_proxy_change()
def is_on(self):
host, port = self.endpoint()
enabled = self.reg_query("ProxyEnable")
try:
fixed_enabled = bool(enabled and int(str(enabled[1]).strip(), 0) == 1)
except (TypeError, ValueError):
fixed_enabled = False
if fixed_enabled:
server = self.reg_query("ProxyServer")
return bool(server and (server[1] or "").strip() == f"{host}:{port}")
pac = self.reg_query("AutoConfigURL")
return bool(pac) and is_our_pac_url(pac[1] or "")
def is_stale(self):
host, port = self.endpoint()
server = self.reg_query("ProxyServer")
pac = self.reg_query("AutoConfigURL")
return (bool(server) and (server[1] or "").strip() == f"{host}:{port}") or \
(bool(pac) and is_our_pac_url(pac[1] or ""))
+802
View File
@@ -0,0 +1,802 @@
"""路线 B 的 Windows TUN 后端:tun2socks + 自有 SOCKS5/UoT 桥接。
这里不启动 sing-box。tun2socks 只负责 Wintun 和用户态 TCP/IP 栈,
所有上游协议由本项目的 ``socks_bridge`` 处理:TCP 使用 HTTP CONNECT,
UDP 使用裸 UoT v2。
"""
from __future__ import annotations
import ctypes
import ipaddress
import json
import os
import shutil
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any, Callable
from .socks_bridge import Socks5Bridge
class TunBackendError(RuntimeError):
"""TUN 后端无法启动或停止。"""
def _run_hidden(command, **kwargs):
"""运行 Windows 网络命令时隐藏控制台窗口。
源码通过 test.bat 启动时这些子进程复用已有控制台;Nuitka GUI 没有父控制台,
未设置 CREATE_NO_WINDOW 就会为每次 PowerShell 查询弹出一个窗口。
"""
kwargs.setdefault("creationflags", getattr(subprocess, "CREATE_NO_WINDOW", 0))
return subprocess.run(command, **kwargs)
class TunBackend:
def __init__(self, user_dir: str | Path, engine_path: str | Path | None = None,
log: Callable[[str], None] | None = None,
detail_log: Callable[[str], None] | None = None):
self.user_dir = Path(user_dir)
self.engine_path = Path(engine_path) if engine_path else None
self.log = log or (lambda _message: None)
self.detail_log = detail_log or self.log
self._process: subprocess.Popen[str] | None = None
self._bridge: Socks5Bridge | None = None
self._lock = threading.RLock()
self._route_added = False
self._ipv6_route_added = False
self._lan_routes: list[str] = []
self._lan_ipv6_routes: list[str] = []
self._node_routes: list[str] = []
self._node_ipv6_routes: list[str] = []
self._physical_gateway = ""
self._physical_ifindex = ""
self._physical_ipv6_gateway = ""
self._physical_ipv6_ifindex = ""
self._device = "RRPP-TUN"
self._address = "10.255.0.1"
self._ipv6_address = "fd42:7272:5050::1"
self._dns_configured = False
self._tun_dns_servers = ("8.8.8.8", "1.1.1.1")
self._ipv6_enabled = False
self._bypass_lan = True
self._cleanup_pending = False
self._state_path = self.user_dir / "tun_state.json"
# Wintun 是三层虚拟接口,默认路由的下一跳使用接口自身地址。
self._gateway = self._address
self._ipv6_gateway = self._ipv6_address
self._recover_stale_state()
@staticmethod
def is_admin() -> bool:
if os.name != "nt":
return True
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def resolve_engine(self) -> Path:
candidates: list[Path] = []
if self.engine_path:
candidates.append(self.engine_path)
# 目录式发布:主 EXE 与 tun2socks.exe、wintun.dll 放在同一目录。
# PyInstaller onefile:主程序会把内置文件解压到 _MEIPASS。
executable_dir = Path(sys.executable).resolve().parent
meipass = getattr(sys, "_MEIPASS", None)
package_dir = Path(__file__).resolve().parents[1]
candidates.extend([
self.user_dir / "tun2socks.exe",
self.user_dir.parent / "tun2socks.exe",
executable_dir / "tun2socks.exe",
Path(meipass) / "tun2socks.exe" if meipass else Path("__rrpp_no_meipass__"),
package_dir / "tun2socks.exe",
package_dir / "runtime" / "tun2socks.exe",
])
found = shutil.which("tun2socks")
if found:
candidates.append(Path(found))
for candidate in candidates:
try:
path = candidate.expanduser().resolve()
except OSError:
continue
if path.is_file():
return path
raise TunBackendError(
"未找到 tun2socks.exe。请将 tun2socks.exe 和 wintun.dll 放在 TUN 程序目录,"
"或在 config.json 的 tun_engine_path 中填写完整路径。"
)
@staticmethod
def _physical_interface() -> str:
command = ["powershell", "-NoProfile", "-Command",
"(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | "
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
"Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias)"]
try:
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
value = (result.stdout or "").strip().splitlines()
if value and value[0].strip():
return value[0].strip()
except Exception:
pass
return ""
@staticmethod
def _physical_source_address(interface: str) -> str:
if not interface:
return ""
escaped = interface.replace("'", "''")
command = ["powershell", "-NoProfile", "-Command",
f"(Get-NetIPAddress -InterfaceAlias '{escaped}' -AddressFamily IPv4 "
"-AddressState Preferred | Select-Object -First 1 -ExpandProperty IPAddress)"]
try:
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
value = (result.stdout or "").strip().splitlines()
if value and value[0].strip():
return value[0].strip()
except Exception:
pass
return ""
@staticmethod
def _physical_route_info() -> tuple[str, str]:
"""返回当前物理默认路由的网关和接口索引。"""
command = ["powershell", "-NoProfile", "-Command",
"Get-NetRoute -DestinationPrefix '0.0.0.0/0' | "
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
"Sort-Object RouteMetric | Select-Object -First 1 "
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
try:
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
data = json.loads((result.stdout or "").strip())
if isinstance(data, dict):
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
except Exception:
pass
return "", ""
@staticmethod
def _physical_ipv6_route_info() -> tuple[str, str]:
"""返回当前物理 IPv6 默认路由的下一跳和接口索引。"""
command = ["powershell", "-NoProfile", "-Command",
"Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' | "
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
"Sort-Object RouteMetric | Select-Object -First 1 "
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
try:
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
data = json.loads((result.stdout or "").strip())
if isinstance(data, dict):
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
except Exception:
pass
return "", ""
@staticmethod
def _active_global_ipv6() -> bool | None:
"""返回是否存在公网 IPv6;无法查询时返回 None,避免误报已接管。"""
if os.name != "nt":
return False
command = [
"powershell", "-NoProfile", "-Command",
"@(Get-NetIPAddress -AddressFamily IPv6 -AddressState Preferred | "
"Where-Object { $_.IPAddress -notlike 'fe80:*' -and "
"$_.IPAddress -ne '::1' -and $_.IPAddress -notlike 'fc*' -and "
"$_.IPAddress -notlike 'fd*' }).IPAddress | ConvertTo-Json -Compress",
]
try:
result = _run_hidden(command, capture_output=True, text=True, timeout=5)
if result.returncode:
return None
value = json.loads((result.stdout or "").strip() or "[]")
if isinstance(value, str):
return bool(value.strip())
return bool(value)
except Exception:
return None
@staticmethod
def _resolve_proxy_addresses(node: dict[str, Any]) -> list[tuple]:
methods = (node.get("methods") or {}).get("http") or {}
host = str(node.get("domain") or "").strip()
port = int(methods.get("port") or node.get("port") or 443)
if not host:
return []
addresses: list[tuple] = []
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
if item[4] not in addresses:
addresses.append(item[4])
return addresses
@staticmethod
def _node_chain(node: dict[str, Any]) -> list[dict[str, Any]]:
chain = node.get("_jump_chain") if isinstance(node, dict) else None
if isinstance(chain, list) and chain:
return [item for item in chain if isinstance(item, dict)]
return [node] if isinstance(node, dict) else []
@staticmethod
def _node_key(node: dict[str, Any]) -> str:
methods = (node.get("methods") or {}).get("http") or {}
return "|".join((str(node.get("domain") or ""),
str(methods.get("port") or node.get("port") or 443),
str(methods.get("username") or node.get("username") or "")))
def _add_node_routes(self, proxy_addresses: list[tuple]) -> None:
"""把 HTTP CONNECT 节点固定走物理网卡,防止默认 TUN 路由形成回环。"""
candidates_v4: list[str] = []
candidates_v6: list[str] = []
for sockaddr in proxy_addresses:
address = str(sockaddr[0])
try:
parsed = ipaddress.ip_address(address.split("%", 1)[0])
except ValueError:
continue
target = candidates_v4 if parsed.version == 4 else candidates_v6
normalized = parsed.compressed
if normalized not in target:
target.append(normalized)
if candidates_v4 and not self._physical_ifindex:
raise TunBackendError("找不到物理 IPv4 接口,无法固定代理节点路由")
for address in candidates_v4:
if address in self._node_routes:
continue
command = ["route", "ADD", address, "MASK", "255.255.255.255"]
if self._physical_gateway:
command.append(self._physical_gateway)
command.extend(["METRIC", "1", "IF", self._physical_ifindex])
result = _run_hidden(command, capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
if result.returncode:
detail = (result.stderr or result.stdout or "").strip()
# 已存在的等价路由不影响启动;其它错误继续暴露,避免静默回环。
if "already exists" not in detail.lower() and "已存在" not in detail:
raise TunBackendError(f"节点物理路由添加失败 ({address}): {detail[:200]}")
continue
self._node_routes.append(address)
self._write_state()
if candidates_v6 and not self._ipv6_enabled:
if not candidates_v4:
raise TunBackendError("IPv6 模式已关闭,代理节点仅包含 IPv6 地址")
candidates_v6 = []
if candidates_v6 and not self._physical_ipv6_ifindex:
# 双栈节点仍可使用 IPv4 地址;只有 IPv6-only 节点需要物理 IPv6 出口。
if candidates_v4:
candidates_v6 = []
else:
raise TunBackendError("代理节点仅包含 IPv6 地址,但找不到物理 IPv6 默认路由")
for address in candidates_v6:
if address in self._node_ipv6_routes:
continue
self._add_ipv6_physical_route(address)
def _add_ipv6_physical_route(self, address: str) -> None:
if not self._physical_ipv6_ifindex:
raise TunBackendError("找不到物理 IPv6 接口,无法固定 IPv6 路由")
command = ["interface", "ipv6", "add", "route", f"prefix={address}/128",
f"interface={self._physical_ipv6_ifindex}"]
if self._physical_ipv6_gateway and self._physical_ipv6_gateway != "::":
command.append(f"nexthop={self._physical_ipv6_gateway}")
command.append("metric=1")
result = _run_hidden(["netsh", *command], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
if result.returncode:
detail = (result.stderr or result.stdout or "").strip()
if "already exists" not in detail.lower() and "已存在" not in detail:
raise TunBackendError(f"IPv6 节点物理路由添加失败 ({address}): {detail[:200]}")
return
self._node_ipv6_routes.append(address)
self._write_state()
def _ensure_physical_route(self, address: str) -> None:
"""为 TUN 内部的直连目标补一条物理出口 /32 路由。"""
if os.name != "nt":
return
try:
parsed = ipaddress.ip_address(str(address).split("%", 1)[0])
except ValueError:
return
if parsed.version == 6:
if not self._ipv6_enabled:
raise TunBackendError("IPv6 模式未启用,无法添加 IPv6 物理路由")
normalized = parsed.compressed
if normalized in self._node_ipv6_routes:
return
self._add_ipv6_physical_route(normalized)
return
if not self._physical_ifindex:
raise TunBackendError("找不到物理 IPv4 接口,无法固定直连路由")
normalized = parsed.compressed
if normalized in self._node_routes:
return
command = ["route", "ADD", normalized, "MASK", "255.255.255.255"]
if self._physical_gateway:
command.append(self._physical_gateway)
command.extend(["METRIC", "1", "IF", self._physical_ifindex])
result = _run_hidden(command, capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
detail = (result.stderr or result.stdout or "").strip()
if result.returncode:
if "already exists" in detail.lower() or "已存在" in detail:
return
raise TunBackendError(f"直连目标物理路由添加失败 ({normalized}): {detail[:200]}")
self._node_routes.append(normalized)
self._write_state()
@staticmethod
def _cleanup_succeeded(result: subprocess.CompletedProcess[str]) -> bool:
if result.returncode == 0:
return True
detail = (result.stderr or result.stdout or "").strip().lower()
# 清理是幂等操作:路由或网卡已经消失时,目标状态已经达到。
return any(marker in detail for marker in (
"not found", "does not exist", "cannot find", "no such",
"找不到", "不存在", "未找到", "没有找到",
))
def _cleanup_netsh(self, args: list[str]) -> bool:
try:
result = _run_hidden(["netsh", *args], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
except Exception as exc:
self.detail_log(f"[TUN] 清理 netsh 失败: {exc}")
return False
if self._cleanup_succeeded(result):
return True
detail = (result.stderr or result.stdout or "").strip()
self.detail_log(f"[TUN] 清理 netsh 未完成: {detail[:300]}")
return False
def _remove_node_routes(self) -> bool:
remaining: list[str] = []
for address in self._node_routes:
try:
result = _run_hidden(["route", "DELETE", address], capture_output=True,
text=True, encoding="mbcs", errors="replace", timeout=15)
except Exception as exc:
self.detail_log(f"[TUN] 清理节点路由失败 ({address}): {exc}")
remaining.append(address)
continue
if not self._cleanup_succeeded(result):
detail = (result.stderr or result.stdout or "").strip()
self.detail_log(f"[TUN] 清理节点路由未完成 ({address}): {detail[:200]}")
remaining.append(address)
self._node_routes = remaining
remaining_v6: list[str] = []
for address in self._node_ipv6_routes:
command = ["interface", "ipv6", "delete", "route", f"prefix={address}/128"]
if self._physical_ipv6_ifindex:
command.append(f"interface={self._physical_ipv6_ifindex}")
try:
result = _run_hidden(["netsh", *command], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
except Exception as exc:
self.detail_log(f"[TUN] 清理 IPv6 节点路由失败 ({address}): {exc}")
remaining_v6.append(address)
continue
if not self._cleanup_succeeded(result):
detail = (result.stderr or result.stdout or "").strip()
self.detail_log(f"[TUN] 清理 IPv6 节点路由未完成 ({address}): {detail[:200]}")
remaining_v6.append(address)
self._node_ipv6_routes = remaining_v6
return not remaining and not remaining_v6
def _netsh(self, args: list[str], *, ignore_error: bool = False) -> None:
result = _run_hidden(["netsh", *args], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
if result.returncode and not ignore_error:
detail = (result.stderr or result.stdout or "").strip()
raise TunBackendError(f"netsh 操作失败: {detail[:300]}")
def _configure_routes(self) -> None:
for _ in range(20):
try:
probe = _run_hidden(
["powershell", "-NoProfile", "-Command",
f"Get-NetAdapter -Name '{self._device}' -ErrorAction SilentlyContinue"],
capture_output=True, text=True, timeout=3,
)
if probe.stdout and self._device.lower() in probe.stdout.lower():
break
except Exception:
pass
time.sleep(0.25)
else:
raise TunBackendError(f"tun2socks 未创建虚拟网卡: {self._device}")
self._netsh(["interface", "ipv4", "set", "address",
f"name={self._device}", "source=static",
f"addr={self._address}", "mask=255.255.255.252"])
self._netsh(["interface", "ipv4", "add", "route", "0.0.0.0/0",
f"{self._device}", self._gateway, "metric=1"])
self._route_added = True
self._write_state()
if self._ipv6_enabled:
self._netsh(["interface", "ipv6", "set", "address",
f"interface={self._device}", f"address={self._ipv6_address}",
"store=active"])
self._netsh(["interface", "ipv6", "add", "route", "prefix=::/0",
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
"metric=1"])
self._ipv6_route_added = True
self._write_state()
if not self._bypass_lan:
# Windows 物理网卡通常已有更具体的私网路由;补同前缀的 TUN
# 路由,确保“内网也代理”不会在进入 tun2socks 前被物理网卡截走。
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"169.254.0.0/16"):
self._netsh(["interface", "ipv4", "add", "route", network,
f"{self._device}", self._gateway, "metric=1"])
self._lan_routes.append(network)
self._write_state()
if self._ipv6_enabled:
for network in ("fc00::/7",):
self._netsh(["interface", "ipv6", "add", "route", f"prefix={network}",
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
"metric=1"])
self._lan_ipv6_routes.append(network)
self._write_state()
self._configure_dns()
def _configure_dns(self) -> None:
"""让 Windows DNS 请求也进入 TUN,避免沿用物理网卡 DNS。"""
if os.name != "nt":
return
ipv4 = [item for item in self._tun_dns_servers if ":" not in item]
ipv6 = [item for item in self._tun_dns_servers if ":" in item] if self._ipv6_enabled else []
if ipv4:
self._netsh([
"interface", "ipv4", "set", "dnsservers",
f"name={self._device}", "source=static",
f"address={ipv4[0]}", "register=none", "validate=no",
])
for index, secondary in enumerate(ipv4[1:], start=2):
self._netsh([
"interface", "ipv4", "add", "dnsservers",
f"name={self._device}", f"address={secondary}",
f"index={index}", "validate=no",
], ignore_error=True)
if ipv6:
self._netsh([
"interface", "ipv6", "set", "dnsservers",
f"name={self._device}", "source=static",
f"address={ipv6[0]}", "register=none", "validate=no",
], ignore_error=True)
for index, secondary in enumerate(ipv6[1:], start=2):
self._netsh([
"interface", "ipv6", "add", "dnsservers",
f"name={self._device}", f"address={secondary}",
f"index={index}", "validate=no",
], ignore_error=True)
self._dns_configured = True
self._write_state()
def _remove_routes(self) -> None:
cleanup_ok = True
if self._dns_configured:
ipv4_ok = self._cleanup_netsh([
"interface", "ipv4", "set", "dnsservers",
f"name={self._device}", "source=dhcp",
])
ipv6_ok = self._cleanup_netsh([
"interface", "ipv6", "set", "dnsservers",
f"name={self._device}", "source=dhcp",
])
cleanup_ok &= ipv4_ok and ipv6_ok
if ipv4_ok and ipv6_ok:
self._dns_configured = False
if self._route_added:
route_ok = self._cleanup_netsh(["interface", "ipv4", "delete", "route",
"0.0.0.0/0", f"{self._device}", self._gateway])
cleanup_ok &= route_ok
if route_ok:
self._route_added = False
if self._ipv6_route_added:
route_ok = self._cleanup_netsh(["interface", "ipv6", "delete", "route",
"prefix=::/0", f"interface={self._device}",
f"nexthop={self._ipv6_gateway}"])
cleanup_ok &= route_ok
if route_ok:
self._ipv6_route_added = False
remaining_lan: list[str] = []
for network in self._lan_routes:
route_ok = self._cleanup_netsh(["interface", "ipv4", "delete", "route",
network, f"{self._device}", self._gateway])
cleanup_ok &= route_ok
if not route_ok:
remaining_lan.append(network)
self._lan_routes = remaining_lan
remaining_lan_v6: list[str] = []
for network in self._lan_ipv6_routes:
route_ok = self._cleanup_netsh(["interface", "ipv6", "delete", "route",
f"prefix={network}", f"interface={self._device}",
f"nexthop={self._ipv6_gateway}"])
cleanup_ok &= route_ok
if not route_ok:
remaining_lan_v6.append(network)
self._lan_ipv6_routes = remaining_lan_v6
cleanup_ok &= self._remove_node_routes()
self._cleanup_pending = not cleanup_ok
if cleanup_ok:
self._clear_state()
else:
self._write_state()
self.detail_log("[TUN] 部分路由/DNS 清理失败,已保留状态并将在下次启动重试")
def _write_state(self) -> None:
"""记录本进程改过的路由/DNS,强杀后下次启动可恢复。"""
try:
self.user_dir.mkdir(parents=True, exist_ok=True)
payload = {
"device": self._device,
"address": self._address,
"gateway": self._gateway,
"ipv6_address": self._ipv6_address,
"ipv6_gateway": self._ipv6_gateway,
"lan_routes": list(self._lan_routes),
"lan_ipv6_routes": list(self._lan_ipv6_routes),
"bypass_lan": bool(self._bypass_lan),
"physical_gateway": self._physical_gateway,
"physical_ifindex": self._physical_ifindex,
"physical_ipv6_gateway": self._physical_ipv6_gateway,
"physical_ipv6_ifindex": self._physical_ipv6_ifindex,
"node_routes": list(self._node_routes),
"node_ipv6_routes": list(self._node_ipv6_routes),
"route_added": bool(self._route_added),
"ipv6_route_added": bool(self._ipv6_route_added),
"ipv6_enabled": bool(self._ipv6_enabled),
"dns_configured": bool(self._dns_configured),
"dns_servers": list(self._tun_dns_servers),
"cleanup_pending": bool(self._cleanup_pending),
"pid": os.getpid(),
}
temp = self._state_path.with_suffix(".tmp")
temp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
temp.replace(self._state_path)
except Exception as exc:
self.detail_log(f"[TUN] 状态记录失败: {exc}")
def _clear_state(self) -> None:
try:
self._state_path.unlink(missing_ok=True)
except Exception:
pass
@staticmethod
def _pid_alive(pid: Any) -> bool:
"""避免合并版误清理仍在运行的旧 TUN 实例。"""
if os.name != "nt":
return False
try:
number = int(pid)
if number <= 0:
return False
kernel = ctypes.windll.kernel32
kernel.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_bool, ctypes.c_ulong]
kernel.OpenProcess.restype = ctypes.c_void_p
kernel.GetExitCodeProcess.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
kernel.CloseHandle.argtypes = [ctypes.c_void_p]
handle = kernel.OpenProcess(0x1000, False, number)
if not handle:
return False
try:
code = ctypes.c_ulong()
return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))
and code.value == 259)
finally:
kernel.CloseHandle(handle)
except (TypeError, ValueError, OSError):
return False
def _recover_stale_state(self) -> None:
"""启动前清理上次崩溃/强杀留下的 RRPP-TUN 路由和 DNS。"""
try:
if not self._state_path.is_file():
return
state = json.loads(self._state_path.read_text(encoding="utf-8"))
if not state.get("cleanup_pending") and self._pid_alive(state.get("pid")):
raise TunBackendError("检测到另一个 TUN 实例仍在运行,请先关闭后再启动")
self._device = str(state.get("device") or self._device)
self._address = str(state.get("address") or self._address)
self._gateway = str(state.get("gateway") or self._gateway)
self._ipv6_address = str(state.get("ipv6_address") or self._ipv6_address)
self._ipv6_gateway = str(state.get("ipv6_gateway") or self._ipv6_gateway)
self._ipv6_enabled = bool(state.get("ipv6_enabled", bool(state.get("ipv6_route_added"))))
self._physical_ipv6_gateway = str(state.get("physical_ipv6_gateway") or "")
self._physical_ipv6_ifindex = str(state.get("physical_ipv6_ifindex") or "")
self._route_added = bool(state.get("route_added"))
self._ipv6_route_added = bool(state.get("ipv6_route_added"))
self._lan_routes = [str(item).strip() for item in (state.get("lan_routes") or [])
if str(item).strip()]
self._lan_ipv6_routes = [str(item).strip() for item in (state.get("lan_ipv6_routes") or [])
if str(item).strip()]
self._node_routes = [str(item).strip() for item in (state.get("node_routes") or [])
if str(item).strip()]
self._node_ipv6_routes = [str(item).strip() for item in (state.get("node_ipv6_routes") or [])
if str(item).strip()]
self._dns_configured = bool(state.get("dns_configured"))
self._cleanup_pending = True
self._remove_routes()
if self._cleanup_pending:
self.detail_log("[TUN] 上次异常退出的部分路由/DNS 仍未清理,状态已保留")
else:
self.log("已清理上次异常退出遗留的 TUN 路由和 DNS")
except TunBackendError:
raise
except Exception as exc:
self.detail_log(f"[TUN] 异常退出残留清理失败: {exc}")
def _read_output(self, process: subprocess.Popen[str]) -> None:
if process.stdout is None:
return
try:
for line in process.stdout:
line = line.rstrip()
if line:
self.detail_log(f"[TUN] {line}")
except OSError:
pass
def start(self, node: dict[str, Any], *, bypass_lan: bool = True,
route_rules: list[dict[str, Any]] | None = None,
dns_servers: tuple[str, ...] | list[str] | None = None,
ipv6_enabled: bool = False,
direct_target_resolver: Callable[[str, str, int], tuple[str, int] | None] | None = None,
route_rule_resolver: Callable[[str], dict[str, Any] | None] | None = None,
route_node_resolver: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
route_nodes: list[dict[str, Any]] | None = None) -> None:
with self._lock:
if self.status()["running"]:
raise TunBackendError("TUN 已经在运行")
if not self.is_admin():
raise TunBackendError("启动虚拟网卡 TUN 需要管理员权限")
self._bypass_lan = bool(bypass_lan)
self._ipv6_enabled = bool(ipv6_enabled)
if dns_servers is not None:
cleaned = tuple(str(item).strip() for item in dns_servers if str(item).strip())
if not cleaned:
raise TunBackendError("自定义 DNS 不能为空")
try:
for item in cleaned:
address = ipaddress.ip_address(item)
if address.is_unspecified or address.is_multicast:
raise ValueError(item)
except ValueError as exc:
raise TunBackendError("自定义 DNS 必须是有效的 IPv4/IPv6 地址") from exc
self._tun_dns_servers = cleaned[:3]
engine = self.resolve_engine()
interface = self._physical_interface()
source_address = self._physical_source_address(interface)
self._physical_gateway, self._physical_ifindex = self._physical_route_info()
if self._ipv6_enabled:
self._physical_ipv6_gateway, self._physical_ipv6_ifindex = self._physical_ipv6_route_info()
else:
self._physical_ipv6_gateway = self._physical_ipv6_ifindex = ""
if not self._physical_ifindex:
raise TunBackendError("找不到物理默认路由,无法保证代理节点不进入 TUN 回环")
if self._ipv6_enabled and not self._physical_ipv6_ifindex:
self.log("警告:未找到物理 IPv6 默认路由,IPv6 目标将仅尝试通过 IPv4 代理节点转发")
try:
proxy_addresses_by_node: dict[str, list[tuple]] = {}
connection_nodes = [node]
connection_nodes.extend(
(item.get("node") if isinstance(item.get("node"), dict) else item)
for item in (route_nodes or ())
if isinstance(item, dict)
)
for connection_node in connection_nodes:
for hop in self._node_chain(connection_node):
try:
addresses = self._resolve_proxy_addresses(hop)
except OSError as exc:
raise TunBackendError(f"无法解析 HTTP CONNECT 节点地址: {exc}") from exc
if not addresses:
raise TunBackendError("无法解析 HTTP CONNECT 节点地址")
self._add_node_routes(addresses)
proxy_addresses_by_node[self._node_key(hop)] = addresses
first_hop = self._node_chain(node)[0]
proxy_addresses = proxy_addresses_by_node.get(self._node_key(first_hop), [])
if not proxy_addresses:
raise TunBackendError("无法解析 HTTP CONNECT 节点地址")
for route_node in route_nodes or ():
if not isinstance(route_node, dict):
continue
route_value = route_node.get("node") if isinstance(route_node.get("node"), dict) else route_node
for hop in self._node_chain(route_value):
addresses = self._resolve_proxy_addresses(hop)
proxy_addresses_by_node[self._node_key(hop)] = addresses
if not source_address:
self.log("警告:未找到物理网卡 IPv4,节点连接可能无法绕过 TUN")
bridge = Socks5Bridge(
node, bypass_lan=bypass_lan, route_rules=route_rules, log=self.detail_log,
source_address=source_address or None,
proxy_addresses=proxy_addresses,
direct_target_resolver=direct_target_resolver,
route_rule_resolver=route_rule_resolver,
route_node_resolver=route_node_resolver,
proxy_addresses_by_node=proxy_addresses_by_node,
direct_route_ensurer=self._ensure_physical_route,
)
socks_port = bridge.start()
except Exception:
self._remove_routes()
raise
self._bridge = bridge
command = [str(engine), "--device", self._device,
"--proxy", f"socks5://127.0.0.1:{socks_port}"]
# tun2socks 的 Windows socket 选项会把 UDP/TCP socket 绑定到
# --interface 指定的网卡;本项目的 SOCKS relay 在本机回环地址,
# 绑定 WLAN 会让 UDP 回环投递失效。Windows 官方用法也不传该参数。
# 节点连接已经通过物理 /32 路由和 source_address 绕过 TUN,
# 因此 Windows 不需要 --interface;Linux/macOS 仍保留原行为。
if interface and os.name != "nt":
command.extend(["--interface", interface])
self.detail_log(f"本地 SOCKS5 桥接: 127.0.0.1:{socks_port}")
try:
self._process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
threading.Thread(target=self._read_output, args=(self._process,), daemon=True).start()
time.sleep(0.5)
if self._process.poll() is not None:
raise TunBackendError(f"tun2socks 启动失败,退出码 {self._process.returncode}")
self._configure_routes()
self._write_state()
self.log(f"虚拟网卡 TUN 已启动,进程 PID={self._process.pid}")
except Exception:
bridge.stop()
self._remove_routes()
self._bridge = None
process = self._process
self._process = None
if process is not None and process.poll() is None:
process.terminate()
raise
def stop(self) -> None:
with self._lock:
process = self._process
bridge = self._bridge
self._process = None
self._bridge = None
self._remove_routes()
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
if bridge is not None:
bridge.stop()
if process is not None:
self.log("虚拟网卡 TUN 已停止")
def status(self) -> dict[str, Any]:
with self._lock:
process = self._process
if process is None:
return {"running": False, "pid": None, "exit_code": None}
code = process.poll()
if code is not None:
self._process = None
self._remove_routes()
bridge = self._bridge
self._bridge = None
if bridge is not None:
bridge.stop()
return {"running": False, "pid": process.pid, "exit_code": code}
return {"running": True, "pid": process.pid, "exit_code": None}
def traffic(self) -> tuple[int, int, int, int]:
if self._bridge is None:
return 0, 0, 0, 0
return self._bridge.traffic()
+253
View File
@@ -0,0 +1,253 @@
"""按需提权的 TUN 控制通道;GUI 与管理员工作进程共用同一个入口。"""
from __future__ import annotations
import ctypes
import hmac
import json
import os
import secrets
import socket
import subprocess
import threading
import time
from typing import Any, Callable
WORKER_FLAG = "--rrpp-tun-worker"
_MAX_MESSAGE = 1024 * 1024
class TunModeError(RuntimeError):
pass
def _send(sock: socket.socket, value: dict[str, Any], lock: threading.Lock | None = None) -> None:
payload = (json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
if len(payload) > _MAX_MESSAGE:
raise TunModeError("TUN 控制消息过大")
if lock is None:
sock.sendall(payload)
else:
with lock:
sock.sendall(payload)
def _receive(reader) -> dict[str, Any]:
line = reader.readline(_MAX_MESSAGE + 1)
if not line or len(line) > _MAX_MESSAGE or not line.endswith(b"\n"):
raise TunModeError("TUN 工作进程连接已断开或消息无效")
value = json.loads(line.decode("utf-8"))
if not isinstance(value, dict):
raise TunModeError("TUN 控制消息格式错误")
return value
class TunModeController:
"""普通权限 GUI 仅在首次开启 TUN 时启动管理员工作进程。"""
def __init__(self, command: list[str], log: Callable[[str], None],
detail_log: Callable[[str], None] | None = None):
self.command = command
self.log = log
self.detail_log = detail_log or log
self._lock = threading.RLock()
self._socket: socket.socket | None = None
self._reader = None
def _launch(self, port: int, token: str) -> None:
args = [*self.command[1:], WORKER_FLAG, str(port), token]
if os.name == "nt":
# PyInstaller onefile 的提权子进程必须独立解包,以便 GUI 退出后仍可清理网络。
previous = os.environ.get("PYINSTALLER_RESET_ENVIRONMENT")
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
try:
result = ctypes.windll.shell32.ShellExecuteW(
None, "runas", self.command[0], subprocess.list2cmdline(args),
os.path.dirname(os.path.abspath(self.command[0])), 0,
)
finally:
if previous is None:
os.environ.pop("PYINSTALLER_RESET_ENVIRONMENT", None)
else:
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = previous
if result <= 32:
raise TunModeError("管理员授权已取消或无法启动 TUN 工作进程")
else:
# 非 Windows 仅供无网卡的协议测试;真实 TUN 后端仍限定 Windows。
subprocess.Popen([self.command[0], *args], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
def start(self, payload: dict[str, Any], cancel_event=None) -> None:
with self._lock:
if self._socket is not None:
raise TunModeError("TUN 工作进程已经启动")
token = secrets.token_hex(32)
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(2)
deadline = time.monotonic() + 90
try:
self._launch(listener.getsockname()[1], token)
while True:
if cancel_event is not None and cancel_event.is_set():
raise TunModeError("用户取消了开启代理")
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TunModeError("等待 TUN 管理员工作进程超时")
listener.settimeout(min(0.5, remaining))
try:
connection, _peer = listener.accept()
except socket.timeout:
continue
connection.settimeout(3)
reader = connection.makefile("rb")
try:
hello = _receive(reader)
valid = (hello.get("type") == "hello" and
hmac.compare_digest(str(hello.get("token") or ""), token))
except Exception:
valid = False
if valid:
connection.settimeout(120)
self._socket, self._reader = connection, reader
break
reader.close()
connection.close()
if cancel_event is not None and cancel_event.is_set():
raise TunModeError("用户取消了开启代理")
self._request("start", payload)
except Exception:
self.close()
raise
finally:
listener.close()
def _request(self, command: str, payload: dict[str, Any] | None = None) -> Any:
if self._socket is None or self._reader is None:
raise TunModeError("TUN 工作进程未连接")
try:
_send(self._socket, {"command": command, "payload": payload or {}})
while True:
message = _receive(self._reader)
if message.get("type") == "log":
callback = self.detail_log if message.get("detail") else self.log
callback(str(message.get("message") or ""))
continue
if message.get("type") != "result":
raise TunModeError("TUN 工作进程响应无效")
if not message.get("ok"):
raise TunModeError(str(message.get("error") or "TUN 操作失败"))
return message.get("value")
except TunModeError:
raise
except OSError as exc:
raise TunModeError(f"TUN 工作进程连接中止: {exc}") from exc
def status(self) -> dict[str, Any]:
with self._lock:
if self._socket is None:
return {"running": False, "pid": None, "exit_code": None}
try:
return self._request("status")
except (OSError, TunModeError, ValueError):
self.close()
return {"running": False, "pid": None, "exit_code": None}
def traffic(self) -> tuple[int, int, int, int]:
with self._lock:
if self._socket is None:
return 0, 0, 0, 0
try:
return tuple(self._request("traffic"))
except (OSError, TunModeError, ValueError):
return 0, 0, 0, 0
def stop(self) -> None:
with self._lock:
try:
if self._socket is not None:
self._request("stop")
finally:
self.close()
def close(self) -> None:
with self._lock:
if self._socket is not None:
try:
self._request("quit")
except (OSError, TunModeError, ValueError):
pass
reader, connection = self._reader, self._socket
self._reader = self._socket = None
if reader is not None:
reader.close()
if connection is not None:
connection.close()
def run_tun_worker(port: int, token: str, backend_factory,
start_backend: Callable[[Any, dict[str, Any]], None]) -> None:
"""管理员进程入口。主 GUI 断开后,必定清理本进程建立的网络状态。"""
connection = socket.create_connection(("127.0.0.1", int(port)), timeout=15)
connection.settimeout(None)
reader = connection.makefile("rb")
send_lock = threading.Lock()
_send(connection, {"type": "hello", "token": token}, send_lock)
def emit(message: str, detail: bool = False) -> None:
try:
_send(connection, {"type": "log", "message": str(message),
"detail": bool(detail)}, send_lock)
except OSError:
pass
class _WorkerLogger:
def __call__(self, message: str) -> None:
emit(message)
def detail(self, message: str) -> None:
emit(message, detail=True)
logger = _WorkerLogger()
backend = None
try:
while True:
try:
request = _receive(reader)
except TunModeError:
break
command = request.get("command")
try:
if command == "start":
if not isinstance(request.get("payload"), dict):
raise TunModeError("TUN 启动参数无效")
# 延迟创建后端,使初始化失败也能通过控制协议返回具体原因,
# 避免 GUI 只看到 Windows 的 10053 连接中止错误。
if backend is None:
backend = backend_factory(logger)
start_backend(backend, request["payload"])
value = None
elif command == "stop":
backend.stop()
value = None
elif command == "status":
value = backend.status()
elif command == "traffic":
value = backend.traffic()
elif command == "quit":
backend.stop()
value = None
else:
raise TunModeError("未知的 TUN 控制命令")
_send(connection, {"type": "result", "ok": True, "value": value}, send_lock)
except Exception as exc:
_send(connection, {"type": "result", "ok": False, "error": str(exc)}, send_lock)
if command == "quit":
break
finally:
if backend is not None:
backend.stop()
reader.close()
connection.close()
+120
View File
@@ -0,0 +1,120 @@
"""SagerNet UDP over TCP v2 帧编解码。
UoT 只负责把 UDP 数据报装入已有的可靠 TCP 字节流;外层连接仍由
HTTP CONNECT + TLS 建立。此模块不依赖 sing-box。
"""
from __future__ import annotations
import ipaddress
import socket
import struct
from dataclasses import dataclass
MAGIC_ADDRESS = "sp.v2.udp-over-tcp.arpa"
MAX_DATAGRAM = 65535
@dataclass(frozen=True)
class Address:
host: str
port: int
def encode_address(host: str, port: int) -> bytes:
host = str(host).strip()
if not 0 <= int(port) <= 65535:
raise ValueError("端口超出范围")
try:
addr = ipaddress.ip_address(host)
except ValueError:
raw = host.encode("idna")
if not 1 <= len(raw) <= 255:
raise ValueError("域名长度超出 UoT 限制")
return b"\x02" + bytes([len(raw)]) + raw + struct.pack("!H", int(port))
if addr.version == 4:
return b"\x00" + addr.packed + struct.pack("!H", int(port))
return b"\x01" + addr.packed + struct.pack("!H", int(port))
def decode_address(data: bytes, offset: int = 0) -> tuple[Address, int]:
if offset >= len(data):
raise ValueError("UoT 地址头不完整")
atyp = data[offset]
offset += 1
if atyp == 0:
size = 4
elif atyp == 1:
size = 16
elif atyp == 2:
if offset >= len(data):
raise ValueError("UoT 域名长度缺失")
size = data[offset]
offset += 1
else:
raise ValueError(f"未知 UoT 地址类型: {atyp}")
end = offset + size
if end + 2 > len(data):
raise ValueError("UoT 地址头不完整")
raw = data[offset:end]
port = struct.unpack("!H", data[end:end + 2])[0]
if atyp == 0:
host = str(ipaddress.IPv4Address(raw))
elif atyp == 1:
host = str(ipaddress.IPv6Address(raw))
else:
host = raw.decode("idna")
return Address(host, port), end + 2
def encode_request(is_connect: bool, destination: Address) -> bytes:
return (b"\x01" if is_connect else b"\x00") + encode_address(
destination.host, destination.port
)
def encode_connect_frame(payload: bytes) -> bytes:
if len(payload) > MAX_DATAGRAM:
raise ValueError("UoT 数据报超过 65535 字节")
return struct.pack("!H", len(payload)) + payload
def encode_datagram_frame(destination: Address, payload: bytes) -> bytes:
if len(payload) > MAX_DATAGRAM:
raise ValueError("UoT 数据报超过 65535 字节")
return encode_address(destination.host, destination.port) + struct.pack("!H", len(payload)) + payload
def read_exact(stream, size: int) -> bytes:
chunks = []
remaining = size
while remaining:
chunk = stream.recv(remaining)
if not chunk:
raise ConnectionError("UoT TCP 流已关闭")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def read_connect_frame(stream) -> bytes:
size = struct.unpack("!H", read_exact(stream, 2))[0]
return read_exact(stream, size)
def read_datagram_frame(stream) -> tuple[Address, bytes]:
# 地址是变长的,只能先读 ATYP,再按类型补齐。
head = read_exact(stream, 1)
atyp = head[0]
if atyp == 0:
rest = read_exact(stream, 6)
elif atyp == 1:
rest = read_exact(stream, 18)
elif atyp == 2:
length = read_exact(stream, 1)
rest = length + read_exact(stream, length[0] + 2)
else:
raise ValueError(f"未知 UoT 地址类型: {atyp}")
address, _ = decode_address(head + rest)
size = struct.unpack("!H", read_exact(stream, 2))[0]
return address, read_exact(stream, size)
+481 -32
View File
@@ -2,12 +2,14 @@
import json import json
import threading import threading
from datetime import datetime
from dataclasses import dataclass from dataclasses import dataclass
from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QApplication, QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QVBoxLayout, QWidget, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget, QTextEdit,
QButtonGroup, QMessageBox,
) )
from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE
@@ -292,6 +294,180 @@ class WhitelistDialog(QDialog):
self.accept() self.accept()
@dataclass(frozen=True)
class FixedRouteServices:
"""域名路由编辑框所需的规则与节点服务。"""
load_icon: object
load_rules: object
available_nodes: object
validate_domain: object
save_rules: object
class FixedRouteDialog(QDialog):
"""为域名配置强制直连或指定节点代理。"""
def __init__(self, services: FixedRouteServices, parent=None):
super().__init__(parent)
self._services = services
self.setWindowTitle("域名路由")
self.setWindowIcon(services.load_icon())
self.setFixedSize(620, 530)
self.setStyleSheet(f"""
QDialog {{ background-color: {C_BG}; }}
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
QLineEdit, QComboBox {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
padding: 5px 8px; color: {C_TEXT}; font-size: 12px; }}
QListWidget {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
padding: 4px; color: {C_TEXT}; font-size: 12px; }}
QListWidget::item {{ padding: 5px 7px; border-radius: 4px; }}
QListWidget::item:selected {{ background-color: #e3e8ee; color: {C_TEXT}; }}
QPushButton#btnOk {{ background-color: {C_GREEN}; color: #fff; border: none;
border-radius: 6px; padding: 5px 12px; font-size: 12px; font-weight: bold; }}
QPushButton#btnOk:hover {{ background-color: #5ed978; }}
QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
border-radius: 6px; padding: 4px 10px; font-size: 11px; }}
QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
""")
layout = QVBoxLayout(self)
layout.setSpacing(9)
layout.setContentsMargins(18, 14, 18, 14)
title = QLabel("域名路由")
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
hint = QLabel("example.com 匹配根域及子域;*.example.com 仅匹配子域")
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
hint.setAlignment(Qt.AlignCenter)
layout.addWidget(hint)
self.lb_err = QLabel("")
self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
self.lb_err.setFixedHeight(16)
self.lb_err.hide()
layout.addWidget(self.lb_err)
self.list_widget = QListWidget()
self.list_widget.setToolTip("勾选启用规则;选中后可删除")
layout.addWidget(self.list_widget, 1)
domain_row = QHBoxLayout()
domain_row.setSpacing(7)
self.ed_domain = QLineEdit()
self.ed_domain.setPlaceholderText("域名,如 example.com 或 *.example.com")
self.ed_domain.returnPressed.connect(self._add_rule)
domain_row.addWidget(self.ed_domain, 1)
self.cb_action = QComboBox()
self.cb_action.addItem("强制不代理", "direct")
self.cb_action.addItem("强制节点代理", "node")
self.cb_action.currentIndexChanged.connect(self._on_action_changed)
domain_row.addWidget(self.cb_action)
self.cb_node = QComboBox()
self.cb_node.setMinimumWidth(190)
domain_row.addWidget(self.cb_node)
self.btn_add = QPushButton("添加")
self.btn_add.setObjectName("btnOk")
self.btn_add.clicked.connect(self._add_rule)
domain_row.addWidget(self.btn_add)
layout.addLayout(domain_row)
middle = QHBoxLayout()
self.btn_delete = QPushButton("删除选中")
self.btn_delete.setObjectName("btnSmall")
self.btn_delete.clicked.connect(self._delete_selected)
middle.addWidget(self.btn_delete)
middle.addStretch()
layout.addLayout(middle)
buttons = QHBoxLayout()
buttons.addStretch()
self.btn_cancel = QPushButton("取消")
self.btn_cancel.setObjectName("btnSmall")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_save = QPushButton("保存")
self.btn_save.setObjectName("btnOk")
self.btn_save.clicked.connect(self._save)
buttons.addWidget(self.btn_save)
layout.addLayout(buttons)
self._reload_nodes()
for rule in services.load_rules():
self._add_item(rule)
self._on_action_changed()
def _reload_nodes(self):
self.cb_node.clear()
for node in self._services.available_nodes():
self.cb_node.addItem(node["label"], node["ref"])
if not self.cb_node.count():
self.cb_node.addItem("暂无可用节点", None)
def _on_action_changed(self, *_):
self.cb_node.setEnabled(self.cb_action.currentData() == "node")
def _add_item(self, rule):
domain = str(rule.get("domain") or rule.get("value") or "").strip()
if not domain:
return
action = str(rule.get("action") or "direct")
node_label = str(rule.get("node_label") or "节点不可用")
target = "强制不代理" if action == "direct" else f"节点代理:{node_label}"
item = QListWidgetItem(f"{domain} → {target}")
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked if rule.get("enabled", True) else Qt.Unchecked)
item.setData(Qt.UserRole, {
"domain": domain,
"action": action,
"node_ref": str(rule.get("node_ref") or ""),
})
self.list_widget.addItem(item)
def _add_rule(self):
domain, error = self._services.validate_domain(self.ed_domain.text())
if error:
self.lb_err.setText(error)
self.lb_err.show()
return
action = self.cb_action.currentData()
node_ref = self.cb_node.currentData() if action == "node" else ""
if action == "node" and not node_ref:
self.lb_err.setText("请先配置至少一个可用节点")
self.lb_err.show()
return
for index in range(self.list_widget.count()):
existing = self.list_widget.item(index).data(Qt.UserRole) or {}
if str(existing.get("domain") or "").lower() == domain.lower():
self.lb_err.setText("该域名规则已存在")
self.lb_err.show()
return
self.lb_err.hide()
self._add_item({
"domain": domain,
"action": action,
"node_ref": node_ref,
"node_label": self.cb_node.currentText() if action == "node" else "",
"enabled": True,
})
self.ed_domain.clear()
def _delete_selected(self):
for item in self.list_widget.selectedItems():
self.list_widget.takeItem(self.list_widget.row(item))
def _save(self):
rules = []
for index in range(self.list_widget.count()):
item = self.list_widget.item(index)
rule = dict(item.data(Qt.UserRole) or {})
rule["enabled"] = item.checkState() == Qt.Checked
rules.append(rule)
self._services.save_rules(rules)
self.accept()
@dataclass(frozen=True) @dataclass(frozen=True)
class JumpServices: class JumpServices:
"""代理跳板管理所需的节点摘要与配置回调。""" """代理跳板管理所需的节点摘要与配置回调。"""
@@ -660,6 +836,94 @@ class ThirdPartyDialog(QDialog):
self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;") self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;")
@dataclass(frozen=True)
class LogServices:
"""运行日志框所需的显式业务依赖。"""
load_icon: object
get_logs: object
class LogDialog(QDialog):
"""显示带时间戳的运行日志;窗口打开时会持续读取最新日志。"""
def __init__(self, services: LogServices, parent=None):
super().__init__(parent)
self._services = services
self.setWindowTitle("运行日志")
self.setWindowIcon(services.load_icon())
self.resize(760, 500)
self.setMinimumSize(560, 320)
self.setStyleSheet(f"""
QDialog {{ background-color: {C_BG}; }}
QLabel {{ color: {C_SUBTEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 11px; }}
QTextEdit {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
padding: 6px 8px; color: {C_TEXT}; font-family: Consolas, "Microsoft YaHei", monospace; font-size: 11px; }}
QPushButton {{ background-color: #d9dde3; color: #4f5965; border: none; border-radius: 6px;
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
QPushButton:hover {{ background-color: #cdd3da; color: #3f4853; }}
""")
layout = QVBoxLayout(self)
layout.setSpacing(8)
layout.setContentsMargins(14, 12, 14, 12)
hint = QLabel("显示当前运行期间的最近日志,认证信息不会写入日志。")
layout.addWidget(hint)
self.text = QTextEdit()
self.text.setReadOnly(True)
self.text.setLineWrapMode(QTextEdit.NoWrap)
layout.addWidget(self.text, 1)
buttons = QHBoxLayout()
buttons.setSpacing(8)
buttons.addStretch()
copy = QPushButton("复制")
copy.clicked.connect(self._copy_logs)
buttons.addWidget(copy)
export = QPushButton("导出")
export.clicked.connect(self._export_logs)
buttons.addWidget(export)
close = QPushButton("关闭")
close.clicked.connect(self.accept)
buttons.addWidget(close)
layout.addLayout(buttons)
self._last_text = None
self._refresh()
self._timer = QTimer(self)
self._timer.timeout.connect(self._refresh)
self._timer.start(500)
def _log_text(self):
text = self.text.toPlainText()
return "" if text == "暂无日志" else text
def _copy_logs(self):
QApplication.clipboard().setText(self._log_text())
def _export_logs(self):
default_name = f"rrpp-log-{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt"
path, _ = QFileDialog.getSaveFileName(
self, "导出运行日志", default_name, "文本文件 (*.txt);;所有文件 (*.*)"
)
if not path:
return
with open(path, "w", encoding="utf-8", newline="\n") as file:
file.write(self._log_text())
def _refresh(self):
text = "\n".join(self._services.get_logs()) or "暂无日志"
if text == self._last_text:
return
scrollbar = self.text.verticalScrollBar()
follow_tail = scrollbar.value() >= scrollbar.maximum() - 4
self.text.setPlainText(text)
self._last_text = text
if follow_tail:
scrollbar.setValue(scrollbar.maximum())
@dataclass(frozen=True) @dataclass(frozen=True)
class SettingsServices: class SettingsServices:
"""设置框所需的配置与业务回调。""" """设置框所需的配置与业务回调。"""
@@ -667,29 +931,41 @@ class SettingsServices:
load_icon: object load_icon: object
version: str version: str
get_port: object get_port: object
get_system_pac: object
get_dns: object
get_ipv6_enabled: object
get_update_via_proxy: object get_update_via_proxy: object
get_skip_update_check: object get_skip_update_check: object
get_node_display_mode: object
get_close_behavior: object
parse_port: object parse_port: object
set_port: object set_port: object
set_system_pac: object
set_dns: object
set_ipv6_enabled: object
set_update_via_proxy: object set_update_via_proxy: object
set_skip_update_check: object set_skip_update_check: object
set_node_display_mode: object
set_close_behavior: object
reset_user_config: object
open_whitelist: object open_whitelist: object
open_jump: object open_jump: object
open_third_party: object open_third_party: object
open_logs: object
has_login: object has_login: object
open_login: object open_login: object
logout: object logout: object
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
"""代理端口、更新方式、白名单与登录状态设置框。""" """代理端口、更新方式、域名路由与登录状态设置框。"""
def __init__(self, services: SettingsServices, parent=None, port_locked=False): def __init__(self, services: SettingsServices, parent=None, port_locked=False):
super().__init__(parent) super().__init__(parent)
self._services = services self._services = services
self.setWindowTitle("设置") self.setWindowTitle("设置")
self.setWindowIcon(services.load_icon()) self.setWindowIcon(services.load_icon())
self.setFixedSize(300, 430) self.setFixedSize(620, 460)
self.setStyleSheet(f""" self.setStyleSheet(f"""
QDialog {{ background-color: {C_BG}; }} QDialog {{ background-color: {C_BG}; }}
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
@@ -697,8 +973,8 @@ class SettingsDialog(QDialog):
padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }} padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }}
""") """)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setSpacing(12) layout.setSpacing(7)
layout.setContentsMargins(20, 16, 20, 16) layout.setContentsMargins(20, 8, 20, 8)
title = QLabel("设置") title = QLabel("设置")
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;") title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
@@ -725,24 +1001,46 @@ class SettingsDialog(QDialog):
self.lb_hint.setFixedHeight(14) self.lb_hint.setFixedHeight(14)
self.lb_hint.setAlignment(Qt.AlignCenter) self.lb_hint.setAlignment(Qt.AlignCenter)
layout.addWidget(self.lb_hint) layout.addWidget(self.lb_hint)
layout.addStretch() layout.addSpacing(2)
columns = QHBoxLayout()
columns.setSpacing(24)
left_col = QVBoxLayout()
right_col = QVBoxLayout()
left_col.setSpacing(9)
right_col.setSpacing(9)
left_heading = QLabel("连接与分流")
right_heading = QLabel("节点与维护")
for heading in (left_heading, right_heading):
heading.setStyleSheet(f"color: {C_TITLE}; font-size: 12px; font-weight: bold;")
heading.setAlignment(Qt.AlignCenter)
left_col.addWidget(left_heading)
right_col.addWidget(right_heading)
columns.addLayout(left_col, 1)
columns.addLayout(right_col, 1)
layout.addLayout(columns, 1)
compact_button_style = f"""
QPushButton {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
border-radius: 5px; padding: 1px 7px; min-width: 34px; min-height: 18px;
font-size: 10px; }}
QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
QPushButton:checked {{ background-color: {C_TITLE}; color: #ffffff; border-color: {C_TITLE}; }}
QPushButton:disabled {{ background-color: #e7eaee; color: #a8afb8; border-color: #d8dde3; }}
"""
whitelist_row = QHBoxLayout() whitelist_row = QHBoxLayout()
whitelist_label = QLabel("代理白名单") whitelist_label = QLabel("域名路由")
whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
whitelist_label.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用") whitelist_label.setToolTip("为域名配置强制不代理,或强制经过指定节点;支持 *.example.com")
whitelist_row.addWidget(whitelist_label) whitelist_row.addWidget(whitelist_label)
whitelist_row.addStretch() whitelist_row.addStretch()
self.btn_whitelist = QPushButton("配置") self.btn_whitelist = QPushButton("配置")
self.btn_whitelist.setCursor(Qt.PointingHandCursor) self.btn_whitelist.setCursor(Qt.PointingHandCursor)
self.btn_whitelist.setStyleSheet(f""" self.btn_whitelist.setStyleSheet(compact_button_style)
QPushButton {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
border-radius: 6px; padding: 2px 10px; font-size: 11px; }}
QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
""")
self.btn_whitelist.clicked.connect(self._open_whitelist) self.btn_whitelist.clicked.connect(self._open_whitelist)
whitelist_row.addWidget(self.btn_whitelist) whitelist_row.addWidget(self.btn_whitelist)
layout.addLayout(whitelist_row) left_col.addLayout(whitelist_row)
jump_row = QHBoxLayout() jump_row = QHBoxLayout()
jump_label = QLabel("代理跳板") jump_label = QLabel("代理跳板")
@@ -750,26 +1048,37 @@ class SettingsDialog(QDialog):
jump_label.setToolTip("把当前可用节点串成 2-3 跳链路;最后一个节点作为对外出口") jump_label.setToolTip("把当前可用节点串成 2-3 跳链路;最后一个节点作为对外出口")
jump_row.addWidget(jump_label) jump_row.addWidget(jump_label)
jump_row.addStretch() jump_row.addStretch()
self.btn_jump = QPushButton("添加") self.btn_jump = QPushButton("配置")
self.btn_jump.setCursor(Qt.PointingHandCursor) self.btn_jump.setCursor(Qt.PointingHandCursor)
self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet()) self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet())
self.btn_jump.clicked.connect(self._open_jump) self.btn_jump.clicked.connect(self._open_jump)
jump_row.addWidget(self.btn_jump) jump_row.addWidget(self.btn_jump)
layout.addLayout(jump_row) left_col.addLayout(jump_row)
third_party_row = QHBoxLayout() third_party_row = QHBoxLayout()
third_party_label = QLabel("第三方") third_party_label = QLabel("第三方订阅")
third_party_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") third_party_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
third_party_label.setToolTip("添加、更新或删除第三方 RRPP Client JSON 订阅") third_party_label.setToolTip("添加、更新或删除第三方 RRPP Client JSON 订阅")
third_party_row.addWidget(third_party_label) third_party_row.addWidget(third_party_label)
third_party_row.addStretch() third_party_row.addStretch()
self.btn_third_party = QPushButton("添加") self.btn_third_party = QPushButton("配置")
self.btn_third_party.setCursor(Qt.PointingHandCursor) self.btn_third_party.setCursor(Qt.PointingHandCursor)
self.btn_third_party.setStyleSheet(self.btn_whitelist.styleSheet()) self.btn_third_party.setStyleSheet(self.btn_whitelist.styleSheet())
self.btn_third_party.clicked.connect(self._open_third_party) self.btn_third_party.clicked.connect(self._open_third_party)
third_party_row.addWidget(self.btn_third_party) third_party_row.addWidget(self.btn_third_party)
layout.addLayout(third_party_row) right_col.addLayout(third_party_row)
log_row = QHBoxLayout()
log_label = QLabel("日志")
log_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
log_label.setToolTip("查看连接域名、规则匹配、路由、错误与流量等运行信息")
log_row.addWidget(log_label)
log_row.addStretch()
self.btn_logs = QPushButton("查看")
self.btn_logs.setCursor(Qt.PointingHandCursor)
self.btn_logs.setStyleSheet(self.btn_whitelist.styleSheet())
self.btn_logs.clicked.connect(self._open_logs)
log_row.addWidget(self.btn_logs)
port_row = QHBoxLayout() port_row = QHBoxLayout()
port_label = QLabel("代理转发端口") port_label = QLabel("代理转发端口")
port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
@@ -779,7 +1088,45 @@ class SettingsDialog(QDialog):
self.ed_port.setFixedSize(54, 22) self.ed_port.setFixedSize(54, 22)
self.ed_port.setEnabled(not port_locked) self.ed_port.setEnabled(not port_locked)
port_row.addWidget(self.ed_port) port_row.addWidget(self.ed_port)
layout.addLayout(port_row) left_col.addLayout(port_row)
system_pac_row = QHBoxLayout()
builtin_pac_label = QLabel("使用系统 PAC (系统代理)")
builtin_pac_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
builtin_pac_label.setToolTip(
"仅系统代理模式使用;关闭时使用内置 PAC 智能分流"
)
system_pac_row.addWidget(builtin_pac_label)
system_pac_row.addStretch()
self.cb_system_pac = ToggleSwitch(checked=services.get_system_pac())
self.cb_system_pac.setToolTip("系统代理且已选 PAC 规则模式时,向 Windows 写入本地 PAC 地址;TUN 模式忽略此项")
system_pac_row.addWidget(self.cb_system_pac)
left_col.addLayout(system_pac_row)
dns_row = QHBoxLayout()
dns_label = QLabel("自定义 DNS (虚拟网卡)")
dns_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
dns_label.setToolTip("TUN 模式使用;支持 IPv4/IPv6,请填写 1-3 个 DNS 地址")
dns_row.addWidget(dns_label)
dns_row.addStretch()
self.ed_dns = QLineEdit(", ".join(services.get_dns()))
self.ed_dns.setPlaceholderText("8.8.8.8, 1.1.1.1")
self.ed_dns.setFixedSize(120, 22)
self.ed_dns.setEnabled(not port_locked)
dns_row.addWidget(self.ed_dns)
left_col.addLayout(dns_row)
ipv6_row = QHBoxLayout()
ipv6_label = QLabel("启用 IPv6 模式 (虚拟网卡)")
ipv6_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
ipv6_label.setToolTip("TUN 模式安装 IPv6 地址和默认路由;默认关闭")
ipv6_row.addWidget(ipv6_label)
ipv6_row.addStretch()
get_ipv6_enabled = getattr(services, "get_ipv6_enabled", lambda: False)
self.cb_ipv6 = ToggleSwitch(checked=bool(get_ipv6_enabled()))
self.cb_ipv6.setToolTip("仅虚拟网卡模式使用;系统代理模式忽略此项")
ipv6_row.addWidget(self.cb_ipv6)
left_col.addLayout(ipv6_row)
update_row = QHBoxLayout() update_row = QHBoxLayout()
update_label = QLabel("使用代理更新") update_label = QLabel("使用代理更新")
@@ -789,7 +1136,7 @@ class SettingsDialog(QDialog):
self.cb_update_proxy = ToggleSwitch(checked=services.get_update_via_proxy()) self.cb_update_proxy = ToggleSwitch(checked=services.get_update_via_proxy())
self.cb_update_proxy.setToolTip("开启后检查更新与下载走节点隧道(CONNECT);默认直连仓库") self.cb_update_proxy.setToolTip("开启后检查更新与下载走节点隧道(CONNECT);默认直连仓库")
update_row.addWidget(self.cb_update_proxy) update_row.addWidget(self.cb_update_proxy)
layout.addLayout(update_row) right_col.addLayout(update_row)
skip_row = QHBoxLayout() skip_row = QHBoxLayout()
skip_label = QLabel("跳过检查更新") skip_label = QLabel("跳过检查更新")
@@ -799,27 +1146,87 @@ class SettingsDialog(QDialog):
self.cb_skip_update = ToggleSwitch(checked=services.get_skip_update_check()) self.cb_skip_update = ToggleSwitch(checked=services.get_skip_update_check())
self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志") self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
skip_row.addWidget(self.cb_skip_update) skip_row.addWidget(self.cb_skip_update)
layout.addLayout(skip_row) right_col.addLayout(skip_row)
layout.addStretch()
node_display_row = QHBoxLayout()
node_display_label = QLabel("节点信息显示")
node_display_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
node_display_label.setToolTip("控制主页面节点副标题显示域名还是节点备注")
node_display_row.addWidget(node_display_label)
node_display_row.addStretch()
self.btn_node_domain = QPushButton("域名")
self.btn_node_remark = QPushButton("备注")
for button in (self.btn_node_domain, self.btn_node_remark):
button.setCheckable(True)
button.setCursor(Qt.PointingHandCursor)
button.setStyleSheet(compact_button_style)
node_display_row.addWidget(button)
self.node_display_group = QButtonGroup(self)
self.node_display_group.setExclusive(True)
self.node_display_group.addButton(self.btn_node_domain)
self.node_display_group.addButton(self.btn_node_remark)
if services.get_node_display_mode() == "remark":
self.btn_node_remark.setChecked(True)
else:
self.btn_node_domain.setChecked(True)
right_col.addLayout(node_display_row)
close_behavior_row = QHBoxLayout()
close_behavior_label = QLabel("默认关闭行为")
close_behavior_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
close_behavior_label.setToolTip("点击主窗口关闭按钮时退出程序,或隐藏到系统托盘")
close_behavior_row.addWidget(close_behavior_label)
close_behavior_row.addStretch()
self.btn_close_exit = QPushButton("退出")
self.btn_close_tray = QPushButton("托盘")
for button in (self.btn_close_exit, self.btn_close_tray):
button.setCheckable(True)
button.setCursor(Qt.PointingHandCursor)
button.setStyleSheet(compact_button_style)
close_behavior_row.addWidget(button)
self.close_behavior_group = QButtonGroup(self)
self.close_behavior_group.setExclusive(True)
self.close_behavior_group.addButton(self.btn_close_exit)
self.close_behavior_group.addButton(self.btn_close_tray)
if services.get_close_behavior() == "tray":
self.btn_close_tray.setChecked(True)
else:
self.btn_close_exit.setChecked(True)
right_col.addLayout(close_behavior_row)
reset_row = QHBoxLayout()
reset_label = QLabel("重置用户配置")
reset_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
reset_label.setToolTip("重置本地偏好、域名路由、跳板和当前节点选择;不会删除登录和订阅")
reset_row.addWidget(reset_label)
reset_row.addStretch()
self.btn_reset = QPushButton("重置")
self.btn_reset.setCursor(Qt.PointingHandCursor)
self.btn_reset.setStyleSheet(compact_button_style)
self.btn_reset.setEnabled(not port_locked)
self.btn_reset.clicked.connect(self._reset_user_config)
reset_row.addWidget(self.btn_reset)
right_col.addLayout(reset_row)
right_col.addLayout(log_row)
left_col.addStretch()
right_col.addStretch()
buttons = QHBoxLayout() buttons = QHBoxLayout()
buttons.setSpacing(10) buttons.setSpacing(10)
buttons.addStretch() buttons.addStretch()
self.btn_save = QPushButton("保存更改") self.btn_save = QPushButton("保存更改")
self.btn_save.setStyleSheet(f""" self.btn_save.setStyleSheet(f"""
QPushButton {{ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px; QPushButton {{ background-color: #d9dde3; color: #4f5965; border: none; border-radius: 6px;
padding: 5px 14px; font-size: 12px; font-weight: bold; }} padding: 5px 14px; font-size: 12px; font-weight: bold; }}
QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }} QPushButton:hover {{ background-color: #cdd3da; color: #3f4853; }}
QPushButton:disabled {{ background-color: #eceff2; color: #a8afb8; }}
""") """)
self.btn_save.setEnabled(not port_locked) self.btn_save.setEnabled(not port_locked)
self.btn_save.clicked.connect(self._save_settings) self.btn_save.clicked.connect(self._save_settings)
buttons.addWidget(self.btn_save) buttons.addWidget(self.btn_save)
self.btn_logout = QPushButton("退出登录" if services.has_login() else "登录") self.btn_logout = QPushButton("退出登录" if services.has_login() else "登录")
self.btn_logout.setStyleSheet(f""" self.btn_logout.setStyleSheet(self.btn_save.styleSheet())
QPushButton {{ background-color: {C_RED}; color: #fff; border: none; border-radius: 6px;
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
QPushButton:hover {{ background-color: #ff8787; }}
""")
self.btn_logout.setToolTip("登录获取官方节点;退出登录不会删除第三方订阅") self.btn_logout.setToolTip("登录获取官方节点;退出登录不会删除第三方订阅")
self.btn_logout.clicked.connect(self._logout) self.btn_logout.clicked.connect(self._logout)
buttons.addWidget(self.btn_logout) buttons.addWidget(self.btn_logout)
@@ -855,15 +1262,57 @@ class SettingsDialog(QDialog):
def _open_third_party(self): def _open_third_party(self):
self._services.open_third_party(self) self._services.open_third_party(self)
def _open_logs(self):
self._services.open_logs(self)
def _reset_user_config(self):
reply = QMessageBox.question(
self,
"重置用户配置",
"确定要恢复本地偏好、域名路由、跳板和当前节点选择的默认值吗?\n\n"
"登录凭据、节点缓存、第三方订阅和 PAC 数据不会被删除。",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
self._services.reset_user_config()
self.ed_port.setText(str(self._services.get_port()))
self.cb_system_pac.setChecked(self._services.get_system_pac())
self.ed_dns.setText(", ".join(self._services.get_dns()))
get_ipv6_enabled = getattr(self._services, "get_ipv6_enabled", lambda: False)
self.cb_ipv6.setChecked(bool(get_ipv6_enabled()))
self.cb_update_proxy.setChecked(self._services.get_update_via_proxy())
self.cb_skip_update.setChecked(self._services.get_skip_update_check())
self.btn_node_domain.setChecked(True)
self.btn_close_exit.setChecked(True)
self.lb_hint.setText("用户配置已重置")
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
def _save_settings(self): def _save_settings(self):
port = self._services.parse_port(self.ed_port.text()) port = self._services.parse_port(self.ed_port.text())
if port is None: if port is None:
self.lb_hint.setText("端口应为 1-65535 的数字") self.lb_hint.setText("端口应为 1-65535 的数字")
self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;") self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
return return
try:
self._services.set_dns(self.ed_dns.text())
except ValueError as exc:
self.lb_hint.setText(str(exc))
self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
return
self._services.set_port(port) self._services.set_port(port)
self._services.set_system_pac(bool(self.cb_system_pac.isChecked()))
set_ipv6_enabled = getattr(self._services, "set_ipv6_enabled", lambda _value: None)
set_ipv6_enabled(bool(self.cb_ipv6.isChecked()))
self._services.set_update_via_proxy(bool(self.cb_update_proxy.isChecked())) self._services.set_update_via_proxy(bool(self.cb_update_proxy.isChecked()))
self._services.set_skip_update_check(bool(self.cb_skip_update.isChecked())) self._services.set_skip_update_check(bool(self.cb_skip_update.isChecked()))
self._services.set_node_display_mode(
"remark" if self.btn_node_remark.isChecked() else "domain"
)
self._services.set_close_behavior(
"tray" if self.btn_close_tray.isChecked() else "exit"
)
self.ed_port.setText(str(port)) self.ed_port.setText(str(port))
self.lb_hint.setText("已保存,下次开启代理生效") self.lb_hint.setText("已保存,下次开启代理生效")
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;") self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
+65 -1
View File
@@ -2,7 +2,23 @@
from PyQt5.QtCore import Qt, QRectF, pyqtSignal from PyQt5.QtCore import Qt, QRectF, pyqtSignal
from PyQt5.QtGui import QColor, QPainter from PyQt5.QtGui import QColor, QPainter
from PyQt5.QtWidgets import QCheckBox, QLabel from PyQt5.QtWidgets import QCheckBox, QLabel, QFrame, QHBoxLayout
try:
from PyQt5.QtWidgets import QGraphicsDropShadowEffect
except ImportError: # 轻量测试桩不提供图形效果类,真实 PyQt5 仍使用阴影。
class QGraphicsDropShadowEffect: # type: ignore[no-redef]
def __init__(self, *_args, **_kwargs):
pass
def setBlurRadius(self, *_args):
pass
def setOffset(self, *_args):
pass
def setColor(self, *_args):
pass
from .theme import C_BORDER, C_GREEN, C_RED, C_TITLE from .theme import C_BORDER, C_GREEN, C_RED, C_TITLE
@@ -102,3 +118,51 @@ class StatusDot(QLabel):
border: 1px solid rgba(136, 153, 170, 50); border: 1px solid rgba(136, 153, 170, 50);
}} }}
""") """)
class ProxyModeCard(QFrame):
"""整个卡片可点击;描边表示选择,圆点表示所选模式的运行状态。"""
clicked = pyqtSignal()
def __init__(self, title: str, parent=None):
super().__init__(parent)
self.setCursor(Qt.PointingHandCursor)
self._glow = QGraphicsDropShadowEffect(self)
self._glow.setBlurRadius(12)
self._glow.setOffset(0, 1)
self.setGraphicsEffect(self._glow)
row = QHBoxLayout(self)
row.setContentsMargins(11, 10, 11, 10)
row.setSpacing(5)
self.title = QLabel(title)
self.title.setStyleSheet("font-weight: bold; background: transparent;")
self.dot = StatusDot()
for child in (self.title, self.dot):
child.setAttribute(Qt.WA_TransparentForMouseEvents)
row.addWidget(self.title)
row.addStretch()
row.addWidget(self.dot)
self.set_state(False, False)
def set_state(self, selected: bool, running: bool) -> None:
border = C_TITLE if selected else "#e2e8ef"
background = "#f5f7f9" if selected else "#fafcfe"
self.setStyleSheet(
f"QFrame {{ background-color: {background}; border: {'2px' if selected else '1px'} "
f"solid {border}; border-radius: 8px; }}"
"QFrame:hover { border-color: #cbd6e2; background-color: #ffffff; }"
"QLabel { border: none; }"
)
self._glow.setColor(QColor(136, 153, 170, 105 if selected else 52))
if not selected:
self.dot.set_disabled()
elif running:
self.dot.set_on()
else:
self.dot.set_off()
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton and self.rect().contains(event.pos()):
self.clicked.emit()
super().mouseReleaseEvent(event)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Jason Lyu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+84
View File
@@ -0,0 +1,84 @@
Prebuilt Binaries License
-------------------------
1. DEFINITIONS. "Software" means the precise contents of the "wintun.dll"
files that are included in the .zip file that contains this document as
downloaded from wintun.net/builds.
2. LICENSE GRANT. WireGuard LLC grants to you a non-exclusive and
non-transferable right to use Software for lawful purposes under certain
obligations and limited rights as set forth in this agreement.
3. RESTRICTIONS. Software is owned and copyrighted by WireGuard LLC. It is
licensed, not sold. Title to Software and all associated intellectual
property rights are retained by WireGuard. You must not:
a. reverse engineer, decompile, disassemble, extract from, or otherwise
modify the Software;
b. modify or create derivative work based upon Software in whole or in
parts, except insofar as only the API interfaces of the "wintun.h" file
distributed alongside the Software (the "Permitted API") are used;
c. remove any proprietary notices, labels, or copyrights from the Software;
d. resell, redistribute, lease, rent, transfer, sublicense, or otherwise
transfer rights of the Software without the prior written consent of
WireGuard LLC, except insofar as the Software is distributed alongside
other software that uses the Software only via the Permitted API;
e. use the name of WireGuard LLC, the WireGuard project, the Wintun
project, or the names of its contributors to endorse or promote products
derived from the Software without specific prior written consent.
4. LIMITED WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTY OF
ANY KIND. WIREGUARD LLC HEREBY EXCLUDES AND DISCLAIMS ALL IMPLIED OR
STATUTORY WARRANTIES, INCLUDING ANY WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE, QUALITY, NON-INFRINGEMENT, TITLE, RESULTS,
EFFORTS, OR QUIET ENJOYMENT. THERE IS NO WARRANTY THAT THE PRODUCT WILL BE
ERROR-FREE OR WILL FUNCTION WITHOUT INTERRUPTION. YOU ASSUME THE ENTIRE
RISK FOR THE RESULTS OBTAINED USING THE PRODUCT. TO THE EXTENT THAT
WIREGUARD LLC MAY NOT DISCLAIM ANY WARRANTY AS A MATTER OF APPLICABLE LAW,
THE SCOPE AND DURATION OF SUCH WARRANTY WILL BE THE MINIMUM PERMITTED UNDER
SUCH LAW. ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS AND
WARRANTIES, INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR
A PARTICULAR PURPOSE OR NON-INFRINGEMENT ARE DISCLAIMED, EXCEPT TO THE
EXTENT THAT THESE DISCLAIMERS ARE HELD TO BE LEGALLY INVALID.
5. LIMITATION OF LIABILITY. To the extent not prohibited by law, in no event
WireGuard LLC or any third-party-developer will be liable for any lost
revenue, profit or data or for special, indirect, consequential, incidental
or punitive damages, however caused regardless of the theory of liability,
arising out of or related to the use of or inability to use Software, even
if WireGuard LLC has been advised of the possibility of such damages.
Solely you are responsible for determining the appropriateness of using
Software and accept full responsibility for all risks associated with its
exercise of rights under this agreement, including but not limited to the
risks and costs of program errors, compliance with applicable laws, damage
to or loss of data, programs or equipment, and unavailability or
interruption of operations. The foregoing limitations will apply even if
the above stated warranty fails of its essential purpose. You acknowledge,
that it is in the nature of software that software is complex and not
completely free of errors. In no event shall WireGuard LLC or any
third-party-developer be liable to you under any theory for any damages
suffered by you or any user of Software or for any special, incidental,
indirect, consequential or similar damages (including without limitation
damages for loss of business profits, business interruption, loss of
business information or any other pecuniary loss) arising out of the use or
inability to use Software, even if WireGuard LLC has been advised of the
possibility of such damages and regardless of the legal or quitable theory
(contract, tort, or otherwise) upon which the claim is based.
6. TERMINATION. This agreement is affected until terminated. You may
terminate this agreement at any time. This agreement will terminate
immediately without notice from WireGuard LLC if you fail to comply with
the terms and conditions of this agreement. Upon termination, you must
delete Software and all copies of Software and cease all forms of
distribution of Software.
7. SEVERABILITY. If any provision of this agreement is held to be
unenforceable, this agreement will remain in effect with the provision
omitted, unless omission would frustrate the intent of the parties, in
which case this agreement will immediately terminate.
8. RESERVATION OF RIGHTS. All rights not expressly granted in this agreement
are reserved by WireGuard LLC. For example, WireGuard LLC reserves the
right at any time to cease development of Software, to alter distribution
details, features, specifications, capabilities, functions, licensing
terms, release dates, APIs, ABIs, general availability, or other
characteristics of the Software.
+12
View File
@@ -0,0 +1,12 @@
RRPP Lite TUN 目录验证运行时
- tun2socks.exe: xjasonlyu/tun2socks v2.7.0, Windows amd64
SHA-256: 076b3c3d6a372ba3ef49f2b415a4105f70c30a3ed3caaed7979390e649892559
- wintun.dll: WireGuard Wintun 0.14.1, amd64
SHA-256: e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce
来源:
https://github.com/xjasonlyu/tun2socks/releases/tag/v2.7.0
https://www.wintun.net/
许可证文件见 LICENSE.tun2socks.txt 和 LICENSE.wintun.txt。
Binary file not shown.
Binary file not shown.
+99
View File
@@ -265,8 +265,99 @@ chmod 644 /etc/nginx/.htpasswd # worker 以 nobody 运行,必须可读
1. `map` 判定私网/回环段 + cron 维护的本机公网 IP 规则 → 直连伪装页 1. `map` 判定私网/回环段 + cron 维护的本机公网 IP 规则 → 直连伪装页
2. `/__proxy` 的 Loop-Guard 头兜底 → 二次进入返回 508 2. `/__proxy` 的 Loop-Guard 头兜底 → 二次进入返回 508
### 5.5 路线 B 裸 UoT relay
TUN 客户端的 UDP 会先通过认证 CONNECT 请求
`sp.v2.udp-over-tcp.arpa:443`。当前 hk2 使用的
`ngx_http_proxy_connect_module` 没有注册 `$connect_host`,因此在 fallback
server 实际接收的 CONNECT 请求上用 `$request` 正则匹配,再把连接转到
`127.0.0.1:15201`;nginx 只负责 TLS、Basic Auth 和字节流转发,不解析 UoT。relay 使用仓库中的
`[uot_relay.py](uot_relay.py)`,只监听回环地址,不需要 sing-box:
```bash
install -m 755 uot_relay.py /opt/rrpp-uot-relay/uot_relay.py
install -m 644 systemd/uot-relay.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now uot-relay
ss -lntp | grep 127.0.0.1:15201
nginx -t && systemctl reload nginx
```
客户端的 CONNECT 端口必须大于 0,当前统一使用 `443`;`15201` 是服务器回环
relay 的内部端口,不写入客户端 CONNECT 目标。nginx fallback 块使用
`proxy_connect_read_timeout 10m` 和 `proxy_connect_send_timeout 10m`,不要再同时
配置会产生 duplicate 的 `proxy_connect_data_timeout`。
--- ---
### 5.5 UoT(UDP over TCP)支持
HTTP CONNECT 只隧道 TCP。要让 UDP 应用(DNS/游戏/QUIC 等)走代理,需 **UoT v2**
(SagerNet 专有协议,见 sing-box 文档 `configuration/shared/udp-over-tcp`):
客户端把 UDP 数据报封装进 TCP 流,经 CONNECT 隧道发到 magic 地址
`sp.v2.udp-over-tcp.arpa`,由本机 **UoT relay** 解封装后转发真实 UDP 目标。
```
客户端 TUN/UDP ──UoT v2──▶ TLS HTTP CONNECT ──▶ nginx proxy_connect
──▶ 127.0.0.1:15201(uot-relay)──▶ 真实 UDP 目标
```
**relay 部署**(Python 标准库实现,见 [uot-relay/uot_relay.py](uot-relay/uot_relay.py)):
```bash
install -d /opt/rrpp-uot-relay
install -m 755 uot-relay/uot_relay.py /opt/rrpp-uot-relay/uot_relay.py
install -m 644 uot-relay/uot-relay.service /etc/systemd/system/uot-relay.service
systemctl daemon-reload && systemctl enable --now uot-relay
```
relay 只监听 `127.0.0.1:15201`,空闲 9 分钟断开(须小于 nginx 隧道超时 10m)。
支持 UoT v2 两种模式:`isConnect=1` 固定目标、`isConnect=0` 每帧自带目标。
**nginx 侧**(模板已含,[nginx/nginx.conf](nginx/nginx.conf)):`map $request` 识别
magic 地址 → `127.0.0.1:15201`,其余 CONNECT 走 `$connect_addr` 默认解析;
`proxy_connect_address $rrpp_uot_connect_address;` 放在 **default_server 块**。
**四个实测坑(2026-09 全节点部署验证)**:
1. 该模块版本**未注册 `$connect_host` 变量**(README 有、源码无)→ 必须用
`$request` 正则匹配(任何阶段可用);
2. `proxy_connect_data_timeout` 与 `proxy_connect_read_timeout` **写同一配置槽**,
同时写报 duplicate → 长连接超时用 read/send timeout 设 10m;
3. **所有 CONNECT 请求都落在 `default_server`(fallback)块**——nginx 按请求
Host 头选虚拟主机,CONNECT 的 Host 是目标域名,永远匹配不到 `server_name`;
4. 模块**拒绝 CONNECT 端口 0**(`port < 1` 返回 400)→ 客户端连 magic 地址时
端口必须非 0(用 443 即可,relay 不读 CONNECT 端口,目标取自 UoT 帧头)。
**客户端协议**:认证与 TCP CONNECT 相同(TLS + Basic Auth),然后
`CONNECT sp.v2.udp-over-tcp.arpa:443 HTTP/1.1`,200 之后收发 UoT v2 帧:
请求头 `| isConnect u8 | ATYP u8 | address | port u16be |`,
connect 模式帧 `| length u16be | data |`,non-connect 帧每帧自带目标地址。
### 5.6 CONNECT 超时口径(避免客户端"卡死")
三个超时要按"客户端最没耐心"来配:
| 项 | 值 | 说明 |
|---|---|---|
| `proxy_connect_connect_timeout` | **10s** | 出站连接目标;须 < 客户端超时(lite 客户端 15s) |
| `proxy_connect_read_timeout` / `send_timeout` | 10m | 隧道空闲超时(UoT 长连接需要;注意与 data_timeout 同槽位) |
**典型故障**:目标 IP 是黑洞(不发 RST)时,若 nginx 出站超时设 60s 而客户端 15s 超时,
客户端会表现为 `The read operation timed out` 而不是收到 502 —— 看起来像"代理卡死",
实际是超时错配。2026-09 实测:改为 10s 后,黑洞目标从 >25s 降至 10.2s 返回,
正常目标(0.4~0.7s)不受影响。
**排查"CONNECT 502"的正确顺序**(重要):
1. 先看目标是否**本身就不可达**——从多个网络(国内/日本/美国/香港)实测该 IP:端口,
若都失败则是目标问题(服务下线/过期 IP/黑洞),不是代理问题;
2. 再看 nginx error.log:`Connection refused` / `timed out` / `Network is unreachable`
分别对应目标拒绝、目标黑洞、路由不可达;
3. 最后才怀疑 CONNECT 模块本身。
> 客户端日志只记 IP 不记域名时,无法判断"是不是 DNS 过期解析"。
> 建议客户端日志同时记录原始域名(实测案例:某 Dropbox 旧 IP 已失效,
> 域名现已解析到其它地址,客户端仍在拨旧 IP)。
## 6. sing-box(VLESS+REALITY / Shadowsocks) ## 6. sing-box(VLESS+REALITY / Shadowsocks)
### 6.1 安装 ### 6.1 安装
@@ -378,6 +469,7 @@ mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <上面生成的s
| nginx | [systemd/nginx.service](systemd/nginx.service) | | nginx | [systemd/nginx.service](systemd/nginx.service) |
| sing-box | [systemd/sing-box.service](systemd/sing-box.service) | | sing-box | [systemd/sing-box.service](systemd/sing-box.service) |
| mtg | [systemd/mtg.service](systemd/mtg.service) | | mtg | [systemd/mtg.service](systemd/mtg.service) |
| uot-relay | [uot-relay/uot-relay.service](uot-relay/uot-relay.service) |
```bash ```bash
cp systemd/*.service /etc/systemd/system/ cp systemd/*.service /etc/systemd/system/
@@ -396,6 +488,7 @@ sysctl --system # 1. BBR
systemctl start sing-box # 2. VLESS/SS systemctl start sing-box # 2. VLESS/SS
systemctl start mtg # 3. MTProto systemctl start mtg # 3. MTProto
systemctl start nginx # 4. nginx(最后,依赖证书/伪装页/规则文件) systemctl start nginx # 4. nginx(最后,依赖证书/伪装页/规则文件)
systemctl start uot-relay # 5. UoT relay(须在 nginx 前可用)
``` ```
### 验证清单 ### 验证清单
@@ -419,6 +512,9 @@ s=socket.connect(('<节点域名>',<mtproto端口>)) if False else socket.create
s.sendall(b'\x16\x03\x01'+struct.pack('>H',100)+os.urandom(100)) s.sendall(b'\x16\x03\x01'+struct.pack('>H',100)+os.urandom(100))
print('resp:',s.recv(8).hex())" # 期望首字节 15 (TLS alert) print('resp:',s.recv(8).hex())" # 期望首字节 15 (TLS alert)
# UoT relay(127.0.0.1:15201 监听 + CONNECT magic 地址)
ss -tlnp | grep 15201 # => uot_relay.py 监听 127.0.0.1:15201
# nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>')) # nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>'))
B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0) B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0)
curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \ curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \
@@ -440,6 +536,9 @@ server_config/
│ ├── nginx.conf ← 完整 nginx 配置(占位符版) │ ├── nginx.conf ← 完整 nginx 配置(占位符版)
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改) │ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
│ └── conf.d/ ← 预留:额外站点配置 │ └── conf.d/ ← 预留:额外站点配置
├── uot-relay/
│ ├── uot_relay.py ← UoT v2 relay(UDP over TCP 解封装)
│ └── uot-relay.service ← systemd unit
├── sing-box/ ├── sing-box/
│ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版) │ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
├── systemd/ ├── systemd/
+23 -6
View File
@@ -16,6 +16,13 @@ http {
default 0; default 0;
"~^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)" 1; "~^(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; include mime.types;
default_type application/octet-stream; default_type application/octet-stream;
sendfile on; sendfile on;
@@ -44,9 +51,14 @@ http {
proxy_connect; proxy_connect;
proxy_connect_allow all; proxy_connect_allow all;
proxy_connect_connect_timeout 60s; proxy_connect_address $rrpp_uot_connect_address;
proxy_connect_read_timeout 120s; # 出站连接超时须小于客户端超时(lite 客户端 15s):
proxy_connect_send_timeout 120s; # 目标黑洞时快速返回 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 / { location / {
auth_basic off; auth_basic off;
@@ -92,9 +104,14 @@ http {
proxy_connect; proxy_connect;
proxy_connect_allow all; proxy_connect_allow all;
proxy_connect_connect_timeout 60s; proxy_connect_address $rrpp_uot_connect_address;
proxy_connect_read_timeout 120s; # 出站连接超时须小于客户端超时(lite 客户端 15s):
proxy_connect_send_timeout 120s; # 目标黑洞时快速返回 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 / { location / {
auth_basic off; auth_basic off;
@@ -0,0 +1,19 @@
[Unit]
Description=RRPP bare UDP over TCP v2 relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
Restart=always
RestartSec=2
User=rrpp
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/rrpp
[Install]
WantedBy=multi-user.target
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=RRPP bare UDP over TCP v2 relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
Restart=always
RestartSec=2
User=rrpp
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/rrpp
[Install]
WantedBy=multi-user.target
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=RRPP UoT v2 relay (UDP over TCP decapsulator, localhost only)
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
Restart=on-failure
RestartSec=5
# 双保险:只许监听本机(脚本本身 bind 127.0.0.1)
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RRPP UoT v2 relay — UDP over TCP 解封装服务(sing-box UoT v2 协议)
协议(SagerNet 专有,见 sing-box 文档 configuration/shared/udp-over-tcp):
请求头: | isConnect u8 | ATYP u8 | address | port u16be |
isConnect=1: 固定目标模式,后续帧全部是 length+data,发往请求头里的目标
isConnect=0: 每帧自带目标,帧格式同 v1: | ATYP | address | port | length u16be | data |
connect 帧: | length u16be | data |
non-connect 帧: | ATYP | address | port | length u16be | data |
监听 127.0.0.1,仅供本机 nginx proxy_connect 转发,不对公网开放。
"""
import socket
import struct
import threading
import select
import time
import sys
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 15201
IDLE_TIMEOUT = 540 # 9 分钟无流量断开(须 < nginx proxy_connect_data_timeout 10m)
MAX_DGRAM = 65507 # UDP over IPv4 单包上限
LOG_PREFIX = "[uot-relay]"
def log(msg):
print(f"{LOG_PREFIX} {time.strftime('%Y-%m-%d %H:%M:%S')} {msg}", flush=True)
def read_exact(f, n):
buf = b""
while len(buf) < n:
chunk = f.read(n - len(buf))
if not chunk:
raise EOFError("connection closed")
buf += chunk
return buf
def parse_addr(f):
"""从流中读取 SOCKS 格式地址(ATYP + address + port)。"""
atyp = read_exact(f, 1)[0]
if atyp == 0x00:
addr = socket.inet_ntop(socket.AF_INET, read_exact(f, 4))
elif atyp == 0x01:
addr = socket.inet_ntop(socket.AF_INET6, read_exact(f, 16))
elif atyp == 0x02:
ln = read_exact(f, 1)[0]
addr = read_exact(f, ln).decode("utf-8", "replace")
else:
raise ValueError(f"bad ATYP {atyp:#x}")
port = struct.unpack(">H", read_exact(f, 2))[0]
return addr, port
def encode_addr(addr, port):
"""SOCKS 格式地址编码(用于 non-connect 模式的回包)。"""
try:
return b"\x00" + socket.inet_pton(socket.AF_INET, addr) + struct.pack(">H", port)
except OSError:
pass
try:
return b"\x01" + socket.inet_pton(socket.AF_INET6, addr) + struct.pack(">H", port)
except OSError:
pass
d = addr.encode("utf-8")
if len(d) > 255:
raise ValueError("domain too long")
return b"\x02" + bytes([len(d)]) + d + struct.pack(">H", port)
def handle(conn, peer):
conn.settimeout(IDLE_TIMEOUT)
f = conn.makefile("rb")
udp = None
stop = threading.Event()
try:
is_connect = read_exact(f, 1)[0]
req_dst = parse_addr(f)
log(f"conn from nginx: isConnect={is_connect} dst={req_dst}")
udp = socket.socket(socket.AF_INET6 if ":" in req_dst[0] else socket.AF_INET,
socket.SOCK_DGRAM)
udp.setblocking(False)
if is_connect:
udp.connect(req_dst) # 固定目标:连通式 UDP
write_lock = threading.Lock()
def tcp_write(data):
with write_lock:
conn.sendall(data)
def uplink():
"""TCP -> UDP:按格式读帧并发送。"""
try:
while not stop.is_set():
if is_connect:
ln = struct.unpack(">H", read_exact(f, 2))[0]
if ln > MAX_DGRAM:
raise ValueError(f"datagram too large: {ln}")
data = read_exact(f, ln)
udp.send(data)
else:
dst = parse_addr(f)
ln = struct.unpack(">H", read_exact(f, 2))[0]
if ln > MAX_DGRAM:
raise ValueError(f"datagram too large: {ln}")
data = read_exact(f, ln)
udp.sendto(data, dst)
except Exception as e:
if not stop.is_set():
log(f"uplink end: {type(e).__name__}: {e}")
finally:
stop.set()
t = threading.Thread(target=uplink, daemon=True)
t.start()
# 主循环:UDP -> TCP,带空闲超时
last = time.time()
while not stop.is_set():
r, _, _ = select.select([udp], [], [], 2.0)
if r:
if is_connect:
data = udp.recv(MAX_DGRAM)
tcp_write(struct.pack(">H", len(data)) + data)
else:
data, src = udp.recvfrom(MAX_DGRAM)
tcp_write(encode_addr(*src) + struct.pack(">H", len(data)) + data)
last = time.time()
elif time.time() - last > IDLE_TIMEOUT:
log("idle timeout, closing")
break
except Exception as e:
log(f"conn error: {type(e).__name__}: {e}")
finally:
stop.set()
try:
f.close()
except Exception:
pass
try:
conn.close()
except Exception:
pass
if udp:
try:
udp.close()
except Exception:
pass
def main():
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((LISTEN_HOST, LISTEN_PORT))
srv.listen(128)
log(f"listening on {LISTEN_HOST}:{LISTEN_PORT}")
while True:
conn, peer = srv.accept()
threading.Thread(target=handle, args=(conn, peer), daemon=True).start()
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""RRPP 裸 UoT v2 relay。
nginx 的 proxy_connect 模块负责 TLS、Basic Auth 和 CONNECT,relay 只监听
127.0.0.1:15201,收到的首字节就是 UoT v2 request,不再解析 HTTP。
"""
from __future__ import annotations
import ipaddress
import logging
import socket
import struct
import threading
MAGIC = "sp.v2.udp-over-tcp.arpa"
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 15201
MAX_PACKET = 65535
IDLE_TIMEOUT = 9 * 60
def read_exact(conn: socket.socket, size: int) -> bytes:
data = bytearray()
while len(data) < size:
chunk = conn.recv(size - len(data))
if not chunk:
raise ConnectionError("客户端连接已关闭")
data.extend(chunk)
return bytes(data)
def read_address(conn: socket.socket):
atyp = read_exact(conn, 1)[0]
if atyp == 0:
host = str(ipaddress.IPv4Address(read_exact(conn, 4)))
elif atyp == 1:
host = str(ipaddress.IPv6Address(read_exact(conn, 16)))
elif atyp == 2:
size = read_exact(conn, 1)[0]
host = read_exact(conn, size).decode("idna")
else:
raise ValueError(f"未知 UoT 地址类型: {atyp}")
return host, struct.unpack("!H", read_exact(conn, 2))[0]
def encode_address(host: str, port: int) -> bytes:
try:
addr = ipaddress.ip_address(host)
except ValueError:
raw = host.encode("idna")
return b"\x02" + bytes([len(raw)]) + raw + struct.pack("!H", port)
return (b"\x00" if addr.version == 4 else b"\x01") + addr.packed + struct.pack("!H", port)
def resolve_destination(host: str, port: int):
return socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_DGRAM)[0][4]
def handle(conn: socket.socket, peer):
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.settimeout(0.2)
# 与 nginx 的 10 分钟读写超时错开,空闲连接由 relay 先回收。
conn.settimeout(IDLE_TIMEOUT)
send_lock = threading.Lock()
try:
is_connect = read_exact(conn, 1)[0]
first_target = read_address(conn)
logging.info("UoT client %s target=%s:%s connect=%s", peer, *first_target, is_connect)
def udp_reader():
while True:
try:
data, source = udp.recvfrom(MAX_PACKET)
except socket.timeout:
continue
except OSError:
return
try:
with send_lock:
if is_connect:
conn.sendall(struct.pack("!H", len(data)) + data)
else:
conn.sendall(encode_address(source[0], source[1]) +
struct.pack("!H", len(data)) + data)
except OSError:
return
threading.Thread(target=udp_reader, daemon=True).start()
if is_connect:
while True:
length = struct.unpack("!H", read_exact(conn, 2))[0]
payload = read_exact(conn, length)
udp.sendto(payload, resolve_destination(*first_target))
else:
while True:
destination = read_address(conn)
length = struct.unpack("!H", read_exact(conn, 2))[0]
payload = read_exact(conn, length)
udp.sendto(payload, resolve_destination(*destination))
except (ConnectionError, OSError, ValueError) as exc:
logging.info("UoT client %s closed: %s", peer, exc)
finally:
udp.close()
conn.close()
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((LISTEN_HOST, LISTEN_PORT))
listener.listen(128)
logging.info("RRPP UoT relay listening on %s:%d", LISTEN_HOST, LISTEN_PORT)
while True:
conn, peer = listener.accept()
threading.Thread(target=handle, args=(conn, peer), daemon=True).start()
if __name__ == "__main__":
main()