Compare commits

..
7 changed files with 164 additions and 67 deletions
+2
View File
@@ -26,6 +26,8 @@ call ..\.venv\Scripts\python.exe -m nuitka ^
--include-data-files=runtime/LICENSE.tun2socks.txt=LICENSE.tun2socks.txt ^ --include-data-files=runtime/LICENSE.tun2socks.txt=LICENSE.tun2socks.txt ^
--include-data-files=runtime/LICENSE.wintun.txt=LICENSE.wintun.txt ^ --include-data-files=runtime/LICENSE.wintun.txt=LICENSE.wintun.txt ^
--include-data-files=runtime/README.txt=README.txt ^ --include-data-files=runtime/README.txt=README.txt ^
--include-data-files=runtime/tun2socks.exe=tun2socks.exe ^
--include-data-files=runtime/wintun.dll=wintun.dll ^
--windows-icon-from-ico=ico.ico ^ --windows-icon-from-ico=ico.ico ^
--windows-console-mode=disable ^ --windows-console-mode=disable ^
--company-name="Global Rosmanka Cloud Nexus Inc" ^ --company-name="Global Rosmanka Cloud Nexus Inc" ^
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "999909190333", "version": "999909190441",
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/", "pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client", "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" "update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
+29 -15
View File
@@ -1056,7 +1056,7 @@ def _verify_proxy_connectivity(timeout=10, opener=None, cancel_event=None):
response.read(1) response.read(1)
_raise_if_start_cancelled(cancel_event) _raise_if_start_cancelled(cancel_event)
results[name] = True results[name] = True
log(f" {name} 可达") log_detail(f" {name} 可达")
except _StartCancelled: except _StartCancelled:
raise raise
except Exception as e: except Exception as e:
@@ -1064,7 +1064,7 @@ def _verify_proxy_connectivity(timeout=10, opener=None, cancel_event=None):
# 这不是“开启失败”,也不能在稍后把界面重新切成已开启。 # 这不是“开启失败”,也不能在稍后把界面重新切成已开启。
_raise_if_start_cancelled(cancel_event) _raise_if_start_cancelled(cancel_event)
results[name] = False results[name] = False
log(f" {name} 不可达: {e}") log_detail(f" {name} 不可达: {e}")
if required: if required:
raise RuntimeError(f"境外隧道验证失败({name}):{e}") from e raise RuntimeError(f"境外隧道验证失败({name}):{e}") from e
return results return results
@@ -1116,7 +1116,7 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma
svr.close() svr.close()
svr = None svr = None
if attempt < 2: if attempt < 2:
log(f"端口 {_TLS_LOCAL_PORT} 绑定失败: {e},1 秒后重试 ({attempt + 1}/3)") log_detail(f"端口 {_TLS_LOCAL_PORT} 绑定失败: {e},1 秒后重试 ({attempt + 1}/3)")
time.sleep(1) time.sleep(1)
if svr is None: if svr is None:
raise last_err raise last_err
@@ -1145,7 +1145,7 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma
if not quiet: if not quiet:
route = "→".join(str(node.get("country") or "?") for node in (jump_chain or [])) route = "→".join(str(node.get("country") or "?") for node in (jump_chain or []))
suffix = f" [{route}]" if route else "" suffix = f" [{route}]" if route else ""
log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port}{suffix})") log_detail(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port}{suffix})")
def _inject_proxy_auth(data: bytes, proxy_node=None) -> bytes: def _inject_proxy_auth(data: bytes, proxy_node=None) -> bytes:
"""在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。 """在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。
@@ -1312,6 +1312,15 @@ def _set_tun_dns_servers(value):
data["tun_dns"] = list(parsed) data["tun_dns"] = list(parsed)
_settings_save(data) _settings_save(data)
def _tun_ipv6_enabled():
"""TUN IPv6 默认关闭,避免未验证的 IPv6 路径接管系统流量。"""
return bool(_settings_load().get("tun_ipv6_enabled", False))
def _set_tun_ipv6_enabled(value):
data = _settings_load()
data["tun_ipv6_enabled"] = bool(value)
_settings_save(data)
def _use_system_pac(): def _use_system_pac():
"""只决定 Windows 系统代理是否使用 PAC;旧设置按原行为读取。""" """只决定 Windows 系统代理是否使用 PAC;旧设置按原行为读取。"""
data = _settings_load() data = _settings_load()
@@ -2250,7 +2259,7 @@ def _connect_route(direct_target, allow_direct_fallback, remote_host, remote_por
except OSError as e: except OSError as e:
if not allow_direct_fallback: if not allow_direct_fallback:
raise raise
log(f"[转发器] 国内直连 {direct_target[0]} 失败,回退上游隧道: {e}") log_detail(f"[转发器] 国内直连 {direct_target[0]} 失败,回退上游隧道: {e}")
if jump_chain: if jump_chain:
if not use_tls: if not use_tls:
raise RuntimeError("跳板模式仅支持 TLS 上游") raise RuntimeError("跳板模式仅支持 TLS 上游")
@@ -2372,7 +2381,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
else: else:
log_detail(f"[连接 #{connection_id}] 无法解析目标: {first_line or '空请求'},客户端={peer_text}") log_detail(f"[连接 #{connection_id}] 无法解析目标: {first_line or '空请求'},客户端={peer_text}")
except Exception as e: except Exception as e:
log(f"[转发器] 读取首包失败: {e}") log_detail(f"[转发器] 读取首包失败: {e}")
log_detail(f"[连接 #{connection_id}] 失败: 读取首包,目标={target_text},规则匹配={rule_match},错误={e}") log_detail(f"[连接 #{connection_id}] 失败: 读取首包,目标={target_text},规则匹配={rule_match},错误={e}")
client.close() client.close()
return return
@@ -2405,7 +2414,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
if via_tunnel: if via_tunnel:
_traffic_add(up=len(buf)) _traffic_add(up=len(buf))
except Exception as e: except Exception as e:
log(f"[转发器] 连接目标失败: {e}") log_detail(f"[转发器] 连接目标失败: {e}")
log_detail(f"[连接 #{connection_id}] 连接失败: 目标={target_text},规则匹配={rule_match},路径={route_summary},错误={e}") log_detail(f"[连接 #{connection_id}] 连接失败: 目标={target_text},规则匹配={rule_match},路径={route_summary},错误={e}")
try: try:
client.close() client.close()
@@ -2632,6 +2641,7 @@ def _settings_services():
get_port=lambda: _TLS_LOCAL_PORT, get_port=lambda: _TLS_LOCAL_PORT,
get_system_pac=_use_system_pac, get_system_pac=_use_system_pac,
get_dns=_tun_dns_servers, get_dns=_tun_dns_servers,
get_ipv6_enabled=_tun_ipv6_enabled,
get_update_via_proxy=_update_via_proxy, get_update_via_proxy=_update_via_proxy,
get_skip_update_check=_skip_update_check, get_skip_update_check=_skip_update_check,
get_node_display_mode=_node_display_mode, get_node_display_mode=_node_display_mode,
@@ -2640,6 +2650,7 @@ def _settings_services():
set_port=_set_local_port, set_port=_set_local_port,
set_system_pac=_set_use_system_pac, set_system_pac=_set_use_system_pac,
set_dns=_set_tun_dns_servers, set_dns=_set_tun_dns_servers,
set_ipv6_enabled=_set_tun_ipv6_enabled,
set_update_via_proxy=_set_update_via_proxy, set_update_via_proxy=_set_update_via_proxy,
set_skip_update_check=_set_skip_update_check, set_skip_update_check=_set_skip_update_check,
set_node_display_mode=_set_node_display_mode, set_node_display_mode=_set_node_display_mode,
@@ -2680,7 +2691,8 @@ def _make_tun_backend(emit):
engine = Path(BASE_DIR) / "runtime" / "tun2socks.exe" engine = Path(BASE_DIR) / "runtime" / "tun2socks.exe"
if not engine.is_file() or not (engine.parent / "wintun.dll").is_file(): if not engine.is_file() or not (engine.parent / "wintun.dll").is_file():
raise RuntimeError("单文件包中的 tun2socks.exe 或 wintun.dll 缺失") raise RuntimeError("单文件包中的 tun2socks.exe 或 wintun.dll 缺失")
return TunBackend(_USER_DIR, engine_path=engine, log=emit, detail_log=emit) detail_emit = getattr(emit, "detail", emit)
return TunBackend(_USER_DIR, engine_path=engine, log=emit, detail_log=detail_emit)
def _start_tun_backend(backend, payload): def _start_tun_backend(backend, payload):
@@ -2722,6 +2734,7 @@ def _start_tun_backend(backend, payload):
bypass_lan=bypass_lan, bypass_lan=bypass_lan,
route_rules=rules, route_rules=rules,
dns_servers=payload["dns_servers"], dns_servers=payload["dns_servers"],
ipv6_enabled=bool(payload.get("ipv6_enabled", False)),
direct_target_resolver=direct_target, direct_target_resolver=direct_target,
route_rule_resolver=user_rule, route_rule_resolver=user_rule,
route_node_resolver=route_node, route_node_resolver=route_node,
@@ -2748,6 +2761,7 @@ def _tun_start_payload(use_pac, bypass_lan):
"route_rules": _route_rules(), "route_rules": _route_rules(),
"route_nodes": route_nodes, "route_nodes": route_nodes,
"dns_servers": _tun_dns_servers(), "dns_servers": _tun_dns_servers(),
"ipv6_enabled": _tun_ipv6_enabled(),
"bypass_lan": bool(bypass_lan), "bypass_lan": bool(bypass_lan),
"smart": bool(use_pac), "smart": bool(use_pac),
} }
@@ -2770,7 +2784,7 @@ class MainWindow(QWidget):
super().__init__() super().__init__()
self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的) self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的)
self._mode = _proxy_mode() self._mode = _proxy_mode()
self._tun_controller = TunModeController(_tun_worker_command(), log) self._tun_controller = TunModeController(_tun_worker_command(), log, log_detail)
self._quitting = False self._quitting = False
self._tray = None self._tray = None
self._tray_menu = None self._tray_menu = None
@@ -3211,11 +3225,11 @@ class MainWindow(QWidget):
_traffic_reset() _traffic_reset()
current_node = _current_node() current_node = _current_node()
jump_chain = _jump_chain(current_node) jump_chain = _jump_chain(current_node)
log("TLS 多跳模式" if jump_chain else "TLS 直连模式") log_detail("TLS 多跳模式" if jump_chain else "TLS 直连模式")
ep = _endpoint_domain() ep = _endpoint_domain()
log(f" 目标域名: {ep}") log_detail(f" 目标域名: {ep}")
if jump_chain: if jump_chain:
log(f" 跳板路径: {_jump_route_text(current_node)}") log_detail(f" 跳板路径: {_jump_route_text(current_node)}")
# PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流 # PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流
use_internal_pac = _should_use_internal_pac(use_pac) use_internal_pac = _should_use_internal_pac(use_pac)
if use_internal_pac: if use_internal_pac:
@@ -3236,7 +3250,7 @@ class MainWindow(QWidget):
# 在内存中启动本地 PAC HTTP 服务,不往 EXE 目录写任何文件 # 在内存中启动本地 PAC HTTP 服务,不往 EXE 目录写任何文件
pac_port = _start_pac_server(pac_content) pac_port = _start_pac_server(pac_content)
pac_url = f"http://127.0.0.1:{pac_port}/proxy.pac" pac_url = f"http://127.0.0.1:{pac_port}/proxy.pac"
log(f"使用 PAC: {pac_url}") log_detail(f"使用 PAC: {pac_url}")
with self._proxy_state_lock: with self._proxy_state_lock:
_raise_if_start_cancelled(cancel_event) _raise_if_start_cancelled(cancel_event)
proxy_on(pac_url=pac_url) proxy_on(pac_url=pac_url)
@@ -3247,10 +3261,10 @@ class MainWindow(QWidget):
proxy_on(bypass_lan=use_bypass_lan) proxy_on(bypass_lan=use_bypass_lan)
self._proxy_set_by_us = True self._proxy_set_by_us = True
if use_internal_pac: if use_internal_pac:
log(f"使用内置 PAC:系统固定代理 127.0.0.1:{PROXY_PORT},转发器内部智能分流") log_detail(f"使用内置 PAC:系统固定代理 127.0.0.1:{PROXY_PORT},转发器内部智能分流")
_raise_if_start_cancelled(cancel_event) _raise_if_start_cancelled(cancel_event)
if use_bypass_lan: if use_bypass_lan:
log("不代理内网 IP:内网地址将直连") log_detail("不代理内网 IP:内网地址将直连")
log("验证代理连通性...") log("验证代理连通性...")
_verify_proxy_connectivity(cancel_event=cancel_event) _verify_proxy_connectivity(cancel_event=cancel_event)
except Exception: except Exception:
+16 -2
View File
@@ -587,6 +587,20 @@ class Socks5Bridge:
except OSError: except OSError:
pass pass
def _proxy_target(self, target: Address) -> Address:
"""为上游 CONNECT 恢复唯一 DNS 域名,给 IPv6 目标提供 IPv4 回退。"""
try:
ipaddress.ip_address(target.host)
except ValueError:
return target
domain = self._dns.lookup_ip(target.host)
if not domain:
return target
self.log(
f"[桥接] IPv6 目标回退域名解析: {target.host} -> {domain}:{target.port}"
)
return Address(domain, target.port)
def _open_target(self, target: Address) -> socket.socket: def _open_target(self, target: Address) -> socket.socket:
policy_host = self._policy_host(target.host) policy_host = self._policy_host(target.host)
rule = self._route_rule(policy_host, target.host) rule = self._route_rule(policy_host, target.host)
@@ -594,12 +608,12 @@ class Socks5Bridge:
if rule and str(rule.get("action") or "").lower() == "node": if rule and str(rule.get("action") or "").lower() == "node":
if route_node is None: if route_node is None:
raise ConnectionError("域名路由指定的节点不可用") raise ConnectionError("域名路由指定的节点不可用")
return self._dialer(route_node).open(target) return self._dialer(route_node).open(self._proxy_target(target))
direct = self._direct_target_for(target.host, target.port, policy_host) direct = self._direct_target_for(target.host, target.port, policy_host)
if direct is not None: if direct is not None:
self._ensure_direct_routes(direct[0], direct[1]) self._ensure_direct_routes(direct[0], direct[1])
return socket.create_connection(direct, 15) return socket.create_connection(direct, 15)
return self._dialer(self.node).open(target) return self._dialer(self.node).open(self._proxy_target(target))
def _tcp(self, client: socket.socket, target: Address): def _tcp(self, client: socket.socket, target: Address):
upstream = self._open_target(target) upstream = self._open_target(target)
+62 -34
View File
@@ -27,6 +27,16 @@ class TunBackendError(RuntimeError):
"""TUN 后端无法启动或停止。""" """TUN 后端无法启动或停止。"""
def _run_hidden(command, **kwargs):
"""运行 Windows 网络命令时隐藏控制台窗口。
源码通过 test.bat 启动时这些子进程复用已有控制台;Nuitka GUI 没有父控制台,
未设置 CREATE_NO_WINDOW 就会为每次 PowerShell 查询弹出一个窗口。
"""
kwargs.setdefault("creationflags", getattr(subprocess, "CREATE_NO_WINDOW", 0))
return subprocess.run(command, **kwargs)
class TunBackend: class TunBackend:
def __init__(self, user_dir: str | Path, engine_path: str | Path | None = None, def __init__(self, user_dir: str | Path, engine_path: str | Path | None = None,
log: Callable[[str], None] | None = None, log: Callable[[str], None] | None = None,
@@ -53,6 +63,7 @@ class TunBackend:
self._ipv6_address = "fd42:7272:5050::1" self._ipv6_address = "fd42:7272:5050::1"
self._dns_configured = False self._dns_configured = False
self._tun_dns_servers = ("8.8.8.8", "1.1.1.1") self._tun_dns_servers = ("8.8.8.8", "1.1.1.1")
self._ipv6_enabled = False
self._bypass_lan = True self._bypass_lan = True
self._cleanup_pending = False self._cleanup_pending = False
self._state_path = self.user_dir / "tun_state.json" self._state_path = self.user_dir / "tun_state.json"
@@ -109,7 +120,7 @@ class TunBackend:
"Where-Object InterfaceAlias -ne 'RRPP-TUN' | " "Where-Object InterfaceAlias -ne 'RRPP-TUN' | "
"Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias)"] "Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias)"]
try: try:
result = subprocess.run(command, capture_output=True, text=True, timeout=5) result = _run_hidden(command, capture_output=True, text=True, timeout=5)
value = (result.stdout or "").strip().splitlines() value = (result.stdout or "").strip().splitlines()
if value and value[0].strip(): if value and value[0].strip():
return value[0].strip() return value[0].strip()
@@ -126,7 +137,7 @@ class TunBackend:
f"(Get-NetIPAddress -InterfaceAlias '{escaped}' -AddressFamily IPv4 " f"(Get-NetIPAddress -InterfaceAlias '{escaped}' -AddressFamily IPv4 "
"-AddressState Preferred | Select-Object -First 1 -ExpandProperty IPAddress)"] "-AddressState Preferred | Select-Object -First 1 -ExpandProperty IPAddress)"]
try: try:
result = subprocess.run(command, capture_output=True, text=True, timeout=5) result = _run_hidden(command, capture_output=True, text=True, timeout=5)
value = (result.stdout or "").strip().splitlines() value = (result.stdout or "").strip().splitlines()
if value and value[0].strip(): if value and value[0].strip():
return value[0].strip() return value[0].strip()
@@ -143,7 +154,7 @@ class TunBackend:
"Sort-Object RouteMetric | Select-Object -First 1 " "Sort-Object RouteMetric | Select-Object -First 1 "
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"] "NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
try: try:
result = subprocess.run(command, capture_output=True, text=True, timeout=5) result = _run_hidden(command, capture_output=True, text=True, timeout=5)
data = json.loads((result.stdout or "").strip()) data = json.loads((result.stdout or "").strip())
if isinstance(data, dict): if isinstance(data, dict):
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "") return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
@@ -160,7 +171,7 @@ class TunBackend:
"Sort-Object RouteMetric | Select-Object -First 1 " "Sort-Object RouteMetric | Select-Object -First 1 "
"NextHop,InterfaceIndex | ConvertTo-Json -Compress"] "NextHop,InterfaceIndex | ConvertTo-Json -Compress"]
try: try:
result = subprocess.run(command, capture_output=True, text=True, timeout=5) result = _run_hidden(command, capture_output=True, text=True, timeout=5)
data = json.loads((result.stdout or "").strip()) data = json.loads((result.stdout or "").strip())
if isinstance(data, dict): if isinstance(data, dict):
return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "") return str(data.get("NextHop") or ""), str(data.get("InterfaceIndex") or "")
@@ -181,7 +192,7 @@ class TunBackend:
"$_.IPAddress -notlike 'fd*' }).IPAddress | ConvertTo-Json -Compress", "$_.IPAddress -notlike 'fd*' }).IPAddress | ConvertTo-Json -Compress",
] ]
try: try:
result = subprocess.run(command, capture_output=True, text=True, timeout=5) result = _run_hidden(command, capture_output=True, text=True, timeout=5)
if result.returncode: if result.returncode:
return None return None
value = json.loads((result.stdout or "").strip() or "[]") value = json.loads((result.stdout or "").strip() or "[]")
@@ -241,7 +252,7 @@ class TunBackend:
if self._physical_gateway: if self._physical_gateway:
command.append(self._physical_gateway) command.append(self._physical_gateway)
command.extend(["METRIC", "1", "IF", self._physical_ifindex]) command.extend(["METRIC", "1", "IF", self._physical_ifindex])
result = subprocess.run(command, capture_output=True, text=True, result = _run_hidden(command, capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15) encoding="mbcs", errors="replace", timeout=15)
if result.returncode: if result.returncode:
detail = (result.stderr or result.stdout or "").strip() detail = (result.stderr or result.stdout or "").strip()
@@ -251,6 +262,10 @@ class TunBackend:
continue continue
self._node_routes.append(address) self._node_routes.append(address)
self._write_state() self._write_state()
if candidates_v6 and not self._ipv6_enabled:
if not candidates_v4:
raise TunBackendError("IPv6 模式已关闭,代理节点仅包含 IPv6 地址")
candidates_v6 = []
if candidates_v6 and not self._physical_ipv6_ifindex: if candidates_v6 and not self._physical_ipv6_ifindex:
# 双栈节点仍可使用 IPv4 地址;只有 IPv6-only 节点需要物理 IPv6 出口。 # 双栈节点仍可使用 IPv4 地址;只有 IPv6-only 节点需要物理 IPv6 出口。
if candidates_v4: if candidates_v4:
@@ -270,7 +285,7 @@ class TunBackend:
if self._physical_ipv6_gateway and self._physical_ipv6_gateway != "::": if self._physical_ipv6_gateway and self._physical_ipv6_gateway != "::":
command.append(f"nexthop={self._physical_ipv6_gateway}") command.append(f"nexthop={self._physical_ipv6_gateway}")
command.append("metric=1") command.append("metric=1")
result = subprocess.run(["netsh", *command], capture_output=True, text=True, result = _run_hidden(["netsh", *command], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15) encoding="mbcs", errors="replace", timeout=15)
if result.returncode: if result.returncode:
detail = (result.stderr or result.stdout or "").strip() detail = (result.stderr or result.stdout or "").strip()
@@ -289,6 +304,8 @@ class TunBackend:
except ValueError: except ValueError:
return return
if parsed.version == 6: if parsed.version == 6:
if not self._ipv6_enabled:
raise TunBackendError("IPv6 模式未启用,无法添加 IPv6 物理路由")
normalized = parsed.compressed normalized = parsed.compressed
if normalized in self._node_ipv6_routes: if normalized in self._node_ipv6_routes:
return return
@@ -303,7 +320,7 @@ class TunBackend:
if self._physical_gateway: if self._physical_gateway:
command.append(self._physical_gateway) command.append(self._physical_gateway)
command.extend(["METRIC", "1", "IF", self._physical_ifindex]) command.extend(["METRIC", "1", "IF", self._physical_ifindex])
result = subprocess.run(command, capture_output=True, text=True, result = _run_hidden(command, capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15) encoding="mbcs", errors="replace", timeout=15)
detail = (result.stderr or result.stdout or "").strip() detail = (result.stderr or result.stdout or "").strip()
if result.returncode: if result.returncode:
@@ -326,7 +343,7 @@ class TunBackend:
def _cleanup_netsh(self, args: list[str]) -> bool: def _cleanup_netsh(self, args: list[str]) -> bool:
try: try:
result = subprocess.run(["netsh", *args], capture_output=True, text=True, result = _run_hidden(["netsh", *args], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15) encoding="mbcs", errors="replace", timeout=15)
except Exception as exc: except Exception as exc:
self.detail_log(f"[TUN] 清理 netsh 失败: {exc}") self.detail_log(f"[TUN] 清理 netsh 失败: {exc}")
@@ -341,7 +358,7 @@ class TunBackend:
remaining: list[str] = [] remaining: list[str] = []
for address in self._node_routes: for address in self._node_routes:
try: try:
result = subprocess.run(["route", "DELETE", address], capture_output=True, result = _run_hidden(["route", "DELETE", address], capture_output=True,
text=True, encoding="mbcs", errors="replace", timeout=15) text=True, encoding="mbcs", errors="replace", timeout=15)
except Exception as exc: except Exception as exc:
self.detail_log(f"[TUN] 清理节点路由失败 ({address}): {exc}") self.detail_log(f"[TUN] 清理节点路由失败 ({address}): {exc}")
@@ -358,7 +375,7 @@ class TunBackend:
if self._physical_ipv6_ifindex: if self._physical_ipv6_ifindex:
command.append(f"interface={self._physical_ipv6_ifindex}") command.append(f"interface={self._physical_ipv6_ifindex}")
try: try:
result = subprocess.run(["netsh", *command], capture_output=True, text=True, result = _run_hidden(["netsh", *command], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15) encoding="mbcs", errors="replace", timeout=15)
except Exception as exc: except Exception as exc:
self.detail_log(f"[TUN] 清理 IPv6 节点路由失败 ({address}): {exc}") self.detail_log(f"[TUN] 清理 IPv6 节点路由失败 ({address}): {exc}")
@@ -372,7 +389,7 @@ class TunBackend:
return not remaining and not remaining_v6 return not remaining and not remaining_v6
def _netsh(self, args: list[str], *, ignore_error: bool = False) -> None: def _netsh(self, args: list[str], *, ignore_error: bool = False) -> None:
result = subprocess.run(["netsh", *args], capture_output=True, text=True, result = _run_hidden(["netsh", *args], capture_output=True, text=True,
encoding="mbcs", errors="replace", timeout=15) encoding="mbcs", errors="replace", timeout=15)
if result.returncode and not ignore_error: if result.returncode and not ignore_error:
detail = (result.stderr or result.stdout or "").strip() detail = (result.stderr or result.stdout or "").strip()
@@ -381,7 +398,7 @@ class TunBackend:
def _configure_routes(self) -> None: def _configure_routes(self) -> None:
for _ in range(20): for _ in range(20):
try: try:
probe = subprocess.run( probe = _run_hidden(
["powershell", "-NoProfile", "-Command", ["powershell", "-NoProfile", "-Command",
f"Get-NetAdapter -Name '{self._device}' -ErrorAction SilentlyContinue"], f"Get-NetAdapter -Name '{self._device}' -ErrorAction SilentlyContinue"],
capture_output=True, text=True, timeout=3, capture_output=True, text=True, timeout=3,
@@ -400,14 +417,15 @@ class TunBackend:
f"{self._device}", self._gateway, "metric=1"]) f"{self._device}", self._gateway, "metric=1"])
self._route_added = True self._route_added = True
self._write_state() self._write_state()
self._netsh(["interface", "ipv6", "set", "address", if self._ipv6_enabled:
f"interface={self._device}", f"address={self._ipv6_address}", self._netsh(["interface", "ipv6", "set", "address",
"store=active"]) f"interface={self._device}", f"address={self._ipv6_address}",
self._netsh(["interface", "ipv6", "add", "route", "prefix=::/0", "store=active"])
f"interface={self._device}", f"nexthop={self._ipv6_gateway}", self._netsh(["interface", "ipv6", "add", "route", "prefix=::/0",
"metric=1"]) f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
self._ipv6_route_added = True "metric=1"])
self._write_state() self._ipv6_route_added = True
self._write_state()
if not self._bypass_lan: if not self._bypass_lan:
# Windows 物理网卡通常已有更具体的私网路由;补同前缀的 TUN # Windows 物理网卡通常已有更具体的私网路由;补同前缀的 TUN
# 路由,确保“内网也代理”不会在进入 tun2socks 前被物理网卡截走。 # 路由,确保“内网也代理”不会在进入 tun2socks 前被物理网卡截走。
@@ -417,12 +435,13 @@ class TunBackend:
f"{self._device}", self._gateway, "metric=1"]) f"{self._device}", self._gateway, "metric=1"])
self._lan_routes.append(network) self._lan_routes.append(network)
self._write_state() self._write_state()
for network in ("fc00::/7",): if self._ipv6_enabled:
self._netsh(["interface", "ipv6", "add", "route", f"prefix={network}", for network in ("fc00::/7",):
f"interface={self._device}", f"nexthop={self._ipv6_gateway}", self._netsh(["interface", "ipv6", "add", "route", f"prefix={network}",
"metric=1"]) f"interface={self._device}", f"nexthop={self._ipv6_gateway}",
self._lan_ipv6_routes.append(network) "metric=1"])
self._write_state() self._lan_ipv6_routes.append(network)
self._write_state()
self._configure_dns() self._configure_dns()
def _configure_dns(self) -> None: def _configure_dns(self) -> None:
@@ -430,7 +449,7 @@ class TunBackend:
if os.name != "nt": if os.name != "nt":
return return
ipv4 = [item for item in self._tun_dns_servers if ":" not in item] 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] ipv6 = [item for item in self._tun_dns_servers if ":" in item] if self._ipv6_enabled else []
if ipv4: if ipv4:
self._netsh([ self._netsh([
"interface", "ipv4", "set", "dnsservers", "interface", "ipv4", "set", "dnsservers",
@@ -531,6 +550,7 @@ class TunBackend:
"node_ipv6_routes": list(self._node_ipv6_routes), "node_ipv6_routes": list(self._node_ipv6_routes),
"route_added": bool(self._route_added), "route_added": bool(self._route_added),
"ipv6_route_added": bool(self._ipv6_route_added), "ipv6_route_added": bool(self._ipv6_route_added),
"ipv6_enabled": bool(self._ipv6_enabled),
"dns_configured": bool(self._dns_configured), "dns_configured": bool(self._dns_configured),
"dns_servers": list(self._tun_dns_servers), "dns_servers": list(self._tun_dns_servers),
"cleanup_pending": bool(self._cleanup_pending), "cleanup_pending": bool(self._cleanup_pending),
@@ -587,6 +607,7 @@ class TunBackend:
self._gateway = str(state.get("gateway") or self._gateway) self._gateway = str(state.get("gateway") or self._gateway)
self._ipv6_address = str(state.get("ipv6_address") or self._ipv6_address) 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._ipv6_gateway = str(state.get("ipv6_gateway") or self._ipv6_gateway)
self._ipv6_enabled = bool(state.get("ipv6_enabled", bool(state.get("ipv6_route_added"))))
self._physical_ipv6_gateway = str(state.get("physical_ipv6_gateway") or "") self._physical_ipv6_gateway = str(state.get("physical_ipv6_gateway") or "")
self._physical_ipv6_ifindex = str(state.get("physical_ipv6_ifindex") or "") self._physical_ipv6_ifindex = str(state.get("physical_ipv6_ifindex") or "")
self._route_added = bool(state.get("route_added")) self._route_added = bool(state.get("route_added"))
@@ -625,6 +646,7 @@ class TunBackend:
def start(self, node: dict[str, Any], *, bypass_lan: bool = True, def start(self, node: dict[str, Any], *, bypass_lan: bool = True,
route_rules: list[dict[str, Any]] | None = None, route_rules: list[dict[str, Any]] | None = None,
dns_servers: tuple[str, ...] | list[str] | None = None, dns_servers: tuple[str, ...] | list[str] | None = None,
ipv6_enabled: bool = False,
direct_target_resolver: Callable[[str, str, int], tuple[str, int] | None] | 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_rule_resolver: Callable[[str], dict[str, Any] | None] | None = None,
route_node_resolver: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None, route_node_resolver: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
@@ -635,24 +657,30 @@ class TunBackend:
if not self.is_admin(): if not self.is_admin():
raise TunBackendError("启动虚拟网卡 TUN 需要管理员权限") raise TunBackendError("启动虚拟网卡 TUN 需要管理员权限")
self._bypass_lan = bool(bypass_lan) self._bypass_lan = bool(bypass_lan)
self._ipv6_enabled = bool(ipv6_enabled)
if dns_servers is not None: if dns_servers is not None:
cleaned = tuple(str(item).strip() for item in dns_servers if str(item).strip()) cleaned = tuple(str(item).strip() for item in dns_servers if str(item).strip())
if not cleaned: if not cleaned:
raise TunBackendError("自定义 DNS 不能为空") raise TunBackendError("自定义 DNS 不能为空")
try: try:
for item in cleaned: for item in cleaned:
socket.inet_pton(socket.AF_INET, item) address = ipaddress.ip_address(item)
except OSError as exc: if address.is_unspecified or address.is_multicast:
raise TunBackendError("当前 TUN 仅支持 IPv4 DNS 服务器") from exc raise ValueError(item)
except ValueError as exc:
raise TunBackendError("自定义 DNS 必须是有效的 IPv4/IPv6 地址") from exc
self._tun_dns_servers = cleaned[:3] self._tun_dns_servers = cleaned[:3]
engine = self.resolve_engine() engine = self.resolve_engine()
interface = self._physical_interface() interface = self._physical_interface()
source_address = self._physical_source_address(interface) source_address = self._physical_source_address(interface)
self._physical_gateway, self._physical_ifindex = self._physical_route_info() self._physical_gateway, self._physical_ifindex = self._physical_route_info()
self._physical_ipv6_gateway, self._physical_ipv6_ifindex = self._physical_ipv6_route_info() if self._ipv6_enabled:
self._physical_ipv6_gateway, self._physical_ipv6_ifindex = self._physical_ipv6_route_info()
else:
self._physical_ipv6_gateway = self._physical_ipv6_ifindex = ""
if not self._physical_ifindex: if not self._physical_ifindex:
raise TunBackendError("找不到物理默认路由,无法保证代理节点不进入 TUN 回环") raise TunBackendError("找不到物理默认路由,无法保证代理节点不进入 TUN 回环")
if not self._physical_ipv6_ifindex: if self._ipv6_enabled and not self._physical_ipv6_ifindex:
self.log("警告:未找到物理 IPv6 默认路由,IPv6 目标将仅尝试通过 IPv4 代理节点转发") self.log("警告:未找到物理 IPv6 默认路由,IPv6 目标将仅尝试通过 IPv4 代理节点转发")
try: try:
proxy_addresses_by_node: dict[str, list[tuple]] = {} proxy_addresses_by_node: dict[str, list[tuple]] = {}
@@ -709,7 +737,7 @@ class TunBackend:
# 因此 Windows 不需要 --interface;Linux/macOS 仍保留原行为。 # 因此 Windows 不需要 --interface;Linux/macOS 仍保留原行为。
if interface and os.name != "nt": if interface and os.name != "nt":
command.extend(["--interface", interface]) command.extend(["--interface", interface])
self.log(f"本地 SOCKS5 桥接: 127.0.0.1:{socks_port}") self.detail_log(f"本地 SOCKS5 桥接: 127.0.0.1:{socks_port}")
try: try:
self._process = subprocess.Popen( self._process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+36 -15
View File
@@ -46,9 +46,11 @@ def _receive(reader) -> dict[str, Any]:
class TunModeController: class TunModeController:
"""普通权限 GUI 仅在首次开启 TUN 时启动管理员工作进程。""" """普通权限 GUI 仅在首次开启 TUN 时启动管理员工作进程。"""
def __init__(self, command: list[str], log: Callable[[str], None]): def __init__(self, command: list[str], log: Callable[[str], None],
detail_log: Callable[[str], None] | None = None):
self.command = command self.command = command
self.log = log self.log = log
self.detail_log = detail_log or log
self._lock = threading.RLock() self._lock = threading.RLock()
self._socket: socket.socket | None = None self._socket: socket.socket | None = None
self._reader = None self._reader = None
@@ -124,17 +126,23 @@ class TunModeController:
def _request(self, command: str, payload: dict[str, Any] | None = None) -> Any: def _request(self, command: str, payload: dict[str, Any] | None = None) -> Any:
if self._socket is None or self._reader is None: if self._socket is None or self._reader is None:
raise TunModeError("TUN 工作进程未连接") raise TunModeError("TUN 工作进程未连接")
_send(self._socket, {"command": command, "payload": payload or {}}) try:
while True: _send(self._socket, {"command": command, "payload": payload or {}})
message = _receive(self._reader) while True:
if message.get("type") == "log": message = _receive(self._reader)
self.log(str(message.get("message") or "")) if message.get("type") == "log":
continue callback = self.detail_log if message.get("detail") else self.log
if message.get("type") != "result": callback(str(message.get("message") or ""))
raise TunModeError("TUN 工作进程响应无效") continue
if not message.get("ok"): if message.get("type") != "result":
raise TunModeError(str(message.get("error") or "TUN 操作失败")) raise TunModeError("TUN 工作进程响应无效")
return message.get("value") if not message.get("ok"):
raise TunModeError(str(message.get("error") or "TUN 操作失败"))
return message.get("value")
except TunModeError:
raise
except OSError as exc:
raise TunModeError(f"TUN 工作进程连接中止: {exc}") from exc
def status(self) -> dict[str, Any]: def status(self) -> dict[str, Any]:
with self._lock: with self._lock:
@@ -187,15 +195,24 @@ def run_tun_worker(port: int, token: str, backend_factory,
send_lock = threading.Lock() send_lock = threading.Lock()
_send(connection, {"type": "hello", "token": token}, send_lock) _send(connection, {"type": "hello", "token": token}, send_lock)
def emit(message: str) -> None: def emit(message: str, detail: bool = False) -> None:
try: try:
_send(connection, {"type": "log", "message": str(message)}, send_lock) _send(connection, {"type": "log", "message": str(message),
"detail": bool(detail)}, send_lock)
except OSError: except OSError:
pass pass
class _WorkerLogger:
def __call__(self, message: str) -> None:
emit(message)
def detail(self, message: str) -> None:
emit(message, detail=True)
logger = _WorkerLogger()
backend = None backend = None
try: try:
backend = backend_factory(emit)
while True: while True:
try: try:
request = _receive(reader) request = _receive(reader)
@@ -206,6 +223,10 @@ def run_tun_worker(port: int, token: str, backend_factory,
if command == "start": if command == "start":
if not isinstance(request.get("payload"), dict): if not isinstance(request.get("payload"), dict):
raise TunModeError("TUN 启动参数无效") raise TunModeError("TUN 启动参数无效")
# 延迟创建后端,使初始化失败也能通过控制协议返回具体原因,
# 避免 GUI 只看到 Windows 的 10053 连接中止错误。
if backend is None:
backend = backend_factory(logger)
start_backend(backend, request["payload"]) start_backend(backend, request["payload"])
value = None value = None
elif command == "stop": elif command == "stop":
+18
View File
@@ -933,6 +933,7 @@ class SettingsServices:
get_port: object get_port: object
get_system_pac: object get_system_pac: object
get_dns: object get_dns: object
get_ipv6_enabled: object
get_update_via_proxy: object get_update_via_proxy: object
get_skip_update_check: object get_skip_update_check: object
get_node_display_mode: object get_node_display_mode: object
@@ -941,6 +942,7 @@ class SettingsServices:
set_port: object set_port: object
set_system_pac: object set_system_pac: object
set_dns: object set_dns: object
set_ipv6_enabled: object
set_update_via_proxy: object set_update_via_proxy: object
set_skip_update_check: object set_skip_update_check: object
set_node_display_mode: object set_node_display_mode: object
@@ -1114,6 +1116,18 @@ class SettingsDialog(QDialog):
dns_row.addWidget(self.ed_dns) dns_row.addWidget(self.ed_dns)
left_col.addLayout(dns_row) left_col.addLayout(dns_row)
ipv6_row = QHBoxLayout()
ipv6_label = QLabel("启用 IPv6 模式 (虚拟网卡)")
ipv6_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
ipv6_label.setToolTip("TUN 模式安装 IPv6 地址和默认路由;默认关闭")
ipv6_row.addWidget(ipv6_label)
ipv6_row.addStretch()
get_ipv6_enabled = getattr(services, "get_ipv6_enabled", lambda: False)
self.cb_ipv6 = ToggleSwitch(checked=bool(get_ipv6_enabled()))
self.cb_ipv6.setToolTip("仅虚拟网卡模式使用;系统代理模式忽略此项")
ipv6_row.addWidget(self.cb_ipv6)
left_col.addLayout(ipv6_row)
update_row = QHBoxLayout() update_row = QHBoxLayout()
update_label = QLabel("使用代理更新") update_label = QLabel("使用代理更新")
update_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") update_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
@@ -1266,6 +1280,8 @@ class SettingsDialog(QDialog):
self.ed_port.setText(str(self._services.get_port())) self.ed_port.setText(str(self._services.get_port()))
self.cb_system_pac.setChecked(self._services.get_system_pac()) self.cb_system_pac.setChecked(self._services.get_system_pac())
self.ed_dns.setText(", ".join(self._services.get_dns())) self.ed_dns.setText(", ".join(self._services.get_dns()))
get_ipv6_enabled = getattr(self._services, "get_ipv6_enabled", lambda: False)
self.cb_ipv6.setChecked(bool(get_ipv6_enabled()))
self.cb_update_proxy.setChecked(self._services.get_update_via_proxy()) self.cb_update_proxy.setChecked(self._services.get_update_via_proxy())
self.cb_skip_update.setChecked(self._services.get_skip_update_check()) self.cb_skip_update.setChecked(self._services.get_skip_update_check())
self.btn_node_domain.setChecked(True) self.btn_node_domain.setChecked(True)
@@ -1287,6 +1303,8 @@ class SettingsDialog(QDialog):
return return
self._services.set_port(port) self._services.set_port(port)
self._services.set_system_pac(bool(self.cb_system_pac.isChecked())) self._services.set_system_pac(bool(self.cb_system_pac.isChecked()))
set_ipv6_enabled = getattr(self._services, "set_ipv6_enabled", lambda _value: None)
set_ipv6_enabled(bool(self.cb_ipv6.isChecked()))
self._services.set_update_via_proxy(bool(self.cb_update_proxy.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_skip_update_check(bool(self.cb_skip_update.isChecked()))
self._services.set_node_display_mode( self._services.set_node_display_mode(