feat(lite): merge TUN mode with IPv6 routing

This commit is contained in:
RoyirinRosmontis
2026-09-19 03:41:41 +08:00
parent 3e29012d9c
commit 50364dc17a
23 changed files with 2996 additions and 237 deletions
+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={},
+4
View File
@@ -21,7 +21,11 @@ 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 ^
--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": "999909182137", "version": "999909190333",
"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"
+272 -195
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect, re, json, uuid import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect, re, json, uuid, ipaddress
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -13,7 +13,7 @@ from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
from rrpp_ui.theme import C_TITLE, C_GREEN, C_YELLOW, C_RED, C_BG, C_CARD, C_BORDER, C_TEXT, C_SUBTEXT from rrpp_ui.theme import C_TITLE, C_GREEN, C_YELLOW, C_RED, C_BG, C_CARD, C_BORDER, C_TEXT, C_SUBTEXT
from rrpp_ui.widgets import NodeLabel, StatusDot, ToggleSwitch from rrpp_ui.widgets import NodeLabel, StatusDot, ToggleSwitch, ProxyModeCard
from rrpp_ui.dialogs import ( from rrpp_ui.dialogs import (
LoginDialog, LoginServices, SettingsDialog, SettingsServices, LogDialog, LogServices, LoginDialog, LoginServices, SettingsDialog, SettingsServices, LogDialog, LogServices,
ThirdPartyDialog, ThirdPartyServices, ThirdPartyDialog, ThirdPartyServices,
@@ -58,6 +58,9 @@ from rrpp_core.network_primitives import (
resolve_host_ips as _resolve_host_ips, resolve_host_ips as _resolve_host_ips,
ttl_cache as _ttl_cache, ttl_cache as _ttl_cache,
) )
from rrpp_core.tun_backend import TunBackend
from rrpp_core.tun_mode import TunModeController, WORKER_FLAG, run_tun_worker
from rrpp_core.system_proxy_mode import SystemProxyMode, is_our_pac_url as _is_our_pac_url
# GUI 窗口在 96 DPI / 100% 缩放下的逻辑尺寸;启用 Qt 高分屏缩放后, # GUI 窗口在 96 DPI / 100% 缩放下的逻辑尺寸;启用 Qt 高分屏缩放后,
# Qt 会自动按 devicePixelRatio 放大/缩小,因此代码里保持逻辑尺寸不变即可。 # Qt 会自动按 devicePixelRatio 放大/缩小,因此代码里保持逻辑尺寸不变即可。
@@ -1004,114 +1007,15 @@ def _run(cmd, warn=True):
log(f"[警告] {' '.join(str(c) for c in cmd)} -> {r.returncode} {err}") log(f"[警告] {' '.join(str(c) for c in cmd)} -> {r.returncode} {err}")
return r return r
def _notify_proxy_change():
"""通知 WinINET 和顶层窗口代理设置已变更,让已运行程序立即重读。"""
try:
import ctypes
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
def _reg_query_value(name):
"""查询 HKCU Internet Settings 下指定值,返回 (类型, 数据);值不存在返回 None。"""
r = _run(["reg", "query", PROXY_REG, "/v", name], warn=False)
if r.returncode != 0:
return None
for line in (r.stdout or "").splitlines():
if name in line:
parts = line.strip().split(None, 2) # 形如 "ProxyServer REG_SZ 127.0.0.1:7897"
if len(parts) >= 3:
return parts[1], parts[2]
return None
def _is_our_pac_url(value: str) -> bool:
"""value 是否指向本程序的本地 PAC 服务(http://127.0.0.1:<端口>/proxy.pac)。"""
import re
return bool(re.fullmatch(r"http://127\.0\.0\.1:\d+/proxy\.pac", value.strip()))
_PROXY_BACKUP_KEY = "prev_proxy_state" # settings.json 里的系统代理原值备份
_proxy_prev = None # 运行期备份:None = 尚未备份
def _backup_proxy_state():
"""覆盖系统代理前把原值备份下来(内存 + settings.json,防崩溃丢失)。
只在尚未备份时执行:本次运行里第一次开启代理时的注册表状态才是用户
原有的(可能是其他代理软件的值),之后反复开/关不应覆盖首次备份。"""
global _proxy_prev
if _proxy_prev is not None:
return
state = {}
for name in ("ProxyEnable", "ProxyServer", "ProxyOverride", "AutoConfigURL"):
v = _reg_query_value(name)
state[name] = list(v) if v else None
_proxy_prev = state
try:
_settings_save({**_settings_load(), _PROXY_BACKUP_KEY: state})
except Exception:
pass # 持久化失败只影响崩溃后的恢复,不影响本次运行
def _restore_proxy_state():
"""恢复开启代理前的系统代理原值;没有备份时退化为清空(历史行为)。"""
global _proxy_prev
state = _proxy_prev
if state is None:
state = _settings_load().get(_PROXY_BACKUP_KEY)
if isinstance(state, dict):
for name in ("ProxyEnable", "ProxyServer", "ProxyOverride", "AutoConfigURL"):
v = state.get(name)
if v:
_run(["reg", "add", PROXY_REG, "/v", name, "/t", v[0], "/d", v[1], "/f"], warn=False)
else:
_run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
log("已恢复开启代理前的系统代理设置")
else:
# 无备份(旧版本升级后首次运行):清空,与历史行为一致
for name in ("ProxyServer", "ProxyOverride", "AutoConfigURL"):
_run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
_run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"])
_proxy_prev = None
# 备份已消费:清除持久化副本,避免下次启动误恢复
try:
data = _settings_load()
if isinstance(data, dict) and _PROXY_BACKUP_KEY in data:
del data[_PROXY_BACKUP_KEY]
_settings_save(data)
except Exception:
pass
def proxy_on(pac_url=None, bypass_lan=False): def proxy_on(pac_url=None, bypass_lan=False):
"""设置 Windows 系统代理。pac_url 不为空时启用 PAC 模式,否则使用固定代理。""" """开启系统代理模式,复用主程序的本地转发器。"""
_backup_proxy_state() # 覆盖前保存用户原有代理设置,关闭时恢复而不是清空 _SYSTEM_PROXY_MODE.enable(pac_url=pac_url, bypass_lan=bypass_lan)
if pac_url:
# PAC 模式:仅设置 AutoConfigURL,ProxyEnable 置 0,
# 避免程序读到"固定代理已启用但服务器为空"的混乱状态
_run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"])
# PAC 模式:清理固定代理遗留项,避免两种设置冲突
_run(["reg", "delete", PROXY_REG, "/v", "ProxyServer", "/f"], warn=False)
_run(["reg", "delete", PROXY_REG, "/v", "ProxyOverride", "/f"], warn=False)
_run(["reg", "add", PROXY_REG, "/v", "AutoConfigURL", "/t", "REG_SZ", "/d", pac_url, "/f"])
log("系统代理已设置 (PAC)")
else:
hp = f"{PROXY_HOST}:{PROXY_PORT}"
_run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f"])
_run(["reg", "add", PROXY_REG, "/v", "ProxyServer", "/t", "REG_SZ", "/d", hp, "/f"])
_run(["reg", "add", PROXY_REG, "/v", "ProxyOverride", "/t", "REG_SZ", "/d", _proxy_override(bypass_lan), "/f"])
log(f"系统代理已设置: {hp}")
_notify_proxy_change()
def proxy_off(): def proxy_off():
"""关闭系统代理并停止 TLS 转发器 / PAC 本地服务。""" """关闭系统代理并停止 TLS 转发器 / PAC 本地服务。"""
log("正在关闭系统代理...") log("正在关闭系统代理...")
_restore_proxy_state() # 恢复用户原有代理设置,而不是简单清空 _SYSTEM_PROXY_MODE.disable()
log("系统代理已关闭") log("系统代理已关闭")
_notify_proxy_change()
_tls_stop() _tls_stop()
_stop_pac_server() _stop_pac_server()
# 打印本次转发流量统计 # 打印本次转发流量统计
@@ -1344,6 +1248,13 @@ def _settings_save(data):
"""本地设置原子写回。""" """本地设置原子写回。"""
_SETTINGS_STORE.save(data) _SETTINGS_STORE.save(data)
_SYSTEM_PROXY_MODE = SystemProxyMode(
run=_run, settings_load=_settings_load, settings_save=_settings_save,
log=log, endpoint=lambda: (PROXY_HOST, PROXY_PORT),
proxy_override=lambda bypass_lan: _proxy_override(bypass_lan),
)
def _parse_port(text): def _parse_port(text):
"""解析端口号;非法(非数字或超出 1-65535)返回 None。""" """解析端口号;非法(非数字或超出 1-65535)返回 None。"""
try: try:
@@ -1370,23 +1281,65 @@ def _set_local_port(port):
_settings_save(data) _settings_save(data)
log(f"代理转发端口已保存: {port}(下次开启代理生效)") log(f"代理转发端口已保存: {port}(下次开启代理生效)")
def _use_builtin_pac(): _DEFAULT_TUN_DNS = ("8.8.8.8", "1.1.1.1")
"""是否使用固定本地代理承载系统流量、由转发器内部执行 PAC 判断。"""
return bool(_settings_load().get("builtin_pac", True))
def _set_builtin_pac(enabled): def _parse_dns_servers(value):
"""保存内置 PAC 偏好,下一次开启代理时生效。""" parts = value if isinstance(value, (list, tuple)) else re.split(r"[,;\s]+", str(value or "").strip())
result = []
for item in parts:
value = str(item).strip()
if not value:
continue
try:
address = ipaddress.ip_address(value)
except ValueError:
return None
if address.is_unspecified or address.is_multicast:
return None
normalized = address.compressed
if normalized not in result:
result.append(normalized)
return tuple(result) if 1 <= len(result) <= 3 else None
def _tun_dns_servers():
return _parse_dns_servers(_settings_load().get("tun_dns")) or _DEFAULT_TUN_DNS
def _set_tun_dns_servers(value):
parsed = _parse_dns_servers(value)
if parsed is None:
raise ValueError("DNS 应为 1-3 个有效 IPv4/IPv6 地址")
data = _settings_load() data = _settings_load()
data["builtin_pac"] = bool(enabled) data["tun_dns"] = list(parsed)
_settings_save(data) _settings_save(data)
def _should_use_system_pac(use_pac, use_builtin_pac): def _use_system_pac():
"""决定系统侧是否下发 PAC;内置 PAC 只保留转发器内部智能分流。""" """只决定 Windows 系统代理是否使用 PAC;旧设置按原行为读取。"""
return bool(use_pac and not use_builtin_pac) data = _settings_load()
if "use_system_pac" in data:
return bool(data["use_system_pac"])
return bool(data.get("pac_mode", True) and not data.get("builtin_pac", True))
def _should_use_internal_pac(use_pac, use_builtin_pac): def _set_use_system_pac(enabled):
"""决定转发器内部是否启用 PAC 智能直连/代理判断。""" data = _settings_load()
return bool(use_pac or use_builtin_pac) data["use_system_pac"] = bool(enabled)
_settings_save(data)
def _should_use_system_pac(use_pac, use_system_pac):
return bool(use_pac and use_system_pac)
def _should_use_internal_pac(use_pac, _legacy_builtin_pac=None):
"""智能分流由主界面 PAC 规则模式独立控制。"""
return bool(use_pac)
def _proxy_mode():
return "tun" if _settings_load().get("proxy_mode") == "tun" else "system"
def _set_proxy_mode(mode):
if mode not in ("system", "tun"):
raise ValueError("未知代理模式")
data = _settings_load()
data["proxy_mode"] = mode
_settings_save(data)
def _update_via_proxy(): def _update_via_proxy():
"""更新检查/下载是否走节点隧道(CONNECT);设置项,默认关=直连仓库。""" """更新检查/下载是否走节点隧道(CONNECT);设置项,默认关=直连仓库。"""
@@ -1488,10 +1441,16 @@ _ROUTE_RULES = None # 全量域名路由规则(含 direct/node)
def _route_domain_normalize(entry: str) -> str: def _route_domain_normalize(entry: str) -> str:
"""规范化域名规则,保留 ``*.`` 通配符,兼容旧白名单输入。""" """规范化域名或 IP/CIDR 规则,保留旧白名单的网段语义。"""
s = str(entry or "").strip().lower() s = str(entry or "").strip().lower()
if "://" in s: if "://" in s:
s = s.split("://", 1)[1] s = s.split("://", 1)[1]
if "/" in s:
try:
import ipaddress
return str(ipaddress.ip_network(s, strict=False))
except ValueError:
pass
s = s.split("/", 1)[0].rstrip(".") s = s.split("/", 1)[0].rstrip(".")
wildcard = s.startswith("*.") wildcard = s.startswith("*.")
if wildcard: if wildcard:
@@ -1549,10 +1508,10 @@ def _route_rules():
return _ROUTE_RULES return _ROUTE_RULES
def _route_rule_for_host(host: str): def _route_rule_for_host(host: str, rules=None):
"""返回目标域名命中的最高优先级规则;规则变更对新连接立即生效。""" """返回目标域名命中的最高优先级规则;规则变更对新连接立即生效。"""
matches = [] matches = []
for rule in _route_rules(): for rule in (_route_rules() if rules is None else rules):
if not rule.get("enabled") or not _route_rule_match(host, rule.get("domain", "")): if not rule.get("enabled") or not _route_rule_match(host, rule.get("domain", "")):
continue continue
domain = rule["domain"] domain = rule["domain"]
@@ -1671,8 +1630,14 @@ def _whitelist_validate_entry(entry: str):
def _fixed_route_validate_domain(entry: str): def _fixed_route_validate_domain(entry: str):
"""校验域名路由规则;支持 ``example.com`` 与 ``*.example.com``。""" """校验域名路由规则;兼容旧白名单的 IP/CIDR。"""
value = _route_domain_normalize(entry) value = _route_domain_normalize(entry)
try:
import ipaddress
ipaddress.ip_network(value, strict=False)
return value, ""
except ValueError:
pass
base = value[2:] if value.startswith("*.") else value base = value[2:] if value.startswith("*.") else value
if not base: if not base:
return "", "域名不能为空" return "", "域名不能为空"
@@ -2542,12 +2507,8 @@ def _release_forwarder(started_here):
_tls_stop() _tls_stop()
def is_proxy_on(): def is_proxy_on():
"""系统代理是否生效:固定代理已开启,或 AutoConfigURL 指向本地 PAC 服务。""" """仅报告本程序的系统代理,不把其它软件的代理当成 RRPP 状态。"""
v = _reg_query_value("ProxyEnable") return _SYSTEM_PROXY_MODE.is_on()
if v and "0x1" in (v[1] or ""):
return True
v = _reg_query_value("AutoConfigURL")
return bool(v) and _is_our_pac_url(v[1] or "")
def _cleanup_stale_proxy(): def _cleanup_stale_proxy():
"""清理上次异常退出残留的系统代理设置,并恢复用户原有设置。 """清理上次异常退出残留的系统代理设置,并恢复用户原有设置。
@@ -2558,11 +2519,7 @@ def _cleanup_stale_proxy():
不能因为地址里带 127.0.0.1 就误删。 不能因为地址里带 127.0.0.1 就误删。
""" """
try: try:
server = _reg_query_value("ProxyServer") if not _SYSTEM_PROXY_MODE.is_stale():
pac = _reg_query_value("AutoConfigURL")
ours = (bool(server) and (server[1] or "").strip() == f"{PROXY_HOST}:{PROXY_PORT}") or \
(bool(pac) and _is_our_pac_url(pac[1] or ""))
if not ours:
return return
proxy_off() # 内部从备份恢复用户原有设置(无备份则清空) proxy_off() # 内部从备份恢复用户原有设置(无备份则清空)
except Exception: except Exception:
@@ -2673,14 +2630,16 @@ def _settings_services():
load_icon=load_icon, load_icon=load_icon,
version=VERSION, version=VERSION,
get_port=lambda: _TLS_LOCAL_PORT, get_port=lambda: _TLS_LOCAL_PORT,
get_builtin_pac=_use_builtin_pac, get_system_pac=_use_system_pac,
get_dns=_tun_dns_servers,
get_update_via_proxy=_update_via_proxy, get_update_via_proxy=_update_via_proxy,
get_skip_update_check=_skip_update_check, get_skip_update_check=_skip_update_check,
get_node_display_mode=_node_display_mode, get_node_display_mode=_node_display_mode,
get_close_behavior=_close_behavior, get_close_behavior=_close_behavior,
parse_port=_parse_port, parse_port=_parse_port,
set_port=_set_local_port, set_port=_set_local_port,
set_builtin_pac=_set_builtin_pac, set_system_pac=_set_use_system_pac,
set_dns=_set_tun_dns_servers,
set_update_via_proxy=_set_update_via_proxy, set_update_via_proxy=_set_update_via_proxy,
set_skip_update_check=_set_skip_update_check, set_skip_update_check=_set_skip_update_check,
set_node_display_mode=_set_node_display_mode, set_node_display_mode=_set_node_display_mode,
@@ -2705,6 +2664,94 @@ def _jump_services():
remove_profile=_remove_jump_profile, remove_profile=_remove_jump_profile,
) )
def _tun_worker_command():
"""同一个源码入口或单文件 EXE,以工作进程参数重新进入。"""
if "__compiled__" in globals():
return [str((_frozen_executable_dir() / Path(sys.argv[0]).name).resolve())]
if getattr(sys, "frozen", False):
return [sys.executable]
return [sys.executable, str(Path(__file__).resolve())]
def _make_tun_backend(emit):
engine = Path(BASE_DIR) / "tun2socks.exe"
if not engine.is_file():
engine = Path(BASE_DIR) / "runtime" / "tun2socks.exe"
if not engine.is_file() or not (engine.parent / "wintun.dll").is_file():
raise RuntimeError("单文件包中的 tun2socks.exe 或 wintun.dll 缺失")
return TunBackend(_USER_DIR, engine_path=engine, log=emit, detail_log=emit)
def _start_tun_backend(backend, payload):
"""管理员工作进程使用与系统代理相同的规则函数。"""
node = payload["node"]
rules = payload["route_rules"]
bypass_lan = bool(payload["bypass_lan"])
smart = bool(payload["smart"])
route_nodes = {item["ref"]: item["node"] for item in payload["route_nodes"]}
def user_rule(host):
return _route_rule_for_host(host, rules)
def route_node(rule):
return route_nodes.get(str(rule.get("node_ref") or ""))
def direct_target(policy_host, original_host, port):
rule = user_rule(policy_host) or user_rule(original_host)
action = str((rule or {}).get("action") or "").lower()
if action == "node":
return None
if action == "direct":
return original_host, port
# TUN 实际连接保持原目标 IP,避免在规则判断后再次 DNS 解析。
if _is_whitelisted(policy_host, _WHITELIST_VERSION) or _is_whitelisted(original_host, _WHITELIST_VERSION):
return original_host, port
if smart and _is_force_proxy(policy_host):
return None
if smart and _is_direct_domain(policy_host):
return original_host, port
if bypass_lan and _is_private_ip(original_host):
return original_host, port
if smart and _is_china_ip(original_host):
return original_host, port
return None
backend.start(
node,
bypass_lan=bypass_lan,
route_rules=rules,
dns_servers=payload["dns_servers"],
direct_target_resolver=direct_target,
route_rule_resolver=user_rule,
route_node_resolver=route_node,
route_nodes=list(route_nodes.values()),
)
def _tun_start_payload(use_pac, bypass_lan):
node = _current_node()
route_nodes = []
seen = set()
for rule in _route_rules():
if not rule.get("enabled") or rule.get("action") != "node":
continue
selected = _fixed_route_node(rule)
if selected is None:
raise RuntimeError(f"域名路由节点不可用: {rule.get('domain')}")
ref = str(rule.get("node_ref") or "")
if ref not in seen:
seen.add(ref)
route_nodes.append({"ref": ref, "node": selected})
return {
"node": node,
"route_rules": _route_rules(),
"route_nodes": route_nodes,
"dns_servers": _tun_dns_servers(),
"bypass_lan": bool(bypass_lan),
"smart": bool(use_pac),
}
# ── 代理白名单弹窗 ───────────────────────────────────── # ── 代理白名单弹窗 ─────────────────────────────────────
# ── 主窗口 ──────────────────────────────────────────── # ── 主窗口 ────────────────────────────────────────────
@@ -2722,6 +2769,8 @@ class MainWindow(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的) self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的)
self._mode = _proxy_mode()
self._tun_controller = TunModeController(_tun_worker_command(), log)
self._quitting = False self._quitting = False
self._tray = None self._tray = None
self._tray_menu = None self._tray_menu = None
@@ -2766,6 +2815,23 @@ class MainWindow(QWidget):
# 节点未配置时延迟弹出登录框(等窗口显示后) # 节点未配置时延迟弹出登录框(等窗口显示后)
QTimer.singleShot(600, self._maybe_show_login) QTimer.singleShot(600, self._maybe_show_login)
def _update_mode_cards(self, system_running: bool, tun_running: bool):
self.system_card.set_state(self._mode == "system", system_running)
self.tun_card.set_state(self._mode == "tun", tun_running)
def _choose_mode(self, mode: str):
if mode == self._mode:
return
if self._start_in_progress or self._proxy_set_by_us or is_proxy_on() or self._tun_controller.status()["running"]:
QMessageBox.information(self, "请先关闭代理", "关闭当前代理后再切换模式。")
return
_set_proxy_mode(mode)
self._mode = mode
self._update_mode_cards(False, False)
self._set_toggle_button_style(is_start=True)
self._update_tray_menu()
log(f"已选择{'系统代理' if mode == 'system' else '虚拟网卡'}模式")
def _setup_tray(self): def _setup_tray(self):
"""创建托盘图标和动态状态菜单;系统不支持托盘时保留窗口模式。""" """创建托盘图标和动态状态菜单;系统不支持托盘时保留窗口模式。"""
if not QSystemTrayIcon.isSystemTrayAvailable(): if not QSystemTrayIcon.isSystemTrayAvailable():
@@ -2803,11 +2869,11 @@ class MainWindow(QWidget):
self._tray_menu.addAction(show_action) self._tray_menu.addAction(show_action)
self._tray_menu.addSeparator() self._tray_menu.addSeparator()
proxy_on = is_proxy_on() proxy_on = is_proxy_on() if self._mode == "system" else self._tun_controller.status()["running"]
pac_on = _should_use_internal_pac(_use_pac_mode(), _use_builtin_pac()) pac_on = _should_use_internal_pac(_use_pac_mode())
bypass_lan = _bypass_lan() bypass_lan = _bypass_lan()
for text in ( for text in (
f"代理:{'开' if proxy_on else '关'}", f"{'系统代理' if self._mode == 'system' else '虚拟网卡'}:{'开' if proxy_on else '关'}",
f"PAC:{'启用' if pac_on else '关闭'}", f"PAC:{'启用' if pac_on else '关闭'}",
f"内网IP:{'不代理' if bypass_lan else '代理'}", f"内网IP:{'不代理' if bypass_lan else '代理'}",
): ):
@@ -2906,27 +2972,16 @@ class MainWindow(QWidget):
layout.addWidget(line) layout.addWidget(line)
# 系统代理状态卡片 mode_row = QHBoxLayout()
px_frame = QFrame() mode_row.setSpacing(8)
px_frame.setStyleSheet(f""" self.system_card = ProxyModeCard("系统代理")
QFrame {{ self.tun_card = ProxyModeCard("虚拟网卡")
background-color: {C_CARD}; self.system_card.clicked.connect(lambda: self._choose_mode("system"))
border: 1px solid {C_BORDER}; self.tun_card.clicked.connect(lambda: self._choose_mode("tun"))
border-radius: 8px; mode_row.addWidget(self.system_card, 1)
}} mode_row.addWidget(self.tun_card, 1)
""") layout.addLayout(mode_row)
px_layout = QHBoxLayout(px_frame) self._update_mode_cards(False, False)
px_layout.setContentsMargins(16, 12, 16, 12)
px_label = QLabel("IE 代理")
px_label.setStyleSheet(f"color: {C_TEXT}; font-weight: bold; background: transparent;")
self.px_dot = StatusDot()
self.px_status = QLabel("检测中...")
self.px_status.setStyleSheet(f"color: {C_SUBTEXT}; background: transparent;")
px_layout.addWidget(px_label)
px_layout.addStretch()
px_layout.addWidget(self.px_status)
px_layout.addWidget(self.px_dot)
layout.addWidget(px_frame)
traffic_cards = QHBoxLayout() traffic_cards = QHBoxLayout()
traffic_cards.setSpacing(8) traffic_cards.setSpacing(8)
@@ -3089,7 +3144,7 @@ class MainWindow(QWidget):
# 在 GUI 线程读取控件状态传参,避免子线程直接访问 Qt 控件(非线程安全) # 在 GUI 线程读取控件状态传参,避免子线程直接访问 Qt 控件(非线程安全)
use_pac = self.cb_pac.isChecked() use_pac = self.cb_pac.isChecked()
use_bypass_lan = self.cb_bypass_lan.isChecked() use_bypass_lan = self.cb_bypass_lan.isChecked()
use_builtin_pac = _use_builtin_pac() use_system_pac = _use_system_pac()
self._start_cancel_event = threading.Event() self._start_cancel_event = threading.Event()
self._start_in_progress = True self._start_in_progress = True
# 网络探测可能需要数秒;此时保留按钮给用户取消,而不是把界面锁死。 # 网络探测可能需要数秒;此时保留按钮给用户取消,而不是把界面锁死。
@@ -3097,12 +3152,12 @@ class MainWindow(QWidget):
self.btn_toggle.setEnabled(True) self.btn_toggle.setEnabled(True)
threading.Thread( threading.Thread(
target=self._start_thread, target=self._start_thread,
args=(use_pac, use_bypass_lan, use_builtin_pac, self._start_cancel_event), args=(use_pac, use_bypass_lan, use_system_pac, self._start_cancel_event),
daemon=True, daemon=True,
).start() ).start()
def _start_thread(self, use_pac, use_bypass_lan, use_builtin_pac, cancel_event): def _start_thread(self, use_pac, use_bypass_lan, use_system_pac, cancel_event):
try: try:
self._start_proxy(use_pac, use_bypass_lan, use_builtin_pac, cancel_event) self._start_proxy(use_pac, use_bypass_lan, use_system_pac, cancel_event)
_raise_if_start_cancelled(cancel_event) _raise_if_start_cancelled(cancel_event)
self._op_done.emit("start", True) self._op_done.emit("start", True)
except _StartCancelled: except _StartCancelled:
@@ -3117,6 +3172,12 @@ class MainWindow(QWidget):
def _cleanup_start_resources(self): def _cleanup_start_resources(self):
"""回收本次启动已建立的资源;仅恢复本程序实际写入过的系统代理。""" """回收本次启动已建立的资源;仅恢复本程序实际写入过的系统代理。"""
if self._mode == "tun":
try:
self._tun_controller.stop()
finally:
self._proxy_set_by_us = False
return
with self._proxy_state_lock: with self._proxy_state_lock:
proxy_owned = self._proxy_set_by_us proxy_owned = self._proxy_set_by_us
self._proxy_set_by_us = False self._proxy_set_by_us = False
@@ -3127,14 +3188,26 @@ class MainWindow(QWidget):
_tls_stop() _tls_stop()
_stop_pac_server() _stop_pac_server()
def _start_proxy(self, use_pac, use_bypass_lan, use_builtin_pac=False, cancel_event=None): def _start_proxy(self, use_pac, use_bypass_lan, use_system_pac=False, cancel_event=None):
"""启动本地转发器并设置系统代理(普通 PAC、内置 PAC 或固定代理)。""" """按当前选择启动系统代理或按需提权的虚拟网卡。"""
if _endpoint_domain() == "???": if _endpoint_domain() == "???":
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网 # 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置") raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置")
if not _node_reachable(_current_node()): if not _node_reachable(_current_node()):
# 当前选中节点是 IPv6 专用而本机没有 IPv6 出口:转发的流量全部连不上 # 当前选中节点是 IPv6 专用而本机没有 IPv6 出口:转发的流量全部连不上
raise RuntimeError("当前节点仅提供 IPv6 地址,本机没有 IPv6 出口,请在节点菜单切换其他节点") raise RuntimeError("当前节点仅提供 IPv6 地址,本机没有 IPv6 出口,请在节点菜单切换其他节点")
if self._mode == "tun":
payload = _tun_start_payload(use_pac, use_bypass_lan)
_raise_if_start_cancelled(cancel_event)
try:
self._tun_controller.start(payload, cancel_event)
_raise_if_start_cancelled(cancel_event)
self._proxy_set_by_us = True
log("虚拟网卡已开启")
except Exception:
self._cleanup_start_resources()
raise
return
_traffic_reset() _traffic_reset()
current_node = _current_node() current_node = _current_node()
jump_chain = _jump_chain(current_node) jump_chain = _jump_chain(current_node)
@@ -3144,7 +3217,7 @@ class MainWindow(QWidget):
if jump_chain: if jump_chain:
log(f" 跳板路径: {_jump_route_text(current_node)}") log(f" 跳板路径: {_jump_route_text(current_node)}")
# PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流 # PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流
use_internal_pac = _should_use_internal_pac(use_pac, use_builtin_pac) use_internal_pac = _should_use_internal_pac(use_pac)
if use_internal_pac: if use_internal_pac:
_load_cn_ranges() _load_cn_ranges()
_load_force_proxy_suffixes() _load_force_proxy_suffixes()
@@ -3155,7 +3228,7 @@ class MainWindow(QWidget):
jump_chain=jump_chain) jump_chain=jump_chain)
try: try:
_raise_if_start_cancelled(cancel_event) _raise_if_start_cancelled(cancel_event)
if _should_use_system_pac(use_pac, use_builtin_pac): if _should_use_system_pac(use_pac, use_system_pac):
# 本地生成全量转发 PAC(不再从服务器拉取),全部流量交给转发器做智能分流 # 本地生成全量转发 PAC(不再从服务器拉取),全部流量交给转发器做智能分流
pac_content = _build_pac(f"{PROXY_HOST}:{PROXY_PORT}") pac_content = _build_pac(f"{PROXY_HOST}:{PROXY_PORT}")
if use_bypass_lan: if use_bypass_lan:
@@ -3173,7 +3246,7 @@ class MainWindow(QWidget):
_raise_if_start_cancelled(cancel_event) _raise_if_start_cancelled(cancel_event)
proxy_on(bypass_lan=use_bypass_lan) proxy_on(bypass_lan=use_bypass_lan)
self._proxy_set_by_us = True self._proxy_set_by_us = True
if use_builtin_pac: if use_internal_pac:
log(f"使用内置 PAC:系统固定代理 127.0.0.1:{PROXY_PORT},转发器内部智能分流") log(f"使用内置 PAC:系统固定代理 127.0.0.1:{PROXY_PORT},转发器内部智能分流")
_raise_if_start_cancelled(cancel_event) _raise_if_start_cancelled(cancel_event)
if use_bypass_lan: if use_bypass_lan:
@@ -3213,10 +3286,13 @@ class MainWindow(QWidget):
threading.Thread(target=self._stop_thread, daemon=True).start() threading.Thread(target=self._stop_thread, daemon=True).start()
def _stop_thread(self): def _stop_thread(self):
"""后台关闭代理:清理系统代理、停止 TLS/PAC 服务。""" """后台关闭当前模式并回收其资源。"""
try: try:
log("正在关闭代理...") log("正在关闭代理...")
proxy_off() if self._mode == "tun":
self._tun_controller.stop()
else:
proxy_off()
self._proxy_set_by_us = False self._proxy_set_by_us = False
log("已关闭") log("已关闭")
self._op_done.emit("stop", True) self._op_done.emit("stop", True)
@@ -3236,6 +3312,8 @@ class MainWindow(QWidget):
"""设置页关闭后同步主页面的本地偏好显示。""" """设置页关闭后同步主页面的本地偏好显示。"""
self.cb_pac.setChecked(_use_pac_mode()) self.cb_pac.setChecked(_use_pac_mode())
self.cb_bypass_lan.setChecked(_bypass_lan()) self.cb_bypass_lan.setChecked(_bypass_lan())
self._mode = _proxy_mode()
self.refresh_status()
self._update_tray_menu() self._update_tray_menu()
# ── 节点登录 ── # ── 节点登录 ──
@@ -3391,32 +3469,26 @@ class MainWindow(QWidget):
def _poll_status(self): def _poll_status(self):
try: try:
px = is_proxy_on() state = {"system": is_proxy_on(), "tun": bool(self._tun_controller.status()["running"])}
except Exception: except Exception:
px = False state = {"system": False, "tun": False}
self._status_ready.emit(px) self._status_ready.emit(state)
def _apply_status(self, px_on_): def _apply_status(self, state):
"""GUI 线程:根据轮询结果刷新状态指示灯、IE代理、流量卡片。""" """GUI 线程:按模式选择和真实运行状态刷新两张状态卡。"""
self._status_busy = False self._status_busy = False
system_on = bool(state.get("system"))
# IE 代理状态 tun_on = bool(state.get("tun"))
if px_on_: self._update_mode_cards(system_on, tun_on)
self.px_dot.set_on() px_on = system_on if self._mode == "system" else tun_on
self.px_status.setText("已开启") if not self._start_in_progress:
self.px_status.setStyleSheet(f"color: {C_GREEN}; background: transparent;") self._set_toggle_button_style(is_start=not px_on)
if not self._start_in_progress:
self._set_toggle_button_style(is_start=False)
else:
self.px_dot.set_off()
self.px_status.setText("已关闭")
self.px_status.setStyleSheet(f"color: {C_RED}; background: transparent;")
if not self._start_in_progress:
self._set_toggle_button_style(is_start=True)
# 流量卡片(始终更新,未连接时显示 0) # 流量卡片(始终更新,未连接时显示 0)
px_on = is_proxy_on()
if px_on: if px_on:
up_rate, down_rate, up_total, down_total = _traffic_rate() if self._mode == "tun":
up_rate, down_rate, up_total, down_total = self._tun_controller.traffic()
else:
up_rate, down_rate, up_total, down_total = _traffic_rate()
else: else:
up_rate, down_rate, up_total, down_total = 0, 0, 0, 0 up_rate, down_rate, up_total, down_total = 0, 0, 0, 0
self.traffic_up.setText(f"↑{_fmt_bytes(up_rate)}/s") self.traffic_up.setText(f"↑{_fmt_bytes(up_rate)}/s")
@@ -3635,10 +3707,7 @@ class MainWindow(QWidget):
QMessageBox.warning(self, "下载失败", f"无法下载新版本:\n{message}") QMessageBox.warning(self, "下载失败", f"无法下载新版本:\n{message}")
def quit_app(self): def quit_app(self):
"""只清理自己设置的系统代理:用户或其他软件设置的代理不受影响。 """退出时按当前模式回收本程序建立的资源。"""
本程序异常退出残留的代理设置由下次启动时 _cleanup_stale_proxy() 处理。
"""
self._quitting = True self._quitting = True
if self._tray is not None: if self._tray is not None:
self._tray.hide() self._tray.hide()
@@ -3650,7 +3719,10 @@ class MainWindow(QWidget):
pass # 清理失败也不能阻止退出,残留交给下次启动清理 pass # 清理失败也不能阻止退出,残留交给下次启动清理
elif self._proxy_set_by_us: elif self._proxy_set_by_us:
try: try:
proxy_off() if self._mode == "tun":
self._tun_controller.stop()
else:
proxy_off()
except Exception: except Exception:
pass # 清理失败也不能阻止退出,残留交给下次启动清理 pass # 清理失败也不能阻止退出,残留交给下次启动清理
QApplication.quit() QApplication.quit()
@@ -3698,7 +3770,12 @@ def _acquire_single_instance():
return True # 互斥体创建失败时放行,避免把用户锁在门外 return True # 互斥体创建失败时放行,避免把用户锁在门外
def main(): def main():
"""主入口:检查管理员权限、依赖文件,然后启动 GUI。""" """普通 GUI 与按需提权 TUN 工作进程的唯一入口。"""
if len(sys.argv) == 4 and sys.argv[1] == WORKER_FLAG:
if not TunBackend.is_admin():
raise RuntimeError("TUN 工作进程需要管理员权限")
run_tun_worker(int(sys.argv[2]), sys.argv[3], _make_tun_backend, _start_tun_backend)
return
_set_per_monitor_dpi_aware() _set_per_monitor_dpi_aware()
# 单实例:第二个实例直接退出(且不能执行下面的残留代理清理, # 单实例:第二个实例直接退出(且不能执行下面的残留代理清理,
+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]
+843
View File
@@ -0,0 +1,843 @@
"""本地 SOCKS5 → HTTP CONNECT/UoT v2 桥接。
tun2socks 只需要一个标准 SOCKS5 出口;TCP 连接转成当前节点的 HTTP
CONNECT,UDP 连接转成 magic address 上的 UoT v2 流。系统代理设置完全不参与。
"""
from __future__ import annotations
import base64
import ipaddress
import select
import socket
import ssl
import struct
import threading
from typing import Any, Callable
from .dns_observer import DnsObserver
from .uot_protocol import (
Address, MAGIC_ADDRESS, encode_datagram_frame, encode_request,
read_datagram_frame,
)
# relay 的内网监听端口与外层 CONNECT 端口是两个概念:nginx 接收
# CONNECT magic-host:443 后,将字节流转发到 127.0.0.1:15201。
DEFAULT_UOT_RELAY_PORT = 15201
DEFAULT_UOT_CONNECT_PORT = 443
def _recv_exact(stream: socket.socket, size: int) -> bytes:
"""按 SOCKS5/TCP 协议长度读取,不能假设一次 recv 就拿全。"""
data = bytearray()
while len(data) < size:
chunk = stream.recv(size - len(data))
if not chunk:
raise ConnectionError("SOCKS5 连接在握手中断开")
data.extend(chunk)
return bytes(data)
def _node_http(node: dict[str, Any]) -> tuple[str, int, str, str]:
methods = (node.get("methods") or {}).get("http") or {}
host = str(node.get("domain") or "").strip()
port = methods.get("port") or node.get("port") or 443
username = methods.get("username") or node.get("username") or ""
password = methods.get("password") or node.get("password") or ""
if not host:
raise ValueError("节点缺少 HTTP CONNECT 地址")
return host, int(port), str(username), str(password)
def _node_uot_connect_port(node: dict[str, Any]) -> int:
"""返回 magic CONNECT 的对外端口,不能使用 0。"""
chain = node.get("_jump_chain") if isinstance(node, dict) else None
if isinstance(chain, list) and chain:
return _node_uot_connect_port(chain[-1])
methods = (node.get("methods") or {}).get("http") or {}
raw = methods.get("uot_connect_port")
if raw is None:
raw = node.get("uot_connect_port")
# 兼容早期把 uot_port 当作 CONNECT 端口的本地配置;服务端 nginx
# 会按 magic host 映射到隐藏的 15201 relay 端口。
if raw is None:
raw = methods.get("uot_port")
if raw is None:
raw = node.get("uot_port")
if raw is None:
raw = DEFAULT_UOT_CONNECT_PORT
port = int(raw)
if not 1 <= port <= 65535:
raise ValueError("UoT CONNECT 端口超出范围")
return port
def _socks_address(stream: socket.socket, kind: int | None = None) -> Address:
# SOCKS5 请求头已经读取过 ATYP 时直接传入;独立解析时才从流中读取。
if kind is None:
kind = _recv_exact(stream, 1)[0]
if kind == 1:
host = socket.inet_ntoa(_recv_exact(stream, 4))
elif kind == 4:
host = socket.inet_ntop(socket.AF_INET6, _recv_exact(stream, 16))
elif kind == 3:
size = _recv_exact(stream, 1)[0]
host = _recv_exact(stream, size).decode("idna")
else:
raise ValueError("SOCKS5 地址类型不支持")
port = struct.unpack("!H", _recv_exact(stream, 2))[0]
return Address(host, port)
def _pack_socks_udp(destination: Address, payload: bytes) -> bytes:
try:
ip = ipaddress.ip_address(destination.host)
except ValueError:
raw = destination.host.encode("idna")
return b"\x00\x00\x00\x03" + bytes([len(raw)]) + raw + struct.pack("!H", destination.port) + payload
atyp = 1 if ip.version == 4 else 4
return b"\x00\x00\x00" + bytes([atyp]) + ip.packed + struct.pack("!H", destination.port) + payload
def _unpack_socks_udp(packet: bytes) -> tuple[Address, bytes]:
if len(packet) < 4 or packet[:2] != b"\x00\x00":
raise ValueError("SOCKS5 UDP 包头无效")
if packet[2] != 0:
# RFC 1928 允许分片,但 tun2socks 当前不会发送分片;静默拼接会把
# 不完整的报文当成 DNS/QUIC 数据,造成更难排查的协议损坏。
raise ValueError("暂不支持 SOCKS5 UDP 分片")
atyp = packet[3]
offset = 4
if atyp == 1:
if len(packet) < offset + 4:
raise ValueError("SOCKS5 UDP IPv4 地址不完整")
host = socket.inet_ntoa(packet[offset:offset + 4]); offset += 4
elif atyp == 4:
if len(packet) < offset + 16:
raise ValueError("SOCKS5 UDP IPv6 地址不完整")
host = socket.inet_ntop(socket.AF_INET6, packet[offset:offset + 16]); offset += 16
elif atyp == 3:
if len(packet) < offset + 1:
raise ValueError("SOCKS5 UDP 域名长度不完整")
size = packet[offset]; offset += 1
if len(packet) < offset + size:
raise ValueError("SOCKS5 UDP 域名地址不完整")
host = packet[offset:offset + size].decode("idna"); offset += size
else:
raise ValueError("SOCKS5 UDP 地址类型不支持")
if len(packet) < offset + 2:
raise ValueError("SOCKS5 UDP 端口不完整")
port = struct.unpack("!H", packet[offset:offset + 2])[0]
return Address(host, port), packet[offset + 2:]
class HttpConnectDialer:
def __init__(self, node: dict[str, Any], timeout: float = 15.0,
source_address: str | None = None,
proxy_addresses: list[tuple] | None = None,
proxy_addresses_by_node: dict[str, list[tuple]] | None = None):
self.node = node
self.timeout = timeout
# TUN 启动后默认路由会指向虚拟网卡。节点本身必须从物理网卡发出,
# 否则上游 CONNECT 会再次进入本地 SOCKS,形成递归回环。
self.source_address = source_address
self.proxy_addresses = proxy_addresses
self.proxy_addresses_by_node = proxy_addresses_by_node or {}
@staticmethod
def _node_key(node: dict[str, Any]) -> str:
methods = (node.get("methods") or {}).get("http") or {}
return "|".join((str(node.get("domain") or ""),
str(methods.get("port") or node.get("port") or 443),
str(methods.get("username") or node.get("username") or "")))
@staticmethod
def _node_credentials(node: dict[str, Any]) -> tuple[str, str]:
methods = (node.get("methods") or {}).get("http") or {}
username = methods.get("username") or node.get("username") or ""
password = methods.get("password") or node.get("password") or ""
return str(username), str(password)
def _open_proxy_socket(self, host: str, port: int,
addresses: list[tuple] | None = None) -> socket.socket:
addresses = addresses or self.proxy_addresses
if not addresses:
addresses = [item[4] for item in socket.getaddrinfo(
host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)]
last_error: OSError | None = None
for sockaddr in addresses:
family = socket.AF_INET6 if len(sockaddr) == 4 else socket.AF_INET
raw = socket.socket(family, socket.SOCK_STREAM)
try:
raw.settimeout(self.timeout)
if self.source_address:
if family == socket.AF_INET and ":" not in self.source_address:
raw.bind((self.source_address, 0))
elif family == socket.AF_INET6 and ":" in self.source_address:
raw.bind((self.source_address, 0, 0, 0))
raw.connect(sockaddr)
return raw
except OSError as exc:
last_error = exc
raw.close()
if last_error:
raise last_error
raise OSError(f"无法解析 HTTP CONNECT 节点: {host}")
def _jump_addresses(self, node: dict[str, Any]) -> list[tuple] | None:
return self.proxy_addresses_by_node.get(self._node_key(node))
def _open_jump(self, target: Address) -> socket.socket:
"""通过 2-3 个 HTTP CONNECT 节点逐层建立 TLS,再连接最终目标。"""
chain = list(self.node.get("_jump_chain") or [])
if not 2 <= len(chain) <= 3:
raise RuntimeError("跳板链路必须包含 2-3 个节点")
ctx = ssl.create_default_context()
first = chain[0]
first_host, first_port, _username, _password = _node_http(first)
stream = self._open_proxy_socket(
first_host, first_port, self._jump_addresses(first))
try:
stream = ctx.wrap_socket(stream, server_hostname=first_host)
for index, next_node in enumerate(chain[1:], 1):
next_host, next_port, _username, _password = _node_http(next_node)
leftover = _http_connect_over(
stream, chain[index - 1], next_host, next_port, resolve_target=True)
stream = _LayeredTlsTransport(stream, ctx, next_host, leftover)
_http_connect_over(
stream, chain[-1], target.host, target.port, resolve_target=False)
return stream
except Exception:
try:
stream.close()
except OSError:
pass
raise
def open(self, target: Address) -> socket.socket:
if self.node.get("_jump_chain"):
return self._open_jump(target)
host, port, username, password = _node_http(self.node)
raw = self._open_proxy_socket(host, port)
ctx = ssl.create_default_context()
stream = ctx.wrap_socket(raw, server_hostname=host)
authority = f"[{target.host}]:{target.port}" if ":" in target.host else f"{target.host}:{target.port}"
auth = ""
if username or password:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
auth = f"Authorization: Basic {token}\r\n"
request = (
f"CONNECT {authority} HTTP/1.1\r\n"
f"Host: {authority}\r\n"
f"{auth}Connection: Keep-Alive\r\n\r\n"
).encode("ascii")
stream.sendall(request)
response = b""
while b"\r\n\r\n" not in response and len(response) < 65536:
chunk = stream.recv(4096)
if not chunk:
break
response += chunk
first = response.split(b"\r\n", 1)[0].decode("latin1", "replace")
if not first.startswith(("HTTP/1.1 200", "HTTP/1.0 200")):
stream.close()
raise ConnectionError(f"HTTP CONNECT 失败: {first[:160]}")
return stream
def _resolve_jump_address(host: str, port: int) -> tuple:
infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
infos.sort(key=lambda item: 0 if item[0] == socket.AF_INET else 1)
if not infos:
raise OSError(f"无法解析跳板地址: {host}")
return infos[0][4]
def _http_connect_over(transport, proxy_node: dict[str, Any], target_host: str,
target_port: int, *, resolve_target: bool) -> bytes:
"""在当前 TLS 层发送 CONNECT;中间跳板使用解析后的 IP,末跳保留目标名。"""
username, password = HttpConnectDialer._node_credentials(proxy_node)
auth_header = ""
if username or password:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
auth_header = f"Authorization: Basic {token}\r\n"
if resolve_target:
address = _resolve_jump_address(target_host, target_port)
authority = f"[{address[0]}]:{address[1]}" if ":" in address[0] else f"{address[0]}:{address[1]}"
else:
authority = f"[{target_host}]:{target_port}" if ":" in target_host else f"{target_host}:{target_port}"
request = (
f"CONNECT {authority} HTTP/1.1\r\n"
f"Host: {authority}\r\n"
f"{auth_header}Connection: Keep-Alive\r\n\r\n"
).encode("ascii")
transport.sendall(request)
response = b""
while b"\r\n\r\n" not in response and len(response) < 65536:
chunk = transport.recv(4096)
if not chunk:
break
response += chunk
marker = b"\r\n\r\n"
end = response.find(marker)
first = response.split(b"\r\n", 1)[0].decode("latin-1", "replace")
if not (first.startswith("HTTP/1.1 200") or first.startswith("HTTP/1.0 200")):
raise ConnectionError(f"跳板 CONNECT 失败: {first[:160]}")
return response[end + len(marker):] if end >= 0 else b""
class _LayeredTlsTransport:
"""在已有 TLS 字节流上承载下一层 TLS。"""
def __init__(self, transport, ctx, server_hostname: str, initial: bytes = b""):
self._transport = transport
self._incoming = ssl.MemoryBIO()
self._outgoing = ssl.MemoryBIO()
self._pending = initial
self._ssl = ctx.wrap_bio(
self._incoming, self._outgoing, server_side=False,
server_hostname=server_hostname)
self._handshake()
def _flush(self):
data = self._outgoing.read()
if data:
self._transport.sendall(data)
def _feed(self):
if self._pending:
data, self._pending = self._pending, b""
else:
data = self._transport.recv(16384)
if not data:
raise ConnectionError("分层 TLS 下层连接已关闭")
self._incoming.write(data)
def _handshake(self):
while True:
try:
self._ssl.do_handshake()
self._flush()
return
except ssl.SSLWantReadError:
self._flush()
self._feed()
except ssl.SSLWantWriteError:
self._flush()
def sendall(self, data):
view = memoryview(data)
while view:
try:
written = self._ssl.write(view)
view = view[written:]
self._flush()
except ssl.SSLWantReadError:
self._flush()
self._feed()
except ssl.SSLWantWriteError:
self._flush()
def recv(self, size):
while True:
try:
return self._ssl.read(size)
except ssl.SSLWantReadError:
self._flush()
self._feed()
except ssl.SSLWantWriteError:
self._flush()
def settimeout(self, value):
self._transport.settimeout(value)
def gettimeout(self):
return self._transport.gettimeout()
def close(self):
self._transport.close()
class Socks5Bridge:
def __init__(self, node: dict[str, Any], bypass_lan: bool = True,
route_rules: list[dict[str, Any]] | None = None,
log: Callable[[str], None] | None = None,
source_address: str | None = None,
proxy_addresses: list[tuple] | None = None,
direct_target_resolver: Callable[[str, str, int], tuple[str, int] | None] | None = None,
route_rule_resolver: Callable[[str], dict[str, Any] | None] | None = None,
route_node_resolver: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
proxy_addresses_by_node: dict[str, list[tuple]] | None = None,
direct_route_ensurer: Callable[[str], None] | None = None):
self.node = node
self.bypass_lan = bypass_lan
self.route_rules = route_rules or []
self.log = log or (lambda _message: None)
self.source_address = source_address
self.proxy_addresses = proxy_addresses
# 由 main.py 注入 Lite 的同一套 PAC/路由判定,TUN 不复制另一份规则语义。
self.direct_target_resolver = direct_target_resolver
self.route_rule_resolver = route_rule_resolver
self.route_node_resolver = route_node_resolver
self.proxy_addresses_by_node = proxy_addresses_by_node or {}
self.direct_route_ensurer = direct_route_ensurer
self.listener: socket.socket | None = None
self._running = threading.Event()
self._threads: list[threading.Thread] = []
self._up = 0
self._down = 0
self._counter_lock = threading.Lock()
self._dns = DnsObserver(log=self.log)
self._udp_resources: list[socket.socket] = []
self._udp_resources_lock = threading.RLock()
def start(self) -> int:
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.listener.bind(("127.0.0.1", 0))
self.listener.listen(32)
self.listener.settimeout(1)
self._running.set()
thread = threading.Thread(target=self._accept_loop, daemon=True)
thread.start(); self._threads.append(thread)
return self.listener.getsockname()[1]
def _accept_loop(self):
while self._running.is_set() and self.listener:
try:
client, _ = self.listener.accept()
except socket.timeout:
continue
except OSError:
break
thread = threading.Thread(target=self._client, args=(client,), daemon=True)
thread.start(); self._threads.append(thread)
def _client(self, client: socket.socket):
try:
client.settimeout(15)
greeting = _recv_exact(client, 2)
if greeting[0] != 5:
return
methods = _recv_exact(client, greeting[1])
if 0 not in methods:
client.sendall(b"\x05\xff")
return
client.sendall(b"\x05\x00")
header = _recv_exact(client, 4)
if header[0] != 5:
return
command = header[1]
destination = _socks_address(client, header[3])
if command == 1:
self.log(f"[桥接] TCP 目标: {self._target_label(destination)}")
self._tcp(client, destination)
elif command == 3:
self._udp(client)
else:
client.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00")
except Exception as exc:
# 停止 TUN 时主动关闭仍在转发的连接,Windows 可能返回
# WSAECONNRESET (10054);这是收尾动作,不应作为用户错误记录。
if self._running.is_set():
self.log(f"[桥接] SOCKS5 请求失败: {exc}")
finally:
try: client.close()
except OSError: pass
def _target_label(self, target: Address) -> str:
host = target.host
try:
ipaddress.ip_address(host)
except ValueError:
ips = self._dns.lookup_domain(host)
resolved = ",".join(ips) if ips else "未知"
return f"domain={host} ip={resolved} port={target.port}"
domain = self._dns.lookup_ip(host) or "未知"
return f"domain={domain} ip={host} port={target.port}"
def _policy_host(self, host: str) -> str:
"""对已观察到的唯一 DNS 映射应用域名规则;实际连接仍使用原目标 IP。"""
try:
ipaddress.ip_address(host)
except ValueError:
return host
return self._dns.lookup_ip(host) or host
def _direct_target(self, host: str, port: int) -> tuple[str, int] | None:
explicit = False
normalized = str(host).rstrip(".").lower()
for rule in self.route_rules:
if not isinstance(rule, dict) or not rule.get("enabled", True):
continue
if str(rule.get("action") or "direct").lower() != "direct":
continue
value = str(rule.get("domain") or rule.get("value") or "").strip().lower().rstrip(".")
if value.startswith("*."):
value = value[2:]
try:
if ipaddress.ip_address(normalized) in ipaddress.ip_network(value, strict=False):
explicit = True
except ValueError:
if normalized == value or normalized.endswith("." + value):
explicit = True
if explicit:
break
if not self.bypass_lan and not explicit:
return None
try:
address = ipaddress.ip_address(host)
return (host, port) if explicit or address.is_private else None
except ValueError:
try:
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
address = item[4][0].split("%", 1)[0]
if explicit or ipaddress.ip_address(address).is_private:
return address, port
except OSError:
pass
return None
def _route_rule(self, host: str, original_host: str | None = None) -> dict[str, Any] | None:
if self.route_rule_resolver is not None:
try:
rule = self.route_rule_resolver(host)
if isinstance(rule, dict):
return rule
if original_host and original_host != host:
rule = self.route_rule_resolver(original_host)
if isinstance(rule, dict):
return rule
except Exception as exc:
self.log(f"[路由] 规则判断失败: {exc}")
for rule in self.route_rules:
if not isinstance(rule, dict) or not rule.get("enabled", True):
continue
value = str(rule.get("domain") or rule.get("value") or "").strip().lower().rstrip(".")
normalized = str(host).rstrip(".").lower()
base = value[2:] if value.startswith("*.") else value
if base and (normalized == base or normalized.endswith("." + base)):
return rule
return None
def _direct_target_for(self, host: str, port: int,
policy_host: str | None = None) -> tuple[str, int] | None:
policy_host = policy_host or self._policy_host(host)
rule = self._route_rule(policy_host, host)
action = str((rule or {}).get("action") or "").lower()
if action == "node":
return None
if self.direct_target_resolver is not None:
try:
return self.direct_target_resolver(policy_host, host, port)
except Exception as exc:
self.log(f"[路由] PAC 直连判断失败: {exc}")
return None
if action == "direct":
return (host, port)
return self._direct_target(host, port)
def _route_node(self, rule: dict[str, Any] | None) -> dict[str, Any] | None:
if not rule or str(rule.get("action") or "").lower() != "node":
return None
if self.route_node_resolver is None:
return None
try:
node = self.route_node_resolver(rule)
return node if isinstance(node, dict) else None
except Exception as exc:
self.log(f"[路由] 固定节点解析失败: {exc}")
return None
@staticmethod
def _node_key(node: dict[str, Any]) -> str:
chain = node.get("_jump_chain")
if isinstance(chain, list) and chain:
return "jump|" + ">".join(
Socks5Bridge._node_key(item) for item in chain if isinstance(item, dict)
)
methods = (node.get("methods") or {}).get("http") or {}
return "|".join((str(node.get("domain") or ""), str(methods.get("port") or node.get("port") or 443),
str(methods.get("username") or node.get("username") or "")))
def _dialer(self, node: dict[str, Any]) -> HttpConnectDialer:
proxy_addresses = self.proxy_addresses
chain = node.get("_jump_chain")
if isinstance(chain, list) and chain:
proxy_addresses = self.proxy_addresses_by_node.get(
self._node_key(chain[0]))
return HttpConnectDialer(
node, source_address=self.source_address,
proxy_addresses=(proxy_addresses if node is self.node else
self.proxy_addresses_by_node.get(self._node_key(node), proxy_addresses)),
proxy_addresses_by_node=self.proxy_addresses_by_node,
)
def _ensure_direct_routes(self, host: str, port: int) -> None:
if self.direct_route_ensurer is None:
return
try:
self.direct_route_ensurer(str(ipaddress.ip_address(host)))
return
except ValueError:
pass
try:
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
self.direct_route_ensurer(str(item[4][0]).split("%", 1)[0])
except OSError:
pass
def _open_target(self, target: Address) -> socket.socket:
policy_host = self._policy_host(target.host)
rule = self._route_rule(policy_host, target.host)
route_node = self._route_node(rule)
if rule and str(rule.get("action") or "").lower() == "node":
if route_node is None:
raise ConnectionError("域名路由指定的节点不可用")
return self._dialer(route_node).open(target)
direct = self._direct_target_for(target.host, target.port, policy_host)
if direct is not None:
self._ensure_direct_routes(direct[0], direct[1])
return socket.create_connection(direct, 15)
return self._dialer(self.node).open(target)
def _tcp(self, client: socket.socket, target: Address):
upstream = self._open_target(target)
upstream.settimeout(None); client.settimeout(None)
client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
self._relay(client, upstream)
def _relay(self, left: socket.socket, right: socket.socket):
sockets = [left, right]
try:
while self._running.is_set():
readable, _, _ = select.select(sockets, [], [], 1)
for source in readable:
data = source.recv(65536)
if not data: return
target = right if source is left else left
target.sendall(data)
with self._counter_lock:
if source is left: self._up += len(data)
else: self._down += len(data)
finally:
for item in sockets:
try: item.close()
except OSError: pass
def _udp(self, control: socket.socket):
# tun2socks v2.7 在 Windows 上通过 ListenPacket("udp", "")
# 创建 IPv6 wildcard socket(日志形如 [::]:port)。SOCKS5 返回的
# UDP relay 地址必须与这个地址族一致,否则 Windows 会在
# wsasendto 阶段以 WSAEINVAL 拒绝向 127.0.0.1:port 发包。
# 优先使用 IPv6 loopback;老系统没有 IPv6 时再回退 IPv4。
try:
udp = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
udp.bind(("::1", 0))
relay_address = b"\x04" + socket.inet_pton(socket.AF_INET6, "::1")
relay_bind = f"[::1]:{udp.getsockname()[1]}"
except OSError:
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.bind(("127.0.0.1", 0))
relay_address = b"\x01" + socket.inet_aton("127.0.0.1")
relay_bind = f"127.0.0.1:{udp.getsockname()[1]}"
port = udp.getsockname()[1]
udp.settimeout(1)
with self._udp_resources_lock:
self._udp_resources.append(udp)
control.sendall(b"\x05\x00\x00" + relay_address + struct.pack("!H", port))
self.log(f"[桥接] SOCKS5 UDP 关联已建立: {relay_bind}")
control.settimeout(1)
streams: dict[str, socket.socket] = {}
direct_flows: dict[tuple[str, int], tuple[socket.socket, tuple, Address]] = {}
malformed = 0
try:
while self._running.is_set():
try:
packet, peer = udp.recvfrom(65535)
except socket.timeout:
try:
if not control.recv(1): break
except socket.timeout:
continue
continue
try:
destination, payload = _unpack_socks_udp(packet)
except Exception as exc:
malformed += 1
if malformed <= 5:
self.log(
f"[桥接] SOCKS5 UDP 数据包解析失败: {exc} len={len(packet)}"
)
continue
if malformed:
malformed = 0
if not streams and not direct_flows:
self.log(
f"[桥接] SOCKS5 UDP 数据报: target={destination.host}:{destination.port} "
f"bytes={len(payload)}"
)
if destination.port == 53:
domain = self._dns.observe_query(payload)
if domain:
self.log(
f"[DNS] 查询: domain={domain} server={destination.host}:{destination.port}"
)
policy_host = self._policy_host(destination.host)
rule = self._route_rule(policy_host, destination.host)
route_node = self._route_node(rule)
if rule and str(rule.get("action") or "").lower() == "node" and route_node is None:
raise ConnectionError("UDP 域名路由指定的节点不可用")
direct = None if route_node is not None else self._direct_target_for(
destination.host, destination.port, policy_host)
if direct is not None:
key = (str(direct[0]), int(direct[1]))
flow = direct_flows.get(key)
if flow is None:
flow_socket, sockaddr = self._open_direct_udp(direct)
flow = (flow_socket, sockaddr, destination)
direct_flows[key] = flow
threading.Thread(
target=self._direct_udp_reader,
args=(flow_socket, udp, destination, peer),
daemon=True,
).start()
self.log(f"[UDP] 直连通道: target={destination.host}:{destination.port}")
flow_socket, direct_addr, _flow_destination = flow
flow_socket.sendto(payload, direct_addr)
with self._counter_lock:
self._up += len(payload)
continue
selected_node = route_node or self.node
stream_key = self._node_key(selected_node)
stream = streams.get(stream_key)
if stream is None:
self.log(
f"[UoT] 建立 UDP 通道: target={destination.host}:{destination.port}"
+ ("(固定节点)" if route_node is not None else "")
)
stream = self._dialer(selected_node).open(
Address(MAGIC_ADDRESS, _node_uot_connect_port(selected_node))
)
stream.settimeout(None)
stream.sendall(encode_request(False, destination))
streams[stream_key] = stream
with self._udp_resources_lock:
self._udp_resources.append(stream)
threading.Thread(
target=self._uot_reader, args=(stream, udp, peer), daemon=True
).start()
# UoT v2 non-connect 模式每个数据报都带目标地址。
stream.sendall(encode_datagram_frame(destination, payload))
with self._counter_lock:
self._up += len(payload)
finally:
udp.close()
for stream in streams.values():
try:
stream.close()
except OSError:
pass
for flow_socket, _sockaddr, _destination in direct_flows.values():
try:
flow_socket.close()
except OSError:
pass
with self._udp_resources_lock:
self._udp_resources = [
item for item in self._udp_resources
if item is not udp and item not in streams.values()
]
def _open_direct_udp_target(self, target: tuple[str, int]) -> tuple[socket.socket, tuple]:
host, port = target
last_error = None
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_DGRAM):
family, socktype, proto, _canonname, sockaddr = item
raw = socket.socket(family, socktype, proto)
try:
destination_ip = str(sockaddr[0]).split("%", 1)[0]
if self.direct_route_ensurer is not None:
self.direct_route_ensurer(destination_ip)
is_loopback = False
try:
is_loopback = ipaddress.ip_address(destination_ip).is_loopback
except ValueError:
pass
if (family == socket.AF_INET and self.source_address and ":" not in self.source_address
and not is_loopback):
raw.bind((self.source_address, 0))
elif (family == socket.AF_INET6 and self.source_address and ":" in self.source_address
and not is_loopback):
raw.bind((self.source_address, 0, 0, 0))
if family == socket.AF_INET:
try:
raw.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except OSError:
pass
raw.settimeout(1)
return raw, sockaddr
except OSError as exc:
last_error = exc
raw.close()
raise last_error or OSError(f"无法解析 UDP 目标: {host}")
def _open_direct_udp(self, target: tuple[str, int]) -> tuple[socket.socket, tuple]:
raw, sockaddr = self._open_direct_udp_target(target)
# 解析后的 sockaddr 直接用于 sendto,保留 IPv6 scope id。
return raw, sockaddr
def _direct_udp_reader(self, raw: socket.socket, udp: socket.socket,
destination: Address, peer):
try:
while self._running.is_set():
try:
payload, _source = raw.recvfrom(65535)
except socket.timeout:
continue
if not payload:
continue
if destination.port == 53:
observed = self._dns.observe_response(payload)
if observed:
domain, ips = observed
self.log(f"[DNS] 直连回包: domain={domain} ip={','.join(ips)}")
udp.sendto(_pack_socks_udp(destination, payload), peer)
with self._counter_lock:
self._down += len(payload)
except OSError:
pass
def _uot_reader(self, stream, udp, peer):
try:
while self._running.is_set():
destination, payload = read_datagram_frame(stream)
observed = self._dns.observe_response(payload)
if observed:
domain, ips = observed
self.log(f"[DNS] 回包: domain={domain} ip={','.join(ips)}")
udp.sendto(_pack_socks_udp(destination, payload), peer)
with self._counter_lock: self._down += len(payload)
except Exception as exc:
if self._running.is_set():
self.log(f"[UoT] UDP 回包读取失败: {exc}")
def stop(self):
self._running.clear()
if self.listener:
try: self.listener.close()
except OSError: pass
self.listener = None
with self._udp_resources_lock:
resources = list(self._udp_resources)
self._udp_resources.clear()
for resource in resources:
try:
resource.close()
except OSError:
pass
def traffic(self) -> tuple[int, int, int, int]:
with self._counter_lock:
return self._up, self._down, self._up, self._down
+137
View File
@@ -0,0 +1,137 @@
"""Windows 系统代理模式:注册表切换、原状态保存与异常退出恢复。"""
from __future__ import annotations
import ctypes
import re
from typing import Callable
PROXY_REG = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings"
BACKUP_KEY = "prev_proxy_state"
_VALUE_NAMES = ("ProxyEnable", "ProxyServer", "ProxyOverride", "AutoConfigURL")
def is_our_pac_url(value: str) -> bool:
return bool(re.fullmatch(r"http://127\.0\.0\.1:\d+/proxy\.pac", str(value).strip()))
def notify_proxy_change() -> None:
"""通知 WinINET 和顶层窗口重新读取系统代理。"""
try:
ctypes.windll.Wininet.InternetSetOptionW(None, 39, None, 0)
ctypes.windll.Wininet.InternetSetOptionW(None, 37, None, 0)
result = ctypes.c_ulong()
ctypes.windll.user32.SendMessageTimeoutW(
0xFFFF, 0x001A, 0, ctypes.c_wchar_p("InternetSettings"),
0x0002, 5000, ctypes.byref(result))
except Exception:
pass
class SystemProxyMode:
def __init__(self, *, run: Callable, settings_load: Callable,
settings_save: Callable, log: Callable,
endpoint: Callable[[], tuple[str, int]],
proxy_override: Callable[[bool], str]):
self.run = run
self.settings_load = settings_load
self.settings_save = settings_save
self.log = log
self.endpoint = endpoint
self.proxy_override = proxy_override
self._previous = None
def reg_query(self, name: str):
result = self.run(["reg", "query", PROXY_REG, "/v", name], warn=False)
if result.returncode:
return None
for line in (result.stdout or "").splitlines():
parts = line.strip().split(None, 2)
if len(parts) >= 3 and parts[0].lower() == name.lower():
return parts[1], parts[2]
return None
def _backup(self):
if self._previous is not None:
return
state = {name: list(value) if (value := self.reg_query(name)) else None
for name in _VALUE_NAMES}
self._previous = state
try:
self.settings_save({**self.settings_load(), BACKUP_KEY: state})
except Exception:
pass
def _restore(self):
state = self._previous
if state is None:
state = self.settings_load().get(BACKUP_KEY)
if isinstance(state, dict):
for name in _VALUE_NAMES:
value = state.get(name)
if value:
self.run(["reg", "add", PROXY_REG, "/v", name, "/t", value[0],
"/d", value[1], "/f"], warn=False)
else:
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
self.log("已恢复开启代理前的系统代理设置")
else:
for name in ("ProxyServer", "ProxyOverride", "AutoConfigURL"):
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
"REG_DWORD", "/d", "0", "/f"])
self._previous = None
try:
data = self.settings_load()
if BACKUP_KEY in data:
del data[BACKUP_KEY]
self.settings_save(data)
except Exception:
pass
def enable(self, pac_url=None, bypass_lan=False):
self._backup()
if pac_url:
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
"REG_DWORD", "/d", "0", "/f"])
for name in ("ProxyServer", "ProxyOverride"):
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
self.run(["reg", "add", PROXY_REG, "/v", "AutoConfigURL", "/t",
"REG_SZ", "/d", pac_url, "/f"])
self.log("系统代理已设置 (PAC)")
else:
host, port = self.endpoint()
address = f"{host}:{port}"
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
"REG_DWORD", "/d", "1", "/f"])
self.run(["reg", "add", PROXY_REG, "/v", "ProxyServer", "/t",
"REG_SZ", "/d", address, "/f"])
self.run(["reg", "add", PROXY_REG, "/v", "ProxyOverride", "/t",
"REG_SZ", "/d", self.proxy_override(bypass_lan), "/f"])
self.log(f"系统代理已设置: {address}")
notify_proxy_change()
def disable(self):
self._restore()
notify_proxy_change()
def is_on(self):
host, port = self.endpoint()
enabled = self.reg_query("ProxyEnable")
try:
fixed_enabled = bool(enabled and int(str(enabled[1]).strip(), 0) == 1)
except (TypeError, ValueError):
fixed_enabled = False
if fixed_enabled:
server = self.reg_query("ProxyServer")
return bool(server and (server[1] or "").strip() == f"{host}:{port}")
pac = self.reg_query("AutoConfigURL")
return bool(pac) and is_our_pac_url(pac[1] or "")
def is_stale(self):
host, port = self.endpoint()
server = self.reg_query("ProxyServer")
pac = self.reg_query("AutoConfigURL")
return (bool(server) and (server[1] or "").strip() == f"{host}:{port}") or \
(bool(pac) and is_our_pac_url(pac[1] or ""))
+774
View File
@@ -0,0 +1,774 @@
"""路线 B 的 Windows TUN 后端:tun2socks + 自有 SOCKS5/UoT 桥接。
这里不启动 sing-box。tun2socks 只负责 Wintun 和用户态 TCP/IP 栈,
所有上游协议由本项目的 ``socks_bridge`` 处理:TCP 使用 HTTP CONNECT,
UDP 使用裸 UoT v2。
"""
from __future__ import annotations
import ctypes
import ipaddress
import json
import os
import shutil
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any, Callable
from .socks_bridge import Socks5Bridge
class TunBackendError(RuntimeError):
"""TUN 后端无法启动或停止。"""
class TunBackend:
def __init__(self, user_dir: str | Path, engine_path: str | Path | None = None,
log: Callable[[str], None] | None = None,
detail_log: Callable[[str], None] | None = None):
self.user_dir = Path(user_dir)
self.engine_path = Path(engine_path) if engine_path else None
self.log = log or (lambda _message: None)
self.detail_log = detail_log or self.log
self._process: subprocess.Popen[str] | None = None
self._bridge: Socks5Bridge | None = None
self._lock = threading.RLock()
self._route_added = False
self._ipv6_route_added = False
self._lan_routes: list[str] = []
self._lan_ipv6_routes: list[str] = []
self._node_routes: list[str] = []
self._node_ipv6_routes: list[str] = []
self._physical_gateway = ""
self._physical_ifindex = ""
self._physical_ipv6_gateway = ""
self._physical_ipv6_ifindex = ""
self._device = "RRPP-TUN"
self._address = "10.255.0.1"
self._ipv6_address = "fd42:7272:5050::1"
self._dns_configured = False
self._tun_dns_servers = ("8.8.8.8", "1.1.1.1")
self._bypass_lan = True
self._cleanup_pending = False
self._state_path = self.user_dir / "tun_state.json"
# Wintun 是三层虚拟接口,默认路由的下一跳使用接口自身地址。
self._gateway = self._address
self._ipv6_gateway = self._ipv6_address
self._recover_stale_state()
@staticmethod
def is_admin() -> bool:
if os.name != "nt":
return True
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def resolve_engine(self) -> Path:
candidates: list[Path] = []
if self.engine_path:
candidates.append(self.engine_path)
# 目录式发布:主 EXE 与 tun2socks.exe、wintun.dll 放在同一目录。
# PyInstaller onefile:主程序会把内置文件解压到 _MEIPASS。
executable_dir = Path(sys.executable).resolve().parent
meipass = getattr(sys, "_MEIPASS", None)
package_dir = Path(__file__).resolve().parents[1]
candidates.extend([
self.user_dir / "tun2socks.exe",
self.user_dir.parent / "tun2socks.exe",
executable_dir / "tun2socks.exe",
Path(meipass) / "tun2socks.exe" if meipass else Path("__rrpp_no_meipass__"),
package_dir / "tun2socks.exe",
package_dir / "runtime" / "tun2socks.exe",
])
found = shutil.which("tun2socks")
if found:
candidates.append(Path(found))
for candidate in candidates:
try:
path = candidate.expanduser().resolve()
except OSError:
continue
if path.is_file():
return path
raise TunBackendError(
"未找到 tun2socks.exe。请将 tun2socks.exe 和 wintun.dll 放在 TUN 程序目录,"
"或在 config.json 的 tun_engine_path 中填写完整路径。"
)
@staticmethod
def _physical_interface() -> str:
command = ["powershell", "-NoProfile", "-Command",
"(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | "
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
"Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias)"]
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
value = (result.stdout or "").strip().splitlines()
if value and value[0].strip():
return value[0].strip()
except Exception:
pass
return ""
@staticmethod
def _physical_source_address(interface: str) -> str:
if not interface:
return ""
escaped = interface.replace("'", "''")
command = ["powershell", "-NoProfile", "-Command",
f"(Get-NetIPAddress -InterfaceAlias '{escaped}' -AddressFamily IPv4 "
"-AddressState Preferred | Select-Object -First 1 -ExpandProperty IPAddress)"]
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
value = (result.stdout or "").strip().splitlines()
if value and value[0].strip():
return value[0].strip()
except Exception:
pass
return ""
@staticmethod
def _physical_route_info() -> tuple[str, str]:
"""返回当前物理默认路由的网关和接口索引。"""
command = ["powershell", "-NoProfile", "-Command",
"Get-NetRoute -DestinationPrefix '0.0.0.0/0' | "
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
"Sort-Object RouteMetric | Select-Object -First 1 "
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
data = json.loads((result.stdout or "").strip())
if isinstance(data, dict):
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
except Exception:
pass
return "", ""
@staticmethod
def _physical_ipv6_route_info() -> tuple[str, str]:
"""返回当前物理 IPv6 默认路由的下一跳和接口索引。"""
command = ["powershell", "-NoProfile", "-Command",
"Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' | "
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
"Sort-Object RouteMetric | Select-Object -First 1 "
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
data = json.loads((result.stdout or "").strip())
if isinstance(data, dict):
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
except Exception:
pass
return "", ""
@staticmethod
def _active_global_ipv6() -> bool | None:
"""返回是否存在公网 IPv6;无法查询时返回 None,避免误报已接管。"""
if os.name != "nt":
return False
command = [
"powershell", "-NoProfile", "-Command",
"@(Get-NetIPAddress -AddressFamily IPv6 -AddressState Preferred | "
"Where-Object { $_.IPAddress -notlike 'fe80:*' -and "
"$_.IPAddress -ne '::1' -and $_.IPAddress -notlike 'fc*' -and "
"$_.IPAddress -notlike 'fd*' }).IPAddress | ConvertTo-Json -Compress",
]
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
if result.returncode:
return None
value = json.loads((result.stdout or "").strip() or "[]")
if isinstance(value, str):
return bool(value.strip())
return bool(value)
except Exception:
return None
@staticmethod
def _resolve_proxy_addresses(node: dict[str, Any]) -> list[tuple]:
methods = (node.get("methods") or {}).get("http") or {}
host = str(node.get("domain") or "").strip()
port = int(methods.get("port") or node.get("port") or 443)
if not host:
return []
addresses: list[tuple] = []
for item in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
if item[4] not in addresses:
addresses.append(item[4])
return addresses
@staticmethod
def _node_chain(node: dict[str, Any]) -> list[dict[str, Any]]:
chain = node.get("_jump_chain") if isinstance(node, dict) else None
if isinstance(chain, list) and chain:
return [item for item in chain if isinstance(item, dict)]
return [node] if isinstance(node, dict) else []
@staticmethod
def _node_key(node: dict[str, Any]) -> str:
methods = (node.get("methods") or {}).get("http") or {}
return "|".join((str(node.get("domain") or ""),
str(methods.get("port") or node.get("port") or 443),
str(methods.get("username") or node.get("username") or "")))
def _add_node_routes(self, proxy_addresses: list[tuple]) -> None:
"""把 HTTP CONNECT 节点固定走物理网卡,防止默认 TUN 路由形成回环。"""
candidates_v4: list[str] = []
candidates_v6: list[str] = []
for sockaddr in proxy_addresses:
address = str(sockaddr[0])
try:
parsed = ipaddress.ip_address(address.split("%", 1)[0])
except ValueError:
continue
target = candidates_v4 if parsed.version == 4 else candidates_v6
normalized = parsed.compressed
if normalized not in target:
target.append(normalized)
if candidates_v4 and not self._physical_ifindex:
raise TunBackendError("找不到物理 IPv4 接口,无法固定代理节点路由")
for address in candidates_v4:
if address in self._node_routes:
continue
command = ["route", "ADD", address, "MASK", "255.255.255.255"]
if self._physical_gateway:
command.append(self._physical_gateway)
command.extend(["METRIC", "1", "IF", self._physical_ifindex])
result = subprocess.run(command, capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
if result.returncode:
detail = (result.stderr or result.stdout or "").strip()
# 已存在的等价路由不影响启动;其它错误继续暴露,避免静默回环。
if "already exists" not in detail.lower() and "已存在" not in detail:
raise TunBackendError(f"节点物理路由添加失败 ({address}): {detail[:200]}")
continue
self._node_routes.append(address)
self._write_state()
if candidates_v6 and not self._physical_ipv6_ifindex:
# 双栈节点仍可使用 IPv4 地址;只有 IPv6-only 节点需要物理 IPv6 出口。
if candidates_v4:
candidates_v6 = []
else:
raise TunBackendError("代理节点仅包含 IPv6 地址,但找不到物理 IPv6 默认路由")
for address in candidates_v6:
if address in self._node_ipv6_routes:
continue
self._add_ipv6_physical_route(address)
def _add_ipv6_physical_route(self, address: str) -> None:
if not self._physical_ipv6_ifindex:
raise TunBackendError("找不到物理 IPv6 接口,无法固定 IPv6 路由")
command = ["interface", "ipv6", "add", "route", f"prefix={address}/128",
f"interface={self._physical_ipv6_ifindex}"]
if self._physical_ipv6_gateway and self._physical_ipv6_gateway != "::":
command.append(f"nexthop={self._physical_ipv6_gateway}")
command.append("metric=1")
result = subprocess.run(["netsh", *command], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
if result.returncode:
detail = (result.stderr or result.stdout or "").strip()
if "already exists" not in detail.lower() and "已存在" not in detail:
raise TunBackendError(f"IPv6 节点物理路由添加失败 ({address}): {detail[:200]}")
return
self._node_ipv6_routes.append(address)
self._write_state()
def _ensure_physical_route(self, address: str) -> None:
"""为 TUN 内部的直连目标补一条物理出口 /32 路由。"""
if os.name != "nt":
return
try:
parsed = ipaddress.ip_address(str(address).split("%", 1)[0])
except ValueError:
return
if parsed.version == 6:
normalized = parsed.compressed
if normalized in self._node_ipv6_routes:
return
self._add_ipv6_physical_route(normalized)
return
if not self._physical_ifindex:
raise TunBackendError("找不到物理 IPv4 接口,无法固定直连路由")
normalized = parsed.compressed
if normalized in self._node_routes:
return
command = ["route", "ADD", normalized, "MASK", "255.255.255.255"]
if self._physical_gateway:
command.append(self._physical_gateway)
command.extend(["METRIC", "1", "IF", self._physical_ifindex])
result = subprocess.run(command, capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
detail = (result.stderr or result.stdout or "").strip()
if result.returncode:
if "already exists" in detail.lower() or "已存在" in detail:
return
raise TunBackendError(f"直连目标物理路由添加失败 ({normalized}): {detail[:200]}")
self._node_routes.append(normalized)
self._write_state()
@staticmethod
def _cleanup_succeeded(result: subprocess.CompletedProcess[str]) -> bool:
if result.returncode == 0:
return True
detail = (result.stderr or result.stdout or "").strip().lower()
# 清理是幂等操作:路由或网卡已经消失时,目标状态已经达到。
return any(marker in detail for marker in (
"not found", "does not exist", "cannot find", "no such",
"找不到", "不存在", "未找到", "没有找到",
))
def _cleanup_netsh(self, args: list[str]) -> bool:
try:
result = subprocess.run(["netsh", *args], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
except Exception as exc:
self.detail_log(f"[TUN] 清理 netsh 失败: {exc}")
return False
if self._cleanup_succeeded(result):
return True
detail = (result.stderr or result.stdout or "").strip()
self.detail_log(f"[TUN] 清理 netsh 未完成: {detail[:300]}")
return False
def _remove_node_routes(self) -> bool:
remaining: list[str] = []
for address in self._node_routes:
try:
result = subprocess.run(["route", "DELETE", address], capture_output=True,
text=True, encoding="mbcs", errors="replace", timeout=15)
except Exception as exc:
self.detail_log(f"[TUN] 清理节点路由失败 ({address}): {exc}")
remaining.append(address)
continue
if not self._cleanup_succeeded(result):
detail = (result.stderr or result.stdout or "").strip()
self.detail_log(f"[TUN] 清理节点路由未完成 ({address}): {detail[:200]}")
remaining.append(address)
self._node_routes = remaining
remaining_v6: list[str] = []
for address in self._node_ipv6_routes:
command = ["interface", "ipv6", "delete", "route", f"prefix={address}/128"]
if self._physical_ipv6_ifindex:
command.append(f"interface={self._physical_ipv6_ifindex}")
try:
result = subprocess.run(["netsh", *command], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
except Exception as exc:
self.detail_log(f"[TUN] 清理 IPv6 节点路由失败 ({address}): {exc}")
remaining_v6.append(address)
continue
if not self._cleanup_succeeded(result):
detail = (result.stderr or result.stdout or "").strip()
self.detail_log(f"[TUN] 清理 IPv6 节点路由未完成 ({address}): {detail[:200]}")
remaining_v6.append(address)
self._node_ipv6_routes = remaining_v6
return not remaining and not remaining_v6
def _netsh(self, args: list[str], *, ignore_error: bool = False) -> None:
result = subprocess.run(["netsh", *args], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15)
if result.returncode and not ignore_error:
detail = (result.stderr or result.stdout or "").strip()
raise TunBackendError(f"netsh 操作失败: {detail[:300]}")
def _configure_routes(self) -> None:
for _ in range(20):
try:
probe = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"Get-NetAdapter -Name '{self._device}' -ErrorAction SilentlyContinue"],
capture_output=True, text=True, timeout=3,
)
if probe.stdout and self._device.lower() in probe.stdout.lower():
break
except Exception:
pass
time.sleep(0.25)
else:
raise TunBackendError(f"tun2socks 未创建虚拟网卡: {self._device}")
self._netsh(["interface", "ipv4", "set", "address",
f"name={self._device}", "source=static",
f"addr={self._address}", "mask=255.255.255.252"])
self._netsh(["interface", "ipv4", "add", "route", "0.0.0.0/0",
f"{self._device}", self._gateway, "metric=1"])
self._route_added = True
self._write_state()
self._netsh(["interface", "ipv6", "set", "address",
f"interface={self._device}", f"address={self._ipv6_address}",
"store=active"])
self._netsh(["interface", "ipv6", "add", "route", "prefix=::/0",
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
"metric=1"])
self._ipv6_route_added = True
self._write_state()
if not self._bypass_lan:
# Windows 物理网卡通常已有更具体的私网路由;补同前缀的 TUN
# 路由,确保“内网也代理”不会在进入 tun2socks 前被物理网卡截走。
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"169.254.0.0/16"):
self._netsh(["interface", "ipv4", "add", "route", network,
f"{self._device}", self._gateway, "metric=1"])
self._lan_routes.append(network)
self._write_state()
for network in ("fc00::/7",):
self._netsh(["interface", "ipv6", "add", "route", f"prefix={network}",
f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
"metric=1"])
self._lan_ipv6_routes.append(network)
self._write_state()
self._configure_dns()
def _configure_dns(self) -> None:
"""让 Windows DNS 请求也进入 TUN,避免沿用物理网卡 DNS。"""
if os.name != "nt":
return
ipv4 = [item for item in self._tun_dns_servers if ":" not in item]
ipv6 = [item for item in self._tun_dns_servers if ":" in item]
if ipv4:
self._netsh([
"interface", "ipv4", "set", "dnsservers",
f"name={self._device}", "source=static",
f"address={ipv4[0]}", "register=none", "validate=no",
])
for index, secondary in enumerate(ipv4[1:], start=2):
self._netsh([
"interface", "ipv4", "add", "dnsservers",
f"name={self._device}", f"address={secondary}",
f"index={index}", "validate=no",
], ignore_error=True)
if ipv6:
self._netsh([
"interface", "ipv6", "set", "dnsservers",
f"name={self._device}", "source=static",
f"address={ipv6[0]}", "register=none", "validate=no",
], ignore_error=True)
for index, secondary in enumerate(ipv6[1:], start=2):
self._netsh([
"interface", "ipv6", "add", "dnsservers",
f"name={self._device}", f"address={secondary}",
f"index={index}", "validate=no",
], ignore_error=True)
self._dns_configured = True
self._write_state()
def _remove_routes(self) -> None:
cleanup_ok = True
if self._dns_configured:
ipv4_ok = self._cleanup_netsh([
"interface", "ipv4", "set", "dnsservers",
f"name={self._device}", "source=dhcp",
])
ipv6_ok = self._cleanup_netsh([
"interface", "ipv6", "set", "dnsservers",
f"name={self._device}", "source=dhcp",
])
cleanup_ok &= ipv4_ok and ipv6_ok
if ipv4_ok and ipv6_ok:
self._dns_configured = False
if self._route_added:
route_ok = self._cleanup_netsh(["interface", "ipv4", "delete", "route",
"0.0.0.0/0", f"{self._device}", self._gateway])
cleanup_ok &= route_ok
if route_ok:
self._route_added = False
if self._ipv6_route_added:
route_ok = self._cleanup_netsh(["interface", "ipv6", "delete", "route",
"prefix=::/0", f"interface={self._device}",
f"nexthop={self._ipv6_gateway}"])
cleanup_ok &= route_ok
if route_ok:
self._ipv6_route_added = False
remaining_lan: list[str] = []
for network in self._lan_routes:
route_ok = self._cleanup_netsh(["interface", "ipv4", "delete", "route",
network, f"{self._device}", self._gateway])
cleanup_ok &= route_ok
if not route_ok:
remaining_lan.append(network)
self._lan_routes = remaining_lan
remaining_lan_v6: list[str] = []
for network in self._lan_ipv6_routes:
route_ok = self._cleanup_netsh(["interface", "ipv6", "delete", "route",
f"prefix={network}", f"interface={self._device}",
f"nexthop={self._ipv6_gateway}"])
cleanup_ok &= route_ok
if not route_ok:
remaining_lan_v6.append(network)
self._lan_ipv6_routes = remaining_lan_v6
cleanup_ok &= self._remove_node_routes()
self._cleanup_pending = not cleanup_ok
if cleanup_ok:
self._clear_state()
else:
self._write_state()
self.detail_log("[TUN] 部分路由/DNS 清理失败,已保留状态并将在下次启动重试")
def _write_state(self) -> None:
"""记录本进程改过的路由/DNS,强杀后下次启动可恢复。"""
try:
self.user_dir.mkdir(parents=True, exist_ok=True)
payload = {
"device": self._device,
"address": self._address,
"gateway": self._gateway,
"ipv6_address": self._ipv6_address,
"ipv6_gateway": self._ipv6_gateway,
"lan_routes": list(self._lan_routes),
"lan_ipv6_routes": list(self._lan_ipv6_routes),
"bypass_lan": bool(self._bypass_lan),
"physical_gateway": self._physical_gateway,
"physical_ifindex": self._physical_ifindex,
"physical_ipv6_gateway": self._physical_ipv6_gateway,
"physical_ipv6_ifindex": self._physical_ipv6_ifindex,
"node_routes": list(self._node_routes),
"node_ipv6_routes": list(self._node_ipv6_routes),
"route_added": bool(self._route_added),
"ipv6_route_added": bool(self._ipv6_route_added),
"dns_configured": bool(self._dns_configured),
"dns_servers": list(self._tun_dns_servers),
"cleanup_pending": bool(self._cleanup_pending),
"pid": os.getpid(),
}
temp = self._state_path.with_suffix(".tmp")
temp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
temp.replace(self._state_path)
except Exception as exc:
self.detail_log(f"[TUN] 状态记录失败: {exc}")
def _clear_state(self) -> None:
try:
self._state_path.unlink(missing_ok=True)
except Exception:
pass
@staticmethod
def _pid_alive(pid: Any) -> bool:
"""避免合并版误清理仍在运行的旧 TUN 实例。"""
if os.name != "nt":
return False
try:
number = int(pid)
if number <= 0:
return False
kernel = ctypes.windll.kernel32
kernel.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_bool, ctypes.c_ulong]
kernel.OpenProcess.restype = ctypes.c_void_p
kernel.GetExitCodeProcess.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
kernel.CloseHandle.argtypes = [ctypes.c_void_p]
handle = kernel.OpenProcess(0x1000, False, number)
if not handle:
return False
try:
code = ctypes.c_ulong()
return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))
and code.value == 259)
finally:
kernel.CloseHandle(handle)
except (TypeError, ValueError, OSError):
return False
def _recover_stale_state(self) -> None:
"""启动前清理上次崩溃/强杀留下的 RRPP-TUN 路由和 DNS。"""
try:
if not self._state_path.is_file():
return
state = json.loads(self._state_path.read_text(encoding="utf-8"))
if not state.get("cleanup_pending") and self._pid_alive(state.get("pid")):
raise TunBackendError("检测到另一个 TUN 实例仍在运行,请先关闭后再启动")
self._device = str(state.get("device") or self._device)
self._address = str(state.get("address") or self._address)
self._gateway = str(state.get("gateway") or self._gateway)
self._ipv6_address = str(state.get("ipv6_address") or self._ipv6_address)
self._ipv6_gateway = str(state.get("ipv6_gateway") or self._ipv6_gateway)
self._physical_ipv6_gateway = str(state.get("physical_ipv6_gateway") or "")
self._physical_ipv6_ifindex = str(state.get("physical_ipv6_ifindex") or "")
self._route_added = bool(state.get("route_added"))
self._ipv6_route_added = bool(state.get("ipv6_route_added"))
self._lan_routes = [str(item).strip() for item in (state.get("lan_routes") or [])
if str(item).strip()]
self._lan_ipv6_routes = [str(item).strip() for item in (state.get("lan_ipv6_routes") or [])
if str(item).strip()]
self._node_routes = [str(item).strip() for item in (state.get("node_routes") or [])
if str(item).strip()]
self._node_ipv6_routes = [str(item).strip() for item in (state.get("node_ipv6_routes") or [])
if str(item).strip()]
self._dns_configured = bool(state.get("dns_configured"))
self._cleanup_pending = True
self._remove_routes()
if self._cleanup_pending:
self.detail_log("[TUN] 上次异常退出的部分路由/DNS 仍未清理,状态已保留")
else:
self.log("已清理上次异常退出遗留的 TUN 路由和 DNS")
except TunBackendError:
raise
except Exception as exc:
self.detail_log(f"[TUN] 异常退出残留清理失败: {exc}")
def _read_output(self, process: subprocess.Popen[str]) -> None:
if process.stdout is None:
return
try:
for line in process.stdout:
line = line.rstrip()
if line:
self.detail_log(f"[TUN] {line}")
except OSError:
pass
def start(self, node: dict[str, Any], *, bypass_lan: bool = True,
route_rules: list[dict[str, Any]] | None = None,
dns_servers: tuple[str, ...] | list[str] | None = None,
direct_target_resolver: Callable[[str, str, int], tuple[str, int] | None] | None = None,
route_rule_resolver: Callable[[str], dict[str, Any] | None] | None = None,
route_node_resolver: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
route_nodes: list[dict[str, Any]] | None = None) -> None:
with self._lock:
if self.status()["running"]:
raise TunBackendError("TUN 已经在运行")
if not self.is_admin():
raise TunBackendError("启动虚拟网卡 TUN 需要管理员权限")
self._bypass_lan = bool(bypass_lan)
if dns_servers is not None:
cleaned = tuple(str(item).strip() for item in dns_servers if str(item).strip())
if not cleaned:
raise TunBackendError("自定义 DNS 不能为空")
try:
for item in cleaned:
socket.inet_pton(socket.AF_INET, item)
except OSError as exc:
raise TunBackendError("当前 TUN 仅支持 IPv4 DNS 服务器") from exc
self._tun_dns_servers = cleaned[:3]
engine = self.resolve_engine()
interface = self._physical_interface()
source_address = self._physical_source_address(interface)
self._physical_gateway, self._physical_ifindex = self._physical_route_info()
self._physical_ipv6_gateway, self._physical_ipv6_ifindex = self._physical_ipv6_route_info()
if not self._physical_ifindex:
raise TunBackendError("找不到物理默认路由,无法保证代理节点不进入 TUN 回环")
if not self._physical_ipv6_ifindex:
self.log("警告:未找到物理 IPv6 默认路由,IPv6 目标将仅尝试通过 IPv4 代理节点转发")
try:
proxy_addresses_by_node: dict[str, list[tuple]] = {}
connection_nodes = [node]
connection_nodes.extend(
(item.get("node") if isinstance(item.get("node"), dict) else item)
for item in (route_nodes or ())
if isinstance(item, dict)
)
for connection_node in connection_nodes:
for hop in self._node_chain(connection_node):
try:
addresses = self._resolve_proxy_addresses(hop)
except OSError as exc:
raise TunBackendError(f"无法解析 HTTP CONNECT 节点地址: {exc}") from exc
if not addresses:
raise TunBackendError("无法解析 HTTP CONNECT 节点地址")
self._add_node_routes(addresses)
proxy_addresses_by_node[self._node_key(hop)] = addresses
first_hop = self._node_chain(node)[0]
proxy_addresses = proxy_addresses_by_node.get(self._node_key(first_hop), [])
if not proxy_addresses:
raise TunBackendError("无法解析 HTTP CONNECT 节点地址")
for route_node in route_nodes or ():
if not isinstance(route_node, dict):
continue
route_value = route_node.get("node") if isinstance(route_node.get("node"), dict) else route_node
for hop in self._node_chain(route_value):
addresses = self._resolve_proxy_addresses(hop)
proxy_addresses_by_node[self._node_key(hop)] = addresses
if not source_address:
self.log("警告:未找到物理网卡 IPv4,节点连接可能无法绕过 TUN")
bridge = Socks5Bridge(
node, bypass_lan=bypass_lan, route_rules=route_rules, log=self.detail_log,
source_address=source_address or None,
proxy_addresses=proxy_addresses,
direct_target_resolver=direct_target_resolver,
route_rule_resolver=route_rule_resolver,
route_node_resolver=route_node_resolver,
proxy_addresses_by_node=proxy_addresses_by_node,
direct_route_ensurer=self._ensure_physical_route,
)
socks_port = bridge.start()
except Exception:
self._remove_routes()
raise
self._bridge = bridge
command = [str(engine), "--device", self._device,
"--proxy", f"socks5://127.0.0.1:{socks_port}"]
# tun2socks 的 Windows socket 选项会把 UDP/TCP socket 绑定到
# --interface 指定的网卡;本项目的 SOCKS relay 在本机回环地址,
# 绑定 WLAN 会让 UDP 回环投递失效。Windows 官方用法也不传该参数。
# 节点连接已经通过物理 /32 路由和 source_address 绕过 TUN,
# 因此 Windows 不需要 --interface;Linux/macOS 仍保留原行为。
if interface and os.name != "nt":
command.extend(["--interface", interface])
self.log(f"本地 SOCKS5 桥接: 127.0.0.1:{socks_port}")
try:
self._process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
threading.Thread(target=self._read_output, args=(self._process,), daemon=True).start()
time.sleep(0.5)
if self._process.poll() is not None:
raise TunBackendError(f"tun2socks 启动失败,退出码 {self._process.returncode}")
self._configure_routes()
self._write_state()
self.log(f"虚拟网卡 TUN 已启动,进程 PID={self._process.pid}")
except Exception:
bridge.stop()
self._remove_routes()
self._bridge = None
process = self._process
self._process = None
if process is not None and process.poll() is None:
process.terminate()
raise
def stop(self) -> None:
with self._lock:
process = self._process
bridge = self._bridge
self._process = None
self._bridge = None
self._remove_routes()
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
if bridge is not None:
bridge.stop()
if process is not None:
self.log("虚拟网卡 TUN 已停止")
def status(self) -> dict[str, Any]:
with self._lock:
process = self._process
if process is None:
return {"running": False, "pid": None, "exit_code": None}
code = process.poll()
if code is not None:
self._process = None
self._remove_routes()
bridge = self._bridge
self._bridge = None
if bridge is not None:
bridge.stop()
return {"running": False, "pid": process.pid, "exit_code": code}
return {"running": True, "pid": process.pid, "exit_code": None}
def traffic(self) -> tuple[int, int, int, int]:
if self._bridge is None:
return 0, 0, 0, 0
return self._bridge.traffic()
+232
View File
@@ -0,0 +1,232 @@
"""按需提权的 TUN 控制通道;GUI 与管理员工作进程共用同一个入口。"""
from __future__ import annotations
import ctypes
import hmac
import json
import os
import secrets
import socket
import subprocess
import threading
import time
from typing import Any, Callable
WORKER_FLAG = "--rrpp-tun-worker"
_MAX_MESSAGE = 1024 * 1024
class TunModeError(RuntimeError):
pass
def _send(sock: socket.socket, value: dict[str, Any], lock: threading.Lock | None = None) -> None:
payload = (json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
if len(payload) > _MAX_MESSAGE:
raise TunModeError("TUN 控制消息过大")
if lock is None:
sock.sendall(payload)
else:
with lock:
sock.sendall(payload)
def _receive(reader) -> dict[str, Any]:
line = reader.readline(_MAX_MESSAGE + 1)
if not line or len(line) > _MAX_MESSAGE or not line.endswith(b"\n"):
raise TunModeError("TUN 工作进程连接已断开或消息无效")
value = json.loads(line.decode("utf-8"))
if not isinstance(value, dict):
raise TunModeError("TUN 控制消息格式错误")
return value
class TunModeController:
"""普通权限 GUI 仅在首次开启 TUN 时启动管理员工作进程。"""
def __init__(self, command: list[str], log: Callable[[str], None]):
self.command = command
self.log = log
self._lock = threading.RLock()
self._socket: socket.socket | None = None
self._reader = None
def _launch(self, port: int, token: str) -> None:
args = [*self.command[1:], WORKER_FLAG, str(port), token]
if os.name == "nt":
# PyInstaller onefile 的提权子进程必须独立解包,以便 GUI 退出后仍可清理网络。
previous = os.environ.get("PYINSTALLER_RESET_ENVIRONMENT")
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
try:
result = ctypes.windll.shell32.ShellExecuteW(
None, "runas", self.command[0], subprocess.list2cmdline(args),
os.path.dirname(os.path.abspath(self.command[0])), 0,
)
finally:
if previous is None:
os.environ.pop("PYINSTALLER_RESET_ENVIRONMENT", None)
else:
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = previous
if result <= 32:
raise TunModeError("管理员授权已取消或无法启动 TUN 工作进程")
else:
# 非 Windows 仅供无网卡的协议测试;真实 TUN 后端仍限定 Windows。
subprocess.Popen([self.command[0], *args], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
def start(self, payload: dict[str, Any], cancel_event=None) -> None:
with self._lock:
if self._socket is not None:
raise TunModeError("TUN 工作进程已经启动")
token = secrets.token_hex(32)
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(2)
deadline = time.monotonic() + 90
try:
self._launch(listener.getsockname()[1], token)
while True:
if cancel_event is not None and cancel_event.is_set():
raise TunModeError("用户取消了开启代理")
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TunModeError("等待 TUN 管理员工作进程超时")
listener.settimeout(min(0.5, remaining))
try:
connection, _peer = listener.accept()
except socket.timeout:
continue
connection.settimeout(3)
reader = connection.makefile("rb")
try:
hello = _receive(reader)
valid = (hello.get("type") == "hello" and
hmac.compare_digest(str(hello.get("token") or ""), token))
except Exception:
valid = False
if valid:
connection.settimeout(120)
self._socket, self._reader = connection, reader
break
reader.close()
connection.close()
if cancel_event is not None and cancel_event.is_set():
raise TunModeError("用户取消了开启代理")
self._request("start", payload)
except Exception:
self.close()
raise
finally:
listener.close()
def _request(self, command: str, payload: dict[str, Any] | None = None) -> Any:
if self._socket is None or self._reader is None:
raise TunModeError("TUN 工作进程未连接")
_send(self._socket, {"command": command, "payload": payload or {}})
while True:
message = _receive(self._reader)
if message.get("type") == "log":
self.log(str(message.get("message") or ""))
continue
if message.get("type") != "result":
raise TunModeError("TUN 工作进程响应无效")
if not message.get("ok"):
raise TunModeError(str(message.get("error") or "TUN 操作失败"))
return message.get("value")
def status(self) -> dict[str, Any]:
with self._lock:
if self._socket is None:
return {"running": False, "pid": None, "exit_code": None}
try:
return self._request("status")
except (OSError, TunModeError, ValueError):
self.close()
return {"running": False, "pid": None, "exit_code": None}
def traffic(self) -> tuple[int, int, int, int]:
with self._lock:
if self._socket is None:
return 0, 0, 0, 0
try:
return tuple(self._request("traffic"))
except (OSError, TunModeError, ValueError):
return 0, 0, 0, 0
def stop(self) -> None:
with self._lock:
try:
if self._socket is not None:
self._request("stop")
finally:
self.close()
def close(self) -> None:
with self._lock:
if self._socket is not None:
try:
self._request("quit")
except (OSError, TunModeError, ValueError):
pass
reader, connection = self._reader, self._socket
self._reader = self._socket = None
if reader is not None:
reader.close()
if connection is not None:
connection.close()
def run_tun_worker(port: int, token: str, backend_factory,
start_backend: Callable[[Any, dict[str, Any]], None]) -> None:
"""管理员进程入口。主 GUI 断开后,必定清理本进程建立的网络状态。"""
connection = socket.create_connection(("127.0.0.1", int(port)), timeout=15)
connection.settimeout(None)
reader = connection.makefile("rb")
send_lock = threading.Lock()
_send(connection, {"type": "hello", "token": token}, send_lock)
def emit(message: str) -> None:
try:
_send(connection, {"type": "log", "message": str(message)}, send_lock)
except OSError:
pass
backend = None
try:
backend = backend_factory(emit)
while True:
try:
request = _receive(reader)
except TunModeError:
break
command = request.get("command")
try:
if command == "start":
if not isinstance(request.get("payload"), dict):
raise TunModeError("TUN 启动参数无效")
start_backend(backend, request["payload"])
value = None
elif command == "stop":
backend.stop()
value = None
elif command == "status":
value = backend.status()
elif command == "traffic":
value = backend.traffic()
elif command == "quit":
backend.stop()
value = None
else:
raise TunModeError("未知的 TUN 控制命令")
_send(connection, {"type": "result", "ok": True, "value": value}, send_lock)
except Exception as exc:
_send(connection, {"type": "result", "ok": False, "error": str(exc)}, send_lock)
if command == "quit":
break
finally:
if backend is not None:
backend.stop()
reader.close()
connection.close()
+120
View File
@@ -0,0 +1,120 @@
"""SagerNet UDP over TCP v2 帧编解码。
UoT 只负责把 UDP 数据报装入已有的可靠 TCP 字节流;外层连接仍由
HTTP CONNECT + TLS 建立。此模块不依赖 sing-box。
"""
from __future__ import annotations
import ipaddress
import socket
import struct
from dataclasses import dataclass
MAGIC_ADDRESS = "sp.v2.udp-over-tcp.arpa"
MAX_DATAGRAM = 65535
@dataclass(frozen=True)
class Address:
host: str
port: int
def encode_address(host: str, port: int) -> bytes:
host = str(host).strip()
if not 0 <= int(port) <= 65535:
raise ValueError("端口超出范围")
try:
addr = ipaddress.ip_address(host)
except ValueError:
raw = host.encode("idna")
if not 1 <= len(raw) <= 255:
raise ValueError("域名长度超出 UoT 限制")
return b"\x02" + bytes([len(raw)]) + raw + struct.pack("!H", int(port))
if addr.version == 4:
return b"\x00" + addr.packed + struct.pack("!H", int(port))
return b"\x01" + addr.packed + struct.pack("!H", int(port))
def decode_address(data: bytes, offset: int = 0) -> tuple[Address, int]:
if offset >= len(data):
raise ValueError("UoT 地址头不完整")
atyp = data[offset]
offset += 1
if atyp == 0:
size = 4
elif atyp == 1:
size = 16
elif atyp == 2:
if offset >= len(data):
raise ValueError("UoT 域名长度缺失")
size = data[offset]
offset += 1
else:
raise ValueError(f"未知 UoT 地址类型: {atyp}")
end = offset + size
if end + 2 > len(data):
raise ValueError("UoT 地址头不完整")
raw = data[offset:end]
port = struct.unpack("!H", data[end:end + 2])[0]
if atyp == 0:
host = str(ipaddress.IPv4Address(raw))
elif atyp == 1:
host = str(ipaddress.IPv6Address(raw))
else:
host = raw.decode("idna")
return Address(host, port), end + 2
def encode_request(is_connect: bool, destination: Address) -> bytes:
return (b"\x01" if is_connect else b"\x00") + encode_address(
destination.host, destination.port
)
def encode_connect_frame(payload: bytes) -> bytes:
if len(payload) > MAX_DATAGRAM:
raise ValueError("UoT 数据报超过 65535 字节")
return struct.pack("!H", len(payload)) + payload
def encode_datagram_frame(destination: Address, payload: bytes) -> bytes:
if len(payload) > MAX_DATAGRAM:
raise ValueError("UoT 数据报超过 65535 字节")
return encode_address(destination.host, destination.port) + struct.pack("!H", len(payload)) + payload
def read_exact(stream, size: int) -> bytes:
chunks = []
remaining = size
while remaining:
chunk = stream.recv(remaining)
if not chunk:
raise ConnectionError("UoT TCP 流已关闭")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def read_connect_frame(stream) -> bytes:
size = struct.unpack("!H", read_exact(stream, 2))[0]
return read_exact(stream, size)
def read_datagram_frame(stream) -> tuple[Address, bytes]:
# 地址是变长的,只能先读 ATYP,再按类型补齐。
head = read_exact(stream, 1)
atyp = head[0]
if atyp == 0:
rest = read_exact(stream, 6)
elif atyp == 1:
rest = read_exact(stream, 18)
elif atyp == 2:
length = read_exact(stream, 1)
rest = length + read_exact(stream, length[0] + 2)
else:
raise ValueError(f"未知 UoT 地址类型: {atyp}")
address, _ = decode_address(head + rest)
size = struct.unpack("!H", read_exact(stream, 2))[0]
return address, read_exact(stream, size)
+65 -27
View File
@@ -931,14 +931,16 @@ class SettingsServices:
load_icon: object load_icon: object
version: str version: str
get_port: object get_port: object
get_builtin_pac: object get_system_pac: object
get_dns: 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_node_display_mode: object
get_close_behavior: object get_close_behavior: object
parse_port: object parse_port: object
set_port: object set_port: object
set_builtin_pac: object set_system_pac: object
set_dns: 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_node_display_mode: object
@@ -961,7 +963,7 @@ class SettingsDialog(QDialog):
self._services = services self._services = services
self.setWindowTitle("设置") self.setWindowTitle("设置")
self.setWindowIcon(services.load_icon()) self.setWindowIcon(services.load_icon())
self.setFixedSize(300, 470) 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; }}
@@ -999,6 +1001,23 @@ class SettingsDialog(QDialog):
layout.addWidget(self.lb_hint) layout.addWidget(self.lb_hint)
layout.addSpacing(2) 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""" compact_button_style = f"""
QPushButton {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER}; 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; border-radius: 5px; padding: 1px 7px; min-width: 34px; min-height: 18px;
@@ -1019,7 +1038,7 @@ class SettingsDialog(QDialog):
self.btn_whitelist.setStyleSheet(compact_button_style) self.btn_whitelist.setStyleSheet(compact_button_style)
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("代理跳板")
@@ -1032,7 +1051,7 @@ class SettingsDialog(QDialog):
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("第三方订阅")
@@ -1045,7 +1064,7 @@ class SettingsDialog(QDialog):
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_row = QHBoxLayout()
log_label = QLabel("日志") log_label = QLabel("日志")
@@ -1067,22 +1086,33 @@ 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)
builtin_pac_row = QHBoxLayout() system_pac_row = QHBoxLayout()
builtin_pac_label = QLabel("使用内置 PAC") builtin_pac_label = QLabel("使用系统 PAC (系统代理)")
builtin_pac_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") builtin_pac_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
builtin_pac_label.setToolTip( builtin_pac_label.setToolTip(
"系统使用固定的本地代理端口;转发器内部仍按 PAC 规则智能判断直连或代理" "仅系统代理模式使用;关闭时使用内置 PAC 智能分流"
) )
builtin_pac_row.addWidget(builtin_pac_label) system_pac_row.addWidget(builtin_pac_label)
builtin_pac_row.addStretch() system_pac_row.addStretch()
self.cb_builtin_pac = ToggleSwitch(checked=services.get_builtin_pac()) self.cb_system_pac = ToggleSwitch(checked=services.get_system_pac())
self.cb_builtin_pac.setToolTip( self.cb_system_pac.setToolTip("系统代理且已选 PAC 规则模式时,向 Windows 写入本地 PAC 地址;TUN 模式忽略此项")
"系统固定指向本地代理端口,转发器内部保留 PAC 智能分流" system_pac_row.addWidget(self.cb_system_pac)
) left_col.addLayout(system_pac_row)
builtin_pac_row.addWidget(self.cb_builtin_pac)
layout.addLayout(builtin_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)
update_row = QHBoxLayout() update_row = QHBoxLayout()
update_label = QLabel("使用代理更新") update_label = QLabel("使用代理更新")
@@ -1092,7 +1122,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("跳过检查更新")
@@ -1102,7 +1132,7 @@ 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)
node_display_row = QHBoxLayout() node_display_row = QHBoxLayout()
node_display_label = QLabel("节点信息显示") node_display_label = QLabel("节点信息显示")
@@ -1125,7 +1155,7 @@ class SettingsDialog(QDialog):
self.btn_node_remark.setChecked(True) self.btn_node_remark.setChecked(True)
else: else:
self.btn_node_domain.setChecked(True) self.btn_node_domain.setChecked(True)
layout.addLayout(node_display_row) right_col.addLayout(node_display_row)
close_behavior_row = QHBoxLayout() close_behavior_row = QHBoxLayout()
close_behavior_label = QLabel("默认关闭行为") close_behavior_label = QLabel("默认关闭行为")
@@ -1148,7 +1178,7 @@ class SettingsDialog(QDialog):
self.btn_close_tray.setChecked(True) self.btn_close_tray.setChecked(True)
else: else:
self.btn_close_exit.setChecked(True) self.btn_close_exit.setChecked(True)
layout.addLayout(close_behavior_row) right_col.addLayout(close_behavior_row)
reset_row = QHBoxLayout() reset_row = QHBoxLayout()
reset_label = QLabel("重置用户配置") reset_label = QLabel("重置用户配置")
@@ -1162,10 +1192,11 @@ class SettingsDialog(QDialog):
self.btn_reset.setEnabled(not port_locked) self.btn_reset.setEnabled(not port_locked)
self.btn_reset.clicked.connect(self._reset_user_config) self.btn_reset.clicked.connect(self._reset_user_config)
reset_row.addWidget(self.btn_reset) reset_row.addWidget(self.btn_reset)
layout.addLayout(reset_row) right_col.addLayout(reset_row)
layout.addLayout(log_row) right_col.addLayout(log_row)
layout.addStretch() left_col.addStretch()
right_col.addStretch()
buttons = QHBoxLayout() buttons = QHBoxLayout()
buttons.setSpacing(10) buttons.setSpacing(10)
@@ -1233,7 +1264,8 @@ class SettingsDialog(QDialog):
return return
self._services.reset_user_config() self._services.reset_user_config()
self.ed_port.setText(str(self._services.get_port())) self.ed_port.setText(str(self._services.get_port()))
self.cb_builtin_pac.setChecked(self._services.get_builtin_pac()) self.cb_system_pac.setChecked(self._services.get_system_pac())
self.ed_dns.setText(", ".join(self._services.get_dns()))
self.cb_update_proxy.setChecked(self._services.get_update_via_proxy()) self.cb_update_proxy.setChecked(self._services.get_update_via_proxy())
self.cb_skip_update.setChecked(self._services.get_skip_update_check()) self.cb_skip_update.setChecked(self._services.get_skip_update_check())
self.btn_node_domain.setChecked(True) self.btn_node_domain.setChecked(True)
@@ -1247,8 +1279,14 @@ class SettingsDialog(QDialog):
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_builtin_pac(bool(self.cb_builtin_pac.isChecked())) self._services.set_system_pac(bool(self.cb_system_pac.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( self._services.set_node_display_mode(
+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.
+23
View File
@@ -265,6 +265,29 @@ 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)支持 ### 5.5 UoT(UDP over TCP)支持
+5 -11
View File
@@ -16,18 +16,12 @@ 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 中命中;当前模块版本没有
# UoT v2(UDP over TCP):CONNECT 目标为 sing-box UoT v2 magic 地址 # $connect_host,使用原始请求行匹配。客户端请求 magic-host:443,nginx
# (sp.v2.udp-over-tcp.arpa)时转发到本机 UoT relay(127.0.0.1:15201), # 将字节流转到本机裸 relay 127.0.0.1:15201。
# 其余 CONNECT 走默认解析($connect_addr)。
# 注意(实测结论,勿踩坑):
# 1. 必须用 $request 匹配——此模块版本未注册 $connect_host 变量;
# 2. proxy_connect_address 必须放在 default_server 块——nginx 按 Host 头
# 选虚拟主机,CONNECT 的 Host 是目标域名,永远落在 default_server;
# 3. 客户端 CONNECT magic 地址时端口必须非 0(模块拒绝 port<1,返回 400)。
map $request $rrpp_uot_connect_address { map $request $rrpp_uot_connect_address {
default $connect_addr; default $connect_addr;
"~^CONNECT sp\.v2\.udp-over-tcp\.arpa" 127.0.0.1:15201; "~^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;
@@ -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
+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()