From eabb3e4472d3602eb4068ee8faacd7ba5ec7acb9 Mon Sep 17 00:00:00 2001 From: opsx Date: Wed, 21 Oct 2026 18:00:21 +0800 Subject: [PATCH] Add bundled WireGuard diagnostics and application proxy --- README.md | 2 + .../internal/service/wireguard/wireguard.go | 9 +- .../service/wireguard/wireguard_test.go | 13 + client/.gitignore | 2 + client/README.md | 24 +- client/package.json | 5 +- client/scripts/build-tunnel-go.sh | 20 + client/src-tauri/src/lib.rs | 148 +++++++ client/src-tauri/src/system.rs | 75 +++- client/src-tauri/src/tunnel.rs | 63 ++- client/src-tauri/tauri.conf.json | 3 +- client/src/App.tsx | 148 +++++-- client/src/platform.ts | 26 +- client/src/system.css | 55 ++- client/tunnel-go/THIRD_PARTY.md | 5 + client/tunnel-go/go.mod | 15 + client/tunnel-go/go.sum | 16 + client/tunnel-go/main.go | 408 ++++++++++++++++++ client/tunnel-go/main_test.go | 85 ++++ .../specs/SDD-003-bundled-wireguard-client.md | 83 ++++ 20 files changed, 1129 insertions(+), 76 deletions(-) create mode 100644 client/scripts/build-tunnel-go.sh create mode 100644 client/tunnel-go/THIRD_PARTY.md create mode 100644 client/tunnel-go/go.mod create mode 100644 client/tunnel-go/go.sum create mode 100644 client/tunnel-go/main.go create mode 100644 client/tunnel-go/main_test.go create mode 100644 docs/specs/SDD-003-bundled-wireguard-client.md diff --git a/README.md b/README.md index 9eebf76..9763b9e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交 ## 快速开始 +桌面客户端位于 [client/](client/README.md):Rust + Tauri v2 管理拨号用户登录、线路与设备登记,打包的 Go WireGuard 网络栈提供出口诊断和仅供主动配置的应用使用的本地 SOCKS5 代理。macOS/Linux 的**整机 VPN**目前仍依赖本机 `wg`/`wg-quick`;Windows 整机隧道未实现。客户端功能和操作见 [客户端说明](client/README.md),免安装系统隧道的设计与待验收项目见 [SDD-003](docs/specs/SDD-003-bundled-wireguard-client.md)。 + ### OpenVPN 飞书 / OIDC 浏览器认证 在「创建专线 → OpenVPN → 认证方式」选择「第三方 OAuth2 / OIDC(飞书登录)」。引导页会展示飞书应用准备、OIDC 信息、公网回调与客户端试连四步。该模式由 [openvpn-auth-oauth2](https://github.com/jkroepke/openvpn-auth-oauth2) 接管 OpenVPN 的 WebAuth 认证;**不会将飞书登录转交给已有的 LDAP 密码校验,也不会将现有 LDAP 用户自动关联为飞书用户**。应用的可用范围应在飞书或身份代理侧明确限定。 diff --git a/backend/internal/service/wireguard/wireguard.go b/backend/internal/service/wireguard/wireguard.go index 68f9e75..14954b5 100644 --- a/backend/internal/service/wireguard/wireguard.go +++ b/backend/internal/service/wireguard/wireguard.go @@ -781,8 +781,12 @@ func renderServerConfig(cfg map[string]interface{}, serverPrivate string, peers } if service.Bool(cfg, "enable_nat", false) { egress := service.Str(cfg, "egress_interface") - fmt.Fprintf(&b, "PostUp = iptables -A FORWARD -i %%i -j ACCEPT; iptables -A FORWARD -o %%i -j ACCEPT; iptables -t nat -A POSTROUTING -o %s -j MASQUERADE\n", egress) - fmt.Fprintf(&b, "PostDown = iptables -D FORWARD -i %%i -j ACCEPT; iptables -D FORWARD -o %%i -j ACCEPT; iptables -t nat -D POSTROUTING -o %s -j MASQUERADE\n", egress) + source := service.Str(cfg, "address") + if _, subnet, err := net.ParseCIDR(source); err == nil { + source = subnet.String() + } + fmt.Fprintf(&b, "PostUp = iptables -A FORWARD -i %%i -j ACCEPT; iptables -A FORWARD -o %%i -j ACCEPT; iptables -t nat -A POSTROUTING -s %s -o %s -j MASQUERADE\n", source, egress) + fmt.Fprintf(&b, "PostDown = iptables -D FORWARD -i %%i -j ACCEPT; iptables -D FORWARD -o %%i -j ACCEPT; iptables -t nat -D POSTROUTING -s %s -o %s -j MASQUERADE\n", source, egress) } for _, p := range peers { if !p.Enabled { @@ -806,4 +810,3 @@ func validateCIDRList(value string) error { } return nil } - diff --git a/backend/internal/service/wireguard/wireguard_test.go b/backend/internal/service/wireguard/wireguard_test.go index 89b9af2..f18b3df 100644 --- a/backend/internal/service/wireguard/wireguard_test.go +++ b/backend/internal/service/wireguard/wireguard_test.go @@ -83,6 +83,19 @@ func TestValidateRejectsUnsafeNATAndInvalidPeerAddress(t *testing.T) { } } +func TestNATOnlyAppliesToWireGuardSubnet(t *testing.T) { + conf := renderServerConfig(map[string]interface{}{ + "address": "10.88.0.1/24", "listen_port": 51820, + "enable_nat": true, "egress_interface": "eth0", + }, "test-key", nil, nil) + if !strings.Contains(conf, "POSTROUTING -s 10.88.0.0/24 -o eth0 -j MASQUERADE") { + t.Fatal("NAT must be limited to the tunnel subnet") + } + if strings.Count(conf, "POSTROUTING -s 10.88.0.0/24") != 2 { + t.Fatal("PostUp and PostDown must use the same subnet") + } +} + func TestInterfaceNameFitsLinuxLimit(t *testing.T) { name := interfaceName(&model.Tunnel{ID: ^uint(0)}) if len(name) > 15 { diff --git a/client/.gitignore b/client/.gitignore index 47e86b1..dae3f65 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -1,7 +1,9 @@ node_modules/ dist/ +*.tsbuildinfo src-tauri/target/ src-tauri/gen/ +src-tauri/binaries/opsx-tunnel-go-* .env .env.* !.env.example diff --git a/client/README.md b/client/README.md index 2607648..60664ec 100644 --- a/client/README.md +++ b/client/README.md @@ -1,19 +1,35 @@ -# OpsX VPN 桌面客户端(第一阶段) +# OpsX VPN 桌面客户端 仓库根目录的 `client/` 是独立的 Rust + Tauri v2 + React/TypeScript 桌面项目。形态参考同级 `opsxshell`、`opsxapi`:界面运行在 Tauri WebView,服务请求经 Rust 命令完成。与仓库的 `frontend/` 管理后台独立构建。 -目前仅实现对现有 `GET /api/health` 的真实检测。在“设置”中输入服务端 HTTP 或 HTTPS 根地址,检测成功自动保存并显示已保存地址,也可直接保存:HTTP 可用于健康检测,但明文传输今后不应承载登录口令或设备令牌,推荐使用可信 HTTPS 域名。地址仅保存在本机前端存储中,不含账号。概览在打开时和每隔 30 秒检查一次已保存的管理服务地址;耗时是控制面请求往返时间,不能当作 VPN 节点延时。线路页不展示虚构节点或延时,等待独立的终端用户线路接口。圆形按钮会说明当前连接前提,不伪装成已连接。没有普通用户设备 API 和系统隧道适配前,不提供登录或一键拨号。 +客户端通过独立的 `/api/client/*` 接口登录拨号用户、获取授权线路、自动登记设备公钥并领取配置;私钥保存在本机 Rust 数据目录,前端不接触令牌和私钥。登录页允许 HTTP 和 HTTPS 地址,HTTP 明确提示明文风险。概览每隔 30 秒检查控制面可达性;该往返耗时不是 VPN 节点延时,连接状态以网关握手为准。 -桌面版托盘显示“尚未连接”,提供打开窗口与退出。设置页通过 Rust 只读检查 `wg`、`wg-quick` 是否出现在本机 PATH;工具存在不代表驱动、权限、路由及实际隧道可用。浏览器界面预览不会调用本机检查。 +当前 macOS/Linux 系统级拨号仍使用本机 `wg` 和 `wg-quick`,Windows 内置隧道尚未实现。设置页报告“系统工具模式”或“当前不可用”,与 Rust 连接路径一致;工具存在不等于路由、DNS 和实际握手正常。浏览器界面预览不会调用本机检查。安装包附带官方 `wireguard-go` 的 Go 网络栈组件,支持无特权出口探测和设置页手动启动的本机 SOCKS5 代理 `socks5h://127.0.0.1:18743`。仅主动配置该代理的应用流量会经过 VPN,域名由隧道内 DNS 解析;不会创建系统 TUN 或修改系统路由,**尚不能代替系统级 VPN 拨号**。系统隧道设计、平台验收与发布门槛见 [SDD-003](../docs/specs/SDD-003-bundled-wireguard-client.md)。 + +## 使用与验收 + +1. 在桌面客户端使用**拨号用户**登录(管理后台的 `admin` 不是拨号用户),选择已运行、已授权的 WireGuard 专线。首次接入时客户端自动生成设备私钥并向服务端登记公钥,无需下载 `.conf`。 +2. 需要整机 VPN 时,macOS/Linux 须先安装系统 `wg` 和 `wg-quick`,在概览页连接并确认服务端握手;当前版本尚不提供免安装的系统级 VPN。 +3. 不安装系统工具时,可在设置页启动本机应用代理,再在需要访问的应用中设置 `socks5h://127.0.0.1:18743`。例如在终端验证 `curl --proxy socks5h://127.0.0.1:18743 http://www.baidu.com`。设置页“测试 VPN 出口”会连接 `223.5.5.5:443`;握手或测试成功不代表整机路由已切换。 + +公网代理需要管理员为该专线下发覆盖目的地和 DNS 服务器的 `AllowedIPs`,并在 VPN 网关开启 IPv4 转发与仅匹配 WireGuard 网段的出口 NAT;现有 `opsx-client-pilot` 测试专线仍只授权 `10.88.0.0/24`,**不能直接用于公网代理**。本机 SOCKS5 监听仅绑定 `127.0.0.1`、无额外口令,其他本机进程也能使用;切换账号、撤销本机设备、退出登录或退出应用时会停止代理。客户端意外退出后代理最多约两秒内检测到父进程消失并退出。 + +浏览器打开 `http://127.0.0.1:5176/` 只能预览 UI;原生登录、Go 代理及系统隧道必须在 Tauri 桌面窗口测试。HTTP 服务端地址允许使用,但账号口令和令牌会明文传输,生产请使用可信 HTTPS。 + +## 开发与构建 ```bash cd client npm install +npm run build:tunnel # 为本机目标架构构建 Go WireGuard 组件;需要 Go 工具链 npm run dev # Tauri 桌面窗口 npm run dev:ui # 仅预览界面;浏览器里不能执行 Tauri 的真实健康检查 npm run build:ui # 界面类型检查与构建 npm run build # Tauri 安装包(需目标 OS 的构建依赖) cd src-tauri && cargo test +cd ../tunnel-go && go test ./... ``` -平台首期应使用**标准 WireGuard UDP**。现有 `/api/login` 是管理账户,Peer 创建/下载要求 operator 以上权限且下载的托管 Peer 私钥由服务端保存;不能直接把这些接口和管理令牌给桌面用户。需要先设计独立的客户端认证、设备公钥注册、分配 IP、撤销和增量更新 Peer。技术依据、验收条件及后续顺序见 [SDD-002](../docs/specs/SDD-002-desktop-wireguard-client.md)。`corplink-rs` 对接的是另一套飞连私有协议,不是 opsx-vpn 的协议实现。 +平台使用**标准 WireGuard UDP**,没有复用管理员登录令牌或飞连私有协议。认证和设备 Peer 接口设计见 [SDD-002](../docs/specs/SDD-002-desktop-wireguard-client.md);当前客户端已实现这部分接口,真实目标 OS 隧道与 DNS/路由恢复仍需实机验收。 + +Go 模块固定 `golang.zx2c4.com/wireguard` 版本并提交 `go.sum`。构建工具链只用于开发和发行打包,员工机器不需要 Go 或 Homebrew。探测结果只报告目标 TCP 是否可达:要验证公网,专线必须给该客户端下发覆盖公网目标的 `AllowedIPs`,网关必须开启 IP 转发及对应网段的出口 NAT。当前测试专线仍只下发私网路由,管理员授权公网后才可在设置页使用公网代理。 diff --git a/client/package.json b/client/package.json index 8014f98..7e2ea25 100644 --- a/client/package.json +++ b/client/package.json @@ -4,10 +4,11 @@ "private": true, "type": "module", "scripts": { - "dev": "tauri dev", + "dev": "npm run build:tunnel && tauri dev", "dev:ui": "vite --host 127.0.0.1 --port 5176 --strictPort", "build:ui": "tsc --noEmit && vite build", - "build": "tauri build", + "build:tunnel": "sh scripts/build-tunnel-go.sh", + "build": "npm run build:tunnel && tauri build", "tauri": "tauri" }, "engines": { diff --git a/client/scripts/build-tunnel-go.sh b/client/scripts/build-tunnel-go.sh new file mode 100644 index 0000000..5999df3 --- /dev/null +++ b/client/scripts/build-tunnel-go.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +client_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +target_triple=$(rustc -vV | sed -n 's/^host: //p') +case "$target_triple" in + aarch64-apple-darwin) go_os=darwin; go_arch=arm64 ;; + x86_64-apple-darwin) go_os=darwin; go_arch=amd64 ;; + x86_64-unknown-linux-gnu) go_os=linux; go_arch=amd64 ;; + aarch64-unknown-linux-gnu) go_os=linux; go_arch=arm64 ;; + *) echo "Unsupported Go tunnel build target: $target_triple" >&2; exit 1 ;; +esac + +mkdir -p "$client_dir/src-tauri/binaries" +( + cd "$client_dir/tunnel-go" + GOOS="$go_os" GOARCH="$go_arch" CGO_ENABLED=0 go build -trimpath \ + -o "$client_dir/src-tauri/binaries/opsx-tunnel-go-$target_triple" . +) +echo "Built Go WireGuard tunnel component for $target_triple" diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 1e1af1c..6eefd12 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -52,6 +52,38 @@ impl Default for ConnState { static CONN: Mutex> = Mutex::new(None); static TRAY_MENU: Mutex>> = Mutex::new(None); +static PROXY: Mutex> = Mutex::new(None); +// Serialize launches and teardown: two simultaneous clicks must never orphan a sidecar. +static PROXY_OPERATION: Mutex<()> = Mutex::new(()); +const PROXY_ADDRESS: &str = "127.0.0.1:18743"; + +fn stop_proxy_inner() { + if let Some(mut child) = PROXY.lock().unwrap().take() { + let _ = child.kill(); + let _ = child.wait(); + } +} + +fn stop_proxy() { + let _operation = PROXY_OPERATION.lock().unwrap(); + stop_proxy_inner(); +} + +#[tauri::command] +fn client_proxy_status() -> bool { + let _operation = PROXY_OPERATION.lock().unwrap(); + let mut guard = PROXY.lock().unwrap(); + if let Some(child) = guard.as_mut() { + if child.try_wait().ok().flatten().is_none() { return true; } + } + *guard = None; + false +} + +#[tauri::command] +fn client_proxy_stop() { + stop_proxy(); +} fn conn_state() -> ConnState { CONN.lock() @@ -128,6 +160,113 @@ struct StatusView { server: Option, } +#[derive(serde::Deserialize, Serialize)] +struct GoProbeResult { + connected: bool, + detail: String, + target: String, +} + +fn go_probe_binary(app: &AppHandle) -> Result { + let binary_name = if cfg!(windows) { "opsx-tunnel-go.exe" } else { "opsx-tunnel-go" }; + let mut candidates = Vec::new(); + if let Ok(dir) = app.path().resource_dir() { + candidates.push(dir.join(binary_name)); + candidates.push(dir.join("binaries").join(binary_name)); + } + if let Ok(dir) = app.path().executable_dir() { + candidates.push(dir.join(binary_name)); + } + #[cfg(debug_assertions)] + { + let triple = match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "aarch64-apple-darwin", + ("macos", "x86_64") => "x86_64-apple-darwin", + ("linux", "x86_64") => "x86_64-unknown-linux-gnu", + ("linux", "aarch64") => "aarch64-unknown-linux-gnu", + _ => "unsupported", + }; + candidates.push(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("binaries") + .join(format!("opsx-tunnel-go-{triple}"))); + } + candidates.into_iter().find(|p| p.is_file()) + .ok_or_else(|| "安装包缺少 Go 隧道组件,请重新安装客户端".into()) +} + +/// 独立探测 WireGuard 数据面及公网 TCP,不更改系统路由/DNS。 +/// 只接收已登录账号的设备记录,探测目标固定为公共地址。 +#[tauri::command] +async fn client_probe(app: AppHandle, tunnel_id: u64) -> Result { + let session = require_session(&app)?; + let dir = data_dir(&app)?; + let record_dir = tunnel::device_dir(&dir, &session.base_url, &session.username); + let mut record = tunnel::load_record_for(&record_dir, tunnel_id)?; + if record.config.tunnel_id != tunnel_id { + return Err("本机设备线路不匹配".into()); + } + // 管理员可能已更新公网路由或撤销设备;探测前始终拉取最新授权。 + record.config = api::device_config(&session.base_url, &session.token, record.config.device_id) + .await.map_err(|err| err.message())?; + if record.config.tunnel_id != tunnel_id { + return Err("服务端设备线路不匹配".into()); + } + let path = tunnel::save_record(&record_dir, &record)?; + let helper = go_probe_binary(&app)?; + let output = tauri::async_runtime::spawn_blocking(move || { + std::process::Command::new(helper) + .arg("probe").arg(path).arg("223.5.5.5:443") + .output() + .map_err(|err| format!("无法启动 Go 隧道探针:{err}")) + }).await.map_err(|err| format!("Go 隧道探针执行失败:{err}"))??; + serde_json::from_slice::(&output.stdout) + .map_err(|_| "Go 隧道探针未返回有效状态".into()) +} + +/// Explicit opt-in local proxy. This has no system routing or DNS side effects. +#[tauri::command] +async fn client_proxy_start(app: AppHandle, tunnel_id: u64) -> Result { + let session = require_session(&app)?; + let dir = tunnel::device_dir(&data_dir(&app)?, &session.base_url, &session.username); + let mut record = tunnel::load_record_for(&dir, tunnel_id)?; + record.config = api::device_config(&session.base_url, &session.token, record.config.device_id) + .await.map_err(|err| err.message())?; + if record.config.tunnel_id != tunnel_id { return Err("服务端设备线路不匹配".into()); } + tunnel::validate_config(&record.private_key, &record.config)?; + let path = tunnel::save_record(&dir, &record)?; + let helper = go_probe_binary(&app)?; + tauri::async_runtime::spawn_blocking(move || { + use std::io::BufRead; + let _operation = PROXY_OPERATION.lock().unwrap(); + stop_proxy_inner(); + let mut child = std::process::Command::new(helper) + .arg("proxy").arg(path).arg(PROXY_ADDRESS) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn().map_err(|err| format!("启动本地代理失败:{err}"))?; + let stdout = child.stdout.take().ok_or("代理未返回状态")?; + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut line = String::new(); + let result = std::io::BufReader::new(stdout).read_line(&mut line); + let _ = sender.send((result, line)); + }); + match receiver.recv_timeout(std::time::Duration::from_secs(5)) { + Ok((Ok(_), line)) if serde_json::from_str::(&line) + .ok().and_then(|v| v.get("status")?.as_str().map(str::to_owned)).as_deref() == Some("ready") => { + *PROXY.lock().unwrap() = Some(child); + Ok(format!("socks5h://{PROXY_ADDRESS}")) + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + Err("本机代理启动失败或端口被占用".into()) + } + } + }).await.map_err(|err| format!("代理启动任务失败:{err}"))? +} + // ---- Tauri 命令 ---- #[tauri::command] @@ -161,6 +300,7 @@ async fn client_login( device_name: device_name.clone(), lines: result.lines.clone(), }; + stop_proxy(); localstore::save(&data_dir(&app)?, &session)?; Ok(SessionView { username: result.username, @@ -194,6 +334,7 @@ async fn client_resume(app: AppHandle) -> Result, String> { } Err(api::ApiError::Message(reqwest::StatusCode::UNAUTHORIZED, _, _)) => { // 令牌失效/被撤销:清除本地会话 + stop_proxy(); if let Err(err) = tunnel::down(&dir) { let mut state = conn_state(); state.phase = ConnPhase::Connecting; // 保留 UI 的断开入口 @@ -223,6 +364,7 @@ async fn client_resume(app: AppHandle) -> Result, String> { #[tauri::command] async fn client_logout(app: AppHandle) -> Result<(), String> { + stop_proxy(); let dir = data_dir(&app)?; if conn_state().phase != ConnPhase::Disconnected { // 保留记录与会话,确保拆隧道失败后可再次操作。 @@ -443,6 +585,7 @@ async fn client_revoke_device(app: AppHandle, device_id: u64) -> Result<(), Stri .as_ref() .is_some_and(|r| r.config.device_id == device_id) { + stop_proxy(); tunnel::clear_record_for(&record_dir, device.tunnel_id); } Ok(()) @@ -490,6 +633,7 @@ pub fn run() { } } "quit" => { + stop_proxy(); // 退出前尽力拆除本机隧道,避免残留接口与路由 if let Ok(dir) = app.path().app_config_dir() { let _ = tunnel::down(&dir); @@ -511,6 +655,10 @@ pub fn run() { client_connect, client_disconnect, client_status, + client_probe, + client_proxy_start, + client_proxy_stop, + client_proxy_status, client_devices, client_revoke_device, client_device_name, diff --git a/client/src-tauri/src/system.rs b/client/src-tauri/src/system.rs index 9f9c9d3..e401c61 100644 --- a/client/src-tauri/src/system.rs +++ b/client/src-tauri/src/system.rs @@ -5,28 +5,81 @@ pub struct SystemReadiness { platform: &'static str, wg_found: bool, wg_quick_found: bool, + engine_mode: EngineMode, + engine_detail: &'static str, } -fn on_path(name: &str) -> bool { - std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).any(|directory| { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum EngineMode { + SystemTools, + Unsupported, +} + +fn engine_for(platform: &str, wg: bool, wg_quick: bool) -> (EngineMode, &'static str) { + match platform { + "windows" => (EngineMode::Unsupported, "Windows 内置隧道尚未实现"), + "macos" | "linux" if wg && wg_quick => ( + EngineMode::SystemTools, + "当前使用系统 WireGuard 工具;免安装引擎尚未交付", + ), + "macos" | "linux" => ( + EngineMode::Unsupported, + "当前版本需要本机 WireGuard 工具;免安装引擎尚未交付", + ), + _ => (EngineMode::Unsupported, "当前系统尚不支持连接"), + } +} + +pub(crate) fn active_engine() -> Result { + let (mode, detail) = engine_for( + std::env::consts::OS, + binary_path("wg").is_some(), + binary_path("wg-quick").is_some(), + ); + if mode == EngineMode::Unsupported { Err(detail) } else { Ok(mode) } +} + +pub(crate) fn binary_path(name: &str) -> Option { + let mut directories: Vec<_> = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect(); + // Finder/Dock 启动的 macOS 应用通常没有 Homebrew 的 PATH。 + #[cfg(target_os = "macos")] + directories.extend(["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].map(std::path::PathBuf::from)); + directories.into_iter().find_map(|directory| { + let path = directory.join(name); + if path.is_file() { return Some(path); } #[cfg(windows)] { - [name.to_string(), format!("{name}.exe")] - .iter() - .any(|candidate| directory.join(candidate).is_file()) - } - #[cfg(not(windows))] - { - directory.join(name).is_file() + let exe = directory.join(format!("{name}.exe")); + if exe.is_file() { return Some(exe); } } + None }) } #[tauri::command] pub fn get_system_readiness() -> SystemReadiness { + let wg_found = binary_path("wg").is_some(); + let wg_quick_found = binary_path("wg-quick").is_some(); + let (engine_mode, engine_detail) = engine_for(std::env::consts::OS, wg_found, wg_quick_found); SystemReadiness { platform: std::env::consts::OS, - wg_found: on_path("wg"), - wg_quick_found: on_path("wg-quick"), + wg_found, + wg_quick_found, + engine_mode, + engine_detail, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_tools_never_report_a_bundled_engine() { + assert_eq!(engine_for("macos", false, false).0, EngineMode::Unsupported); + assert_eq!(engine_for("linux", true, false).0, EngineMode::Unsupported); + assert_eq!(engine_for("macos", true, true).0, EngineMode::SystemTools); + assert_eq!(engine_for("windows", true, true).0, EngineMode::Unsupported); } } diff --git a/client/src-tauri/src/tunnel.rs b/client/src-tauri/src/tunnel.rs index 49e9df5..4bba221 100644 --- a/client/src-tauri/src/tunnel.rs +++ b/client/src-tauri/src/tunnel.rs @@ -139,7 +139,7 @@ fn valid_cidrs(value: &str) -> bool { }) } -fn validate_config(private_key: &str, cfg: &DeviceConfig) -> Result<(), String> { +pub(crate) fn validate_config(private_key: &str, cfg: &DeviceConfig) -> Result<(), String> { if !valid_key(private_key) || !valid_key(&cfg.server_public_key) || !valid_key(&cfg.preshared_key) @@ -175,11 +175,9 @@ fn validate_config(private_key: &str, cfg: &DeviceConfig) -> Result<(), String> } fn find_binary(name: &str) -> Result { - std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) - .map(|dir| dir.join(name)) - .find(|p| p.is_file()) + crate::system::binary_path(name) .map(|p| p.to_string_lossy().to_string()) - .ok_or_else(|| format!("未在本机 PATH 找到 {name},请先安装 wireguard-tools")) + .ok_or_else(|| format!("未找到 {name},请安装 wireguard-tools 后重试(在设置页查看安装提示)")) } /// 以系统授权方式执行 wg-quick up/down。 @@ -210,7 +208,7 @@ fn run_wg_quick(action: &str, conf: &Path) -> Result<(), String> { return Err("配置路径包含非法字符".into()); } let command = format!( - "{} {} {}", + "PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH; export PATH; {} {} {}", shell_escape(&wg_quick), action, shell_escape(&conf_str) @@ -221,11 +219,11 @@ fn run_wg_quick(action: &str, conf: &Path) -> Result<(), String> { .args(["-e", &script]) .status() .map_err(|err| format!("无法请求管理员授权:{err}"))?; - return if status.success() { + if status.success() { Ok(()) } else { Err(format!("wg-quick {action} 执行失败(可能被取消授权)")) - }; + } } #[cfg(target_os = "windows")] { @@ -242,23 +240,44 @@ fn shell_escape(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } -/// 建立隧道:渲染配置 → 0600 落盘 → 提权 wg-quick up。 -pub fn up(dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result { - validate_config(private_key, cfg)?; - let path = write_conf(dir, &render_config(private_key, cfg))?; - // 重复连接时先尽力清理旧接口,忽略失败(接口可能本就不存在) - let _ = run_wg_quick("down", &path); - run_wg_quick("up", &path)?; - Ok(path) +/// 所有引擎必须提供同一启停契约;以后内置引擎在此实现而非改动控制面。 +trait TunnelEngine { + fn up(&self, dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result; + fn down(&self, dir: &Path) -> Result<(), String>; } -/// 拆除隧道。配置文件可能已被清理,尽力而为。 -pub fn down(dir: &Path) -> Result<(), String> { - let path = dir.join("opsx-wg.conf"); - if !path.is_file() { - return Ok(()); +struct SystemToolsEngine; + +impl TunnelEngine for SystemToolsEngine { + fn up(&self, dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result { + validate_config(private_key, cfg)?; + let path = write_conf(dir, &render_config(private_key, cfg))?; + // 重复连接时先尽力清理旧接口,忽略失败(接口可能本就不存在) + let _ = run_wg_quick("down", &path); + run_wg_quick("up", &path)?; + Ok(path) } - run_wg_quick("down", &path) + + fn down(&self, dir: &Path) -> Result<(), String> { + let path = dir.join("opsx-wg.conf"); + if !path.is_file() { + return Ok(()); + } + run_wg_quick("down", &path) + } +} + +/// 建立隧道:仅使用检测到的执行层,不能回退到 PATH 中的其他二进制。 +pub fn up(dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result { + match crate::system::active_engine().map_err(str::to_string)? { + crate::system::EngineMode::SystemTools => SystemToolsEngine.up(dir, private_key, cfg), + crate::system::EngineMode::Unsupported => Err("当前系统不支持连接".into()), + } +} + +/// 拆除已建立的接口,即使连接后本机工具被移走也要尝试清理。 +pub fn down(dir: &Path) -> Result<(), String> { + SystemToolsEngine.down(dir) } #[cfg(test)] diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index 8a011bf..68b0f1e 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -17,6 +17,7 @@ "active": true, "targets": "all", "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], - "category": "Utility" + "category": "Utility", + "externalBin": ["binaries/opsx-tunnel-go"] } } diff --git a/client/src/App.tsx b/client/src/App.tsx index 606e5c3..31dbefb 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' import { - checkServer, clientConnect, clientDeviceName, clientDevices, clientDisconnect, + checkServer, clientConnect, clientDeviceName, clientDevices, clientDisconnect, clientProbe, clientProxyStart, clientProxyStop, clientProxyStatus, clientLines, clientLogin, clientLogout, clientResume, clientRevokeDevice, clientStatus, getSystemReadiness, type ConnState, type DeviceInfo, type Line, type SessionView, type StatusView, @@ -50,6 +50,9 @@ export default function App() { const [selectedLine, setSelectedLine] = useState(null) const [lines, setLines] = useState([]) const [actionError, setActionError] = useState('') + const [actionBusy, setActionBusy] = useState(false) + const actionLock = useRef(false) + const [readiness, setReadiness] = useState(null) const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)) // 启动时恢复本机会话 @@ -100,11 +103,30 @@ export default function App() { if (session && page === 'routes') void refreshLines() }, [session, page, refreshLines]) + useEffect(() => { + if (!session) return + getSystemReadiness().then(setReadiness).catch(() => undefined) + }, [session]) + + function beginAction() { + if (actionLock.current) return false + actionLock.current = true + setActionBusy(true) + return true + } + + function endAction() { + actionLock.current = false + setActionBusy(false) + } + async function handleConnect(tunnelId?: number) { + if (!beginAction()) return setActionError('') const target = tunnelId ?? selectedLine ?? lines.find(l => l.state === 'running')?.tunnel_id ?? lines[0]?.tunnel_id if (!target) { setActionError('没有可用线路,请先在“线路”页查看授权。') + endAction() return } try { @@ -114,10 +136,11 @@ export default function App() { setPage('overview') } catch (cause) { setActionError(cause instanceof Error ? cause.message : String(cause)) - } + } finally { endAction() } } async function handleDisconnect() { + if (!beginAction()) return setActionError('') try { await clientDisconnect() @@ -125,12 +148,14 @@ export default function App() { setStatus(null) } catch (cause) { setActionError(cause instanceof Error ? cause.message : String(cause)) - } + } finally { endAction() } } async function handleLogout() { + if (!beginAction()) return try { await clientLogout() } catch (cause) { setActionError(cause instanceof Error ? cause.message : String(cause)) + endAction() return } setSession(null) @@ -139,6 +164,7 @@ export default function App() { setLines([]) setSelectedLine(null) setPage('overview') + endAction() } if (booting) { @@ -180,9 +206,9 @@ export default function App() {
@@ -190,20 +216,26 @@ export default function App() { {connected ? '已连接' : connecting ? '连接中…' : '本客户端未连接'} {connected ? `${conn?.client_address ?? ''} · 已连接 ${formatDuration(conn?.since_unix ?? 0, now)}` - : connecting ? (conn?.detail || '正在建立隧道') : (conn?.detail || actionError || '点击上方按钮接入企业网络')} + : connecting ? (conn?.detail || '正在建立隧道') : readiness?.engine_mode === 'unsupported' + ? '本机隧道环境尚未就绪' + : (actionError || conn?.detail || '点击上方按钮接入企业网络')}
+ {readiness?.engine_mode === 'unsupported' &&
+ {readiness.engine_detail} + +
} {connected && status?.server &&
下行 {formatBytes(status.server.rx_bytes)} 上行 {formatBytes(status.server.tx_bytes)} 端点 {conn?.endpoint}
} - {actionError && !connecting &&
{actionError}
} + {actionError && !connecting && readiness?.engine_mode !== 'unsupported' &&
{actionError}
} {status?.server?.detail &&
{status.server.detail}
} -
setPage('routes')} onKeyDown={e => e.key === 'Enter' && setPage('routes')}> +
+
@@ -213,7 +245,7 @@ export default function App() { 本机设备{session.device_name} 授权线路{session.offline ? '服务端暂不可达' : `${lines.length} 条`}
- +
@@ -236,14 +268,14 @@ export default function App() {

{line.remark || line.endpoint || '—'}

{line.state === 'running' ? '运行中' : '未运行'} - )} } - {page === 'settings' && } + {page === 'settings' && } } @@ -289,10 +321,20 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void }) } return
+
+
OpsX VPN
+
+ SECURE ACCESS / 企业网络 +

