Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50364dc17a | ||
|
|
3e29012d9c | ||
|
|
e4d57bc90d | ||
|
|
fd433fc43e | ||
|
|
a054a547e5 | ||
|
|
2e56f82e7b | ||
|
|
e16ef58182 | ||
|
|
f4d777113c | ||
|
|
26926f6469 |
@@ -1,9 +1,9 @@
|
||||
# Royirin Rosmontis Private Proxy
|
||||
Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu
|
||||
## 警告
|
||||
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本
|
||||
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
|
||||
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本。common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
|
||||
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows 应用 (.exe)
|
||||
- 因为 Windows 系统的安全机制,Release 发布的所有程序都为 Pyinstaller 打包版本,故启动速度可能较慢,尤其是首次启动。相对而言,Nuitka 打包的版本启动较快,但可能仅能在自己电脑上运行。如果需要 Nuitka 版本,仓库提供了对应的打包程序,请自备虚拟环境
|
||||
|
||||
### 如何区分 common 版本与 Lite 版本
|
||||
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳
|
||||
|
||||
+7
-2
@@ -9,6 +9,11 @@ qt_datas, qt_binaries, qt_hiddenimports = collect_all('PyQt5')
|
||||
# 只内置 config.json(普通设置)。nodes.json / pac_*.json 属用户数据:
|
||||
# 运行时读 %LOCALAPPDATA%\RRPP(首启自动创建模板 / 从 CDN 拉取),不进 EXE。
|
||||
_JSON_DATAS = [(str(Path(SPECPATH) / 'config.json'), '.')]
|
||||
_TUN_RUNTIME = [
|
||||
(str(Path(SPECPATH) / 'runtime' / name), '.')
|
||||
for name in ('tun2socks.exe', 'wintun.dll', 'LICENSE.tun2socks.txt',
|
||||
'LICENSE.wintun.txt', 'README.txt')
|
||||
]
|
||||
print(f"[spec] 打包 json 数据文件: {[Path(src).name for src, _ in _JSON_DATAS]}")
|
||||
|
||||
# 自定义 manifest:普通权限运行 + 声明 PerMonitorV2 DPI 感知,
|
||||
@@ -46,9 +51,9 @@ _MANIFEST = r'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=['..\\.venv\\Lib\\site-packages'],
|
||||
pathex=[],
|
||||
binaries=qt_binaries,
|
||||
datas=[('ico.ico', '.')] + _JSON_DATAS + qt_datas,
|
||||
datas=[('ico.ico', '.')] + _JSON_DATAS + _TUN_RUNTIME + qt_datas,
|
||||
hiddenimports=qt_hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
|
||||
@@ -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 ^
|
||||
--standalone --onefile ^
|
||||
--enable-plugin=pyqt5 ^
|
||||
--user-package-configuration-file=rrpp-tun.nuitka-package.config.yml ^
|
||||
--include-data-files=config.json=config.json ^
|
||||
--include-data-files=runtime/LICENSE.tun2socks.txt=LICENSE.tun2socks.txt ^
|
||||
--include-data-files=runtime/LICENSE.wintun.txt=LICENSE.wintun.txt ^
|
||||
--include-data-files=runtime/README.txt=README.txt ^
|
||||
--windows-icon-from-ico=ico.ico ^
|
||||
--windows-console-mode=disable ^
|
||||
--company-name="Global Rosmanka Cloud Nexus Inc" ^
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "999909182030",
|
||||
"version": "999909190333",
|
||||
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
|
||||
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client",
|
||||
"update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
|
||||
|
||||
+402
-196
@@ -1,19 +1,19 @@
|
||||
# -*- 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 pathlib import Path
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QWidget, QLabel, QPushButton,
|
||||
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
|
||||
QCheckBox, QMenu, QDialog, QProgressDialog,
|
||||
QCheckBox, QMenu, QDialog, QProgressDialog, QAction, QSystemTrayIcon,
|
||||
)
|
||||
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
|
||||
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.widgets import NodeLabel, StatusDot, ToggleSwitch
|
||||
from rrpp_ui.widgets import NodeLabel, StatusDot, ToggleSwitch, ProxyModeCard
|
||||
from rrpp_ui.dialogs import (
|
||||
LoginDialog, LoginServices, SettingsDialog, SettingsServices, LogDialog, LogServices,
|
||||
ThirdPartyDialog, ThirdPartyServices,
|
||||
@@ -58,6 +58,9 @@ from rrpp_core.network_primitives import (
|
||||
resolve_host_ips as _resolve_host_ips,
|
||||
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 高分屏缩放后,
|
||||
# Qt 会自动按 devicePixelRatio 放大/缩小,因此代码里保持逻辑尺寸不变即可。
|
||||
@@ -816,7 +819,9 @@ def _endpoint_label():
|
||||
domain = _endpoint_domain()
|
||||
if domain == "???":
|
||||
return "无可用节点(请登录)"
|
||||
return f"{domain} ({_endpoint_country()})"
|
||||
display = _node_display_mode()
|
||||
name = str(node.get("remark") or "").strip() if display == "remark" else ""
|
||||
return f"{name or domain} ({_endpoint_country()})"
|
||||
|
||||
def _proxy_auth_basic(node=None):
|
||||
"""正向代理 Basic 认证凭证:methods.http.{username,password}(新格式)
|
||||
@@ -1002,114 +1007,15 @@ def _run(cmd, warn=True):
|
||||
log(f"[警告] {' '.join(str(c) for c in cmd)} -> {r.returncode} {err}")
|
||||
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):
|
||||
"""设置 Windows 系统代理。pac_url 不为空时启用 PAC 模式,否则使用固定代理。"""
|
||||
_backup_proxy_state() # 覆盖前保存用户原有代理设置,关闭时恢复而不是清空
|
||||
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()
|
||||
"""开启系统代理模式,复用主程序的本地转发器。"""
|
||||
_SYSTEM_PROXY_MODE.enable(pac_url=pac_url, bypass_lan=bypass_lan)
|
||||
|
||||
def proxy_off():
|
||||
"""关闭系统代理并停止 TLS 转发器 / PAC 本地服务。"""
|
||||
log("正在关闭系统代理...")
|
||||
_restore_proxy_state() # 恢复用户原有代理设置,而不是简单清空
|
||||
_SYSTEM_PROXY_MODE.disable()
|
||||
log("系统代理已关闭")
|
||||
_notify_proxy_change()
|
||||
_tls_stop()
|
||||
_stop_pac_server()
|
||||
# 打印本次转发流量统计
|
||||
@@ -1342,6 +1248,13 @@ def _settings_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):
|
||||
"""解析端口号;非法(非数字或超出 1-65535)返回 None。"""
|
||||
try:
|
||||
@@ -1368,23 +1281,65 @@ def _set_local_port(port):
|
||||
_settings_save(data)
|
||||
log(f"代理转发端口已保存: {port}(下次开启代理生效)")
|
||||
|
||||
def _use_builtin_pac():
|
||||
"""是否使用固定本地代理承载系统流量、由转发器内部执行 PAC 判断。"""
|
||||
return bool(_settings_load().get("builtin_pac", False))
|
||||
_DEFAULT_TUN_DNS = ("8.8.8.8", "1.1.1.1")
|
||||
|
||||
def _set_builtin_pac(enabled):
|
||||
"""保存内置 PAC 偏好,下一次开启代理时生效。"""
|
||||
def _parse_dns_servers(value):
|
||||
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["builtin_pac"] = bool(enabled)
|
||||
data["tun_dns"] = list(parsed)
|
||||
_settings_save(data)
|
||||
|
||||
def _should_use_system_pac(use_pac, use_builtin_pac):
|
||||
"""决定系统侧是否下发 PAC;内置 PAC 只保留转发器内部智能分流。"""
|
||||
return bool(use_pac and not use_builtin_pac)
|
||||
def _use_system_pac():
|
||||
"""只决定 Windows 系统代理是否使用 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):
|
||||
"""决定转发器内部是否启用 PAC 智能直连/代理判断。"""
|
||||
return bool(use_pac or use_builtin_pac)
|
||||
def _set_use_system_pac(enabled):
|
||||
data = _settings_load()
|
||||
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():
|
||||
"""更新检查/下载是否走节点隧道(CONNECT);设置项,默认关=直连仓库。"""
|
||||
@@ -1433,6 +1388,47 @@ def _set_bypass_lan(enabled):
|
||||
data["bypass_lan"] = bool(enabled)
|
||||
_settings_save(data)
|
||||
|
||||
|
||||
def _node_display_mode():
|
||||
"""主页面节点信息显示方式:domain 或 remark。"""
|
||||
return "remark" if _settings_load().get("node_display") == "remark" else "domain"
|
||||
|
||||
|
||||
def _set_node_display_mode(mode):
|
||||
"""保存主页面节点信息显示方式。"""
|
||||
data = _settings_load()
|
||||
data["node_display"] = "remark" if mode == "remark" else "domain"
|
||||
_settings_save(data)
|
||||
|
||||
|
||||
def _close_behavior():
|
||||
"""窗口关闭行为:exit(退出)或 tray(最小化到托盘)。"""
|
||||
return "tray" if _settings_load().get("close_behavior") == "tray" else "exit"
|
||||
|
||||
|
||||
def _set_close_behavior(mode):
|
||||
"""保存窗口关闭行为。"""
|
||||
data = _settings_load()
|
||||
data["close_behavior"] = "tray" if mode == "tray" else "exit"
|
||||
_settings_save(data)
|
||||
|
||||
|
||||
def _reset_user_config():
|
||||
"""重置 settings.json;保留登录、节点缓存、第三方订阅和 PAC 数据。"""
|
||||
global _WHITELIST, _ROUTE_RULES, _WHITELIST_VERSION, _NODES, _SELECTED_NODE
|
||||
_settings_save({})
|
||||
_WHITELIST = None
|
||||
_ROUTE_RULES = None
|
||||
_WHITELIST_VERSION += 1
|
||||
_is_whitelisted.cache_clear()
|
||||
_NODES = None
|
||||
_SELECTED_NODE = 0
|
||||
default_port = _port_val()
|
||||
global _TLS_LOCAL_PORT, PROXY_PORT
|
||||
_TLS_LOCAL_PORT = default_port
|
||||
PROXY_PORT = default_port
|
||||
log("已重置用户配置:本地偏好、域名路由、跳板和当前节点选择已恢复默认")
|
||||
|
||||
# ── 域名路由(兼容旧代理白名单,settings.json 的 whitelist 键) ────────
|
||||
# 每条规则 {"value": 域名/IP/CIDR, "enabled": 是否启用,
|
||||
# "action": "direct" | "node", "node_ref": 节点稳定标识}。
|
||||
@@ -1445,10 +1441,16 @@ _ROUTE_RULES = None # 全量域名路由规则(含 direct/node)
|
||||
|
||||
|
||||
def _route_domain_normalize(entry: str) -> str:
|
||||
"""规范化域名规则,保留 ``*.`` 通配符,兼容旧白名单输入。"""
|
||||
"""规范化域名或 IP/CIDR 规则,保留旧白名单的网段语义。"""
|
||||
s = str(entry or "").strip().lower()
|
||||
if "://" in s:
|
||||
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(".")
|
||||
wildcard = s.startswith("*.")
|
||||
if wildcard:
|
||||
@@ -1506,10 +1508,10 @@ def _route_rules():
|
||||
return _ROUTE_RULES
|
||||
|
||||
|
||||
def _route_rule_for_host(host: str):
|
||||
def _route_rule_for_host(host: str, rules=None):
|
||||
"""返回目标域名命中的最高优先级规则;规则变更对新连接立即生效。"""
|
||||
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", "")):
|
||||
continue
|
||||
domain = rule["domain"]
|
||||
@@ -1628,8 +1630,14 @@ def _whitelist_validate_entry(entry: str):
|
||||
|
||||
|
||||
def _fixed_route_validate_domain(entry: str):
|
||||
"""校验域名路由规则;支持 ``example.com`` 与 ``*.example.com``。"""
|
||||
"""校验域名路由规则;兼容旧白名单的 IP/CIDR。"""
|
||||
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
|
||||
if not base:
|
||||
return "", "域名不能为空"
|
||||
@@ -2499,12 +2507,8 @@ def _release_forwarder(started_here):
|
||||
_tls_stop()
|
||||
|
||||
def is_proxy_on():
|
||||
"""系统代理是否生效:固定代理已开启,或 AutoConfigURL 指向本地 PAC 服务。"""
|
||||
v = _reg_query_value("ProxyEnable")
|
||||
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 "")
|
||||
"""仅报告本程序的系统代理,不把其它软件的代理当成 RRPP 状态。"""
|
||||
return _SYSTEM_PROXY_MODE.is_on()
|
||||
|
||||
def _cleanup_stale_proxy():
|
||||
"""清理上次异常退出残留的系统代理设置,并恢复用户原有设置。
|
||||
@@ -2515,11 +2519,7 @@ def _cleanup_stale_proxy():
|
||||
不能因为地址里带 127.0.0.1 就误删。
|
||||
"""
|
||||
try:
|
||||
server = _reg_query_value("ProxyServer")
|
||||
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:
|
||||
if not _SYSTEM_PROXY_MODE.is_stale():
|
||||
return
|
||||
proxy_off() # 内部从备份恢复用户原有设置(无备份则清空)
|
||||
except Exception:
|
||||
@@ -2630,14 +2630,21 @@ def _settings_services():
|
||||
load_icon=load_icon,
|
||||
version=VERSION,
|
||||
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_skip_update_check=_skip_update_check,
|
||||
get_node_display_mode=_node_display_mode,
|
||||
get_close_behavior=_close_behavior,
|
||||
parse_port=_parse_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_skip_update_check=_set_skip_update_check,
|
||||
set_node_display_mode=_set_node_display_mode,
|
||||
set_close_behavior=_set_close_behavior,
|
||||
reset_user_config=_reset_user_config,
|
||||
open_whitelist=lambda parent: FixedRouteDialog(_fixed_route_services(), parent).exec_(),
|
||||
open_jump=lambda parent: JumpDialog(_jump_services(), parent).exec_(),
|
||||
open_third_party=lambda parent: ThirdPartyDialog(_third_party_services(), parent).exec_(),
|
||||
@@ -2657,6 +2664,94 @@ def _jump_services():
|
||||
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),
|
||||
}
|
||||
|
||||
# ── 代理白名单弹窗 ─────────────────────────────────────
|
||||
|
||||
# ── 主窗口 ────────────────────────────────────────────
|
||||
@@ -2674,6 +2769,11 @@ class MainWindow(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的)
|
||||
self._mode = _proxy_mode()
|
||||
self._tun_controller = TunModeController(_tun_worker_command(), log)
|
||||
self._quitting = False
|
||||
self._tray = None
|
||||
self._tray_menu = None
|
||||
# 启动完成前仍允许用户取消。事件由 GUI 线程置位,后台启动线程只读取;
|
||||
# 锁保护系统代理写入和取消清理之间的交接,避免遗留代理。
|
||||
self._start_cancel_event = None
|
||||
@@ -2695,6 +2795,7 @@ class MainWindow(QWidget):
|
||||
_user_init_local() # 首启初始化用户数据目录(节点模板),须在 init_ui 读节点之前
|
||||
|
||||
self.init_ui()
|
||||
self._setup_tray()
|
||||
|
||||
# 每 2 秒刷新一次状态指示灯
|
||||
self.timer = QTimer()
|
||||
@@ -2714,6 +2815,82 @@ class MainWindow(QWidget):
|
||||
# 节点未配置时延迟弹出登录框(等窗口显示后)
|
||||
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):
|
||||
"""创建托盘图标和动态状态菜单;系统不支持托盘时保留窗口模式。"""
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
log("当前系统不支持托盘图标,关闭窗口时只能退出程序")
|
||||
return
|
||||
self._tray = QSystemTrayIcon(load_icon(), self)
|
||||
self._tray.setToolTip("Royirin Rosmontis Private Proxy")
|
||||
self._tray_menu = QMenu(self)
|
||||
self._tray_menu.aboutToShow.connect(self._update_tray_menu)
|
||||
self._tray.setContextMenu(self._tray_menu)
|
||||
self._tray.activated.connect(self._on_tray_activated)
|
||||
self._tray.show()
|
||||
self._update_tray_menu()
|
||||
|
||||
def _on_tray_activated(self, reason):
|
||||
"""双击托盘图标显示主页面。"""
|
||||
if reason == QSystemTrayIcon.DoubleClick:
|
||||
self._show_main_window()
|
||||
|
||||
def _show_main_window(self):
|
||||
"""从托盘恢复主页面并置于前台。"""
|
||||
self.show()
|
||||
self.showNormal()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
|
||||
def _update_tray_menu(self):
|
||||
"""刷新托盘菜单中的三行状态信息。"""
|
||||
if self._tray_menu is None:
|
||||
return
|
||||
self._tray_menu.clear()
|
||||
|
||||
show_action = QAction("显示主页面", self._tray_menu)
|
||||
show_action.triggered.connect(self._show_main_window)
|
||||
self._tray_menu.addAction(show_action)
|
||||
self._tray_menu.addSeparator()
|
||||
|
||||
proxy_on = is_proxy_on() if self._mode == "system" else self._tun_controller.status()["running"]
|
||||
pac_on = _should_use_internal_pac(_use_pac_mode())
|
||||
bypass_lan = _bypass_lan()
|
||||
for text in (
|
||||
f"{'系统代理' if self._mode == 'system' else '虚拟网卡'}:{'开' if proxy_on else '关'}",
|
||||
f"PAC:{'启用' if pac_on else '关闭'}",
|
||||
f"内网IP:{'不代理' if bypass_lan else '代理'}",
|
||||
):
|
||||
status_action = QAction(text, self._tray_menu)
|
||||
status_action.setEnabled(False)
|
||||
self._tray_menu.addAction(status_action)
|
||||
|
||||
self._tray_menu.addSeparator()
|
||||
exit_action = QAction("退出", self._tray_menu)
|
||||
exit_action.triggered.connect(self._exit_from_tray)
|
||||
self._tray_menu.addAction(exit_action)
|
||||
|
||||
def _exit_from_tray(self):
|
||||
"""托盘菜单中的显式退出。"""
|
||||
self._quitting = True
|
||||
self.close()
|
||||
|
||||
def _verify_node_thread(self):
|
||||
"""等节点配置在线同步完成后,校验当前选中节点是否可用,必要时自动回退。
|
||||
|
||||
@@ -2795,27 +2972,16 @@ class MainWindow(QWidget):
|
||||
layout.addWidget(line)
|
||||
|
||||
|
||||
# 系统代理状态卡片
|
||||
px_frame = QFrame()
|
||||
px_frame.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {C_CARD};
|
||||
border: 1px solid {C_BORDER};
|
||||
border-radius: 8px;
|
||||
}}
|
||||
""")
|
||||
px_layout = QHBoxLayout(px_frame)
|
||||
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)
|
||||
mode_row = QHBoxLayout()
|
||||
mode_row.setSpacing(8)
|
||||
self.system_card = ProxyModeCard("系统代理")
|
||||
self.tun_card = ProxyModeCard("虚拟网卡")
|
||||
self.system_card.clicked.connect(lambda: self._choose_mode("system"))
|
||||
self.tun_card.clicked.connect(lambda: self._choose_mode("tun"))
|
||||
mode_row.addWidget(self.system_card, 1)
|
||||
mode_row.addWidget(self.tun_card, 1)
|
||||
layout.addLayout(mode_row)
|
||||
self._update_mode_cards(False, False)
|
||||
|
||||
traffic_cards = QHBoxLayout()
|
||||
traffic_cards.setSpacing(8)
|
||||
@@ -2904,7 +3070,11 @@ class MainWindow(QWidget):
|
||||
layout.addWidget(hints)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""关闭窗口时自动清理代理和隧道。"""
|
||||
"""按用户设置退出或隐藏到托盘。"""
|
||||
if not self._quitting and _close_behavior() == "tray" and self._tray is not None:
|
||||
event.ignore()
|
||||
self.hide()
|
||||
return
|
||||
try:
|
||||
self.quit_app()
|
||||
finally:
|
||||
@@ -2974,7 +3144,7 @@ class MainWindow(QWidget):
|
||||
# 在 GUI 线程读取控件状态传参,避免子线程直接访问 Qt 控件(非线程安全)
|
||||
use_pac = self.cb_pac.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_in_progress = True
|
||||
# 网络探测可能需要数秒;此时保留按钮给用户取消,而不是把界面锁死。
|
||||
@@ -2982,12 +3152,12 @@ class MainWindow(QWidget):
|
||||
self.btn_toggle.setEnabled(True)
|
||||
threading.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,
|
||||
).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:
|
||||
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)
|
||||
self._op_done.emit("start", True)
|
||||
except _StartCancelled:
|
||||
@@ -3002,6 +3172,12 @@ class MainWindow(QWidget):
|
||||
|
||||
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:
|
||||
proxy_owned = self._proxy_set_by_us
|
||||
self._proxy_set_by_us = False
|
||||
@@ -3012,14 +3188,26 @@ class MainWindow(QWidget):
|
||||
_tls_stop()
|
||||
_stop_pac_server()
|
||||
|
||||
def _start_proxy(self, use_pac, use_bypass_lan, use_builtin_pac=False, cancel_event=None):
|
||||
"""启动本地转发器并设置系统代理(普通 PAC、内置 PAC 或固定代理)。"""
|
||||
def _start_proxy(self, use_pac, use_bypass_lan, use_system_pac=False, cancel_event=None):
|
||||
"""按当前选择启动系统代理或按需提权的虚拟网卡。"""
|
||||
if _endpoint_domain() == "???":
|
||||
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
|
||||
raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置")
|
||||
if not _node_reachable(_current_node()):
|
||||
# 当前选中节点是 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()
|
||||
current_node = _current_node()
|
||||
jump_chain = _jump_chain(current_node)
|
||||
@@ -3029,7 +3217,7 @@ class MainWindow(QWidget):
|
||||
if jump_chain:
|
||||
log(f" 跳板路径: {_jump_route_text(current_node)}")
|
||||
# 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:
|
||||
_load_cn_ranges()
|
||||
_load_force_proxy_suffixes()
|
||||
@@ -3040,7 +3228,7 @@ class MainWindow(QWidget):
|
||||
jump_chain=jump_chain)
|
||||
try:
|
||||
_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_content = _build_pac(f"{PROXY_HOST}:{PROXY_PORT}")
|
||||
if use_bypass_lan:
|
||||
@@ -3058,7 +3246,7 @@ class MainWindow(QWidget):
|
||||
_raise_if_start_cancelled(cancel_event)
|
||||
proxy_on(bypass_lan=use_bypass_lan)
|
||||
self._proxy_set_by_us = True
|
||||
if use_builtin_pac:
|
||||
if use_internal_pac:
|
||||
log(f"使用内置 PAC:系统固定代理 127.0.0.1:{PROXY_PORT},转发器内部智能分流")
|
||||
_raise_if_start_cancelled(cancel_event)
|
||||
if use_bypass_lan:
|
||||
@@ -3098,10 +3286,13 @@ class MainWindow(QWidget):
|
||||
threading.Thread(target=self._stop_thread, daemon=True).start()
|
||||
|
||||
def _stop_thread(self):
|
||||
"""后台关闭代理:清理系统代理、停止 TLS/PAC 服务。"""
|
||||
"""后台关闭当前模式并回收其资源。"""
|
||||
try:
|
||||
log("正在关闭代理...")
|
||||
proxy_off()
|
||||
if self._mode == "tun":
|
||||
self._tun_controller.stop()
|
||||
else:
|
||||
proxy_off()
|
||||
self._proxy_set_by_us = False
|
||||
log("已关闭")
|
||||
self._op_done.emit("stop", True)
|
||||
@@ -3114,8 +3305,17 @@ class MainWindow(QWidget):
|
||||
def _show_settings_dialog(self):
|
||||
"""打开设置弹窗;第三方订阅与登录状态可独立管理。"""
|
||||
SettingsDialog(_settings_services(), self, port_locked=self._proxy_set_by_us).exec_()
|
||||
self._refresh_preferences()
|
||||
self._refresh_subtitle()
|
||||
|
||||
def _refresh_preferences(self):
|
||||
"""设置页关闭后同步主页面的本地偏好显示。"""
|
||||
self.cb_pac.setChecked(_use_pac_mode())
|
||||
self.cb_bypass_lan.setChecked(_bypass_lan())
|
||||
self._mode = _proxy_mode()
|
||||
self.refresh_status()
|
||||
self._update_tray_menu()
|
||||
|
||||
# ── 节点登录 ──
|
||||
def _maybe_show_login(self):
|
||||
"""首次无节点时提示登录;用户可跳过并在主界面添加第三方订阅。"""
|
||||
@@ -3206,6 +3406,7 @@ class MainWindow(QWidget):
|
||||
def _refresh_subtitle(self):
|
||||
"""刷新节点副标题文字。"""
|
||||
self.subtitle.setText(_endpoint_label())
|
||||
self._update_tray_menu()
|
||||
|
||||
def _update_node_label(self):
|
||||
"""代理运行中时提示禁止切换节点。"""
|
||||
@@ -3268,37 +3469,32 @@ class MainWindow(QWidget):
|
||||
|
||||
def _poll_status(self):
|
||||
try:
|
||||
px = is_proxy_on()
|
||||
state = {"system": is_proxy_on(), "tun": bool(self._tun_controller.status()["running"])}
|
||||
except Exception:
|
||||
px = False
|
||||
self._status_ready.emit(px)
|
||||
def _apply_status(self, px_on_):
|
||||
"""GUI 线程:根据轮询结果刷新状态指示灯、IE代理、流量卡片。"""
|
||||
state = {"system": False, "tun": False}
|
||||
self._status_ready.emit(state)
|
||||
def _apply_status(self, state):
|
||||
"""GUI 线程:按模式选择和真实运行状态刷新两张状态卡。"""
|
||||
self._status_busy = False
|
||||
|
||||
# IE 代理状态
|
||||
if px_on_:
|
||||
self.px_dot.set_on()
|
||||
self.px_status.setText("已开启")
|
||||
self.px_status.setStyleSheet(f"color: {C_GREEN}; background: transparent;")
|
||||
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)
|
||||
system_on = bool(state.get("system"))
|
||||
tun_on = bool(state.get("tun"))
|
||||
self._update_mode_cards(system_on, tun_on)
|
||||
px_on = system_on if self._mode == "system" else tun_on
|
||||
if not self._start_in_progress:
|
||||
self._set_toggle_button_style(is_start=not px_on)
|
||||
|
||||
# 流量卡片(始终更新,未连接时显示 0)
|
||||
px_on = is_proxy_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:
|
||||
up_rate, down_rate, up_total, down_total = 0, 0, 0, 0
|
||||
self.traffic_up.setText(f"↑{_fmt_bytes(up_rate)}/s")
|
||||
self.traffic_down.setText(f"↓{_fmt_bytes(down_rate)}/s")
|
||||
self.traffic_total.setText(_fmt_bytes(up_total + down_total))
|
||||
self._update_tray_menu()
|
||||
def _check_update(self, manual=False):
|
||||
"""后台检查服务器是否有新版本;manual=True 时无视「跳过检查更新」设置。"""
|
||||
# 先初始化结果,任何路径(含跳过)完成后 _on_update_done 都能安全读取
|
||||
@@ -3511,10 +3707,10 @@ class MainWindow(QWidget):
|
||||
QMessageBox.warning(self, "下载失败", f"无法下载新版本:\n{message}")
|
||||
|
||||
def quit_app(self):
|
||||
"""只清理自己设置的系统代理:用户或其他软件设置的代理不受影响。
|
||||
|
||||
本程序异常退出残留的代理设置由下次启动时 _cleanup_stale_proxy() 处理。
|
||||
"""
|
||||
"""退出时按当前模式回收本程序建立的资源。"""
|
||||
self._quitting = True
|
||||
if self._tray is not None:
|
||||
self._tray.hide()
|
||||
if self._start_in_progress and self._start_cancel_event is not None:
|
||||
self._start_cancel_event.set()
|
||||
try:
|
||||
@@ -3523,7 +3719,10 @@ class MainWindow(QWidget):
|
||||
pass # 清理失败也不能阻止退出,残留交给下次启动清理
|
||||
elif self._proxy_set_by_us:
|
||||
try:
|
||||
proxy_off()
|
||||
if self._mode == "tun":
|
||||
self._tun_controller.stop()
|
||||
else:
|
||||
proxy_off()
|
||||
except Exception:
|
||||
pass # 清理失败也不能阻止退出,残留交给下次启动清理
|
||||
QApplication.quit()
|
||||
@@ -3571,7 +3770,12 @@ def _acquire_single_instance():
|
||||
return True # 互斥体创建失败时放行,避免把用户锁在门外
|
||||
|
||||
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()
|
||||
|
||||
# 单实例:第二个实例直接退出(且不能执行下面的残留代理清理,
|
||||
@@ -3608,7 +3812,9 @@ def main():
|
||||
app = QApplication(sys.argv)
|
||||
icon = load_icon()
|
||||
app.setWindowIcon(icon)
|
||||
app.setQuitOnLastWindowClosed(True)
|
||||
# 主窗口可按设置隐藏到托盘;显式点击托盘“退出”或关闭行为为“退出”时,
|
||||
# MainWindow.quit_app() 会负责结束 QApplication。
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
- module-name: 'main'
|
||||
dlls:
|
||||
- from_filenames:
|
||||
relative_path: 'runtime'
|
||||
prefixes:
|
||||
- 'tun2socks'
|
||||
suffixes:
|
||||
- 'exe'
|
||||
executable: 'yes'
|
||||
dest_path: '.'
|
||||
when: 'win32'
|
||||
- from_filenames:
|
||||
relative_path: 'runtime'
|
||||
prefixes:
|
||||
- 'wintun'
|
||||
suffixes:
|
||||
- 'dll'
|
||||
dest_path: '.'
|
||||
when: 'win32'
|
||||
@@ -0,0 +1,152 @@
|
||||
"""轻量 DNS 观察器,用于把 TUN 里的目标 IP 关联回解析域名。
|
||||
|
||||
TUN 送到 SOCKS5 的请求通常只带目标 IP。这里观察经 UoT 或直连的
|
||||
UDP DNS 回包,保存短期的 ``IP -> 域名`` 映射供分流和日志使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def _read_name(packet: bytes, offset: int) -> tuple[str, int]:
|
||||
labels: list[str] = []
|
||||
cursor = offset
|
||||
next_offset = None
|
||||
jumps = 0
|
||||
while True:
|
||||
if cursor >= len(packet):
|
||||
raise ValueError("DNS 名称超出数据包")
|
||||
length = packet[cursor]
|
||||
if length == 0:
|
||||
cursor += 1
|
||||
if next_offset is None:
|
||||
next_offset = cursor
|
||||
break
|
||||
if length & 0xC0 == 0xC0:
|
||||
if cursor + 1 >= len(packet):
|
||||
raise ValueError("DNS 压缩指针不完整")
|
||||
pointer = ((length & 0x3F) << 8) | packet[cursor + 1]
|
||||
if next_offset is None:
|
||||
next_offset = cursor + 2
|
||||
cursor = pointer
|
||||
jumps += 1
|
||||
if jumps > 16:
|
||||
raise ValueError("DNS 名称压缩指针循环")
|
||||
continue
|
||||
if length > 63 or cursor + 1 + length > len(packet):
|
||||
raise ValueError("DNS 标签不完整")
|
||||
labels.append(packet[cursor + 1:cursor + 1 + length].decode("idna"))
|
||||
cursor += 1 + length
|
||||
return ".".join(labels).rstrip(".").lower(), int(next_offset)
|
||||
|
||||
|
||||
def _question(packet: bytes) -> tuple[int, str, int]:
|
||||
if len(packet) < 12:
|
||||
raise ValueError("DNS 头不完整")
|
||||
txid, flags, questions, _answers, _authority, _additional = struct.unpack(
|
||||
"!HHHHHH", packet[:12]
|
||||
)
|
||||
if flags & 0x8000 or questions < 1:
|
||||
raise ValueError("不是 DNS 查询")
|
||||
name, offset = _read_name(packet, 12)
|
||||
if offset + 4 > len(packet):
|
||||
raise ValueError("DNS 问题段不完整")
|
||||
return txid, name, offset + 4
|
||||
|
||||
|
||||
class DnsObserver:
|
||||
def __init__(self, log: Callable[[str], None] | None = None,
|
||||
max_ttl: int = 300):
|
||||
self.log = log or (lambda _message: None)
|
||||
self.max_ttl = max(1, int(max_ttl))
|
||||
self._lock = threading.RLock()
|
||||
self._domain_to_ips: dict[str, tuple[float, tuple[str, ...]]] = {}
|
||||
# 一个 CDN IP 可能同时服务多个域名;不能把最后一次 DNS 回包当成唯一身份。
|
||||
self._ip_to_domains: dict[str, tuple[float, set[str]]] = {}
|
||||
|
||||
def observe_query(self, packet: bytes) -> str | None:
|
||||
try:
|
||||
_txid, domain, _ = _question(packet)
|
||||
return domain or None
|
||||
except (ValueError, UnicodeError):
|
||||
return None
|
||||
|
||||
def observe_response(self, packet: bytes) -> tuple[str, tuple[str, ...]] | None:
|
||||
try:
|
||||
if len(packet) < 12:
|
||||
raise ValueError("DNS 头不完整")
|
||||
_txid, flags, questions, answers, _authority, _additional = struct.unpack(
|
||||
"!HHHHHH", packet[:12]
|
||||
)
|
||||
if not (flags & 0x8000) or questions < 1:
|
||||
raise ValueError("不是 DNS 回包")
|
||||
domain, offset = _read_name(packet, 12)
|
||||
if offset + 4 > len(packet):
|
||||
raise ValueError("DNS 问题段不完整")
|
||||
offset += 4
|
||||
ips: list[str] = []
|
||||
ttl = self.max_ttl
|
||||
for _ in range(answers):
|
||||
_owner, offset = _read_name(packet, offset)
|
||||
if offset + 10 > len(packet):
|
||||
raise ValueError("DNS 答案头不完整")
|
||||
rtype, rclass, record_ttl, size = struct.unpack(
|
||||
"!HHIH", packet[offset:offset + 10]
|
||||
)
|
||||
offset += 10
|
||||
if offset + size > len(packet):
|
||||
raise ValueError("DNS 答案数据不完整")
|
||||
raw = packet[offset:offset + size]
|
||||
offset += size
|
||||
if rclass != 1 or rtype not in (1, 28):
|
||||
continue
|
||||
if (rtype == 1 and size != 4) or (rtype == 28 and size != 16):
|
||||
continue
|
||||
ips.append(str(ipaddress.ip_address(raw)))
|
||||
ttl = min(ttl, max(1, int(record_ttl)))
|
||||
if not domain or not ips:
|
||||
return None
|
||||
expires = time.monotonic() + min(ttl, self.max_ttl)
|
||||
unique = tuple(dict.fromkeys(ips))
|
||||
with self._lock:
|
||||
self._domain_to_ips[domain] = (expires, unique)
|
||||
for ip in unique:
|
||||
previous = self._ip_to_domains.get(ip)
|
||||
domains = set(previous[1]) if previous and previous[0] > time.monotonic() else set()
|
||||
domains.add(domain)
|
||||
self._ip_to_domains[ip] = (expires, domains)
|
||||
return domain, unique
|
||||
except (ValueError, UnicodeError, OSError):
|
||||
return None
|
||||
|
||||
def lookup_ip(self, ip: str) -> str | None:
|
||||
try:
|
||||
normalized = str(ipaddress.ip_address(ip))
|
||||
except ValueError:
|
||||
return None
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
item = self._ip_to_domains.get(normalized)
|
||||
if not item:
|
||||
return None
|
||||
if item[0] <= now:
|
||||
self._ip_to_domains.pop(normalized, None)
|
||||
return None
|
||||
return next(iter(item[1])) if len(item[1]) == 1 else None
|
||||
|
||||
def lookup_domain(self, domain: str) -> tuple[str, ...]:
|
||||
key = str(domain).rstrip(".").lower()
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
item = self._domain_to_ips.get(key)
|
||||
if not item:
|
||||
return ()
|
||||
if item[0] <= now:
|
||||
self._domain_to_ips.pop(key, None)
|
||||
return ()
|
||||
return item[1]
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Windows 系统代理模式:注册表切换、原状态保存与异常退出恢复。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import re
|
||||
from typing import Callable
|
||||
|
||||
|
||||
PROXY_REG = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings"
|
||||
BACKUP_KEY = "prev_proxy_state"
|
||||
_VALUE_NAMES = ("ProxyEnable", "ProxyServer", "ProxyOverride", "AutoConfigURL")
|
||||
|
||||
|
||||
def is_our_pac_url(value: str) -> bool:
|
||||
return bool(re.fullmatch(r"http://127\.0\.0\.1:\d+/proxy\.pac", str(value).strip()))
|
||||
|
||||
|
||||
def notify_proxy_change() -> None:
|
||||
"""通知 WinINET 和顶层窗口重新读取系统代理。"""
|
||||
try:
|
||||
ctypes.windll.Wininet.InternetSetOptionW(None, 39, None, 0)
|
||||
ctypes.windll.Wininet.InternetSetOptionW(None, 37, None, 0)
|
||||
result = ctypes.c_ulong()
|
||||
ctypes.windll.user32.SendMessageTimeoutW(
|
||||
0xFFFF, 0x001A, 0, ctypes.c_wchar_p("InternetSettings"),
|
||||
0x0002, 5000, ctypes.byref(result))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class SystemProxyMode:
|
||||
def __init__(self, *, run: Callable, settings_load: Callable,
|
||||
settings_save: Callable, log: Callable,
|
||||
endpoint: Callable[[], tuple[str, int]],
|
||||
proxy_override: Callable[[bool], str]):
|
||||
self.run = run
|
||||
self.settings_load = settings_load
|
||||
self.settings_save = settings_save
|
||||
self.log = log
|
||||
self.endpoint = endpoint
|
||||
self.proxy_override = proxy_override
|
||||
self._previous = None
|
||||
|
||||
def reg_query(self, name: str):
|
||||
result = self.run(["reg", "query", PROXY_REG, "/v", name], warn=False)
|
||||
if result.returncode:
|
||||
return None
|
||||
for line in (result.stdout or "").splitlines():
|
||||
parts = line.strip().split(None, 2)
|
||||
if len(parts) >= 3 and parts[0].lower() == name.lower():
|
||||
return parts[1], parts[2]
|
||||
return None
|
||||
|
||||
def _backup(self):
|
||||
if self._previous is not None:
|
||||
return
|
||||
state = {name: list(value) if (value := self.reg_query(name)) else None
|
||||
for name in _VALUE_NAMES}
|
||||
self._previous = state
|
||||
try:
|
||||
self.settings_save({**self.settings_load(), BACKUP_KEY: state})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _restore(self):
|
||||
state = self._previous
|
||||
if state is None:
|
||||
state = self.settings_load().get(BACKUP_KEY)
|
||||
if isinstance(state, dict):
|
||||
for name in _VALUE_NAMES:
|
||||
value = state.get(name)
|
||||
if value:
|
||||
self.run(["reg", "add", PROXY_REG, "/v", name, "/t", value[0],
|
||||
"/d", value[1], "/f"], warn=False)
|
||||
else:
|
||||
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
|
||||
self.log("已恢复开启代理前的系统代理设置")
|
||||
else:
|
||||
for name in ("ProxyServer", "ProxyOverride", "AutoConfigURL"):
|
||||
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
|
||||
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
|
||||
"REG_DWORD", "/d", "0", "/f"])
|
||||
self._previous = None
|
||||
try:
|
||||
data = self.settings_load()
|
||||
if BACKUP_KEY in data:
|
||||
del data[BACKUP_KEY]
|
||||
self.settings_save(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def enable(self, pac_url=None, bypass_lan=False):
|
||||
self._backup()
|
||||
if pac_url:
|
||||
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
|
||||
"REG_DWORD", "/d", "0", "/f"])
|
||||
for name in ("ProxyServer", "ProxyOverride"):
|
||||
self.run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
|
||||
self.run(["reg", "add", PROXY_REG, "/v", "AutoConfigURL", "/t",
|
||||
"REG_SZ", "/d", pac_url, "/f"])
|
||||
self.log("系统代理已设置 (PAC)")
|
||||
else:
|
||||
host, port = self.endpoint()
|
||||
address = f"{host}:{port}"
|
||||
self.run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t",
|
||||
"REG_DWORD", "/d", "1", "/f"])
|
||||
self.run(["reg", "add", PROXY_REG, "/v", "ProxyServer", "/t",
|
||||
"REG_SZ", "/d", address, "/f"])
|
||||
self.run(["reg", "add", PROXY_REG, "/v", "ProxyOverride", "/t",
|
||||
"REG_SZ", "/d", self.proxy_override(bypass_lan), "/f"])
|
||||
self.log(f"系统代理已设置: {address}")
|
||||
notify_proxy_change()
|
||||
|
||||
def disable(self):
|
||||
self._restore()
|
||||
notify_proxy_change()
|
||||
|
||||
def is_on(self):
|
||||
host, port = self.endpoint()
|
||||
enabled = self.reg_query("ProxyEnable")
|
||||
try:
|
||||
fixed_enabled = bool(enabled and int(str(enabled[1]).strip(), 0) == 1)
|
||||
except (TypeError, ValueError):
|
||||
fixed_enabled = False
|
||||
if fixed_enabled:
|
||||
server = self.reg_query("ProxyServer")
|
||||
return bool(server and (server[1] or "").strip() == f"{host}:{port}")
|
||||
pac = self.reg_query("AutoConfigURL")
|
||||
return bool(pac) and is_our_pac_url(pac[1] or "")
|
||||
|
||||
def is_stale(self):
|
||||
host, port = self.endpoint()
|
||||
server = self.reg_query("ProxyServer")
|
||||
pac = self.reg_query("AutoConfigURL")
|
||||
return (bool(server) and (server[1] or "").strip() == f"{host}:{port}") or \
|
||||
(bool(pac) and is_our_pac_url(pac[1] or ""))
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
+168
-31
@@ -9,6 +9,7 @@ from PyQt5.QtCore import Qt, QTimer, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
|
||||
QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget, QTextEdit,
|
||||
QButtonGroup, QMessageBox,
|
||||
)
|
||||
|
||||
from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE
|
||||
@@ -930,14 +931,21 @@ class SettingsServices:
|
||||
load_icon: object
|
||||
version: str
|
||||
get_port: object
|
||||
get_builtin_pac: object
|
||||
get_system_pac: object
|
||||
get_dns: object
|
||||
get_update_via_proxy: object
|
||||
get_skip_update_check: object
|
||||
get_node_display_mode: object
|
||||
get_close_behavior: object
|
||||
parse_port: object
|
||||
set_port: object
|
||||
set_builtin_pac: object
|
||||
set_system_pac: object
|
||||
set_dns: object
|
||||
set_update_via_proxy: object
|
||||
set_skip_update_check: object
|
||||
set_node_display_mode: object
|
||||
set_close_behavior: object
|
||||
reset_user_config: object
|
||||
open_whitelist: object
|
||||
open_jump: object
|
||||
open_third_party: object
|
||||
@@ -955,7 +963,7 @@ class SettingsDialog(QDialog):
|
||||
self._services = services
|
||||
self.setWindowTitle("设置")
|
||||
self.setWindowIcon(services.load_icon())
|
||||
self.setFixedSize(300, 380)
|
||||
self.setFixedSize(620, 460)
|
||||
self.setStyleSheet(f"""
|
||||
QDialog {{ background-color: {C_BG}; }}
|
||||
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
||||
@@ -963,8 +971,8 @@ class SettingsDialog(QDialog):
|
||||
padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }}
|
||||
""")
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 10, 20, 10)
|
||||
layout.setSpacing(7)
|
||||
layout.setContentsMargins(20, 8, 20, 8)
|
||||
title = QLabel("设置")
|
||||
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
@@ -991,7 +999,33 @@ class SettingsDialog(QDialog):
|
||||
self.lb_hint.setFixedHeight(14)
|
||||
self.lb_hint.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.lb_hint)
|
||||
layout.addStretch()
|
||||
layout.addSpacing(2)
|
||||
|
||||
columns = QHBoxLayout()
|
||||
columns.setSpacing(24)
|
||||
left_col = QVBoxLayout()
|
||||
right_col = QVBoxLayout()
|
||||
left_col.setSpacing(9)
|
||||
right_col.setSpacing(9)
|
||||
left_heading = QLabel("连接与分流")
|
||||
right_heading = QLabel("节点与维护")
|
||||
for heading in (left_heading, right_heading):
|
||||
heading.setStyleSheet(f"color: {C_TITLE}; font-size: 12px; font-weight: bold;")
|
||||
heading.setAlignment(Qt.AlignCenter)
|
||||
left_col.addWidget(left_heading)
|
||||
right_col.addWidget(right_heading)
|
||||
columns.addLayout(left_col, 1)
|
||||
columns.addLayout(right_col, 1)
|
||||
layout.addLayout(columns, 1)
|
||||
|
||||
compact_button_style = f"""
|
||||
QPushButton {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
||||
border-radius: 5px; padding: 1px 7px; min-width: 34px; min-height: 18px;
|
||||
font-size: 10px; }}
|
||||
QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||||
QPushButton:checked {{ background-color: {C_TITLE}; color: #ffffff; border-color: {C_TITLE}; }}
|
||||
QPushButton:disabled {{ background-color: #e7eaee; color: #a8afb8; border-color: #d8dde3; }}
|
||||
"""
|
||||
|
||||
whitelist_row = QHBoxLayout()
|
||||
whitelist_label = QLabel("域名路由")
|
||||
@@ -1001,14 +1035,10 @@ class SettingsDialog(QDialog):
|
||||
whitelist_row.addStretch()
|
||||
self.btn_whitelist = QPushButton("配置")
|
||||
self.btn_whitelist.setCursor(Qt.PointingHandCursor)
|
||||
self.btn_whitelist.setStyleSheet(f"""
|
||||
QPushButton {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
||||
border-radius: 6px; padding: 2px 10px; font-size: 11px; }}
|
||||
QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||||
""")
|
||||
self.btn_whitelist.setStyleSheet(compact_button_style)
|
||||
self.btn_whitelist.clicked.connect(self._open_whitelist)
|
||||
whitelist_row.addWidget(self.btn_whitelist)
|
||||
layout.addLayout(whitelist_row)
|
||||
left_col.addLayout(whitelist_row)
|
||||
|
||||
jump_row = QHBoxLayout()
|
||||
jump_label = QLabel("代理跳板")
|
||||
@@ -1021,7 +1051,7 @@ class SettingsDialog(QDialog):
|
||||
self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet())
|
||||
self.btn_jump.clicked.connect(self._open_jump)
|
||||
jump_row.addWidget(self.btn_jump)
|
||||
layout.addLayout(jump_row)
|
||||
left_col.addLayout(jump_row)
|
||||
|
||||
third_party_row = QHBoxLayout()
|
||||
third_party_label = QLabel("第三方订阅")
|
||||
@@ -1034,7 +1064,7 @@ class SettingsDialog(QDialog):
|
||||
self.btn_third_party.setStyleSheet(self.btn_whitelist.styleSheet())
|
||||
self.btn_third_party.clicked.connect(self._open_third_party)
|
||||
third_party_row.addWidget(self.btn_third_party)
|
||||
layout.addLayout(third_party_row)
|
||||
right_col.addLayout(third_party_row)
|
||||
|
||||
log_row = QHBoxLayout()
|
||||
log_label = QLabel("日志")
|
||||
@@ -1056,22 +1086,33 @@ class SettingsDialog(QDialog):
|
||||
self.ed_port.setFixedSize(54, 22)
|
||||
self.ed_port.setEnabled(not port_locked)
|
||||
port_row.addWidget(self.ed_port)
|
||||
layout.addLayout(port_row)
|
||||
left_col.addLayout(port_row)
|
||||
|
||||
builtin_pac_row = QHBoxLayout()
|
||||
builtin_pac_label = QLabel("使用内置 PAC")
|
||||
system_pac_row = QHBoxLayout()
|
||||
builtin_pac_label = QLabel("使用系统 PAC (系统代理)")
|
||||
builtin_pac_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||
builtin_pac_label.setToolTip(
|
||||
"系统使用固定的本地代理端口;转发器内部仍按 PAC 规则智能判断直连或代理"
|
||||
"仅系统代理模式使用;关闭时使用内置 PAC 智能分流"
|
||||
)
|
||||
builtin_pac_row.addWidget(builtin_pac_label)
|
||||
builtin_pac_row.addStretch()
|
||||
self.cb_builtin_pac = ToggleSwitch(checked=services.get_builtin_pac())
|
||||
self.cb_builtin_pac.setToolTip(
|
||||
"系统固定指向本地代理端口,转发器内部保留 PAC 智能分流"
|
||||
)
|
||||
builtin_pac_row.addWidget(self.cb_builtin_pac)
|
||||
layout.addLayout(builtin_pac_row)
|
||||
system_pac_row.addWidget(builtin_pac_label)
|
||||
system_pac_row.addStretch()
|
||||
self.cb_system_pac = ToggleSwitch(checked=services.get_system_pac())
|
||||
self.cb_system_pac.setToolTip("系统代理且已选 PAC 规则模式时,向 Windows 写入本地 PAC 地址;TUN 模式忽略此项")
|
||||
system_pac_row.addWidget(self.cb_system_pac)
|
||||
left_col.addLayout(system_pac_row)
|
||||
|
||||
dns_row = QHBoxLayout()
|
||||
dns_label = QLabel("自定义 DNS (虚拟网卡)")
|
||||
dns_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||
dns_label.setToolTip("TUN 模式使用;支持 IPv4/IPv6,请填写 1-3 个 DNS 地址")
|
||||
dns_row.addWidget(dns_label)
|
||||
dns_row.addStretch()
|
||||
self.ed_dns = QLineEdit(", ".join(services.get_dns()))
|
||||
self.ed_dns.setPlaceholderText("8.8.8.8, 1.1.1.1")
|
||||
self.ed_dns.setFixedSize(120, 22)
|
||||
self.ed_dns.setEnabled(not port_locked)
|
||||
dns_row.addWidget(self.ed_dns)
|
||||
left_col.addLayout(dns_row)
|
||||
|
||||
update_row = QHBoxLayout()
|
||||
update_label = QLabel("使用代理更新")
|
||||
@@ -1081,7 +1122,7 @@ class SettingsDialog(QDialog):
|
||||
self.cb_update_proxy = ToggleSwitch(checked=services.get_update_via_proxy())
|
||||
self.cb_update_proxy.setToolTip("开启后检查更新与下载走节点隧道(CONNECT);默认直连仓库")
|
||||
update_row.addWidget(self.cb_update_proxy)
|
||||
layout.addLayout(update_row)
|
||||
right_col.addLayout(update_row)
|
||||
|
||||
skip_row = QHBoxLayout()
|
||||
skip_label = QLabel("跳过检查更新")
|
||||
@@ -1091,9 +1132,71 @@ class SettingsDialog(QDialog):
|
||||
self.cb_skip_update = ToggleSwitch(checked=services.get_skip_update_check())
|
||||
self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
|
||||
skip_row.addWidget(self.cb_skip_update)
|
||||
layout.addLayout(skip_row)
|
||||
layout.addLayout(log_row)
|
||||
layout.addStretch()
|
||||
right_col.addLayout(skip_row)
|
||||
|
||||
node_display_row = QHBoxLayout()
|
||||
node_display_label = QLabel("节点信息显示")
|
||||
node_display_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||
node_display_label.setToolTip("控制主页面节点副标题显示域名还是节点备注")
|
||||
node_display_row.addWidget(node_display_label)
|
||||
node_display_row.addStretch()
|
||||
self.btn_node_domain = QPushButton("域名")
|
||||
self.btn_node_remark = QPushButton("备注")
|
||||
for button in (self.btn_node_domain, self.btn_node_remark):
|
||||
button.setCheckable(True)
|
||||
button.setCursor(Qt.PointingHandCursor)
|
||||
button.setStyleSheet(compact_button_style)
|
||||
node_display_row.addWidget(button)
|
||||
self.node_display_group = QButtonGroup(self)
|
||||
self.node_display_group.setExclusive(True)
|
||||
self.node_display_group.addButton(self.btn_node_domain)
|
||||
self.node_display_group.addButton(self.btn_node_remark)
|
||||
if services.get_node_display_mode() == "remark":
|
||||
self.btn_node_remark.setChecked(True)
|
||||
else:
|
||||
self.btn_node_domain.setChecked(True)
|
||||
right_col.addLayout(node_display_row)
|
||||
|
||||
close_behavior_row = QHBoxLayout()
|
||||
close_behavior_label = QLabel("默认关闭行为")
|
||||
close_behavior_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||
close_behavior_label.setToolTip("点击主窗口关闭按钮时退出程序,或隐藏到系统托盘")
|
||||
close_behavior_row.addWidget(close_behavior_label)
|
||||
close_behavior_row.addStretch()
|
||||
self.btn_close_exit = QPushButton("退出")
|
||||
self.btn_close_tray = QPushButton("托盘")
|
||||
for button in (self.btn_close_exit, self.btn_close_tray):
|
||||
button.setCheckable(True)
|
||||
button.setCursor(Qt.PointingHandCursor)
|
||||
button.setStyleSheet(compact_button_style)
|
||||
close_behavior_row.addWidget(button)
|
||||
self.close_behavior_group = QButtonGroup(self)
|
||||
self.close_behavior_group.setExclusive(True)
|
||||
self.close_behavior_group.addButton(self.btn_close_exit)
|
||||
self.close_behavior_group.addButton(self.btn_close_tray)
|
||||
if services.get_close_behavior() == "tray":
|
||||
self.btn_close_tray.setChecked(True)
|
||||
else:
|
||||
self.btn_close_exit.setChecked(True)
|
||||
right_col.addLayout(close_behavior_row)
|
||||
|
||||
reset_row = QHBoxLayout()
|
||||
reset_label = QLabel("重置用户配置")
|
||||
reset_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||
reset_label.setToolTip("重置本地偏好、域名路由、跳板和当前节点选择;不会删除登录和订阅")
|
||||
reset_row.addWidget(reset_label)
|
||||
reset_row.addStretch()
|
||||
self.btn_reset = QPushButton("重置")
|
||||
self.btn_reset.setCursor(Qt.PointingHandCursor)
|
||||
self.btn_reset.setStyleSheet(compact_button_style)
|
||||
self.btn_reset.setEnabled(not port_locked)
|
||||
self.btn_reset.clicked.connect(self._reset_user_config)
|
||||
reset_row.addWidget(self.btn_reset)
|
||||
right_col.addLayout(reset_row)
|
||||
|
||||
right_col.addLayout(log_row)
|
||||
left_col.addStretch()
|
||||
right_col.addStretch()
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
buttons.setSpacing(10)
|
||||
@@ -1148,16 +1251,50 @@ class SettingsDialog(QDialog):
|
||||
def _open_logs(self):
|
||||
self._services.open_logs(self)
|
||||
|
||||
def _reset_user_config(self):
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"重置用户配置",
|
||||
"确定要恢复本地偏好、域名路由、跳板和当前节点选择的默认值吗?\n\n"
|
||||
"登录凭据、节点缓存、第三方订阅和 PAC 数据不会被删除。",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
self._services.reset_user_config()
|
||||
self.ed_port.setText(str(self._services.get_port()))
|
||||
self.cb_system_pac.setChecked(self._services.get_system_pac())
|
||||
self.ed_dns.setText(", ".join(self._services.get_dns()))
|
||||
self.cb_update_proxy.setChecked(self._services.get_update_via_proxy())
|
||||
self.cb_skip_update.setChecked(self._services.get_skip_update_check())
|
||||
self.btn_node_domain.setChecked(True)
|
||||
self.btn_close_exit.setChecked(True)
|
||||
self.lb_hint.setText("用户配置已重置")
|
||||
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
|
||||
|
||||
def _save_settings(self):
|
||||
port = self._services.parse_port(self.ed_port.text())
|
||||
if port is None:
|
||||
self.lb_hint.setText("端口应为 1-65535 的数字")
|
||||
self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||||
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_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_skip_update_check(bool(self.cb_skip_update.isChecked()))
|
||||
self._services.set_node_display_mode(
|
||||
"remark" if self.btn_node_remark.isChecked() else "domain"
|
||||
)
|
||||
self._services.set_close_behavior(
|
||||
"tray" if self.btn_close_tray.isChecked() else "exit"
|
||||
)
|
||||
self.ed_port.setText(str(port))
|
||||
self.lb_hint.setText("已保存,下次开启代理生效")
|
||||
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
|
||||
|
||||
+65
-1
@@ -2,7 +2,23 @@
|
||||
|
||||
from PyQt5.QtCore import Qt, QRectF, pyqtSignal
|
||||
from PyQt5.QtGui import QColor, QPainter
|
||||
from PyQt5.QtWidgets import QCheckBox, QLabel
|
||||
from PyQt5.QtWidgets import QCheckBox, QLabel, QFrame, QHBoxLayout
|
||||
|
||||
try:
|
||||
from PyQt5.QtWidgets import QGraphicsDropShadowEffect
|
||||
except ImportError: # 轻量测试桩不提供图形效果类,真实 PyQt5 仍使用阴影。
|
||||
class QGraphicsDropShadowEffect: # type: ignore[no-redef]
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def setBlurRadius(self, *_args):
|
||||
pass
|
||||
|
||||
def setOffset(self, *_args):
|
||||
pass
|
||||
|
||||
def setColor(self, *_args):
|
||||
pass
|
||||
|
||||
from .theme import C_BORDER, C_GREEN, C_RED, C_TITLE
|
||||
|
||||
@@ -102,3 +118,51 @@ class StatusDot(QLabel):
|
||||
border: 1px solid rgba(136, 153, 170, 50);
|
||||
}}
|
||||
""")
|
||||
|
||||
|
||||
class ProxyModeCard(QFrame):
|
||||
"""整个卡片可点击;描边表示选择,圆点表示所选模式的运行状态。"""
|
||||
|
||||
clicked = pyqtSignal()
|
||||
|
||||
def __init__(self, title: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self._glow = QGraphicsDropShadowEffect(self)
|
||||
self._glow.setBlurRadius(12)
|
||||
self._glow.setOffset(0, 1)
|
||||
self.setGraphicsEffect(self._glow)
|
||||
row = QHBoxLayout(self)
|
||||
row.setContentsMargins(11, 10, 11, 10)
|
||||
row.setSpacing(5)
|
||||
self.title = QLabel(title)
|
||||
self.title.setStyleSheet("font-weight: bold; background: transparent;")
|
||||
self.dot = StatusDot()
|
||||
for child in (self.title, self.dot):
|
||||
child.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||
row.addWidget(self.title)
|
||||
row.addStretch()
|
||||
row.addWidget(self.dot)
|
||||
self.set_state(False, False)
|
||||
|
||||
def set_state(self, selected: bool, running: bool) -> None:
|
||||
border = C_TITLE if selected else "#e2e8ef"
|
||||
background = "#f5f7f9" if selected else "#fafcfe"
|
||||
self.setStyleSheet(
|
||||
f"QFrame {{ background-color: {background}; border: {'2px' if selected else '1px'} "
|
||||
f"solid {border}; border-radius: 8px; }}"
|
||||
"QFrame:hover { border-color: #cbd6e2; background-color: #ffffff; }"
|
||||
"QLabel { border: none; }"
|
||||
)
|
||||
self._glow.setColor(QColor(136, 153, 170, 105 if selected else 52))
|
||||
if not selected:
|
||||
self.dot.set_disabled()
|
||||
elif running:
|
||||
self.dot.set_on()
|
||||
else:
|
||||
self.dot.set_off()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.LeftButton and self.rect().contains(event.pos()):
|
||||
self.clicked.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Jason Lyu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,84 @@
|
||||
Prebuilt Binaries License
|
||||
-------------------------
|
||||
|
||||
1. DEFINITIONS. "Software" means the precise contents of the "wintun.dll"
|
||||
files that are included in the .zip file that contains this document as
|
||||
downloaded from wintun.net/builds.
|
||||
|
||||
2. LICENSE GRANT. WireGuard LLC grants to you a non-exclusive and
|
||||
non-transferable right to use Software for lawful purposes under certain
|
||||
obligations and limited rights as set forth in this agreement.
|
||||
|
||||
3. RESTRICTIONS. Software is owned and copyrighted by WireGuard LLC. It is
|
||||
licensed, not sold. Title to Software and all associated intellectual
|
||||
property rights are retained by WireGuard. You must not:
|
||||
a. reverse engineer, decompile, disassemble, extract from, or otherwise
|
||||
modify the Software;
|
||||
b. modify or create derivative work based upon Software in whole or in
|
||||
parts, except insofar as only the API interfaces of the "wintun.h" file
|
||||
distributed alongside the Software (the "Permitted API") are used;
|
||||
c. remove any proprietary notices, labels, or copyrights from the Software;
|
||||
d. resell, redistribute, lease, rent, transfer, sublicense, or otherwise
|
||||
transfer rights of the Software without the prior written consent of
|
||||
WireGuard LLC, except insofar as the Software is distributed alongside
|
||||
other software that uses the Software only via the Permitted API;
|
||||
e. use the name of WireGuard LLC, the WireGuard project, the Wintun
|
||||
project, or the names of its contributors to endorse or promote products
|
||||
derived from the Software without specific prior written consent.
|
||||
|
||||
4. LIMITED WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTY OF
|
||||
ANY KIND. WIREGUARD LLC HEREBY EXCLUDES AND DISCLAIMS ALL IMPLIED OR
|
||||
STATUTORY WARRANTIES, INCLUDING ANY WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE, QUALITY, NON-INFRINGEMENT, TITLE, RESULTS,
|
||||
EFFORTS, OR QUIET ENJOYMENT. THERE IS NO WARRANTY THAT THE PRODUCT WILL BE
|
||||
ERROR-FREE OR WILL FUNCTION WITHOUT INTERRUPTION. YOU ASSUME THE ENTIRE
|
||||
RISK FOR THE RESULTS OBTAINED USING THE PRODUCT. TO THE EXTENT THAT
|
||||
WIREGUARD LLC MAY NOT DISCLAIM ANY WARRANTY AS A MATTER OF APPLICABLE LAW,
|
||||
THE SCOPE AND DURATION OF SUCH WARRANTY WILL BE THE MINIMUM PERMITTED UNDER
|
||||
SUCH LAW. ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS AND
|
||||
WARRANTIES, INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR
|
||||
A PARTICULAR PURPOSE OR NON-INFRINGEMENT ARE DISCLAIMED, EXCEPT TO THE
|
||||
EXTENT THAT THESE DISCLAIMERS ARE HELD TO BE LEGALLY INVALID.
|
||||
|
||||
5. LIMITATION OF LIABILITY. To the extent not prohibited by law, in no event
|
||||
WireGuard LLC or any third-party-developer will be liable for any lost
|
||||
revenue, profit or data or for special, indirect, consequential, incidental
|
||||
or punitive damages, however caused regardless of the theory of liability,
|
||||
arising out of or related to the use of or inability to use Software, even
|
||||
if WireGuard LLC has been advised of the possibility of such damages.
|
||||
Solely you are responsible for determining the appropriateness of using
|
||||
Software and accept full responsibility for all risks associated with its
|
||||
exercise of rights under this agreement, including but not limited to the
|
||||
risks and costs of program errors, compliance with applicable laws, damage
|
||||
to or loss of data, programs or equipment, and unavailability or
|
||||
interruption of operations. The foregoing limitations will apply even if
|
||||
the above stated warranty fails of its essential purpose. You acknowledge,
|
||||
that it is in the nature of software that software is complex and not
|
||||
completely free of errors. In no event shall WireGuard LLC or any
|
||||
third-party-developer be liable to you under any theory for any damages
|
||||
suffered by you or any user of Software or for any special, incidental,
|
||||
indirect, consequential or similar damages (including without limitation
|
||||
damages for loss of business profits, business interruption, loss of
|
||||
business information or any other pecuniary loss) arising out of the use or
|
||||
inability to use Software, even if WireGuard LLC has been advised of the
|
||||
possibility of such damages and regardless of the legal or quitable theory
|
||||
(contract, tort, or otherwise) upon which the claim is based.
|
||||
|
||||
6. TERMINATION. This agreement is affected until terminated. You may
|
||||
terminate this agreement at any time. This agreement will terminate
|
||||
immediately without notice from WireGuard LLC if you fail to comply with
|
||||
the terms and conditions of this agreement. Upon termination, you must
|
||||
delete Software and all copies of Software and cease all forms of
|
||||
distribution of Software.
|
||||
|
||||
7. SEVERABILITY. If any provision of this agreement is held to be
|
||||
unenforceable, this agreement will remain in effect with the provision
|
||||
omitted, unless omission would frustrate the intent of the parties, in
|
||||
which case this agreement will immediately terminate.
|
||||
|
||||
8. RESERVATION OF RIGHTS. All rights not expressly granted in this agreement
|
||||
are reserved by WireGuard LLC. For example, WireGuard LLC reserves the
|
||||
right at any time to cease development of Software, to alter distribution
|
||||
details, features, specifications, capabilities, functions, licensing
|
||||
terms, release dates, APIs, ABIs, general availability, or other
|
||||
characteristics of the Software.
|
||||
@@ -0,0 +1,12 @@
|
||||
RRPP Lite TUN 目录验证运行时
|
||||
|
||||
- tun2socks.exe: xjasonlyu/tun2socks v2.7.0, Windows amd64
|
||||
SHA-256: 076b3c3d6a372ba3ef49f2b415a4105f70c30a3ed3caaed7979390e649892559
|
||||
- wintun.dll: WireGuard Wintun 0.14.1, amd64
|
||||
SHA-256: e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce
|
||||
|
||||
来源:
|
||||
https://github.com/xjasonlyu/tun2socks/releases/tag/v2.7.0
|
||||
https://www.wintun.net/
|
||||
|
||||
许可证文件见 LICENSE.tun2socks.txt 和 LICENSE.wintun.txt。
|
||||
Binary file not shown.
Binary file not shown.
@@ -265,8 +265,99 @@ chmod 644 /etc/nginx/.htpasswd # worker 以 nobody 运行,必须可读
|
||||
1. `map` 判定私网/回环段 + cron 维护的本机公网 IP 规则 → 直连伪装页
|
||||
2. `/__proxy` 的 Loop-Guard 头兜底 → 二次进入返回 508
|
||||
|
||||
### 5.5 路线 B 裸 UoT relay
|
||||
|
||||
TUN 客户端的 UDP 会先通过认证 CONNECT 请求
|
||||
`sp.v2.udp-over-tcp.arpa:443`。当前 hk2 使用的
|
||||
`ngx_http_proxy_connect_module` 没有注册 `$connect_host`,因此在 fallback
|
||||
server 实际接收的 CONNECT 请求上用 `$request` 正则匹配,再把连接转到
|
||||
`127.0.0.1:15201`;nginx 只负责 TLS、Basic Auth 和字节流转发,不解析 UoT。relay 使用仓库中的
|
||||
`[uot_relay.py](uot_relay.py)`,只监听回环地址,不需要 sing-box:
|
||||
|
||||
```bash
|
||||
install -m 755 uot_relay.py /opt/rrpp-uot-relay/uot_relay.py
|
||||
install -m 644 systemd/uot-relay.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now uot-relay
|
||||
ss -lntp | grep 127.0.0.1:15201
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
客户端的 CONNECT 端口必须大于 0,当前统一使用 `443`;`15201` 是服务器回环
|
||||
relay 的内部端口,不写入客户端 CONNECT 目标。nginx fallback 块使用
|
||||
`proxy_connect_read_timeout 10m` 和 `proxy_connect_send_timeout 10m`,不要再同时
|
||||
配置会产生 duplicate 的 `proxy_connect_data_timeout`。
|
||||
|
||||
---
|
||||
|
||||
### 5.5 UoT(UDP over TCP)支持
|
||||
|
||||
HTTP CONNECT 只隧道 TCP。要让 UDP 应用(DNS/游戏/QUIC 等)走代理,需 **UoT v2**
|
||||
(SagerNet 专有协议,见 sing-box 文档 `configuration/shared/udp-over-tcp`):
|
||||
客户端把 UDP 数据报封装进 TCP 流,经 CONNECT 隧道发到 magic 地址
|
||||
`sp.v2.udp-over-tcp.arpa`,由本机 **UoT relay** 解封装后转发真实 UDP 目标。
|
||||
|
||||
```
|
||||
客户端 TUN/UDP ──UoT v2──▶ TLS HTTP CONNECT ──▶ nginx proxy_connect
|
||||
──▶ 127.0.0.1:15201(uot-relay)──▶ 真实 UDP 目标
|
||||
```
|
||||
|
||||
**relay 部署**(Python 标准库实现,见 [uot-relay/uot_relay.py](uot-relay/uot_relay.py)):
|
||||
|
||||
```bash
|
||||
install -d /opt/rrpp-uot-relay
|
||||
install -m 755 uot-relay/uot_relay.py /opt/rrpp-uot-relay/uot_relay.py
|
||||
install -m 644 uot-relay/uot-relay.service /etc/systemd/system/uot-relay.service
|
||||
systemctl daemon-reload && systemctl enable --now uot-relay
|
||||
```
|
||||
|
||||
relay 只监听 `127.0.0.1:15201`,空闲 9 分钟断开(须小于 nginx 隧道超时 10m)。
|
||||
支持 UoT v2 两种模式:`isConnect=1` 固定目标、`isConnect=0` 每帧自带目标。
|
||||
|
||||
**nginx 侧**(模板已含,[nginx/nginx.conf](nginx/nginx.conf)):`map $request` 识别
|
||||
magic 地址 → `127.0.0.1:15201`,其余 CONNECT 走 `$connect_addr` 默认解析;
|
||||
`proxy_connect_address $rrpp_uot_connect_address;` 放在 **default_server 块**。
|
||||
|
||||
**四个实测坑(2026-09 全节点部署验证)**:
|
||||
1. 该模块版本**未注册 `$connect_host` 变量**(README 有、源码无)→ 必须用
|
||||
`$request` 正则匹配(任何阶段可用);
|
||||
2. `proxy_connect_data_timeout` 与 `proxy_connect_read_timeout` **写同一配置槽**,
|
||||
同时写报 duplicate → 长连接超时用 read/send timeout 设 10m;
|
||||
3. **所有 CONNECT 请求都落在 `default_server`(fallback)块**——nginx 按请求
|
||||
Host 头选虚拟主机,CONNECT 的 Host 是目标域名,永远匹配不到 `server_name`;
|
||||
4. 模块**拒绝 CONNECT 端口 0**(`port < 1` 返回 400)→ 客户端连 magic 地址时
|
||||
端口必须非 0(用 443 即可,relay 不读 CONNECT 端口,目标取自 UoT 帧头)。
|
||||
|
||||
**客户端协议**:认证与 TCP CONNECT 相同(TLS + Basic Auth),然后
|
||||
`CONNECT sp.v2.udp-over-tcp.arpa:443 HTTP/1.1`,200 之后收发 UoT v2 帧:
|
||||
请求头 `| isConnect u8 | ATYP u8 | address | port u16be |`,
|
||||
connect 模式帧 `| length u16be | data |`,non-connect 帧每帧自带目标地址。
|
||||
|
||||
### 5.6 CONNECT 超时口径(避免客户端"卡死")
|
||||
|
||||
三个超时要按"客户端最没耐心"来配:
|
||||
|
||||
| 项 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| `proxy_connect_connect_timeout` | **10s** | 出站连接目标;须 < 客户端超时(lite 客户端 15s) |
|
||||
| `proxy_connect_read_timeout` / `send_timeout` | 10m | 隧道空闲超时(UoT 长连接需要;注意与 data_timeout 同槽位) |
|
||||
|
||||
**典型故障**:目标 IP 是黑洞(不发 RST)时,若 nginx 出站超时设 60s 而客户端 15s 超时,
|
||||
客户端会表现为 `The read operation timed out` 而不是收到 502 —— 看起来像"代理卡死",
|
||||
实际是超时错配。2026-09 实测:改为 10s 后,黑洞目标从 >25s 降至 10.2s 返回,
|
||||
正常目标(0.4~0.7s)不受影响。
|
||||
|
||||
**排查"CONNECT 502"的正确顺序**(重要):
|
||||
1. 先看目标是否**本身就不可达**——从多个网络(国内/日本/美国/香港)实测该 IP:端口,
|
||||
若都失败则是目标问题(服务下线/过期 IP/黑洞),不是代理问题;
|
||||
2. 再看 nginx error.log:`Connection refused` / `timed out` / `Network is unreachable`
|
||||
分别对应目标拒绝、目标黑洞、路由不可达;
|
||||
3. 最后才怀疑 CONNECT 模块本身。
|
||||
|
||||
> 客户端日志只记 IP 不记域名时,无法判断"是不是 DNS 过期解析"。
|
||||
> 建议客户端日志同时记录原始域名(实测案例:某 Dropbox 旧 IP 已失效,
|
||||
> 域名现已解析到其它地址,客户端仍在拨旧 IP)。
|
||||
|
||||
## 6. sing-box(VLESS+REALITY / Shadowsocks)
|
||||
|
||||
### 6.1 安装
|
||||
@@ -378,6 +469,7 @@ mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <上面生成的s
|
||||
| nginx | [systemd/nginx.service](systemd/nginx.service) |
|
||||
| sing-box | [systemd/sing-box.service](systemd/sing-box.service) |
|
||||
| mtg | [systemd/mtg.service](systemd/mtg.service) |
|
||||
| uot-relay | [uot-relay/uot-relay.service](uot-relay/uot-relay.service) |
|
||||
|
||||
```bash
|
||||
cp systemd/*.service /etc/systemd/system/
|
||||
@@ -396,6 +488,7 @@ sysctl --system # 1. BBR
|
||||
systemctl start sing-box # 2. VLESS/SS
|
||||
systemctl start mtg # 3. MTProto
|
||||
systemctl start nginx # 4. nginx(最后,依赖证书/伪装页/规则文件)
|
||||
systemctl start uot-relay # 5. UoT relay(须在 nginx 前可用)
|
||||
```
|
||||
|
||||
### 验证清单
|
||||
@@ -419,6 +512,9 @@ s=socket.connect(('<节点域名>',<mtproto端口>)) if False else socket.create
|
||||
s.sendall(b'\x16\x03\x01'+struct.pack('>H',100)+os.urandom(100))
|
||||
print('resp:',s.recv(8).hex())" # 期望首字节 15 (TLS alert)
|
||||
|
||||
# UoT relay(127.0.0.1:15201 监听 + CONNECT magic 地址)
|
||||
ss -tlnp | grep 15201 # => uot_relay.py 监听 127.0.0.1:15201
|
||||
|
||||
# nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>'))
|
||||
B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0)
|
||||
curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \
|
||||
@@ -440,6 +536,9 @@ server_config/
|
||||
│ ├── nginx.conf ← 完整 nginx 配置(占位符版)
|
||||
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
|
||||
│ └── conf.d/ ← 预留:额外站点配置
|
||||
├── uot-relay/
|
||||
│ ├── uot_relay.py ← UoT v2 relay(UDP over TCP 解封装)
|
||||
│ └── uot-relay.service ← systemd unit
|
||||
├── sing-box/
|
||||
│ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
|
||||
├── systemd/
|
||||
|
||||
@@ -16,6 +16,13 @@ http {
|
||||
default 0;
|
||||
"~^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)" 1;
|
||||
}
|
||||
# UoT v2 magic CONNECT 必须在 fallback server 中命中;当前模块版本没有
|
||||
# $connect_host,使用原始请求行匹配。客户端请求 magic-host:443,nginx
|
||||
# 将字节流转到本机裸 relay 127.0.0.1:15201。
|
||||
map $request $rrpp_uot_connect_address {
|
||||
default $connect_addr;
|
||||
"~^CONNECT[[:space:]]+sp\.v2\.udp-over-tcp\.arpa" 127.0.0.1:15201;
|
||||
}
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
@@ -44,9 +51,14 @@ http {
|
||||
|
||||
proxy_connect;
|
||||
proxy_connect_allow all;
|
||||
proxy_connect_connect_timeout 60s;
|
||||
proxy_connect_read_timeout 120s;
|
||||
proxy_connect_send_timeout 120s;
|
||||
proxy_connect_address $rrpp_uot_connect_address;
|
||||
# 出站连接超时须小于客户端超时(lite 客户端 15s):
|
||||
# 目标黑洞时快速返回 502,避免客户端"read operation timed out"干等
|
||||
proxy_connect_connect_timeout 10s;
|
||||
# 注:此模块版本 proxy_connect_data_timeout 与 proxy_connect_read_timeout
|
||||
# 写同一字段(同时配置报 duplicate),长连接超时用 read/send timeout 设置
|
||||
proxy_connect_read_timeout 10m;
|
||||
proxy_connect_send_timeout 10m;
|
||||
|
||||
location / {
|
||||
auth_basic off;
|
||||
@@ -92,9 +104,14 @@ http {
|
||||
|
||||
proxy_connect;
|
||||
proxy_connect_allow all;
|
||||
proxy_connect_connect_timeout 60s;
|
||||
proxy_connect_read_timeout 120s;
|
||||
proxy_connect_send_timeout 120s;
|
||||
proxy_connect_address $rrpp_uot_connect_address;
|
||||
# 出站连接超时须小于客户端超时(lite 客户端 15s):
|
||||
# 目标黑洞时快速返回 502,避免客户端"read operation timed out"干等
|
||||
proxy_connect_connect_timeout 10s;
|
||||
# 注:此模块版本 proxy_connect_data_timeout 与 proxy_connect_read_timeout
|
||||
# 写同一字段(同时配置报 duplicate),长连接超时用 read/send timeout 设置
|
||||
proxy_connect_read_timeout 10m;
|
||||
proxy_connect_send_timeout 10m;
|
||||
|
||||
location / {
|
||||
auth_basic off;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=RRPP bare UDP over TCP v2 relay
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
User=rrpp
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/rrpp
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=RRPP bare UDP over TCP v2 relay
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
User=rrpp
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/rrpp
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=RRPP UoT v2 relay (UDP over TCP decapsulator, localhost only)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /opt/rrpp-uot-relay/uot_relay.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# 双保险:只许监听本机(脚本本身 bind 127.0.0.1)
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
RRPP UoT v2 relay — UDP over TCP 解封装服务(sing-box UoT v2 协议)
|
||||
|
||||
协议(SagerNet 专有,见 sing-box 文档 configuration/shared/udp-over-tcp):
|
||||
|
||||
请求头: | isConnect u8 | ATYP u8 | address | port u16be |
|
||||
isConnect=1: 固定目标模式,后续帧全部是 length+data,发往请求头里的目标
|
||||
isConnect=0: 每帧自带目标,帧格式同 v1: | ATYP | address | port | length u16be | data |
|
||||
|
||||
connect 帧: | length u16be | data |
|
||||
non-connect 帧: | ATYP | address | port | length u16be | data |
|
||||
|
||||
监听 127.0.0.1,仅供本机 nginx proxy_connect 转发,不对公网开放。
|
||||
"""
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import select
|
||||
import time
|
||||
import sys
|
||||
|
||||
LISTEN_HOST = "127.0.0.1"
|
||||
LISTEN_PORT = 15201
|
||||
IDLE_TIMEOUT = 540 # 9 分钟无流量断开(须 < nginx proxy_connect_data_timeout 10m)
|
||||
MAX_DGRAM = 65507 # UDP over IPv4 单包上限
|
||||
LOG_PREFIX = "[uot-relay]"
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"{LOG_PREFIX} {time.strftime('%Y-%m-%d %H:%M:%S')} {msg}", flush=True)
|
||||
|
||||
|
||||
def read_exact(f, n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = f.read(n - len(buf))
|
||||
if not chunk:
|
||||
raise EOFError("connection closed")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def parse_addr(f):
|
||||
"""从流中读取 SOCKS 格式地址(ATYP + address + port)。"""
|
||||
atyp = read_exact(f, 1)[0]
|
||||
if atyp == 0x00:
|
||||
addr = socket.inet_ntop(socket.AF_INET, read_exact(f, 4))
|
||||
elif atyp == 0x01:
|
||||
addr = socket.inet_ntop(socket.AF_INET6, read_exact(f, 16))
|
||||
elif atyp == 0x02:
|
||||
ln = read_exact(f, 1)[0]
|
||||
addr = read_exact(f, ln).decode("utf-8", "replace")
|
||||
else:
|
||||
raise ValueError(f"bad ATYP {atyp:#x}")
|
||||
port = struct.unpack(">H", read_exact(f, 2))[0]
|
||||
return addr, port
|
||||
|
||||
|
||||
def encode_addr(addr, port):
|
||||
"""SOCKS 格式地址编码(用于 non-connect 模式的回包)。"""
|
||||
try:
|
||||
return b"\x00" + socket.inet_pton(socket.AF_INET, addr) + struct.pack(">H", port)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
return b"\x01" + socket.inet_pton(socket.AF_INET6, addr) + struct.pack(">H", port)
|
||||
except OSError:
|
||||
pass
|
||||
d = addr.encode("utf-8")
|
||||
if len(d) > 255:
|
||||
raise ValueError("domain too long")
|
||||
return b"\x02" + bytes([len(d)]) + d + struct.pack(">H", port)
|
||||
|
||||
|
||||
def handle(conn, peer):
|
||||
conn.settimeout(IDLE_TIMEOUT)
|
||||
f = conn.makefile("rb")
|
||||
udp = None
|
||||
stop = threading.Event()
|
||||
try:
|
||||
is_connect = read_exact(f, 1)[0]
|
||||
req_dst = parse_addr(f)
|
||||
log(f"conn from nginx: isConnect={is_connect} dst={req_dst}")
|
||||
|
||||
udp = socket.socket(socket.AF_INET6 if ":" in req_dst[0] else socket.AF_INET,
|
||||
socket.SOCK_DGRAM)
|
||||
udp.setblocking(False)
|
||||
if is_connect:
|
||||
udp.connect(req_dst) # 固定目标:连通式 UDP
|
||||
|
||||
write_lock = threading.Lock()
|
||||
|
||||
def tcp_write(data):
|
||||
with write_lock:
|
||||
conn.sendall(data)
|
||||
|
||||
def uplink():
|
||||
"""TCP -> UDP:按格式读帧并发送。"""
|
||||
try:
|
||||
while not stop.is_set():
|
||||
if is_connect:
|
||||
ln = struct.unpack(">H", read_exact(f, 2))[0]
|
||||
if ln > MAX_DGRAM:
|
||||
raise ValueError(f"datagram too large: {ln}")
|
||||
data = read_exact(f, ln)
|
||||
udp.send(data)
|
||||
else:
|
||||
dst = parse_addr(f)
|
||||
ln = struct.unpack(">H", read_exact(f, 2))[0]
|
||||
if ln > MAX_DGRAM:
|
||||
raise ValueError(f"datagram too large: {ln}")
|
||||
data = read_exact(f, ln)
|
||||
udp.sendto(data, dst)
|
||||
except Exception as e:
|
||||
if not stop.is_set():
|
||||
log(f"uplink end: {type(e).__name__}: {e}")
|
||||
finally:
|
||||
stop.set()
|
||||
|
||||
t = threading.Thread(target=uplink, daemon=True)
|
||||
t.start()
|
||||
|
||||
# 主循环:UDP -> TCP,带空闲超时
|
||||
last = time.time()
|
||||
while not stop.is_set():
|
||||
r, _, _ = select.select([udp], [], [], 2.0)
|
||||
if r:
|
||||
if is_connect:
|
||||
data = udp.recv(MAX_DGRAM)
|
||||
tcp_write(struct.pack(">H", len(data)) + data)
|
||||
else:
|
||||
data, src = udp.recvfrom(MAX_DGRAM)
|
||||
tcp_write(encode_addr(*src) + struct.pack(">H", len(data)) + data)
|
||||
last = time.time()
|
||||
elif time.time() - last > IDLE_TIMEOUT:
|
||||
log("idle timeout, closing")
|
||||
break
|
||||
except Exception as e:
|
||||
log(f"conn error: {type(e).__name__}: {e}")
|
||||
finally:
|
||||
stop.set()
|
||||
try:
|
||||
f.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if udp:
|
||||
try:
|
||||
udp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind((LISTEN_HOST, LISTEN_PORT))
|
||||
srv.listen(128)
|
||||
log(f"listening on {LISTEN_HOST}:{LISTEN_PORT}")
|
||||
while True:
|
||||
conn, peer = srv.accept()
|
||||
threading.Thread(target=handle, args=(conn, peer), daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RRPP 裸 UoT v2 relay。
|
||||
|
||||
nginx 的 proxy_connect 模块负责 TLS、Basic Auth 和 CONNECT,relay 只监听
|
||||
127.0.0.1:15201,收到的首字节就是 UoT v2 request,不再解析 HTTP。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
|
||||
MAGIC = "sp.v2.udp-over-tcp.arpa"
|
||||
LISTEN_HOST = "127.0.0.1"
|
||||
LISTEN_PORT = 15201
|
||||
MAX_PACKET = 65535
|
||||
IDLE_TIMEOUT = 9 * 60
|
||||
|
||||
|
||||
def read_exact(conn: socket.socket, size: int) -> bytes:
|
||||
data = bytearray()
|
||||
while len(data) < size:
|
||||
chunk = conn.recv(size - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("客户端连接已关闭")
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def read_address(conn: socket.socket):
|
||||
atyp = read_exact(conn, 1)[0]
|
||||
if atyp == 0:
|
||||
host = str(ipaddress.IPv4Address(read_exact(conn, 4)))
|
||||
elif atyp == 1:
|
||||
host = str(ipaddress.IPv6Address(read_exact(conn, 16)))
|
||||
elif atyp == 2:
|
||||
size = read_exact(conn, 1)[0]
|
||||
host = read_exact(conn, size).decode("idna")
|
||||
else:
|
||||
raise ValueError(f"未知 UoT 地址类型: {atyp}")
|
||||
return host, struct.unpack("!H", read_exact(conn, 2))[0]
|
||||
|
||||
|
||||
def encode_address(host: str, port: int) -> bytes:
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
raw = host.encode("idna")
|
||||
return b"\x02" + bytes([len(raw)]) + raw + struct.pack("!H", port)
|
||||
return (b"\x00" if addr.version == 4 else b"\x01") + addr.packed + struct.pack("!H", port)
|
||||
|
||||
|
||||
def resolve_destination(host: str, port: int):
|
||||
return socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_DGRAM)[0][4]
|
||||
|
||||
|
||||
def handle(conn: socket.socket, peer):
|
||||
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
udp.settimeout(0.2)
|
||||
# 与 nginx 的 10 分钟读写超时错开,空闲连接由 relay 先回收。
|
||||
conn.settimeout(IDLE_TIMEOUT)
|
||||
send_lock = threading.Lock()
|
||||
try:
|
||||
is_connect = read_exact(conn, 1)[0]
|
||||
first_target = read_address(conn)
|
||||
logging.info("UoT client %s target=%s:%s connect=%s", peer, *first_target, is_connect)
|
||||
|
||||
def udp_reader():
|
||||
while True:
|
||||
try:
|
||||
data, source = udp.recvfrom(MAX_PACKET)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
with send_lock:
|
||||
if is_connect:
|
||||
conn.sendall(struct.pack("!H", len(data)) + data)
|
||||
else:
|
||||
conn.sendall(encode_address(source[0], source[1]) +
|
||||
struct.pack("!H", len(data)) + data)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
threading.Thread(target=udp_reader, daemon=True).start()
|
||||
if is_connect:
|
||||
while True:
|
||||
length = struct.unpack("!H", read_exact(conn, 2))[0]
|
||||
payload = read_exact(conn, length)
|
||||
udp.sendto(payload, resolve_destination(*first_target))
|
||||
else:
|
||||
while True:
|
||||
destination = read_address(conn)
|
||||
length = struct.unpack("!H", read_exact(conn, 2))[0]
|
||||
payload = read_exact(conn, length)
|
||||
udp.sendto(payload, resolve_destination(*destination))
|
||||
except (ConnectionError, OSError, ValueError) as exc:
|
||||
logging.info("UoT client %s closed: %s", peer, exc)
|
||||
finally:
|
||||
udp.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind((LISTEN_HOST, LISTEN_PORT))
|
||||
listener.listen(128)
|
||||
logging.info("RRPP UoT relay listening on %s:%d", LISTEN_HOST, LISTEN_PORT)
|
||||
while True:
|
||||
conn, peer = listener.accept()
|
||||
threading.Thread(target=handle, args=(conn, peer), daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user