feat(lite): merge TUN mode with IPv6 routing
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user