连接工作网络,
从这里开始。

+

选择您的企业服务端,用已授权的 VPN 账号安全接入。

+
独立设备身份 · WireGuard 加密隧道
+
+ OPSX / PRIVATE NETWORK +
-
OpsX VPN企业网络接入
-

登录

-

使用管理员为您开通的 VPN 拨号账号登录;首次连接会自动为本机登记设备。

+
账号登录
+

欢迎使用 OpsX VPN

+

请使用管理员开通的 VPN 拨号账号登录

@@ -304,7 +346,7 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
{health === 'reachable' &&

✓ 服务可达

} {health === 'error' &&

服务不可达,请确认地址与网络。

} - {address.trim().toLowerCase().startsWith('http://') &&

HTTP 为明文传输,登录口令可能被窃听,建议使用可信 HTTPS 域名。

} + {address.trim().toLowerCase().startsWith('http://') &&

HTTP 为明文传输,建议使用可信 HTTPS 域名。

}
setUsername(e.target.value)} />
@@ -313,10 +355,11 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
setOtp(e.target.value)} />
} - +
setDeviceName(e.target.value)} placeholder="自动填充本机主机名" />
{error &&
{error}
} +

首次连接时会自动登记本机设备

@@ -357,11 +400,46 @@ function ServerCard({ baseUrl }: { baseUrl?: string }) { // ---- 设置页 ---- -function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: () => void }) { +function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange }: { session: SessionView; lines: Line[]; onLogout: () => void; actionBusy: boolean; onReadinessChange: (value: SystemReadiness) => void }) { const [readiness, setReadiness] = useState(null) const [readinessError, setReadinessError] = useState('') const [devices, setDevices] = useState([]) const [deviceError, setDeviceError] = useState('') + const [probeBusy, setProbeBusy] = useState(false) + const [probeResult, setProbeResult] = useState('') + const [proxyRunning, setProxyRunning] = useState(false) + const [proxyBusy, setProxyBusy] = useState(false) + const [proxyError, setProxyError] = useState('') + + useEffect(() => { clientProxyStatus().then(setProxyRunning).catch(() => undefined) }, []) + + async function toggleProxy() { + setProxyBusy(true) + setProxyError('') + try { + if (proxyRunning) { await clientProxyStop(); setProxyRunning(false) } + else { + const line = lines.find(l => l.state === 'running') + if (!line) throw new Error('当前没有正在运行的授权线路。') + await clientProxyStart(line.tunnel_id) + setProxyRunning(true) + } + } catch (cause) { setProxyError(cause instanceof Error ? cause.message : String(cause)) } + finally { setProxyBusy(false) } + } + + async function runProbe() { + const line = lines.find(l => l.state === 'running') + if (!line) { setProbeResult('当前没有正在运行的授权线路。'); return } + setProbeBusy(true) + setProbeResult('') + try { + const result = await clientProbe(line.tunnel_id) + setProbeResult(result.detail) + } catch (cause) { + setProbeResult(cause instanceof Error ? cause.message : String(cause)) + } finally { setProbeBusy(false) } + } const loadDevices = useCallback(() => { clientDevices().then(setDevices).catch(err => setDeviceError(String(err?.message ?? err))) @@ -370,11 +448,11 @@ function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: ( useEffect(() => { let active = true getSystemReadiness() - .then(result => { if (active) setReadiness(result) }) + .then(result => { if (active) { setReadiness(result); onReadinessChange(result) } }) .catch(() => { if (active) setReadinessError('本机检测仅在 Tauri 桌面应用中可用。') }) loadDevices() return () => { active = false } - }, [loadDevices]) + }, [loadDevices, onReadinessChange]) async function revoke(id: number, name: string) { if (!window.confirm(`确定撤销设备「${name}」?该设备将立即失去接入能力,需要重新登记。`)) return @@ -398,7 +476,7 @@ function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: ( 姓名{session.display_name || '—'} 服务端{localStorage.getItem('opsx_server_address') || '—'} - +
@@ -416,10 +494,30 @@ function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: (

本机环境

只读检查,不代表已具备拨号能力

{readiness ?
操作系统{readiness.platform} - wg 命令{readiness.wg_found ? 'PATH 中已发现' : 'PATH 中未发现'} - wg-quick 命令{readiness.wg_quick_found ? 'PATH 中已发现' : 'PATH 中未发现'} + 隧道引擎{readiness.engine_mode === 'system_tools' ? '系统工具模式' : '当前不可用'} + wg 命令{readiness.wg_found ? '已找到' : '未找到'} + wg-quick 命令{readiness.wg_quick_found ? '已找到' : '未找到'}
:

{readinessError || '正在检测本机环境…'}

} -

建立隧道时系统会弹出提权授权;Windows 暂请使用 WireGuard 官方客户端导入配置。

+ {readiness &&

{readiness.engine_detail}

} + {readiness && (!readiness.wg_found || !readiness.wg_quick_found) && readiness.platform !== 'windows' &&
+ 安装 WireGuard 工具 +

{readiness.platform === 'macos' ? '在终端运行 brew install wireguard-tools。' : '请按发行版安装 wireguard-tools(Ubuntu / Debian:sudo apt install wireguard-tools)。'}

+ +
} +

建立隧道时系统会弹出提权授权;Windows 桌面隧道暂不支持,请使用 WireGuard 官方客户端。

+
+ Go 隧道出口诊断 +

通过当前设备的 WireGuard 参数连接公网 TCP,不修改本机路由或 DNS;这项检查不代表系统 VPN 已连接。测试线路需授权公网路由,网关需开启出口 NAT。

+ + {probeResult &&

{probeResult}

} +
+
+ 本地 SOCKS5 代理(试用) +

通过内置 Go WireGuard 隧道为已配置代理的应用提供连接,不修改系统路由或 DNS。应用代理地址:socks5h://127.0.0.1:18743;使用 socks5h 才会经 VPN 解析域名。线路须允许目标公网路由并开启网关 NAT。

+ + {proxyRunning &&

本地代理正在运行;只有主动配置此代理的应用会走 VPN。

} + {proxyError &&

{proxyError}

} +
} diff --git a/client/src/platform.ts b/client/src/platform.ts index c4c214e..47cddee 100644 --- a/client/src/platform.ts +++ b/client/src/platform.ts @@ -1,7 +1,13 @@ import { invoke } from '@tauri-apps/api/core' export type ServerHealth = { address: string; reachable: boolean } -export type SystemReadiness = { platform: string; wg_found: boolean; wg_quick_found: boolean } +export type SystemReadiness = { + platform: string + wg_found: boolean + wg_quick_found: boolean + engine_mode: 'system_tools' | 'unsupported' + engine_detail: string +} export type Line = { tunnel_id: number @@ -40,6 +46,8 @@ export type DeviceStatus = { detail?: string | null } +export type ProbeResult = { connected: boolean; detail: string; target: string } + export type StatusView = { conn: ConnState; server: DeviceStatus | null } export type DeviceInfo = { @@ -93,6 +101,22 @@ export function clientStatus(): Promise { return isDesktop() ? invoke('client_status') : desktopOnly() } +export function clientProbe(tunnelId: number): Promise { + return isDesktop() ? invoke('client_probe', { tunnelId }) : desktopOnly() +} + +export function clientProxyStart(tunnelId: number): Promise { + return isDesktop() ? invoke('client_proxy_start', { tunnelId }) : desktopOnly() +} + +export function clientProxyStop(): Promise { + return isDesktop() ? invoke('client_proxy_stop') : desktopOnly() +} + +export function clientProxyStatus(): Promise { + return isDesktop() ? invoke('client_proxy_status') : desktopOnly() +} + export function clientDevices(): Promise { return isDesktop() ? invoke('client_devices') : desktopOnly() } diff --git a/client/src/system.css b/client/src/system.css index 8c84e06..8f4982f 100644 --- a/client/src/system.css +++ b/client/src/system.css @@ -1,4 +1,11 @@ .system-card { margin-top: 20px; } +/* 导航保持在视口内,长设置页只滚动右侧内容。 */ +body { min-width: 0; } +.sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; } +.workspace { min-width: 0; } +.topbar-brand { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.system-grid strong, .node-selector-copy { min-width: 0; overflow-wrap: anywhere; } +@media (max-width: 720px) { .system-grid { grid-template-columns: minmax(90px, 32%) 1fr; } } .system-card .info-heading { margin-bottom: 15px; } .system-grid { display: grid; grid-template-columns: 140px 1fr; gap: 14px; padding: 16px 0; border-top: 1px solid #edf1f7; font-size: 12px; } .system-grid span { color: #8292a8; } @@ -19,18 +26,52 @@ .connection-guide button { display: block; padding: 3px 0 0; background: none; border: 0; color: #306eee; font-weight: 600; } /* ---- 登录页 ---- */ -.login-shell { align-items: center; justify-content: center; background: linear-gradient(160deg, #eef4ff, #f8faff 60%, #f0f6ff); } -.login-card { width: 420px; max-width: calc(100vw - 48px); background: #fff; border: 1px solid #e8edf5; border-radius: 20px; box-shadow: 0 18px 48px rgba(29, 72, 137, .08); padding: 36px 38px 30px; } -.login-card h1 { font-size: 24px; margin: 22px 0 6px; } -.login-form { display: grid; gap: 4px; margin-top: 18px; } -.login-form label { font-size: 11px; font-weight: 700; color: #5d6e85; margin: 10px 0 6px; } -.login-submit { margin-top: 22px; height: 44px; border: 0; border-radius: 10px; background: #3476f5; color: #fff; font-size: 14px; font-weight: 700; } +.login-shell { min-height: 100vh; align-items: stretch; justify-content: center; padding: 30px; background: #f0f4fb; } +.login-intro { width: min(43%, 430px); min-height: 640px; padding: 36px; display: flex; flex-direction: column; color: white; border-radius: 24px 0 0 24px; background: radial-gradient(circle at 15% 75%, #5989ef 0, transparent 40%), linear-gradient(150deg, #1648b4, #2769dc 65%, #4382eb); position: relative; overflow: hidden; } +.login-intro::after { content: ''; position: absolute; width: 300px; height: 300px; right: -140px; top: 22%; border: 1px solid #ffffff3a; border-radius: 50%; box-shadow: 0 0 0 42px #ffffff09, 0 0 0 86px #ffffff08; pointer-events: none; } +.login-brand { display: flex; align-items: center; gap: 11px; font-size: 20px; letter-spacing: -.04em; position: relative; z-index: 1; } +.login-brand-mark { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; background: #ffffff2b; } +.login-intro-content { margin: auto 0; position: relative; z-index: 1; } +.login-eyebrow { font-size: 11px; font-weight: 750; letter-spacing: .15em; color: #bdd4ff; } +.login-intro h2 { margin: 19px 0 16px; font-size: clamp(27px, 3vw, 37px); line-height: 1.25; letter-spacing: -.04em; } +.login-intro p { max-width: 280px; color: #dce9ff; line-height: 1.8; font-size: 13px; } +.login-feature { margin-top: 46px; padding: 13px 0; display: flex; align-items: center; gap: 10px; border-top: 1px solid #ffffff46; font-size: 12px; color: #eaf2ff; } +.login-intro-foot { font-size: 10px; letter-spacing: .16em; color: #c6d9ff; } +.login-card { width: min(57%, 550px); min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; background: white; border: 1px solid #e8edf5; border-left: 0; border-radius: 0 24px 24px 0; box-shadow: 0 22px 55px #1d488915; padding: 42px clamp(30px, 5vw, 68px); } +.login-card-head { display: flex; align-items: center; justify-content: space-between; color: #3574e9; } +.login-step { border-radius: 100px; background: #eff5ff; padding: 7px 12px; font-size: 11px; font-weight: 700; } +.login-card-icon { color: #3476f5; } +.login-card h1 { font-size: 26px; margin: 24px 0 6px; } +.login-description { margin: 0; font-size: 12px; color: #8391a6; } +.login-form { display: grid; gap: 4px; margin-top: 20px; } +.login-form label { font-size: 12px; font-weight: 700; color: #4f6078; margin: 10px 0 5px; } +.login-form .input-row input { height: 43px; background: #fbfcff; font-size: 13px; } +.login-submit { margin-top: 23px; height: 46px; border: 0; border-radius: 10px; background: #3476f5; color: #fff; font-size: 14px; font-weight: 700; box-shadow: 0 8px 18px #3476f533; } .login-submit:hover { background: #205ed9; } .login-submit:disabled { opacity: .6; cursor: wait; } +.login-footnote { margin: 11px 0 0; text-align: center; font-size: 11px; color: #94a1b2; } +@media (max-width: 780px) { + .login-shell { padding: 18px; } + .login-intro { width: 34%; padding: 25px; } + .login-card { width: 66%; padding: 32px; } + .login-intro h2 { font-size: 26px; } + .login-feature { line-height: 1.5; } +} +@media (max-width: 620px) { + .login-shell { display: block; padding: 12px; } + .login-intro { width: 100%; min-height: auto; padding: 15px 22px; border-radius: 18px 18px 0 0; } + .login-intro-content, .login-intro-foot { display: none; } + .login-card { width: 100%; border-left: 1px solid #e8edf5; border-radius: 0 0 18px 18px; padding: 25px; } +} +.install-guide { padding: 14px; border: 1px solid #dae7ff; border-radius: 10px; background: #f5f8ff; font-size: 12px; color: #354969; } +.install-guide p { margin: 7px 0 12px; line-height: 1.6; overflow-wrap: anywhere; } +.probe-guide { margin-top: 15px; } +.secondary-action:disabled { opacity: .55; cursor: not-allowed; } /* ---- 连接状态 ---- */ .power-display.on { background: linear-gradient(145deg, #2fce88, #17a268); box-shadow: 0 16px 30px #17a26849, inset 0 1px 2px #ffffff66; } -.power-display.busy { opacity: .75; cursor: wait; animation: pulse 1.4s ease-in-out infinite; } +.power-display.busy { animation: pulse 1.4s ease-in-out infinite; } +.power-display:disabled { opacity: .6; cursor: wait; } @keyframes pulse { 50% { transform: scale(.96); } } .preview-pill.online { border-color: #bdebd6; background: #ebf8f2; color: #168861; } .traffic-grid { display: flex; justify-content: center; gap: 18px; margin: -8px 0 18px; font-size: 11px; color: #63738a; } diff --git a/client/tunnel-go/THIRD_PARTY.md b/client/tunnel-go/THIRD_PARTY.md new file mode 100644 index 0000000..e067c35 --- /dev/null +++ b/client/tunnel-go/THIRD_PARTY.md @@ -0,0 +1,5 @@ +# Go 隧道组件的第三方依赖 + +该组件使用官方 `golang.zx2c4.com/wireguard`,版本固定在 `go.mod`,校验值固定在 `go.sum`。WireGuard Go 使用 MIT 许可证;网络栈依赖 gVisor 使用 Apache-2.0 许可证。发行安装包前须随安装包附上相应完整许可证文本、依赖版本清单,并复核依赖和代码签名。当前仓库中的构建与诊断原型不等于已经完成发行审查。 + +上游: diff --git a/client/tunnel-go/go.mod b/client/tunnel-go/go.mod new file mode 100644 index 0000000..41770ef --- /dev/null +++ b/client/tunnel-go/go.mod @@ -0,0 +1,15 @@ +module github.com/opsx/opsx-vpn/client/tunnel-go + +go 1.23.1 + +require golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f + +require ( + github.com/google/btree v1.1.2 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/time v0.7.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect +) diff --git a/client/tunnel-go/go.sum b/client/tunnel-go/go.sum new file mode 100644 index 0000000..ff0570b --- /dev/null +++ b/client/tunnel-go/go.sum @@ -0,0 +1,16 @@ +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f h1:gJI6OVshWW0mvGyTVmW8mJoaw2OM69wVCeZkwpX+Jdc= +golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= diff --git a/client/tunnel-go/main.go b/client/tunnel-go/main.go new file mode 100644 index 0000000..1470fb9 --- /dev/null +++ b/client/tunnel-go/main.go @@ -0,0 +1,408 @@ +// OpsX VPN WireGuard probe and application proxy. The upstream Go userspace +// engine uses an in-process network stack and does not alter OS routes. +// The private key stays in the local 0600 device record and is never printed. +package main + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/netip" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "golang.zx2c4.com/wireguard/conn" + "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun/netstack" +) + +const version = "0.1.0" + +type record struct { + PrivateKey string `json:"private_key"` + Config struct { + ClientAddress string `json:"client_address"` + ServerPublicKey string `json:"server_public_key"` + PresharedKey string `json:"preshared_key"` + Endpoint string `json:"endpoint"` + AllowedIPs string `json:"allowed_ips"` + DNS string `json:"dns"` + MTU uint32 `json:"mtu"` + Keepalive uint32 `json:"keepalive"` + } `json:"config"` +} + +type result struct { + Connected bool `json:"connected"` + Detail string `json:"detail"` + Target string `json:"target,omitempty"` +} + +func readRecord(path string) (*record, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("无法读取本机设备配置: %w", err) + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 { + return nil, errors.New("本机设备配置权限必须为 0600") + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("无法读取本机设备配置: %w", err) + } + var r record + if err := json.Unmarshal(data, &r); err != nil { + return nil, errors.New("本机设备配置格式无效") + } + return &r, nil +} + +func key(value string) (string, error) { + b, err := base64.StdEncoding.DecodeString(value) + if err != nil || len(b) != 32 { + return "", errors.New("WireGuard 密钥格式无效") + } + return hex.EncodeToString(b), nil +} + +func permittedRoute(value string, target netip.Addr) (bool, error) { + for _, raw := range strings.Split(value, ",") { + prefix, err := netip.ParsePrefix(strings.TrimSpace(raw)) + if err != nil { + return false, errors.New("WireGuard 路由无效") + } + if prefix.Contains(target) { + return true, nil + } + } + return false, nil +} + +func uapi(r *record, target netip.Addr) (string, []netip.Addr, error) { + priv, err := key(r.PrivateKey) + if err != nil { + return "", nil, err + } + pub, err := key(r.Config.ServerPublicKey) + if err != nil { + return "", nil, err + } + psk, err := key(r.Config.PresharedKey) + if err != nil { + return "", nil, err + } + addr, err := netip.ParsePrefix(r.Config.ClientAddress) + if err != nil { + return "", nil, errors.New("客户端地址无效") + } + host, port, err := net.SplitHostPort(r.Config.Endpoint) + if err != nil || host == "" || port == "" { + return "", nil, errors.New("网关端点无效") + } + portNum, err := strconv.Atoi(port) + if err != nil || portNum < 1 || portNum > 65535 { + return "", nil, errors.New("网关端口无效") + } + if strings.ContainsAny(host, "\n\r= ") { + return "", nil, errors.New("网关地址无效") + } + var allowed []string + var permitted bool + for _, raw := range strings.Split(r.Config.AllowedIPs, ",") { + prefix, err := netip.ParsePrefix(strings.TrimSpace(raw)) + if err != nil { + return "", nil, errors.New("WireGuard 路由无效") + } + allowed = append(allowed, "allowed_ip="+prefix.String()) + permitted = permitted || !target.IsValid() || prefix.Contains(target) + } + if !permitted { + return "", nil, errors.New("探测目标不在授权路由内") + } + if r.Config.Keepalive > 65535 { + return "", nil, errors.New("保活参数无效") + } + lines := []string{ + "private_key=" + priv, "replace_peers=true", "public_key=" + pub, + "preshared_key=" + psk, "endpoint=" + r.Config.Endpoint, + "persistent_keepalive_interval=" + strconv.FormatUint(uint64(r.Config.Keepalive), 10), + } + lines = append(lines, allowed...) + return strings.Join(lines, "\n") + "\n", []netip.Addr{addr.Addr()}, nil +} + +func startDevice(r *record, target netip.Addr) (*device.Device, *netstack.Net, error) { + settings, ips, err := uapi(r, target) + if err != nil { + return nil, nil, err + } + mtu := int(r.Config.MTU) + if mtu == 0 { + mtu = 1420 + } + if mtu < 576 || mtu > 9000 { + return nil, nil, errors.New("MTU 无效") + } + var dns []netip.Addr + for _, raw := range strings.Split(r.Config.DNS, ",") { + if strings.TrimSpace(raw) == "" { + continue + } + ip, err := netip.ParseAddr(strings.TrimSpace(raw)) + if err != nil { + return nil, nil, errors.New("DNS 地址无效") + } + dns = append(dns, ip) + } + tun, stack, err := netstack.CreateNetTUN(ips, dns, mtu) + if err != nil { + return nil, nil, fmt.Errorf("创建 Go 网络栈失败: %w", err) + } + dev := device.NewDevice(tun, conn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, "")) + if err := dev.IpcSet(settings); err != nil { + dev.Close() + return nil, nil, errors.New("WireGuard 连接参数无效") + } + if err := dev.Up(); err != nil { + dev.Close() + return nil, nil, fmt.Errorf("启动 Go 隧道失败: %w", err) + } + return dev, stack, nil +} + +func probe(ctx context.Context, r *record, target string) error { + addr, err := netip.ParseAddrPort(target) + if err != nil { + return errors.New("探测地址必须为 IP:端口") + } + dev, stack, err := startDevice(r, addr.Addr()) + if err != nil { + return err + } + defer dev.Close() + channel := make(chan error, 1) + go func() { + c, err := stack.DialContext(ctx, "tcp", target) + if err == nil { + _ = c.Close() + } + channel <- err + }() + select { + case err := <-channel: + if err != nil { + return fmt.Errorf("VPN 出口 TCP 连接失败: %w", err) + } + return nil + case <-ctx.Done(): + return errors.New("VPN 出口连接超时,请检查握手、网关 NAT 和防火墙") + } +} + +// A loopback-only SOCKS5 listener provides real, user-space TCP access without +// claiming to alter the operating system's routing table. DNS is resolved in +// the VPN netstack, and every destination is checked against AllowedIPs. +func serveSOCKS(r *record, address string) error { + listenAddr, err := netip.ParseAddrPort(address) + if err != nil || !listenAddr.Addr().IsLoopback() || listenAddr.Port() == 0 { + return errors.New("代理只允许监听明确指定的本机回环端口") + } + dev, stack, err := startDevice(r, netip.Addr{}) + if err != nil { + return err + } + defer dev.Close() + listener, err := net.Listen("tcp", address) + if err != nil { + return fmt.Errorf("本机代理监听失败: %w", err) + } + defer listener.Close() + parentPID := os.Getppid() + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for range ticker.C { + if os.Getppid() != parentPID { + _ = listener.Close() + return + } + } + }() + // The launcher waits for this line before reporting the proxy ready. + _ = json.NewEncoder(os.Stdout).Encode(map[string]string{"status": "ready", "listen": address}) + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(stop) + go func() { <-stop; _ = listener.Close() }() + clients := make(chan struct{}, 128) + for { + client, err := listener.Accept() + if err != nil { + return nil + } + select { + case clients <- struct{}{}: + go func() { + defer func() { <-clients }() + handleSOCKS(client, r, stack) + }() + default: + _ = client.Close() + } + } +} + +func handleSOCKS(client net.Conn, r *record, stack *netstack.Net) { + defer client.Close() + _ = client.SetDeadline(time.Now().Add(15 * time.Second)) + var greeting [2]byte + if _, err := io.ReadFull(client, greeting[:]); err != nil || greeting[0] != 5 { + return + } + methods := make([]byte, int(greeting[1])) + if _, err := io.ReadFull(client, methods); err != nil { + return + } + noAuth := false + for _, method := range methods { + if method == 0 { + noAuth = true + } + } + if !noAuth { + _, _ = client.Write([]byte{5, 255}) + return + } + if _, err := client.Write([]byte{5, 0}); err != nil { + return + } + var header [4]byte + if _, err := io.ReadFull(client, header[:]); err != nil || header[0] != 5 { + return + } + if header[1] != 1 || header[2] != 0 { + socksReply(client, 7) + return + } + var host string + switch header[3] { + case 1: + var ip [4]byte + if _, err := io.ReadFull(client, ip[:]); err != nil { + return + } + host = netip.AddrFrom4(ip).String() + case 4: + var ip [16]byte + if _, err := io.ReadFull(client, ip[:]); err != nil { + return + } + host = netip.AddrFrom16(ip).String() + case 3: + var size [1]byte + if _, err := io.ReadFull(client, size[:]); err != nil || size[0] == 0 { + return + } + name := make([]byte, size[0]) + if _, err := io.ReadFull(client, name); err != nil { + return + } + host = string(name) + default: + socksReply(client, 8) + return + } + var port [2]byte + if _, err := io.ReadFull(client, port[:]); err != nil { + return + } + portNum := int(port[0])<<8 | int(port[1]) + if portNum == 0 { + socksReply(client, 1) + return + } + addresses := []string{host} + if _, err := netip.ParseAddr(host); err != nil { + addresses, err = stack.LookupHost(host) + if err != nil { + socksReply(client, 4) + return + } + } + for _, address := range addresses { + ip, err := netip.ParseAddr(address) + if err != nil { + continue + } + allowed, err := permittedRoute(r.Config.AllowedIPs, ip) + if err != nil || !allowed { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + upstream, err := stack.DialContext(ctx, "tcp", net.JoinHostPort(address, strconv.Itoa(portNum))) + cancel() + if err != nil { + continue + } + defer upstream.Close() + if err := socksReply(client, 0); err != nil { + return + } + _ = client.SetDeadline(time.Time{}) + go func() { _, _ = io.Copy(upstream, client); _ = upstream.Close() }() + _, _ = io.Copy(client, upstream) + return + } + socksReply(client, 4) +} + +func socksReply(conn net.Conn, code byte) error { + _, err := conn.Write([]byte{5, code, 0, 1, 127, 0, 0, 1, 0, 0}) + return err +} + +func main() { + if len(os.Args) == 2 && os.Args[1] == "--version" { + fmt.Println("opsx-tunnel-go " + version) + return + } + if len(os.Args) != 4 || (os.Args[1] != "probe" && os.Args[1] != "proxy") { + fmt.Fprintln(os.Stderr, "usage: opsx-tunnel-go ") + os.Exit(2) + } + // stdout only contains a non-secret, machine-readable result. Never echo the + // configuration, keys, token, or arbitrary upstream error strings. + r, err := readRecord(os.Args[2]) + if err == nil && os.Args[1] == "proxy" { + err = serveSOCKS(r, os.Args[3]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + if err == nil { + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + err = probe(ctx, r, os.Args[3]) + } + res := result{Connected: err == nil, Target: os.Args[3]} + if err == nil { + res.Detail = "WireGuard 出口 TCP 已连通" + } else { + res.Detail = err.Error() + } + _ = json.NewEncoder(os.Stdout).Encode(res) + if err != nil { + os.Exit(1) + } +} diff --git a/client/tunnel-go/main_test.go b/client/tunnel-go/main_test.go new file mode 100644 index 0000000..917e2ed --- /dev/null +++ b/client/tunnel-go/main_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/base64" + "net/netip" + "os" + "path/filepath" + "strings" + "testing" +) + +func testRecord() *record { + r := &record{} + k := base64.StdEncoding.EncodeToString(make([]byte, 32)) + r.PrivateKey = k + r.Config.ServerPublicKey = k + r.Config.PresharedKey = k + r.Config.ClientAddress = "10.88.0.3/32" + r.Config.Endpoint = "121.4.111.208:51820" + r.Config.AllowedIPs = "0.0.0.0/0, ::/0" + r.Config.Keepalive = 25 + return r +} + +func TestUAPIAuthorizationAndEscaping(t *testing.T) { + r := testRecord() + config, ips, err := uapi(r, netip.MustParseAddr("1.1.1.1")) + if err != nil || len(ips) != 1 || ips[0].String() != "10.88.0.3" || !strings.Contains(config, "allowed_ip=0.0.0.0/0") { + t.Fatalf("valid config rejected: %v", err) + } + r.Config.AllowedIPs = "10.88.0.0/24" + if _, _, err := uapi(r, netip.MustParseAddr("1.1.1.1")); err == nil { + t.Fatal("outside allowed routes") + } + r.Config.AllowedIPs = "0.0.0.0/0" + r.Config.Endpoint = "example.com:51820\nprivate_key=override" + if _, _, err := uapi(r, netip.MustParseAddr("1.1.1.1")); err == nil { + t.Fatal("injected UAPI command") + } +} + +func TestRejectReadableKeyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "record.json") + if err := os.WriteFile(path, []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + if _, err := readRecord(path); err == nil { + t.Fatal("world-readable key file accepted") + } +} + +func TestRejectSymlinkedKeyFile(t *testing.T) { + dir := t.TempDir() + record := filepath.Join(dir, "record.json") + if err := os.WriteFile(record, []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link.json") + if err := os.Symlink(record, link); err != nil { + t.Fatal(err) + } + if _, err := readRecord(link); err == nil { + t.Fatal("symlinked device record accepted") + } +} + +func TestProxyCannotBindPublicInterface(t *testing.T) { + for _, address := range []string{"0.0.0.0:19090", "192.0.2.1:19090", "127.0.0.1:0", "garbage"} { + if err := serveSOCKS(testRecord(), address); err == nil { + t.Fatalf("unsafe proxy bind accepted: %s", address) + } + } +} + +func TestProxyRespectsAuthorizedRoutes(t *testing.T) { + if ok, err := permittedRoute("10.88.0.0/24", netip.MustParseAddr("223.5.5.5")); err != nil || ok { + t.Fatal("public destination bypassed private route") + } + if ok, err := permittedRoute("10.88.0.0/24, 223.5.5.5/32", netip.MustParseAddr("223.5.5.5")); err != nil || !ok { + t.Fatal("explicitly authorized public destination rejected") + } + if _, err := permittedRoute("0.0.0.0/0\nprivate_key=bad", netip.MustParseAddr("1.1.1.1")); err == nil { + t.Fatal("invalid routes accepted") + } +} diff --git a/docs/specs/SDD-003-bundled-wireguard-client.md b/docs/specs/SDD-003-bundled-wireguard-client.md new file mode 100644 index 0000000..6c54dd6 --- /dev/null +++ b/docs/specs/SDD-003-bundled-wireguard-client.md @@ -0,0 +1,83 @@ +# SDD-003:随客户端分发 WireGuard 隧道组件 + +| 字段 | 内容 | +|---|---| +| 版本 / 变更级别 | 0.1 / S3(系统隧道、路由、DNS 与特权执行) | +| 设计状态 | Draft,已核对本地实现与 opsxdb / opsxapi 的桌面打包方式 | +| 实现状态 | 已引入可打包 Go 版标准 WireGuard 无特权探针和显式启停的本机 SOCKS5 模式;系统 TUN/路由/DNS 尚未完成 | +| 负责人 / 日期 | opsx-vpn / 2026-10-21 | +| 关联需求 | 企业员工安装 OpsX VPN 即可拨号,无需 Homebrew 或手动安装 wireguard-tools | +| 批准状态 | 用户已要求先评估设计后开发;具体平台发布签名尚待配置 | + +## 1. 背景、目标与范围 + +- **当前行为与证据**:`client/src-tauri/src/tunnel.rs` 使用本机 `wg-quick` 创建接口,macOS 通过管理员授权执行,Linux 依赖 `pkexec`;`system.rs` 只检查工具位置;Windows 返回未实现。`client/src-tauri/tauri.conf.json` 未打包隧道组件。`../opsxdb/src-tauri/tauri.conf.json` 使用 `externalBin` 打包 Go sidecar,`../opsxdb/run.sh` 按目标 triple 复制二进制;`../opsxapi/src-tauri/src/lib.rs` 则在 Rust 内直接实现网络业务。两者均未实现 VPN 特权 helper。现有 `docs/specs/SDD-002-desktop-wireguard-client.md` 中普通拨号用户和设备 API 已进入第二阶段,目标 OS 的真实握手仍待验。 +- **目标**:Rust 保持唯一的客户端控制面及本机私钥拥有者;按 OS 引入可随应用签名分发的隧道执行层。macOS 优先;用户不必安装 Homebrew 或 WireGuard 命令。Linux 复用内核 WireGuard,但打包必要工具与权限策略;Windows 使用专门的 Windows WireGuard 驱动/服务方案,不把 macOS 的 Go TUN 实现当通用驱动。 +- **本轮实施边界**:实现可打包的 Go 用户空间 WireGuard netstack 探针和明确标注的本机 SOCKS5 应用代理;Rust 执行层仍明确报告系统工具依赖。完成可签名的特权 helper、路由/DNS 回滚和目标 OS 握手验收之前,不能在 UI 上宣称已实现免依赖系统拨号。 +- **保留行为**:普通用户认证、设备公钥注册、配置领取及服务端 WireGuard UDP 协议不改;前端不接收私钥,服务端不接收客户端私钥。 + +## 2. 需求与约束 + +- FR-01:启用免安装分发通道的发布版仅在安装包内存在**正确目标架构、可校验版本**的隧道组件且系统适配就绪时报告“内置引擎就绪”;缺失时应明确报错,绝不悄悄运行 PATH 中同名程序。当前过渡版明确标记为“系统工具模式”,不得作为免安装版分发。 +- FR-02:开发模式仍可使用系统 `wg-quick`,并明确标记为“系统工具模式”;二者的就绪检测和实际连接路径必须一致。 +- FR-03:随包组件只接受固定动作及受约束的本机配置路径,不接受来自 WebView 的任意命令或路径;日志不可含私钥、令牌、完整配置。 +- FR-04:macOS 正式接入必须完成受控提权、TUN、路由与 DNS 原值保存和恢复;授权取消、进程崩溃、升级或断开均须保持可恢复性。系统辅助程序的签名与安装按目标发行证书验收。 +- FR-05:Windows/Linux 按各自系统 API 和权限交付;未验收的平台应明确“尚不支持内置隧道”,不能用模拟连接替代。 +- NFR-01:正式发布不得从远程地址动态下载可执行文件、不得通过 shell 插入远端配置、不得使用旧飞连魔改 WireGuard 协议。 +- NFR-02:非管理员安装包、系统提权、代码签名、升级与卸载必须分别测试;不能将可执行文件放进用户可写目录再让 root 执行。 + +## 3. 架构与方案 + +```text +React → Tauri invoke → Rust(登录、设备私钥、连接状态) + ├─ 开发过渡:系统 wg-quick + └─ 发布引擎:签名的特权隧道组件 + ├─ macOS:WireGuard Go 引擎 + 受控网络配置/恢复 + ├─ Linux:内核 WireGuard + 受控配置服务 + └─ Windows:WireGuardNT/官方隧道服务适配 +``` + +- 参考 opsxdb 的按 triple 构建与 Tauri `externalBin`,**不复用**它开放 HTTP 端口的后端模型;隧道组件无需监听 TCP 端口,特权边界必须固定动作、受限 IPC 和签名校验。opsxapi 证明纯 Rust 控制面足够,不必把登录 API 搬进 Go。 +- 采用标准上游 WireGuard 协议。`wireguard-go` 是用户空间引擎,不能单独完成 route/DNS/提权,必须由系统适配器完成,且需单独审查依赖版本与许可证。严禁直接移植 `PinkD/wireguard-go` 的私有扩展。 +- `client/tunnel-go` 当前通过 `netstack.CreateNetTUN` 实现数据面探针,Rust `client_probe` 只对已登录用户的本机设备记录发起固定公共 TCP 目标 `1.1.1.1:443` 的连接。此模式能在无需管理员权限时检查端到端握手与网关出口;不提供系统 VPN 路由,不能作为“已连接”状态依据,也不能静默读取记录自动运行。 +- 测试专线 `opsx-client-pilot` 当前给该设备下发的 `AllowedIPs` 为 `10.88.0.0/24`;服务器 `net.ipv4.ip_forward=1`,公网验证使用的临时 NAT 规则已清理。正常公网使用前需要由管理员明确更新该专线的客户端授权路由并持久化对应网段及出口网卡的 NAT。 +- 本机代理固定监听 `127.0.0.1:18743`,只接受 SOCKS5 CONNECT;由 VPN 网络栈解析域名,并按服务器下发的 AllowedIPs 逐目的地址校验。前端明确显示只有配置了此代理的应用才经过 VPN;退出登录或退出应用时停止进程。此代理在本机回环端口没有额外认证,设备上的其他本地进程也可使用它,正式发行前应评估本机进程间隔离要求。 +- **顺序**:先实现选择器和构建/验证契约,区分内置和系统工具;再建立 macOS 受限 helper 与网络快照/恢复;真机完成全隧道及分流、DNS、睡眠/唤醒、异常退出与真实握手;随后分别实现 Linux/Windows。未通过门槛前保持现有模式并显示准确的依赖信息。 + +## 4. 接口、数据与配置契约 + +- `get_system_readiness` 向前兼容既有 `platform/wg_found/wg_quick_found`,新增 `engine_mode`(`bundled` / `system_tools` / `unsupported`)及可读 `engine_detail`;由 Rust 而非 UI 决定。`bundled` 仅可在实际引擎、权限和系统配置均通过验收后返回。 +- 内置组件的目标 triple 路径和版本由构建产物显式声明;发布流程核对哈希、签名和可执行权限。不能从用户可写数据目录或 PATH 寻找发布 helper。 +- 服务端 API、数据库及客户端设备密钥数据模型不变;未来 helper 通过最小化本地 IPC 收到连接参数,不接触用户口令或管理令牌。 + +## 5. 风险与验收 + +| 风险或失败场景 | 防护 / 降级 / 恢复 | 可观察信号 | +|---|---|---| +| Go 引擎已启动但没有路由/DNS | 连接前检查系统适配就绪;握手只说明对端通信,不代表全隧道可用 | 界面报告未完成的阶段,路由/DNS 实测 | +| 提权执行用户替换的二进制 | 验证签名,固定安装路径与所有权,不在用户数据目录执行 | 签名/权限校验失败,拒绝连接 | +| 崩溃残留默认路由或 DNS | 启动前快照、事务回滚、下次启动修复 | 异常结束后系统路由/DNS 与原值一致 | +| 三平台能力误报 | 平台分别发布和验收;未支持者显示真实原因 | 每平台独立安装和断开报告 | + +| AC | 对应需求 | 前置条件与操作 | 预期结果 | 验证方式 | 结果 | +|---|---|---|---|---|---| +| AC-01 | FR-01, FR-02 | 无 Homebrew 的 macOS 打开当前过渡版/未来免安装版 | 过渡版明确显示依赖;免安装版仅在组件和权限实际就绪时显示内置可用 | 单测、构建、真机 | 当前过渡版选择器单测通过;免安装版 Not Run | +| AC-01a | FR-01, NFR-01 | 构建固定版本 Go 组件,输入授权路由内/外目标 | 仅支持授权内目标,不输出密钥,打包客户端可定位正确架构组件 | Go 单测、构建和实际出口探测 | Go 单测、macOS arm64 构建、临时授权公网出口与 SOCKS5h 网页请求通过;正式安装包签名 Not Run | +| AC-02 | FR-03, NFR-01/02 | 尝试替换组件、传非法路径、控制字符 | 拒绝执行,机密不记录 | 隔离测试、签名检查 | Not Run | +| AC-03 | FR-04 | macOS 连接、断开、撤销授权、强制退出及重启 | 真实握手;路由/DNS 保持或恢复 | 两台目标机人工/自动验收 | Not Run | +| AC-04 | FR-05 | Windows/Linux 干净系统安装、拨号、卸载 | 无独立工具安装步骤,未支持平台不假装就绪 | 各平台测试 | Not Run | + +## 6. 发布与恢复 + +- 第一阶段仅交付设计与隔离执行层的基础代码,不默认启用内置模式;持续提供现有 `wg-quick` 开发通道。切换正式引擎前收集目标架构及代码签名资料。 +- 2026-10-21 用户明确授权后,使用 0600 临时设备配置将 AllowedIPs 扩为全路由,在服务器仅为 `10.88.0.0/24` 添加临时出口 NAT;Go 数据面实际连接 `223.5.5.5:443` 成功,通过 SOCKS5h 获取 `http://www.baidu.com` 返回 HTTP 200。原始设备记录仍只允许 `10.88.0.0/24`;验证后已删除唯一带 `opsx-vpn-wg4-pilot` 标记的临时 NAT 规则。服务器自身访问 `1.1.1.1:443` 也超时,客户端探测目标改为可达的 `223.5.5.5:443`。这些验证不证明系统 VPN 或路由/DNS 恢复完成。 +- 先在独立试验机测试安装与签名,再验证路由/DNS 回滚与握手,然后分批发版;不能仅靠 `cargo check` 或浏览器预览宣称可分发。 +- 出现网络配置残留时用保存的系统快照恢复并停用有问题的组件;保留旧安装包与开发模式供排障。服务端不变,无数据库回滚。 + +## 7. 待决问题 + +| 问题 | 是否阻塞 | 推荐方案 | +|---|---|---| +| macOS Developer ID、Network Extension entitlement 与签名/公证资源 | 阻塞 macOS 正式分发 | 评估上游官方 WireGuardKit/Network Extension 与 Go helper 两条路线;以能稳定签名、恢复网络为准 | +| 目标平台与架构矩阵 | 阻塞跨平台正式验收 | 先 macOS arm64,再 macOS x86_64、Linux、Windows 分别验收 | +| Go helper 许可证、依赖锁定及安全更新 | 阻塞打包发布 | 固定上游 tag/模块校验和、生成第三方许可清单、随应用更新 |