Files

254 lines
9.9 KiB
Python

"""按需提权的 TUN 控制通道;GUI 与管理员工作进程共用同一个入口。"""
from __future__ import annotations
import ctypes
import hmac
import json
import os
import secrets
import socket
import subprocess
import threading
import time
from typing import Any, Callable
WORKER_FLAG = "--rrpp-tun-worker"
_MAX_MESSAGE = 1024 * 1024
class TunModeError(RuntimeError):
pass
def _send(sock: socket.socket, value: dict[str, Any], lock: threading.Lock | None = None) -> None:
payload = (json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
if len(payload) > _MAX_MESSAGE:
raise TunModeError("TUN 控制消息过大")
if lock is None:
sock.sendall(payload)
else:
with lock:
sock.sendall(payload)
def _receive(reader) -> dict[str, Any]:
line = reader.readline(_MAX_MESSAGE + 1)
if not line or len(line) > _MAX_MESSAGE or not line.endswith(b"\n"):
raise TunModeError("TUN 工作进程连接已断开或消息无效")
value = json.loads(line.decode("utf-8"))
if not isinstance(value, dict):
raise TunModeError("TUN 控制消息格式错误")
return value
class TunModeController:
"""普通权限 GUI 仅在首次开启 TUN 时启动管理员工作进程。"""
def __init__(self, command: list[str], log: Callable[[str], None],
detail_log: Callable[[str], None] | None = None):
self.command = command
self.log = log
self.detail_log = detail_log or log
self._lock = threading.RLock()
self._socket: socket.socket | None = None
self._reader = None
def _launch(self, port: int, token: str) -> None:
args = [*self.command[1:], WORKER_FLAG, str(port), token]
if os.name == "nt":
# PyInstaller onefile 的提权子进程必须独立解包,以便 GUI 退出后仍可清理网络。
previous = os.environ.get("PYINSTALLER_RESET_ENVIRONMENT")
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
try:
result = ctypes.windll.shell32.ShellExecuteW(
None, "runas", self.command[0], subprocess.list2cmdline(args),
os.path.dirname(os.path.abspath(self.command[0])), 0,
)
finally:
if previous is None:
os.environ.pop("PYINSTALLER_RESET_ENVIRONMENT", None)
else:
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = previous
if result <= 32:
raise TunModeError("管理员授权已取消或无法启动 TUN 工作进程")
else:
# 非 Windows 仅供无网卡的协议测试;真实 TUN 后端仍限定 Windows。
subprocess.Popen([self.command[0], *args], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
def start(self, payload: dict[str, Any], cancel_event=None) -> None:
with self._lock:
if self._socket is not None:
raise TunModeError("TUN 工作进程已经启动")
token = secrets.token_hex(32)
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(2)
deadline = time.monotonic() + 90
try:
self._launch(listener.getsockname()[1], token)
while True:
if cancel_event is not None and cancel_event.is_set():
raise TunModeError("用户取消了开启代理")
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TunModeError("等待 TUN 管理员工作进程超时")
listener.settimeout(min(0.5, remaining))
try:
connection, _peer = listener.accept()
except socket.timeout:
continue
connection.settimeout(3)
reader = connection.makefile("rb")
try:
hello = _receive(reader)
valid = (hello.get("type") == "hello" and
hmac.compare_digest(str(hello.get("token") or ""), token))
except Exception:
valid = False
if valid:
connection.settimeout(120)
self._socket, self._reader = connection, reader
break
reader.close()
connection.close()
if cancel_event is not None and cancel_event.is_set():
raise TunModeError("用户取消了开启代理")
self._request("start", payload)
except Exception:
self.close()
raise
finally:
listener.close()
def _request(self, command: str, payload: dict[str, Any] | None = None) -> Any:
if self._socket is None or self._reader is None:
raise TunModeError("TUN 工作进程未连接")
try:
_send(self._socket, {"command": command, "payload": payload or {}})
while True:
message = _receive(self._reader)
if message.get("type") == "log":
callback = self.detail_log if message.get("detail") else self.log
callback(str(message.get("message") or ""))
continue
if message.get("type") != "result":
raise TunModeError("TUN 工作进程响应无效")
if not message.get("ok"):
raise TunModeError(str(message.get("error") or "TUN 操作失败"))
return message.get("value")
except TunModeError:
raise
except OSError as exc:
raise TunModeError(f"TUN 工作进程连接中止: {exc}") from exc
def status(self) -> dict[str, Any]:
with self._lock:
if self._socket is None:
return {"running": False, "pid": None, "exit_code": None}
try:
return self._request("status")
except (OSError, TunModeError, ValueError):
self.close()
return {"running": False, "pid": None, "exit_code": None}
def traffic(self) -> tuple[int, int, int, int]:
with self._lock:
if self._socket is None:
return 0, 0, 0, 0
try:
return tuple(self._request("traffic"))
except (OSError, TunModeError, ValueError):
return 0, 0, 0, 0
def stop(self) -> None:
with self._lock:
try:
if self._socket is not None:
self._request("stop")
finally:
self.close()
def close(self) -> None:
with self._lock:
if self._socket is not None:
try:
self._request("quit")
except (OSError, TunModeError, ValueError):
pass
reader, connection = self._reader, self._socket
self._reader = self._socket = None
if reader is not None:
reader.close()
if connection is not None:
connection.close()
def run_tun_worker(port: int, token: str, backend_factory,
start_backend: Callable[[Any, dict[str, Any]], None]) -> None:
"""管理员进程入口。主 GUI 断开后,必定清理本进程建立的网络状态。"""
connection = socket.create_connection(("127.0.0.1", int(port)), timeout=15)
connection.settimeout(None)
reader = connection.makefile("rb")
send_lock = threading.Lock()
_send(connection, {"type": "hello", "token": token}, send_lock)
def emit(message: str, detail: bool = False) -> None:
try:
_send(connection, {"type": "log", "message": str(message),
"detail": bool(detail)}, send_lock)
except OSError:
pass
class _WorkerLogger:
def __call__(self, message: str) -> None:
emit(message)
def detail(self, message: str) -> None:
emit(message, detail=True)
logger = _WorkerLogger()
backend = None
try:
while True:
try:
request = _receive(reader)
except TunModeError:
break
command = request.get("command")
try:
if command == "start":
if not isinstance(request.get("payload"), dict):
raise TunModeError("TUN 启动参数无效")
# 延迟创建后端,使初始化失败也能通过控制协议返回具体原因,
# 避免 GUI 只看到 Windows 的 10053 连接中止错误。
if backend is None:
backend = backend_factory(logger)
start_backend(backend, request["payload"])
value = None
elif command == "stop":
backend.stop()
value = None
elif command == "status":
value = backend.status()
elif command == "traffic":
value = backend.traffic()
elif command == "quit":
backend.stop()
value = None
else:
raise TunModeError("未知的 TUN 控制命令")
_send(connection, {"type": "result", "ok": True, "value": value}, send_lock)
except Exception as exc:
_send(connection, {"type": "result", "ok": False, "error": str(exc)}, send_lock)
if command == "quit":
break
finally:
if backend is not None:
backend.stop()
reader.close()
connection.close()