diff --git a/lite/RRPP_Lite.spec b/lite/RRPP_Lite.spec index fe31960..ce1134b 100644 --- a/lite/RRPP_Lite.spec +++ b/lite/RRPP_Lite.spec @@ -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''' 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={}, diff --git a/lite/build_nuitka.bat b/lite/build_nuitka.bat index bce5563..cfd384d 100644 --- a/lite/build_nuitka.bat +++ b/lite/build_nuitka.bat @@ -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" ^ diff --git a/lite/config.json b/lite/config.json index bcfb439..25ff255 100644 --- a/lite/config.json +++ b/lite/config.json @@ -1,5 +1,5 @@ { - "version": "999909182137", + "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" diff --git a/lite/main.py b/lite/main.py index a317e63..0e68efb 100644 --- a/lite/main.py +++ b/lite/main.py @@ -1,6 +1,6 @@ # -*- 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 @@ -13,7 +13,7 @@ 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 放大/缩小,因此代码里保持逻辑尺寸不变即可。 @@ -1004,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() # 打印本次转发流量统计 @@ -1344,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: @@ -1370,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", True)) +_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);设置项,默认关=直连仓库。""" @@ -1488,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: @@ -1549,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"] @@ -1671,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 "", "域名不能为空" @@ -2542,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(): """清理上次异常退出残留的系统代理设置,并恢复用户原有设置。 @@ -2558,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: @@ -2673,14 +2630,16 @@ 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, @@ -2705,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), + } + # ── 代理白名单弹窗 ───────────────────────────────────── # ── 主窗口 ──────────────────────────────────────────── @@ -2722,6 +2769,8 @@ 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 @@ -2766,6 +2815,23 @@ 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(): @@ -2803,11 +2869,11 @@ class MainWindow(QWidget): self._tray_menu.addAction(show_action) self._tray_menu.addSeparator() - proxy_on = is_proxy_on() - pac_on = _should_use_internal_pac(_use_pac_mode(), _use_builtin_pac()) + 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 proxy_on else '关'}", + f"{'系统代理' if self._mode == 'system' else '虚拟网卡'}:{'开' if proxy_on else '关'}", f"PAC:{'启用' if pac_on else '关闭'}", f"内网IP:{'不代理' if bypass_lan else '代理'}", ): @@ -2906,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) @@ -3089,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 # 网络探测可能需要数秒;此时保留按钮给用户取消,而不是把界面锁死。 @@ -3097,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: @@ -3117,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 @@ -3127,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) @@ -3144,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() @@ -3155,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: @@ -3173,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: @@ -3213,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) @@ -3236,6 +3312,8 @@ class MainWindow(QWidget): """设置页关闭后同步主页面的本地偏好显示。""" 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() # ── 节点登录 ── @@ -3391,32 +3469,26 @@ 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") @@ -3635,10 +3707,7 @@ 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() @@ -3650,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() @@ -3698,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() # 单实例:第二个实例直接退出(且不能执行下面的残留代理清理, diff --git a/lite/rrpp-tun.nuitka-package.config.yml b/lite/rrpp-tun.nuitka-package.config.yml new file mode 100644 index 0000000..f4ebb43 --- /dev/null +++ b/lite/rrpp-tun.nuitka-package.config.yml @@ -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' diff --git a/lite/rrpp_core/dns_observer.py b/lite/rrpp_core/dns_observer.py new file mode 100644 index 0000000..e8581d9 --- /dev/null +++ b/lite/rrpp_core/dns_observer.py @@ -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] diff --git a/lite/rrpp_core/socks_bridge.py b/lite/rrpp_core/socks_bridge.py new file mode 100644 index 0000000..9ecbf99 --- /dev/null +++ b/lite/rrpp_core/socks_bridge.py @@ -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 diff --git a/lite/rrpp_core/system_proxy_mode.py b/lite/rrpp_core/system_proxy_mode.py new file mode 100644 index 0000000..6298c85 --- /dev/null +++ b/lite/rrpp_core/system_proxy_mode.py @@ -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 "")) diff --git a/lite/rrpp_core/tun_backend.py b/lite/rrpp_core/tun_backend.py new file mode 100644 index 0000000..68f9bf2 --- /dev/null +++ b/lite/rrpp_core/tun_backend.py @@ -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() diff --git a/lite/rrpp_core/tun_mode.py b/lite/rrpp_core/tun_mode.py new file mode 100644 index 0000000..93a1355 --- /dev/null +++ b/lite/rrpp_core/tun_mode.py @@ -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() diff --git a/lite/rrpp_core/uot_protocol.py b/lite/rrpp_core/uot_protocol.py new file mode 100644 index 0000000..977263e --- /dev/null +++ b/lite/rrpp_core/uot_protocol.py @@ -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) diff --git a/lite/rrpp_ui/dialogs.py b/lite/rrpp_ui/dialogs.py index 6caee3e..8a8c810 100644 --- a/lite/rrpp_ui/dialogs.py +++ b/lite/rrpp_ui/dialogs.py @@ -931,14 +931,16 @@ 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 @@ -961,7 +963,7 @@ class SettingsDialog(QDialog): self._services = services self.setWindowTitle("设置") self.setWindowIcon(services.load_icon()) - self.setFixedSize(300, 470) + 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; }} @@ -999,6 +1001,23 @@ class SettingsDialog(QDialog): layout.addWidget(self.lb_hint) 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; @@ -1019,7 +1038,7 @@ class SettingsDialog(QDialog): 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("代理跳板") @@ -1032,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("第三方订阅") @@ -1045,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("日志") @@ -1067,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("使用代理更新") @@ -1092,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("跳过检查更新") @@ -1102,7 +1132,7 @@ 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) + right_col.addLayout(skip_row) node_display_row = QHBoxLayout() node_display_label = QLabel("节点信息显示") @@ -1125,7 +1155,7 @@ class SettingsDialog(QDialog): self.btn_node_remark.setChecked(True) else: self.btn_node_domain.setChecked(True) - layout.addLayout(node_display_row) + right_col.addLayout(node_display_row) close_behavior_row = QHBoxLayout() close_behavior_label = QLabel("默认关闭行为") @@ -1148,7 +1178,7 @@ class SettingsDialog(QDialog): self.btn_close_tray.setChecked(True) else: self.btn_close_exit.setChecked(True) - layout.addLayout(close_behavior_row) + right_col.addLayout(close_behavior_row) reset_row = QHBoxLayout() reset_label = QLabel("重置用户配置") @@ -1162,10 +1192,11 @@ class SettingsDialog(QDialog): self.btn_reset.setEnabled(not port_locked) self.btn_reset.clicked.connect(self._reset_user_config) reset_row.addWidget(self.btn_reset) - layout.addLayout(reset_row) + right_col.addLayout(reset_row) - layout.addLayout(log_row) - layout.addStretch() + right_col.addLayout(log_row) + left_col.addStretch() + right_col.addStretch() buttons = QHBoxLayout() buttons.setSpacing(10) @@ -1233,7 +1264,8 @@ class SettingsDialog(QDialog): return self._services.reset_user_config() self.ed_port.setText(str(self._services.get_port())) - self.cb_builtin_pac.setChecked(self._services.get_builtin_pac()) + self.cb_system_pac.setChecked(self._services.get_system_pac()) + self.ed_dns.setText(", ".join(self._services.get_dns())) self.cb_update_proxy.setChecked(self._services.get_update_via_proxy()) self.cb_skip_update.setChecked(self._services.get_skip_update_check()) self.btn_node_domain.setChecked(True) @@ -1247,8 +1279,14 @@ class SettingsDialog(QDialog): 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( diff --git a/lite/rrpp_ui/widgets.py b/lite/rrpp_ui/widgets.py index 20ed0c6..f978449 100644 --- a/lite/rrpp_ui/widgets.py +++ b/lite/rrpp_ui/widgets.py @@ -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) diff --git a/lite/runtime/LICENSE.tun2socks.txt b/lite/runtime/LICENSE.tun2socks.txt new file mode 100644 index 0000000..a609dd6 --- /dev/null +++ b/lite/runtime/LICENSE.tun2socks.txt @@ -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. diff --git a/lite/runtime/LICENSE.wintun.txt b/lite/runtime/LICENSE.wintun.txt new file mode 100644 index 0000000..9ed5018 --- /dev/null +++ b/lite/runtime/LICENSE.wintun.txt @@ -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. diff --git a/lite/runtime/README.txt b/lite/runtime/README.txt new file mode 100644 index 0000000..9ffc382 --- /dev/null +++ b/lite/runtime/README.txt @@ -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。 diff --git a/lite/runtime/tun2socks.exe b/lite/runtime/tun2socks.exe new file mode 100644 index 0000000..05dcaf9 Binary files /dev/null and b/lite/runtime/tun2socks.exe differ diff --git a/lite/runtime/wintun.dll b/lite/runtime/wintun.dll new file mode 100644 index 0000000..aee04e7 Binary files /dev/null and b/lite/runtime/wintun.dll differ diff --git a/server_config/README.md b/server_config/README.md index 4ed29da..5382b42 100644 --- a/server_config/README.md +++ b/server_config/README.md @@ -265,6 +265,29 @@ 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)支持 diff --git a/server_config/nginx/nginx.conf b/server_config/nginx/nginx.conf index 9f60949..e71f079 100644 --- a/server_config/nginx/nginx.conf +++ b/server_config/nginx/nginx.conf @@ -16,18 +16,12 @@ http { default 0; "~^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)" 1; } - - # UoT v2(UDP over TCP):CONNECT 目标为 sing-box UoT v2 magic 地址 - # (sp.v2.udp-over-tcp.arpa)时转发到本机 UoT relay(127.0.0.1:15201), - # 其余 CONNECT 走默认解析($connect_addr)。 - # 注意(实测结论,勿踩坑): - # 1. 必须用 $request 匹配——此模块版本未注册 $connect_host 变量; - # 2. proxy_connect_address 必须放在 default_server 块——nginx 按 Host 头 - # 选虚拟主机,CONNECT 的 Host 是目标域名,永远落在 default_server; - # 3. 客户端 CONNECT magic 地址时端口必须非 0(模块拒绝 port<1,返回 400)。 + # 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 sp\.v2\.udp-over-tcp\.arpa" 127.0.0.1:15201; + default $connect_addr; + "~^CONNECT[[:space:]]+sp\.v2\.udp-over-tcp\.arpa" 127.0.0.1:15201; } include mime.types; default_type application/octet-stream; diff --git a/server_config/systemd/rrpp-uot-relay.service b/server_config/systemd/rrpp-uot-relay.service new file mode 100644 index 0000000..ff717cc --- /dev/null +++ b/server_config/systemd/rrpp-uot-relay.service @@ -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 diff --git a/server_config/systemd/uot-relay.service b/server_config/systemd/uot-relay.service new file mode 100644 index 0000000..ff717cc --- /dev/null +++ b/server_config/systemd/uot-relay.service @@ -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 diff --git a/server_config/uot_relay.py b/server_config/uot_relay.py new file mode 100644 index 0000000..c6a1a21 --- /dev/null +++ b/server_config/uot_relay.py @@ -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()