777 lines
37 KiB
Python
777 lines
37 KiB
Python
"""路线 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:
|
||
address = ipaddress.ip_address(item)
|
||
if address.is_unspecified or address.is_multicast:
|
||
raise ValueError(item)
|
||
except ValueError as exc:
|
||
raise TunBackendError("自定义 DNS 必须是有效的 IPv4/IPv6 地址") from exc
|
||
self._tun_dns_servers = cleaned[:3]
|
||
engine = self.resolve_engine()
|
||
interface = self._physical_interface()
|
||
source_address = self._physical_source_address(interface)
|
||
self._physical_gateway, self._physical_ifindex = self._physical_route_info()
|
||
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()
|