diff --git a/backend/internal/api/client.go b/backend/internal/api/client.go index 0f973f2..dd7e856 100644 --- a/backend/internal/api/client.go +++ b/backend/internal/api/client.go @@ -269,12 +269,13 @@ func authorizedLines(username, password, otp string) ([]model.VPNUser, bool, err } type clientLine struct { - TunnelID uint `json:"tunnel_id"` - Name string `json:"name"` - Remark string `json:"remark"` - State string `json:"state"` - Endpoint string `json:"endpoint"` - ExpireAt *time.Time `json:"expire_at"` + TunnelID uint `json:"tunnel_id"` + Name string `json:"name"` + Remark string `json:"remark"` + State string `json:"state"` + Endpoint string `json:"endpoint"` + RoutingMode string `json:"routing_mode"` + ExpireAt *time.Time `json:"expire_at"` } // linesForUsers 把授权拨号记录映射为 WireGuard 线路列表。 @@ -290,15 +291,25 @@ func linesForUsers(users []model.VPNUser) []clientLine { if err != nil || t.Protocol != "wireguard" || (t.ExpireAt != nil && time.Now().After(*t.ExpireAt)) { continue } - line := clientLine{TunnelID: t.ID, Name: t.Name, Remark: t.Remark, State: t.State, ExpireAt: t.ExpireAt} + line := clientLine{TunnelID: t.ID, Name: t.Name, Remark: t.Remark, State: t.State, ExpireAt: t.ExpireAt, RoutingMode: "split"} if cfg, err := store.TunnelConfig(t); err == nil { line.Endpoint = wireguardEndpoint(cfg) + line.RoutingMode = routingModeForAllowedIPs(fmt.Sprint(cfg["allowed_ips"])) } lines = append(lines, line) } return lines } +func routingModeForAllowedIPs(value string) string { + for _, entry := range strings.Split(value, ",") { + if strings.TrimSpace(entry) == "0.0.0.0/0" { + return "global_ipv4" + } + } + return "split" +} + func wireguardEndpoint(cfg map[string]interface{}) string { if ep, ok := cfg["public_endpoint"].(string); ok && ep != "" { return ep diff --git a/backend/internal/api/client_routing_test.go b/backend/internal/api/client_routing_test.go new file mode 100644 index 0000000..5aade8f --- /dev/null +++ b/backend/internal/api/client_routing_test.go @@ -0,0 +1,16 @@ +package api + +import "testing" + +func TestRoutingModeReflectsOnlyAuthorizedDefaultRoute(t *testing.T) { + for _, tc := range []struct{ allowed, want string }{ + {"10.88.0.0/24", "split"}, + {"10.88.0.0/24, 0.0.0.0/0", "global_ipv4"}, + {"0.0.0.0/00", "split"}, + {"", "split"}, + } { + if got := routingModeForAllowedIPs(tc.allowed); got != tc.want { + t.Errorf("%q: got %q, want %q", tc.allowed, got, tc.want) + } + } +} diff --git a/backend/internal/service/wireguard/device.go b/backend/internal/service/wireguard/device.go index bf56296..37929a0 100644 --- a/backend/internal/service/wireguard/device.go +++ b/backend/internal/service/wireguard/device.go @@ -265,6 +265,7 @@ func (s *Service) DeviceConnectionParams(t *model.Tunnel, peerID string) (map[st "endpoint": service.StrOr(cfg, "public_endpoint", fmt.Sprintf("your-server-ip:%d", int(service.Num(cfg, "listen_port", defaultPort)))), "listen_port": int(service.Num(cfg, "listen_port", defaultPort)), "allowed_ips": allowed, + "latency_probe": service.Str(cfg, "latency_probe"), "dns": service.StrOr(cfg, "dns", service.DefaultDNS()), "mtu": int(service.Num(cfg, "mtu", 0)), "keepalive": int(service.Num(cfg, "keepalive", 25)), diff --git a/backend/internal/service/wireguard/wireguard.go b/backend/internal/service/wireguard/wireguard.go index 14954b5..8851eea 100644 --- a/backend/internal/service/wireguard/wireguard.go +++ b/backend/internal/service/wireguard/wireguard.go @@ -106,6 +106,11 @@ func isWireGuardKey(key string) bool { } func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) { + // Static peers still authenticate by their key; this setting only describes + // how the managed desktop client obtains its device grant. + if mode := service.Str(cfg, "auth_mode"); mode != "" && mode != "password" { + return nil, fmt.Errorf("WireGuard 尚不支持第三方 OAuth2/OIDC 登录,请使用客户端账号与设备密钥") + } if err := service.MustValidate(cfg, "address"); err != nil { return nil, err } @@ -127,6 +132,13 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, if err := validateCIDRList(service.Str(out, "allowed_ips")); err != nil { return nil, fmt.Errorf("allowed_ips: %w", err) } + if target := service.Str(out, "latency_probe"); target != "" { + host, port, err := net.SplitHostPort(target) + probePort, parseErr := strconv.Atoi(port) + if err != nil || parseErr != nil || net.ParseIP(host) == nil || probePort < 1 || probePort > 65535 { + return nil, fmt.Errorf("latency_probe 必须是可经 VPN 访问的 IP:端口") + } + } if service.Str(out, "dns") == "" { out["dns"] = service.DefaultDNS() } diff --git a/backend/internal/service/wireguard/wireguard_test.go b/backend/internal/service/wireguard/wireguard_test.go index f18b3df..1bbe7af 100644 --- a/backend/internal/service/wireguard/wireguard_test.go +++ b/backend/internal/service/wireguard/wireguard_test.go @@ -83,6 +83,32 @@ func TestValidateRejectsUnsafeNATAndInvalidPeerAddress(t *testing.T) { } } +func TestValidateWireGuardAuthMode(t *testing.T) { + for _, mode := range []string{"", "password"} { + if _, err := New().Validate(map[string]interface{}{"address": "10.8.0.1/24", "auth_mode": mode}); err != nil { + t.Fatalf("legacy/password mode %q rejected: %v", mode, err) + } + } + for _, mode := range []string{"oidc", "unknown"} { + if _, err := New().Validate(map[string]interface{}{"address": "10.8.0.1/24", "auth_mode": mode}); err == nil { + t.Fatalf("unsupported authentication mode %q accepted", mode) + } + } +} + +func TestValidateLatencyProbeTarget(t *testing.T) { + for _, target := range []string{"10.88.0.1:22", "[2001:db8::1]:443", ""} { + if _, err := New().Validate(map[string]interface{}{"address": "10.8.0.1/24", "latency_probe": target}); err != nil { + t.Fatalf("valid target %q rejected: %v", target, err) + } + } + for _, target := range []string{"example.com:443", "10.88.0.1:0", "10.88.0.1:65536", "invalid"} { + if _, err := New().Validate(map[string]interface{}{"address": "10.8.0.1/24", "latency_probe": target}); err == nil { + t.Fatalf("invalid target %q accepted", target) + } + } +} + func TestNATOnlyAppliesToWireGuardSubnet(t *testing.T) { conf := renderServerConfig(map[string]interface{}{ "address": "10.88.0.1/24", "listen_port": 51820, diff --git a/client/README.md b/client/README.md index 60664ec..b4354cf 100644 --- a/client/README.md +++ b/client/README.md @@ -4,13 +4,19 @@ 客户端通过独立的 `/api/client/*` 接口登录拨号用户、获取授权线路、自动登记设备公钥并领取配置;私钥保存在本机 Rust 数据目录,前端不接触令牌和私钥。登录页允许 HTTP 和 HTTPS 地址,HTTP 明确提示明文风险。概览每隔 30 秒检查控制面可达性;该往返耗时不是 VPN 节点延时,连接状态以网关握手为准。 -当前 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)。 +登录入口只需填写接入点的服务端地址(自动带入上次使用的地址),验证服务端健康与客户端能力后按服务端 `password/oidc` 能力展示对应入口。当前 WireGuard 客户端接口只支持密码/MFA;飞书 / Lark 仅在服务端声明 OIDC 能力时展示接入状态,浏览器一键登录按钮暂不可用。OpenVPN 的 `openvpn-auth-oauth2` 不能直接签发桌面 WireGuard 客户端会话,不能仅根据邮箱将飞书身份关联拨号用户。 + +客户端不调用本机 `wg` 或 `wg-quick`,也不会在内置直连未就绪时回退到系统工具。浏览器界面预览不会调用本机检查。安装包附带官方 `wireguard-go` 的 Go 网络栈组件,支持无特权出口探测和本机 SOCKS5 代理 `socks5h://127.0.0.1:18743`;概览和线路页可显式启动代理,并自动登记拨号用户的本机设备。macOS 上在设备配置包含 `0.0.0.0/0` 时,还可以手动启用系统 SOCKS 代理,使遵循系统代理的应用无需逐个设置;不修改系统路由,不保证所有应用或 UDP 经过节点。已有系统代理/PAC 时拒绝接管,关闭时只还原自身设置,客户端异常结束后下次启动会清理残留。实际 macOS 代理权限、浏览器 DNS 行为与故障恢复仍需真机验收。系统隧道设计、平台验收与发布门槛见 [SDD-003](../docs/specs/SDD-003-bundled-wireguard-client.md)。 + +内置 Go 原生 TUN 目前已具备 macOS 接口创建、网关物理出口只读检查、IPv4 路由计划和握手计数查询;Rust 侧有受限的子进程桥接,但尚未挂载连接按钮。系统地址、路由、DNS 的特权配置和异常恢复仍需实现并通过真机验收,现阶段不能将接口创建视为可联网。 ## 使用与验收 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`;握手或测试成功不代表整机路由已切换。 +2. 当前版本的内置直连尚未完成,不能通过安装系统 WireGuard 工具启用直连。 +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`;握手或测试成功不代表整机路由已切换。 + +概览的“选择线路”弹窗和线路页只列出该账号真正获授权的 WireGuard 专线;极速/全局按钮按专线的 `AllowedIPs` 选择具备该路由能力的线路。没有该模式的授权线路时按钮不可选;同一线路不能由客户端私自扩大授权路由。每条线路的“测速”使用已登记设备经 WireGuard 到管理员配置的 `latency_probe`(可访问的 IP:端口,缺省 `223.5.5.5:443`)的 TCP 建链时间,包含隧道启动与握手,并非节点 RTT;绿色表示 ≤120 ms,黄色表示较高耗时,失败或未测量时保持灰色。专线若未授权探测目标或网关未启用所需转发,测速不可用,不能把控制面健康请求当作节点延迟。 公网代理需要管理员为该专线下发覆盖目的地和 DNS 服务器的 `AllowedIPs`,并在 VPN 网关开启 IPv4 转发与仅匹配 WireGuard 网段的出口 NAT;现有 `opsx-client-pilot` 测试专线仍只授权 `10.88.0.0/24`,**不能直接用于公网代理**。本机 SOCKS5 监听仅绑定 `127.0.0.1`、无额外口令,其他本机进程也能使用;切换账号、撤销本机设备、退出登录或退出应用时会停止代理。客户端意外退出后代理最多约两秒内检测到父进程消失并退出。 diff --git a/client/src-tauri/src/api.rs b/client/src-tauri/src/api.rs index a2fdd86..fe4e464 100644 --- a/client/src-tauri/src/api.rs +++ b/client/src-tauri/src/api.rs @@ -11,6 +11,8 @@ pub struct Line { pub remark: String, pub state: String, pub endpoint: String, + #[serde(default)] + pub routing_mode: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -21,6 +23,15 @@ pub struct LoginResult { pub lines: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientCapabilities { + pub password: bool, + pub oidc: bool, + #[serde(default)] + pub oidc_provider: Option, + pub wireguard: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceConfig { pub device_id: u64, @@ -33,6 +44,8 @@ pub struct DeviceConfig { pub preshared_key: String, pub endpoint: String, pub allowed_ips: String, + #[serde(default)] + pub latency_probe: String, pub dns: String, pub mtu: u32, pub keepalive: u32, @@ -143,13 +156,8 @@ pub async fn login( device_name: &str, ) -> Result { // 旧版服务端会把未知 /api/client/* 路径返回 200 HTML;先确认能力,避免发送密码。 - let capabilities = client()? - .get(api_url(base_url, "/api/client/capabilities")?) - .send() - .await - .map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?; - let supported: serde_json::Value = parse_response(capabilities).await?; - if supported.get("password").and_then(|v| v.as_bool()) != Some(true) { + let supported = capabilities(base_url).await?; + if !supported.password { return Err(ApiError::Transport("服务端尚未启用客户端密码登录".into())); } let resp = client()? @@ -167,6 +175,15 @@ pub async fn login( parse_response(resp).await } +pub async fn capabilities(base_url: &str) -> Result { + let response = client()? + .get(api_url(base_url, "/api/client/capabilities")?) + .send() + .await + .map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?; + parse_response(response).await +} + pub async fn logout(base_url: &str, token: &str) -> Result<(), ApiError> { let resp = client()? .post(api_url(base_url, "/api/client/logout")?) @@ -292,4 +309,19 @@ mod tests { ); assert!(api_url("ftp://vpn.example.com", "/api/client/lines").is_err()); } + + #[test] + fn client_auth_capabilities_accept_legacy_and_provider_specific_responses() { + let current: ClientCapabilities = serde_json::from_str( + r#"{"password":true,"oidc":false,"wireguard":true}"#, + ).unwrap(); + assert!(current.password); + assert!(!current.oidc); + assert!(current.oidc_provider.is_none()); + let future: ClientCapabilities = serde_json::from_str( + r#"{"password":false,"oidc":true,"oidc_provider":"feishu","wireguard":true}"#, + ).unwrap(); + assert!(!future.password); + assert_eq!(future.oidc_provider.as_deref(), Some("feishu")); + } } diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 6eefd12..aab5b0c 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -1,7 +1,10 @@ mod api; mod localstore; +#[cfg(target_os = "macos")] +mod native_tunnel; mod server; mod system; +mod system_proxy; mod tunnel; use std::sync::Mutex; @@ -13,7 +16,7 @@ use tauri::{ AppHandle, Manager, }; -use api::{DeviceConfig, DeviceInfo, DeviceStatus, Line}; +use api::{DeviceInfo, DeviceStatus, Line}; // ---- 全局连接状态(UI 与托盘共用同一事实来源)---- @@ -53,20 +56,29 @@ impl Default for ConnState { static CONN: Mutex> = Mutex::new(None); static TRAY_MENU: Mutex>> = Mutex::new(None); static PROXY: Mutex> = Mutex::new(None); +static PROXY_DIR: Mutex> = Mutex::new(None); +static PROXY_ROUTE: 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() { +fn proxy_dir() -> Result { + PROXY_DIR.lock().unwrap().clone().ok_or("无法定位系统代理恢复目录".into()) +} + +fn stop_proxy_inner() -> Result<(), String> { + if let Ok(dir) = proxy_dir() { system_proxy::restore(&dir)?; } if let Some(mut child) = PROXY.lock().unwrap().take() { let _ = child.kill(); let _ = child.wait(); } + *PROXY_ROUTE.lock().unwrap() = None; + Ok(()) } -fn stop_proxy() { +fn stop_proxy() -> Result<(), String> { let _operation = PROXY_OPERATION.lock().unwrap(); - stop_proxy_inner(); + stop_proxy_inner() } #[tauri::command] @@ -77,12 +89,45 @@ fn client_proxy_status() -> bool { if child.try_wait().ok().flatten().is_none() { return true; } } *guard = None; + if let Ok(dir) = proxy_dir() { + if let Err(err) = system_proxy::restore(&dir) { eprintln!("代理进程停止后无法恢复系统设置:{err}"); } + } + *PROXY_ROUTE.lock().unwrap() = None; false } #[tauri::command] -fn client_proxy_stop() { - stop_proxy(); +fn client_proxy_stop() -> Result<(), String> { + stop_proxy() +} + +#[tauri::command] +fn client_system_proxy_status() -> Result { + let _operation = PROXY_OPERATION.lock().unwrap(); + system_proxy::active(&proxy_dir()?) +} + +fn has_global_ipv4_route(route: &str) -> bool { + route.split(',').any(|cidr| cidr.trim() == "0.0.0.0/0") +} + +#[tauri::command] +fn client_system_proxy_enable() -> Result<(), String> { + let _operation = PROXY_OPERATION.lock().unwrap(); + let route = PROXY_ROUTE.lock().unwrap().clone().ok_or("请先启动 Go 应用代理".to_string())?; + if !has_global_ipv4_route(&route) { + return Err("当前线路只授权内网;系统代理需要全局 IPv4 路由,否则普通网页会无法访问".into()); + } + if !PROXY.lock().unwrap().as_mut().is_some_and(|child| child.try_wait().ok().flatten().is_none()) { + return Err("本地代理已停止,请重新启动".into()); + } + system_proxy::enable(&proxy_dir()?) +} + +#[tauri::command] +fn client_system_proxy_disable() -> Result<(), String> { + let _operation = PROXY_OPERATION.lock().unwrap(); + system_proxy::restore(&proxy_dir()?) } fn conn_state() -> ConnState { @@ -165,6 +210,12 @@ struct GoProbeResult { connected: bool, detail: String, target: String, + latency_ms: Option, +} + +#[tauri::command] +async fn client_capabilities(base_url: String) -> Result { + api::capabilities(&base_url).await.map_err(|err| err.message()) } fn go_probe_binary(app: &AppHandle) -> Result { @@ -194,8 +245,8 @@ fn go_probe_binary(app: &AppHandle) -> Result { .ok_or_else(|| "安装包缺少 Go 隧道组件,请重新安装客户端".into()) } -/// 独立探测 WireGuard 数据面及公网 TCP,不更改系统路由/DNS。 -/// 只接收已登录账号的设备记录,探测目标固定为公共地址。 +/// 独立探测 WireGuard 数据面及已配置目标的 TCP,不更改系统路由/DNS。 +/// 只接收已登录账号的设备记录;目标由服务端下发,否则使用默认公网地址。 #[tauri::command] async fn client_probe(app: AppHandle, tunnel_id: u64) -> Result { let session = require_session(&app)?; @@ -213,9 +264,14 @@ async fn client_probe(app: AppHandle, tunnel_id: u64) -> Result Result Result { + if conn_state().phase != ConnPhase::Disconnected { + return Err("请先断开整机 VPN,再启动应用代理,避免同一设备密钥同时建立两条隧道".into()); + } 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) + let lines = api::lines(&session.base_url, &session.token) .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 line = lines.iter().find(|line| line.tunnel_id == tunnel_id) + .ok_or("没有该线路的接入授权")?; + if line.state != "running" { + return Err(format!("线路「{}」未在运行,请联系管理员启动", line.name)); + } + let dir = tunnel::device_dir(&data_dir(&app)?, &session.base_url, &session.username); + let record = prepare_device(&dir, &session, tunnel_id, &session.device_name).await?; 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(); + if conn_state().phase != ConnPhase::Disconnected { + return Err("整机 VPN 已在连接中,请先断开再启动应用代理".into()); + } + stop_proxy_inner()?; let mut child = std::process::Command::new(helper) .arg("proxy").arg(path).arg(PROXY_ADDRESS) .stdin(std::process::Stdio::null()) @@ -256,6 +321,7 @@ async fn client_proxy_start(app: AppHandle, tunnel_id: u64) -> Result(&line) .ok().and_then(|v| v.get("status")?.as_str().map(str::to_owned)).as_deref() == Some("ready") => { *PROXY.lock().unwrap() = Some(child); + *PROXY_ROUTE.lock().unwrap() = Some(record.config.allowed_ips.clone()); Ok(format!("socks5h://{PROXY_ADDRESS}")) } _ => { @@ -300,7 +366,7 @@ async fn client_login( device_name: device_name.clone(), lines: result.lines.clone(), }; - stop_proxy(); + stop_proxy()?; localstore::save(&data_dir(&app)?, &session)?; Ok(SessionView { username: result.username, @@ -334,20 +400,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 的断开入口 - state.detail = format!("登录已失效,但本机隧道未拆除:{err}"); - set_conn(Some(&app), state); - return Ok(Some(SessionView { - username: session.username, - display_name: session.display_name, - device_name: session.device_name, - lines: vec![], - offline: true, - })); - } + stop_proxy()?; set_conn(Some(&app), ConnState::default()); localstore::clear(&dir); Ok(None) @@ -364,13 +417,9 @@ async fn client_resume(app: AppHandle) -> Result, String> { #[tauri::command] async fn client_logout(app: AppHandle) -> Result<(), String> { - stop_proxy(); + stop_proxy()?; let dir = data_dir(&app)?; - if conn_state().phase != ConnPhase::Disconnected { - // 保留记录与会话,确保拆隧道失败后可再次操作。 - tunnel::down(&dir)?; - set_conn(Some(&app), ConnState::default()); - } + set_conn(Some(&app), ConnState::default()); if let Some(session) = localstore::load(&dir) { let _ = api::logout(&session.base_url, &session.token).await; } @@ -387,83 +436,28 @@ async fn client_lines(app: AppHandle) -> Result, String> { .map_err(|err| err.message()) } -/// 一键连接:已登记设备则刷新参数,否则生成密钥对并注册;随后提权起隧道。 +/// 直连路径尚未接入特权网络服务;禁止回退到系统工具。 #[tauri::command] -async fn client_connect(app: AppHandle, tunnel_id: u64) -> Result { - let session = require_session(&app)?; - let fresh_lines = api::lines(&session.base_url, &session.token) - .await - .map_err(|err| err.message())?; - let line = fresh_lines - .iter() - .find(|l| l.tunnel_id == tunnel_id) - .cloned() - .ok_or_else(|| "没有该线路的接入授权".to_string())?; - if line.state != "running" { - return Err(format!("线路「{}」未在运行,请联系管理员启动", line.name)); - } - let dir = data_dir(&app)?; - if conn_state().phase != ConnPhase::Disconnected { - // 切换线路前先拆旧接口;拆除失败时保持原连接和本机记录。 - tunnel::down(&dir)?; - set_conn(Some(&app), ConnState::default()); - } - - let mut state = conn_state(); - state.phase = ConnPhase::Connecting; - state.detail = "正在准备设备接入参数".to_string(); - state.tunnel_name = line.name.clone(); - set_conn(Some(&app), state); - - let result = do_connect(&dir, &session, tunnel_id, &session.device_name).await; - match result { - Ok(config) => { - let next = ConnState { - phase: ConnPhase::Connecting, // 只有服务端观测到握手后才转为 Connected - detail: "隧道已建立,等待握手确认".to_string(), - tunnel_name: config.tunnel_name.clone(), - client_address: config.client_address.clone(), - endpoint: config.endpoint.clone(), - device_id: config.device_id, - since_unix: now_unix(), - }; - set_conn(Some(&app), next.clone()); - Ok(next) - } - Err(err) => { - let mut back = conn_state(); - back.phase = ConnPhase::Disconnected; - back.detail = err.clone(); - set_conn(Some(&app), back); - Err(err) - } - } +async fn client_connect(_app: AppHandle, _tunnel_id: u64) -> Result { + Err("内置直连尚未完成系统网络配置;当前可使用 Go 应用代理".into()) } -async fn do_connect( - dir: &std::path::Path, +// 拨号用户授权后取得设备配置,供 Go 应用代理使用。 +async fn prepare_device( + record_dir: &std::path::Path, session: &localstore::Session, tunnel_id: u64, device_name: &str, -) -> Result { - // 已登记同线路设备:刷新连接参数;否则生成新密钥对并注册 - let record_dir = tunnel::device_dir(dir, &session.base_url, &session.username); - let record = tunnel::load_record_for(&record_dir, tunnel_id).ok(); - let config = match record { +) -> Result { + let record = tunnel::load_record_for(record_dir, tunnel_id).ok(); + let prepared = match record { Some(rec) if rec.config.tunnel_id == tunnel_id => { let cfg = api::device_config(&session.base_url, &session.token, rec.config.device_id) .await .map_err(|err| { format!("刷新设备参数失败(设备可能已被撤销):{}", err.message()) })?; - tunnel::save_record( - &record_dir, - &tunnel::TunnelRecord { - private_key: rec.private_key, - config: cfg.clone(), - }, - )?; - cfg + tunnel::TunnelRecord { private_key: rec.private_key, config: cfg } } _ => { let (private_key, public_key) = tunnel::generate_keypair()?; @@ -476,28 +470,19 @@ async fn do_connect( ) .await .map_err(|err| err.message())?; - tunnel::save_record( - &record_dir, - &tunnel::TunnelRecord { - private_key, - config: cfg.clone(), - }, - )?; - cfg + tunnel::TunnelRecord { private_key, config: cfg } } }; - tunnel::up( - dir, - &tunnel::load_record_for(&record_dir, tunnel_id)?.private_key, - &config, - )?; - Ok(config) + if prepared.config.tunnel_id != tunnel_id { + return Err("服务端设备线路不匹配".into()); + } + tunnel::validate_config(&prepared.private_key, &prepared.config)?; + tunnel::save_record(record_dir, &prepared)?; + Ok(prepared) } #[tauri::command] async fn client_disconnect(app: AppHandle) -> Result<(), String> { - let dir = data_dir(&app)?; - tunnel::down(&dir)?; set_conn(Some(&app), ConnState::default()); Ok(()) } @@ -575,7 +560,6 @@ async fn client_revoke_device(app: AppHandle, device_id: u64) -> Result<(), Stri .is_some_and(|r| r.config.device_id == device_id) && conn_state().device_id == device_id { - tunnel::down(&dir)?; set_conn(Some(&app), ConnState::default()); } api::revoke_device(&session.base_url, &session.token, device_id) @@ -585,7 +569,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(); + stop_proxy()?; tunnel::clear_record_for(&record_dir, device.tunnel_id); } Ok(()) @@ -612,6 +596,17 @@ fn now_unix() -> i64 { pub fn run() { tauri::Builder::default() .setup(|app| { + if let Ok(dir) = app.path().app_config_dir() { + *PROXY_DIR.lock().unwrap() = Some(dir.clone()); + if let Err(err) = system_proxy::restore(&dir) { + eprintln!("无法恢复系统代理:{err}"); + } + } + std::thread::spawn(|| loop { + std::thread::sleep(std::time::Duration::from_secs(3)); + // Covers an unexpected Go process exit even with no open WebView. + let _ = client_proxy_status(); + }); let status = MenuItem::with_id(app, "status", "VPN:尚未连接", false, None::<&str>)?; let open = MenuItem::with_id(app, "open", "打开 OpsX VPN", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; @@ -633,10 +628,9 @@ pub fn run() { } } "quit" => { - stop_proxy(); - // 退出前尽力拆除本机隧道,避免残留接口与路由 - if let Ok(dir) = app.path().app_config_dir() { - let _ = tunnel::down(&dir); + if let Err(err) = stop_proxy() { + eprintln!("无法关闭系统代理,客户端保持运行:{err}"); + return; } app.exit(0); } @@ -649,6 +643,7 @@ pub fn run() { server::check_server, system::get_system_readiness, client_login, + client_capabilities, client_resume, client_logout, client_lines, @@ -659,11 +654,87 @@ pub fn run() { client_proxy_start, client_proxy_stop, client_proxy_status, + client_system_proxy_status, + client_system_proxy_enable, + client_system_proxy_disable, client_devices, client_revoke_device, client_device_name, client_conn_state, ]) - .run(tauri::generate_context!()) - .expect("failed to start OpsX VPN client"); + .build(tauri::generate_context!()) + .expect("failed to build OpsX VPN client") + .run(|_, event| { + if let tauri::RunEvent::ExitRequested { api, .. } = event { + if let Err(err) = stop_proxy() { + eprintln!("无法恢复系统代理,暂缓退出:{err}"); + api.prevent_exit(); + } + } + }); +} + +#[cfg(test)] +mod device_flow_tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + #[test] + fn system_proxy_requires_an_actual_global_route() { + assert!(!has_global_ipv4_route("10.88.0.0/24")); + assert!(!has_global_ipv4_route("10.88.0.0/24, 0.0.0.0/00")); + assert!(has_global_ipv4_route("10.88.0.0/24, 0.0.0.0/0")); + } + + #[test] + fn application_proxy_can_register_then_refresh_a_device_without_system_tools() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (_, public_key) = tunnel::generate_keypair().unwrap(); + let (_, psk) = tunnel::generate_keypair().unwrap(); + let (sender, receiver) = std::sync::mpsc::channel(); + let response = serde_json::json!({ + "device_id": 11, "device_name": "test-mac", "tunnel_id": 4, + "tunnel_name": "test", "peer_id": "dev-11", "client_address": "10.8.0.7/32", + "server_public_key": public_key, "preshared_key": psk, + "endpoint": "127.0.0.1:51820", "allowed_ips": "10.8.0.0/24", + "dns": "10.8.0.1", "mtu": 1420, "keepalive": 25 + }).to_string(); + let server = std::thread::spawn(move || { + for (index, expected) in ["POST /api/client/devices ", "GET /api/client/devices/11/config "].iter().enumerate() { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(3))).unwrap(); + let mut request = vec![]; + let mut buffer = [0u8; 4096]; + loop { + let len = stream.read(&mut buffer).unwrap(); + assert!(len > 0); + request.extend_from_slice(&buffer[..len]); + if request.windows(4).any(|part| part == b"\r\n\r\n") { break; } + } + let raw = String::from_utf8_lossy(&request); + assert!(raw.starts_with(expected), "request #{index} used wrong endpoint"); + assert!(raw.contains("authorization: Bearer test-token") || raw.contains("Authorization: Bearer test-token")); + sender.send(raw.into_owned()).unwrap(); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response.len(), response).unwrap(); + } + }); + let session = localstore::Session { + base_url: format!("http://{address}"), token: "test-token".into(), + username: "alice".into(), display_name: "Alice".into(), + device_name: "test-mac".into(), lines: vec![], + }; + let dir = std::env::temp_dir().join(format!("opsx-device-flow-{}", std::process::id())); + let first = tauri::async_runtime::block_on(prepare_device(&dir, &session, 4, "test-mac")).unwrap(); + let second = tauri::async_runtime::block_on(prepare_device(&dir, &session, 4, "test-mac")).unwrap(); + assert_eq!(first.private_key, second.private_key); + assert_eq!(first.config.device_id, 11); + assert_eq!(tunnel::load_record_for(&dir, 4).unwrap().private_key, first.private_key); + for request in receiver.try_iter() { + assert!(!request.contains(&first.private_key), "private key sent to server"); + } + server.join().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/client/src-tauri/src/native_tunnel.rs b/client/src-tauri/src/native_tunnel.rs new file mode 100644 index 0000000..86b3062 --- /dev/null +++ b/client/src-tauri/src/native_tunnel.rs @@ -0,0 +1,212 @@ +//! The unprivileged Go data-plane process. This module deliberately does not +//! configure OS routes or report an application-level connected state. +#![cfg(target_os = "macos")] +#![allow(dead_code)] // Connected after the privileged network helper is installed. + +use std::{ + io::{BufRead, BufReader, Write}, + path::Path, + process::{Child, ChildStdin, Command, Stdio}, + sync::mpsc::{self, Receiver}, + time::{Duration, Instant}, +}; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub(crate) struct NativePlan { + pub interface: String, + pub client_address: String, + pub routes: Vec, + pub endpoint: String, + pub endpoint_bypass: bool, + pub physical_exit: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct PhysicalExit { + pub interface: String, + pub gateway: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct NativeCounters { + pub handshake_unix: u64, + pub rx_bytes: u64, + pub tx_bytes: u64, +} + +#[derive(Deserialize)] +struct Event { + status: String, + plan: Option, + handshake_unix: Option, + rx_bytes: Option, + tx_bytes: Option, +} + +pub(crate) struct NativeProcess { + child: Child, + input: ChildStdin, + events: Receiver, +} + +impl NativeProcess { + pub(crate) fn start(binary: &Path, record: &Path) -> Result<(Self, NativePlan), String> { + let mut child = Command::new(binary) + .arg("tun") + .arg(record) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // Upstream errors might mention addresses; never forward process output to the UI. + .stderr(Stdio::null()) + .spawn() + .map_err(|_| "无法启动内置 Go 隧道组件".to_string())?; + let input = child.stdin.take().ok_or("无法控制 Go 隧道组件")?; + let output = child.stdout.take().ok_or("无法读取 Go 隧道组件")?; + let (sender, events) = mpsc::sync_channel(4); + std::thread::spawn(move || { + for line in BufReader::new(output).lines() { + let Ok(line) = line else { break }; + if sender.send(line).is_err() { + break; + } + } + }); + let process = Self { + child, + input, + events, + }; + let line = process + .events + .recv_timeout(Duration::from_secs(10)) + .map_err(|_| "内置隧道没有及时创建本机接口".to_string())?; + let plan = decode_plan(&line)?; + Ok((process, plan)) + } + + pub(crate) fn counters(&mut self) -> Result { + if self + .child + .try_wait() + .map_err(|_| "无法检查内置隧道状态")? + .is_some() + { + return Err("内置 Go 隧道已退出".into()); + } + self.input + .write_all(b"status\n") + .map_err(|_| "内置隧道已断开")?; + let line = self + .events + .recv_timeout(Duration::from_secs(3)) + .map_err(|_| "无法获取内置隧道握手状态".to_string())?; + decode_counters(&line) + } + + pub(crate) fn stop(mut self) { + let _ = self.input.write_all(b"stop\n"); + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + if self.child.try_wait().ok().flatten().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Drop for NativeProcess { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +fn decode_plan(line: &str) -> Result { + let event: Event = serde_json::from_str(line).map_err(|_| "内置隧道接口事件无效")?; + if event.status != "interface_created" { + return Err("内置隧道未创建接口".into()); + } + let plan = event.plan.ok_or("内置隧道没有路由计划")?; + if !plan.interface.starts_with("utun") + || plan.interface[4..].is_empty() + || !plan.interface[4..].bytes().all(|b| b.is_ascii_digit()) + || !ipv4_cidr(&plan.client_address) + || plan.routes.is_empty() + || !plan.routes.iter().all(|r| ipv4_cidr(r)) + || !ipv4(&plan.endpoint) + || (plan.endpoint_bypass && plan.physical_exit.is_none()) + { + return Err("内置隧道网络计划无效".into()); + } + if let Some(exit) = &plan.physical_exit { + if exit.interface.starts_with("utun") + || exit.interface == "lo0" + || exit.interface.len() < 2 + || exit.interface.len() > 16 + || !exit.interface.bytes().all(|b| b.is_ascii_alphanumeric()) + || exit.gateway.as_ref().is_some_and(|gateway| !ipv4(gateway)) + { + return Err("内置隧道物理出口无效".into()); + } + } + Ok(plan) +} + +fn ipv4(value: &str) -> bool { + value.parse::().is_ok() +} + +fn ipv4_cidr(value: &str) -> bool { + let Some((ip, prefix)) = value.split_once('/') else { + return false; + }; + ipv4(ip) && prefix.parse::().is_ok_and(|bits| bits <= 32) +} + +fn decode_counters(line: &str) -> Result { + let event: Event = serde_json::from_str(line).map_err(|_| "内置隧道状态无效")?; + if event.status != "device_status" { + return Err("内置隧道状态类型无效".into()); + } + Ok(NativeCounters { + handshake_unix: event.handshake_unix.ok_or("缺少握手时间")?, + rx_bytes: event.rx_bytes.ok_or("缺少接收计数")?, + tx_bytes: event.tx_bytes.ok_or("缺少发送计数")?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interface_event_does_not_claim_connection() { + let json = r#"{"status":"interface_created","plan":{"interface":"utun12","client_address":"10.88.0.3/32","routes":["10.88.0.0/24"],"endpoint":"121.4.111.208","endpoint_bypass":false}}"#; + let plan = decode_plan(json).unwrap(); + assert_eq!(plan.interface, "utun12"); + assert!(decode_counters(json).is_err()); + assert!(decode_plan(&json.replace("utun12", "utun1;whoami")).is_err()); + assert!(decode_plan(&json.replace("10.88.0.0/24", "::/0")).is_err()); + assert!(decode_plan(r#"{"status":"ready"}"#).is_err()); + } + + #[test] + fn handshake_and_bytes_require_explicit_status_event() { + let status = decode_counters( + r#"{"status":"device_status","handshake_unix":123,"rx_bytes":2,"tx_bytes":3}"#, + ) + .unwrap(); + assert_eq!( + (status.handshake_unix, status.rx_bytes, status.tx_bytes), + (123, 2, 3) + ); + assert!(decode_counters(r#"{"status":"device_status","handshake_unix":123}"#).is_err()); + } +} diff --git a/client/src-tauri/src/system.rs b/client/src-tauri/src/system.rs index e401c61..aa79389 100644 --- a/client/src-tauri/src/system.rs +++ b/client/src-tauri/src/system.rs @@ -3,8 +3,7 @@ use serde::Serialize; #[derive(Serialize)] pub struct SystemReadiness { platform: &'static str, - wg_found: bool, - wg_quick_found: bool, + app_proxy_available: bool, engine_mode: EngineMode, engine_detail: &'static str, } @@ -12,60 +11,26 @@ pub struct SystemReadiness { #[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) { +fn engine_for(platform: &str) -> (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)] - { - 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); +pub fn get_system_readiness(app: tauri::AppHandle) -> SystemReadiness { + let (engine_mode, engine_detail) = engine_for(std::env::consts::OS); SystemReadiness { platform: std::env::consts::OS, - wg_found, - wg_quick_found, + app_proxy_available: crate::go_probe_binary(&app).is_ok(), engine_mode, engine_detail, } @@ -76,10 +41,9 @@ 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); + fn direct_connection_stays_unavailable_until_bundled_engine_is_ready() { + for platform in ["macos", "linux", "windows"] { + assert_eq!(engine_for(platform).0, EngineMode::Unsupported); + } } } diff --git a/client/src-tauri/src/system_proxy.rs b/client/src-tauri/src/system_proxy.rs new file mode 100644 index 0000000..b23e229 --- /dev/null +++ b/client/src-tauri/src/system_proxy.rs @@ -0,0 +1,121 @@ +//! macOS SOCKS proxy ownership. Never clear a setting we no longer own. +use std::path::Path; + +#[cfg(target_os = "macos")] +mod mac { + use super::*; + use serde::{Deserialize, Serialize}; + use std::io::Write; + use std::process::Command; + + const HOST: &str = "127.0.0.1"; + const PORT: &str = "18743"; + const RECORD: &str = "system-proxy.json"; + + #[derive(Serialize, Deserialize)] + struct Record { services: Vec } + + fn networksetup(args: &[&str]) -> Result { + let result = Command::new("/usr/sbin/networksetup").args(args).output() + .map_err(|e| format!("无法读取系统网络设置:{e}"))?; + if !result.status.success() { + return Err(format!("无法更新系统代理:{}", String::from_utf8_lossy(&result.stderr).trim())); + } + Ok(String::from_utf8_lossy(&result.stdout).into_owned()) + } + + fn enabled(value: &str) -> bool { + value.lines().any(|line| line.trim().eq_ignore_ascii_case("Enabled: Yes") || line.trim().eq_ignore_ascii_case("Auto Proxy Discovery: On")) + } + + fn services() -> Result, String> { + let result = networksetup(&["-listallnetworkservices"])?; + Ok(result.lines().skip(1).filter(|s| !s.starts_with('*') && !s.trim().is_empty()) + .map(str::to_owned).collect()) + } + + fn ours(service: &str) -> Result { + let value = networksetup(&["-getsocksfirewallproxy", service])?; + Ok(enabled(&value) && value.lines().any(|v| v.trim() == format!("Server: {HOST}")) + && value.lines().any(|v| v.trim() == format!("Port: {PORT}"))) + } + + fn record_path(dir: &Path) -> std::path::PathBuf { dir.join(RECORD) } + + pub fn active(dir: &Path) -> Result { + if !record_path(dir).exists() { return Ok(false); } + let record: Record = serde_json::from_slice(&std::fs::read(record_path(dir)).map_err(|e| e.to_string())?) + .map_err(|_| "系统代理恢复记录损坏,请检查网络设置".to_string())?; + for service in &record.services { + if ours(service)? { return Ok(true); } + } + Ok(false) + } + + pub fn restore(dir: &Path) -> Result<(), String> { + let path = record_path(dir); + if !path.exists() { return Ok(()); } + let record: Record = serde_json::from_slice(&std::fs::read(&path).map_err(|e| e.to_string())?) + .map_err(|_| "系统代理恢复记录损坏,请检查网络设置".to_string())?; + for service in &record.services { + if ours(service)? { + networksetup(&["-setsocksfirewallproxystate", service, "off"])?; + } + } + std::fs::remove_file(path).map_err(|e| format!("无法清理系统代理恢复记录:{e}")) + } + + pub fn enable(dir: &Path) -> Result<(), String> { + // A stale record may mean a prior process crashed. Never overwrite it. + restore(dir)?; + let list = services()?; + if list.is_empty() { return Err("没有可用的 macOS 网络服务".into()); } + for service in &list { + for kind in ["-getwebproxy", "-getsecurewebproxy", "-getsocksfirewallproxy", "-getautoproxyurl", "-getproxyautodiscovery"] { + let current = networksetup(&[kind, service])?; + if enabled(¤t) || (kind == "-getsocksfirewallproxy" + && current.lines().any(|line| line.starts_with("Server:") + && line.trim() != "Server:" && line.trim() != format!("Server: {HOST}"))) { + return Err(format!("网络服务「{service}」已有代理设置,请先关闭其他代理再启用")); + } + } + } + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + // Record ownership before the first OS write, so interrupted startup can recover. + let path = record_path(dir); + let data = serde_json::to_vec(&Record { services: list.clone() }).map_err(|e| e.to_string())?; + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path) + .map_err(|e| format!("无法保存系统代理恢复记录:{e}"))?; + #[cfg(unix)] { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())?; + } + file.write_all(&data).and_then(|_| file.sync_all()).map_err(|e| format!("无法持久化系统代理恢复记录:{e}"))?; + for service in &list { + if let Err(err) = networksetup(&["-setsocksfirewallproxy", service, HOST, PORT]) { + return match restore(dir) { Ok(()) => Err(err), Err(recovery) => Err(format!("{err};恢复失败:{recovery}")) }; + } + } + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::enabled; + #[test] + fn parses_networksetup_state() { + assert!(enabled("Enabled: Yes\nServer: 127.0.0.1")); + assert!(!enabled("Enabled: No\nServer: 127.0.0.1")); + } + } +} + +#[cfg(target_os = "macos")] +pub use mac::{active, enable, restore}; + +#[cfg(not(target_os = "macos"))] +pub fn active(_: &Path) -> Result { Ok(false) } +#[cfg(not(target_os = "macos"))] +pub fn restore(_: &Path) -> Result<(), String> { Ok(()) } +#[cfg(not(target_os = "macos"))] +pub fn enable(_: &Path) -> Result<(), String> { Err("系统代理目前仅支持 macOS".into()) } diff --git a/client/src-tauri/src/tunnel.rs b/client/src-tauri/src/tunnel.rs index 4bba221..fa81954 100644 --- a/client/src-tauri/src/tunnel.rs +++ b/client/src-tauri/src/tunnel.rs @@ -1,8 +1,4 @@ -// 本机 WireGuard 隧道:密钥生成、配置渲染与提权启停。 -// -// 私钥只在本机生成、只保存在本机数据目录(0600),绝不上传。 -// 启停需要系统权限:Linux 走 pkexec,macOS 走 osascript 管理员授权; -// Windows 暂无受控提权通道,明确报错而不是静默失败。 +// 本机设备密钥与配置记录。内置网络引擎未就绪前不启动系统隧道。 use std::path::{Path, PathBuf}; use base64::Engine; @@ -19,25 +15,6 @@ pub fn generate_keypair() -> Result<(String, String), String> { Ok((b64.encode(secret.to_bytes()), b64.encode(public.to_bytes()))) } -/// 用设备私钥 + 服务端下发的参数渲染 wg-quick 配置文本。 -pub fn render_config(private_key: &str, cfg: &DeviceConfig) -> String { - let mut out = format!( - "[Interface]\nPrivateKey = {private_key}\nAddress = {}\n", - cfg.client_address - ); - if !cfg.dns.is_empty() { - out.push_str(&format!("DNS = {}\n", cfg.dns)); - } - if cfg.mtu > 0 { - out.push_str(&format!("MTU = {}\n", cfg.mtu)); - } - out.push_str(&format!( - "\n[Peer]\nPublicKey = {}\nPresharedKey = {}\nEndpoint = {}\nAllowedIPs = {}\nPersistentKeepalive = {}\n", - cfg.server_public_key, cfg.preshared_key, cfg.endpoint, cfg.allowed_ips, cfg.keepalive - )); - out -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TunnelRecord { pub private_key: String, @@ -83,7 +60,6 @@ pub fn clear_record_for(dir: &Path, tunnel_id: u64) { pub fn clear_record(dir: &Path) { let _ = std::fs::remove_file(dir.join("tunnel.json")); - let _ = std::fs::remove_file(dir.join("opsx-wg.conf")); } #[cfg(unix)] @@ -109,13 +85,6 @@ fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> { std::fs::write(path, bytes).map_err(|err| format!("无法写入 {}:{err}", path.display())) } -fn write_conf(dir: &Path, text: &str) -> Result { - std::fs::create_dir_all(dir).map_err(|err| format!("无法创建数据目录:{err}"))?; - let path = dir.join("opsx-wg.conf"); - write_private(&path, text.as_bytes())?; - Ok(path) -} - fn valid_key(value: &str) -> bool { use base64::Engine; base64::engine::general_purpose::STANDARD @@ -174,112 +143,6 @@ pub(crate) fn validate_config(private_key: &str, cfg: &DeviceConfig) -> Result<( Ok(()) } -fn find_binary(name: &str) -> Result { - crate::system::binary_path(name) - .map(|p| p.to_string_lossy().to_string()) - .ok_or_else(|| format!("未找到 {name},请安装 wireguard-tools 后重试(在设置页查看安装提示)")) -} - -/// 以系统授权方式执行 wg-quick up/down。 -/// 命令参数全部由本机生成,不拼接任何用户输入。 -fn run_wg_quick(action: &str, conf: &Path) -> Result<(), String> { - let wg_quick = find_binary("wg-quick")?; - let conf_str = conf.to_string_lossy().to_string(); - #[cfg(target_os = "linux")] - { - let pkexec = find_binary("pkexec").map_err(|_| { - "未找到 pkexec,无法安全提权;请安装 polkit 或手动配置 sudo 权限".to_string() - })?; - let status = std::process::Command::new(pkexec) - .args([&wg_quick, action, &conf_str]) - .status() - .map_err(|err| format!("无法启动提权授权:{err}"))?; - return if status.success() { - Ok(()) - } else { - Err(format!( - "wg-quick {action} 执行失败(可能被取消授权或系统策略拒绝)" - )) - }; - } - #[cfg(target_os = "macos")] - { - if conf_str.chars().any(char::is_control) || wg_quick.chars().any(char::is_control) { - 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) - ); - let literal = command.replace('\\', "\\\\").replace('"', "\\\""); - let script = format!("do shell script \"{literal}\" with administrator privileges"); - let status = std::process::Command::new("osascript") - .args(["-e", &script]) - .status() - .map_err(|err| format!("无法请求管理员授权:{err}"))?; - if status.success() { - Ok(()) - } else { - Err(format!("wg-quick {action} 执行失败(可能被取消授权)")) - } - } - #[cfg(target_os = "windows")] - { - let _ = (wg_quick, conf_str); - Err( - "Windows 隧道提权通道尚未实现,请使用 WireGuard 官方客户端导入配置,或等待后续版本" - .to_string(), - ) - } -} - -#[cfg(target_os = "macos")] -fn shell_escape(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - -/// 所有引擎必须提供同一启停契约;以后内置引擎在此实现而非改动控制面。 -trait TunnelEngine { - fn up(&self, dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result; - fn down(&self, dir: &Path) -> Result<(), String>; -} - -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) - } - - 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)] mod tests { use super::*; @@ -296,6 +159,7 @@ mod tests { preshared_key: "psk".to_string(), endpoint: "vpn.example.com:51820".to_string(), allowed_ips: "0.0.0.0/0, ::/0".to_string(), + latency_probe: "".to_string(), dns: "223.5.5.5".to_string(), mtu: 1420, keepalive: 25, @@ -314,24 +178,6 @@ mod tests { assert_ne!(private_key, private2); } - #[test] - fn rendered_config_contains_all_fields() { - let conf = render_config("private-key", &sample_config()); - for needle in [ - "PrivateKey = private-key", - "Address = 10.8.0.7/32", - "DNS = 223.5.5.5", - "MTU = 1420", - "PublicKey = serverpub", - "PresharedKey = psk", - "Endpoint = vpn.example.com:51820", - "AllowedIPs = 0.0.0.0/0, ::/0", - "PersistentKeepalive = 25", - ] { - assert!(conf.contains(needle), "配置缺少 {needle}:\n{conf}"); - } - } - #[test] fn rejects_injected_server_config_before_root_helper() { let key = base64::engine::general_purpose::STANDARD.encode([7_u8; 32]); diff --git a/client/src/App.tsx b/client/src/App.tsx index 31dbefb..aa61955 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,15 +1,28 @@ import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' import { - checkServer, clientConnect, clientDeviceName, clientDevices, clientDisconnect, clientProbe, clientProxyStart, clientProxyStop, clientProxyStatus, + checkServer, clientCapabilities, clientDeviceName, clientDevices, clientDisconnect, clientProbe, clientProxyStart, clientProxyStop, clientProxyStatus, clientSystemProxyStatus, clientSystemProxyEnable, clientSystemProxyDisable, clientLines, clientLogin, clientLogout, clientResume, clientRevokeDevice, clientStatus, getSystemReadiness, - type ConnState, type DeviceInfo, type Line, type SessionView, type StatusView, + type ClientCapabilities, type ConnState, type DeviceInfo, type Line, type ProbeResult, type SessionView, type StatusView, type SystemReadiness, } from './platform' import './system.css' +import './entry.css' type Page = 'overview' | 'routes' | 'settings' +function unitLabel(address: string) { + try { return new URL(address).host } catch { return address } +} + +function Signal({ ms }: { ms?: number }) { + const quality = ms === undefined ? 'unknown' : ms <= 120 ? 'good' : 'slow' + return + + {ms === undefined ? '未测速' : `${ms} ms`} + +} + function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close' | 'user' | 'device'; size?: number }) { const paths = { home: , @@ -48,12 +61,22 @@ export default function App() { const [conn, setConn] = useState(null) const [status, setStatus] = useState(null) const [selectedLine, setSelectedLine] = useState(null) + const [selectedMode, setSelectedMode] = useState<'split' | 'global_ipv4'>('split') 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)) + const [nodePickerOpen, setNodePickerOpen] = useState(false) + const [latencies, setLatencies] = useState>({}) + const [probeBusyLine, setProbeBusyLine] = useState(null) + const [probeMessage, setProbeMessage] = useState('') + const [proxyRunning, setProxyRunning] = useState(false) + const [systemProxyOn, setSystemProxyOn] = useState(false) + const [proxyBusy, setProxyBusy] = useState(false) + const [proxyError, setProxyError] = useState('') + const proxyActionLock = useRef(false) // 启动时恢复本机会话 useEffect(() => { @@ -108,6 +131,25 @@ export default function App() { getSystemReadiness().then(setReadiness).catch(() => undefined) }, [session]) + useEffect(() => { + if (!session) return + let active = true + const refresh = () => { + clientProxyStatus().then(running => { if (active) setProxyRunning(running) }).catch(() => undefined) + clientSystemProxyStatus().then(on => { if (active) setSystemProxyOn(on) }).catch(error => { if (active) setProxyError(String(error)) }) + } + void refresh() + const timer = window.setInterval(refresh, 5000) + return () => { active = false; window.clearInterval(timer) } + }, [session]) + + useEffect(() => { + if (!nodePickerOpen) return + const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setNodePickerOpen(false) } + window.addEventListener('keydown', closeOnEscape) + return () => window.removeEventListener('keydown', closeOnEscape) + }, [nodePickerOpen]) + function beginAction() { if (actionLock.current) return false actionLock.current = true @@ -120,25 +162,6 @@ export default function App() { 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 { - const next = await clientConnect(target) - setConn(next) - setSelectedLine(target) - setPage('overview') - } catch (cause) { - setActionError(cause instanceof Error ? cause.message : String(cause)) - } finally { endAction() } - } - async function handleDisconnect() { if (!beginAction()) return setActionError('') @@ -151,6 +174,59 @@ export default function App() { } finally { endAction() } } + async function measureLine(line: Line) { + if (probeBusyLine !== null) return + setProbeBusyLine(line.tunnel_id) + setProbeMessage('') + try { + const result: ProbeResult = await clientProbe(line.tunnel_id) + if (!result.connected || result.latency_ms === undefined) throw new Error(result.detail) + setLatencies(previous => ({ ...previous, [line.tunnel_id]: result.latency_ms! })) + } catch (cause) { + setLatencies(previous => { const next = { ...previous }; delete next[line.tunnel_id]; return next }) + setProbeMessage(`${line.name}:${cause instanceof Error ? cause.message : String(cause)}`) + } finally { setProbeBusyLine(null) } + } + + async function toggleProxy(tunnelId?: number) { + if (proxyActionLock.current) return + proxyActionLock.current = true + setProxyBusy(true) + setProxyError('') + try { + if (await clientProxyStatus() && tunnelId === undefined) { + await clientProxyStop() + setProxyRunning(false) + setSystemProxyOn(false) + } else { + if (await clientProxyStatus()) { await clientProxyStop(); setProxyRunning(false); setSystemProxyOn(false) } + const target = tunnelId ?? selectedLine ?? lines.find(line => line.state === 'running')?.tunnel_id + const line = lines.find(item => item.tunnel_id === target && item.state === 'running') + if (!line) throw new Error('请先选择一条正在运行的授权线路。') + await clientProxyStart(line.tunnel_id) + const running = await clientProxyStatus() + setProxyRunning(running) + if (!running) throw new Error('Go 应用代理启动后已退出,请检查专线和本机端口。') + setSelectedLine(line.tunnel_id) + setSelectedMode(line.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split') + } + } catch (cause) { setProxyError(cause instanceof Error ? cause.message : String(cause)) } + finally { proxyActionLock.current = false; setProxyBusy(false) } + } + + async function toggleSystemProxy() { + if (proxyActionLock.current) return + proxyActionLock.current = true + setProxyBusy(true) + setProxyError('') + try { + if (systemProxyOn) await clientSystemProxyDisable() + else await clientSystemProxyEnable() + setSystemProxyOn(await clientSystemProxyStatus()) + } catch (cause) { setProxyError(cause instanceof Error ? cause.message : String(cause)) } + finally { proxyActionLock.current = false; setProxyBusy(false) } + } + async function handleLogout() { if (!beginAction()) return try { await clientLogout() } catch (cause) { @@ -163,6 +239,10 @@ export default function App() { setStatus(null) setLines([]) setSelectedLine(null) + setSelectedMode('split') + setNodePickerOpen(false) + setLatencies({}) + setProxyRunning(false) setPage('overview') endAction() } @@ -172,13 +252,14 @@ export default function App() { } if (!session) { - return { setSession(view); setLines(view.lines) }} /> + return { setSession(view); setLines(view.lines); setLatencies({}) }} /> } const phase = conn?.phase ?? 'disconnected' const connected = phase === 'connected' const connecting = phase === 'connecting' const currentLineName = conn?.tunnel_name || lines.find(l => l.tunnel_id === selectedLine)?.name || '' + const currentLine = lines.find(l => l.tunnel_id === selectedLine) ?? lines.find(l => l.state === 'running') return
} @@ -284,6 +406,8 @@ export default function App() { function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void }) { const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '') + const [stage, setStage] = useState<'unit' | 'auth'>('unit') + const [capabilities, setCapabilities] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [otp, setOtp] = useState('') @@ -295,13 +419,42 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void }) useEffect(() => { clientDeviceName().then(setDeviceName).catch(() => undefined) }, []) + async function handleContinue(event: FormEvent) { + event.preventDefault() + setBusy(true) + setError('') + try { + const healthResult = await checkServer(address) + if (!healthResult.reachable) throw new Error('服务端未就绪') + const offered = await clientCapabilities(healthResult.address) + if (!offered.wireguard) throw new Error('该服务器尚未提供 WireGuard 客户端接入') + setAddress(healthResult.address) + localStorage.setItem('opsx_server_address', healthResult.address) + setCapabilities(offered) + setHealth('reachable') + setStage('auth') + } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } + finally { setBusy(false) } + } + + function changeUnit() { + setStage('unit') + setCapabilities(null) + setUsername('') + setPassword('') + setOtp('') + setMfaRequired(false) + setError('') + } + async function handleSubmit(event: FormEvent) { event.preventDefault() setError('') setBusy(true) try { const view = await clientLogin(address, username, password, otp, deviceName) - localStorage.setItem('opsx_server_address', address.trim().replace(/\/$/, '')) + setPassword('') + setOtp('') onLoggedIn(view) } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause) @@ -326,27 +479,36 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
SECURE ACCESS / 企业网络

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

-

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

+

选择您所属企业的服务端,用管理员开通的账号安全接入。

独立设备身份 · WireGuard 加密隧道
OPSX / PRIVATE NETWORK
-
账号登录
-

欢迎使用 OpsX VPN

-

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

-
- -
- { setAddress(e.target.value); setHealth('idle') }} /> - -
- {health === 'reachable' &&

✓ 服务可达

} - {health === 'error' &&

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

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

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

} +
{stage === 'unit' ? '01 / 选择接入点' : '02 / 身份认证'}
+ {stage === 'unit' ? <> +

选择接入点

+

输入企业提供的服务器地址,继续使用 VPN 拨号用户登录。

+ + +
{ setAddress(event.target.value); setHealth('idle') }} /> +
+ {health === 'reachable' &&

✓ 服务可达

} + {health === 'error' &&

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

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

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

} + {error &&
{error}
} + +

浏览器预览无法执行本机认证;请使用桌面客户端继续。

+
+ : <> + +

登录 {unitLabel(address)}

+

使用该接入点提供的认证方式登录 VPN 拨号用户

+ {capabilities?.oidc &&
{['feishu', 'lark'].includes(capabilities.oidc_provider?.toLowerCase() || '') ? '飞书 / Lark · ' : '第三方认证 · '}OAuth2 / OIDC服务端支持 OIDC;客户端浏览器回调与拨号用户授权映射尚未接通
} + {capabilities?.password && <> + {capabilities.oidc &&
使用账号密码登录
} +
+ {address.startsWith('http://') &&

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

}
setUsername(e.target.value)} />
@@ -361,6 +523,9 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })

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

+ } + {!capabilities?.password &&

{capabilities?.oidc ? '此接入点暂未开放客户端浏览器登录,请联系管理员启用密码登录。' : '此接入点暂未提供可用的客户端认证方式。'}

} + }
} @@ -400,33 +565,13 @@ function ServerCard({ baseUrl }: { baseUrl?: string }) { // ---- 设置页 ---- -function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange }: { session: SessionView; lines: Line[]; onLogout: () => void; actionBusy: boolean; onReadinessChange: (value: SystemReadiness) => void }) { +function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange, proxyRunning, proxyBusy, proxyError, onToggleProxy, onProxyStatusChange, systemProxyOn, onToggleSystemProxy }: { session: SessionView; lines: Line[]; onLogout: () => void; actionBusy: boolean; onReadinessChange: (value: SystemReadiness) => void; proxyRunning: boolean; proxyBusy: boolean; proxyError: string; onToggleProxy: (tunnelId?: number) => Promise; onProxyStatusChange: (running: boolean) => void; systemProxyOn: boolean; onToggleSystemProxy: () => Promise }) { 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') @@ -459,6 +604,7 @@ function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange setDeviceError('') try { await clientRevokeDevice(id) + clientProxyStatus().then(onProxyStatusChange).catch(() => undefined) loadDevices() } catch (cause) { setDeviceError(cause instanceof Error ? cause.message : String(cause)) @@ -494,28 +640,25 @@ function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange

本机环境

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

{readiness ?
操作系统{readiness.platform} - 隧道引擎{readiness.engine_mode === 'system_tools' ? '系统工具模式' : '当前不可用'} - wg 命令{readiness.wg_found ? '已找到' : '未找到'} - wg-quick 命令{readiness.wg_quick_found ? '已找到' : '未找到'} + 内置直连尚未就绪 + Go 应用代理组件{readiness.app_proxy_available ? '已找到' : '缺失'}
:

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

} {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 官方客户端。

+

内置直连尚需系统路由与 DNS 配置;当前可使用下方 Go 节点代理。

Go 隧道出口诊断 -

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

+

通过当前设备的 WireGuard 参数连接管理员设置的探测目标(默认公网 TCP),不修改本机路由或 DNS;这项检查不代表系统 VPN 已连接。目标必须在授权路由内。

{probeResult &&

{probeResult}

}
本地 SOCKS5 代理(试用) -

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

- - {proxyRunning &&

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

} +

通过内置 Go WireGuard 提供 SOCKS5 连接,不修改系统路由或 DNS。手动配置地址:socks5h://127.0.0.1:18743;使用 socks5h 才会经 VPN 解析域名。目标地址必须在专线授权路由内;公网还需网关转发和 NAT。

+ + {proxyRunning &&

本地代理正在运行;可手动配置应用,也可在 macOS 上启用系统代理。

} + {(proxyRunning || systemProxyOn) && } + {proxyRunning &&

系统代理只适用于遵循 macOS 设置的应用,需要线路允许全局 IPv4;部分软件和 UDP 流量不会使用它。

} + {systemProxyOn &&

系统 SOCKS 代理已启用。

} {proxyError &&

{proxyError}

}
diff --git a/client/src/entry.css b/client/src/entry.css new file mode 100644 index 0000000..5e66ee7 --- /dev/null +++ b/client/src/entry.css @@ -0,0 +1,67 @@ +/* Enterprise entry: the address is the only trustworthy unit identity today. */ +.login-form input:focus-visible { outline: 2px solid #3977eb; outline-offset: 2px; } +.login-back { align-self: flex-start; border: 0; padding: 6px 0; margin: 18px 0 0; background: none; color: #3974e3; cursor: pointer; font-weight: 650; } +.lark-login-card { display: flex; align-items: center; gap: 13px; margin-top: 23px; padding: 16px; border: 1px solid #dfe8f6; border-radius: 14px; background: #fbfcff; } +.lark-login-card > div { display: grid; gap: 4px; flex: 1; min-width: 0; } +.lark-login-card strong { font-size: 14px; color: #263956; } +.lark-login-card small { color: #8190a7; font-size: 11px; line-height: 1.5; } +.lark-login-card button { border: 0; border-radius: 8px; padding: 9px 12px; color: #8595af; background: #e9eef7; font-weight: 700; white-space: nowrap; cursor: not-allowed; } +.lark-mark { position: relative; width: 42px; height: 42px; flex: none; border-radius: 12px; background: #f0f5ff; overflow: hidden; } +.lark-mark i { position: absolute; width: 22px; height: 11px; border-radius: 12px 2px 12px 2px; transform: rotate(-35deg); } +.lark-mark i:nth-child(1) { background: #2868f5; left: 9px; top: 21px; } +.lark-mark i:nth-child(2) { background: #16c6b1; left: 13px; top: 11px; } +.lark-mark i:nth-child(3) { background: #17479c; left: 19px; top: 22px; width: 13px; } +.login-divider { display: flex; align-items: center; gap: 13px; color: #99a6ba; font-size: 11px; margin-top: 25px; } +.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: #e5eaf3; } + +/* Measured TCP setup through the selected WireGuard line, never a fabricated RTT. */ +.node-signal { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-size: 12px; font-variant-numeric: tabular-nums; color: #18a464; } +.node-signal.slow { color: #d68819; } +.node-signal.unknown { color: #9ba8b9; } +.signal-bars { display: flex; align-items: end; gap: 2px; height: 15px; } +.signal-bars i { display: block; width: 3px; border-radius: 2px 2px 0 0; background: currentColor; } +.signal-bars i:nth-child(1) { height: 6px; } +.signal-bars i:nth-child(2) { height: 10px; } +.signal-bars i:nth-child(3) { height: 15px; } +.node-signal.unknown .signal-bars i:nth-child(n+2) { opacity: .35; } +.node-signal.slow .signal-bars i:nth-child(3) { opacity: .4; } +.latency-action { border: 0; background: transparent; color: #3876e9; padding: 7px 3px; font-size: 11px; white-space: nowrap; cursor: pointer; } +.latency-action:disabled { color: #a2aec0; cursor: default; } + +.node-dialog-backdrop { position: fixed; z-index: 20; inset: 0; display: grid; place-items: center; padding: 20px; background: #1b304c88; backdrop-filter: blur(3px); } +.node-dialog { position: relative; width: min(820px, 100%); max-height: min(740px, calc(100vh - 40px)); overflow: auto; padding: 34px; border-radius: 22px; background: white; box-shadow: 0 26px 80px #14283c55; } +.node-dialog h2 { margin: 8px 0 7px; color: #182e51; letter-spacing: -.03em; } +.node-dialog-intro { margin: 0 0 25px; color: #75869f; line-height: 1.65; font-size: 12px; } +.dialog-close { position: absolute; top: 20px; right: 24px; width: 30px; height: 30px; border: 0; background: #f0f4fa; border-radius: 50%; font-size: 21px; color: #788ba7; cursor: pointer; } +.node-dialog-grid { display: grid; grid-template-columns: minmax(210px, 32%) 1fr; gap: 24px; } +.dialog-column-label, .dialog-list-head { color: #9aabc0; font-size: 11px; font-weight: 700; } +.mode-column { display: grid; align-content: start; gap: 10px; } +.mode-column .dialog-column-label { margin-bottom: 8px; } +.mode-option { display: flex; align-items: center; gap: 11px; padding: 13px; border: 1px solid transparent; border-radius: 12px; color: #75859b; } +.mode-option:is(button) { width: 100%; text-align: left; background: transparent; font: inherit; cursor: pointer; } +.mode-option:is(button):hover:not(:disabled) { background: #f6f9ff; } +.mode-option:is(button):disabled { opacity: .52; cursor: not-allowed; } +.mode-option.selected { border-color: #dbe7fb; background: #f6f9ff; color: #2353ac; } +.mode-option > span:last-child { display: grid; gap: 3px; } +.mode-option strong { font-size: 13px; color: #263953; } +.mode-option small, .mode-help { font-size: 10px; line-height: 1.6; color: #91a0b5; } +.mode-mark { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 11px; background: #e8effe; font-size: 21px; } +.mode-mark.globe { background: #eef1f6; } +.mode-help { margin: 8px 0; } +.dialog-lines { min-width: 0; } +.dialog-list-head { display: flex; justify-content: space-between; padding: 0 64px 14px 12px; } +.dialog-line { display: flex; align-items: center; gap: 11px; min-height: 61px; padding: 5px 11px; border: 1px solid transparent; border-radius: 12px; } +.dialog-line.picked { border-color: #d8e7ff; background: #f7faff; } +.dialog-line-select { display: flex; flex: 1; align-items: center; gap: 11px; min-width: 0; text-align: left; border: 0; padding: 5px 0; background: transparent; cursor: pointer; } +.dialog-line-select:disabled { opacity: .55; cursor: default; } +.selection-radio { width: 19px; height: 19px; display: grid; place-items: center; flex: none; border: 1px solid #d6deea; border-radius: 50%; color: white; font-size: 12px; } +.picked .selection-radio { border-color: #3476f5; background: #3476f5; } +.dialog-line-text { min-width: 0; display: grid; gap: 3px; } +.dialog-line-text strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #293e5b; } +.dialog-line-text small { color: #95a5b8; font-size: 10px; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 28px; } +.dialog-actions button { min-width: 108px; padding: 11px 17px; border-radius: 10px; cursor: pointer; font-weight: 700; } +.dialog-cancel { border: 0; background: #f2f4f8; color: #3e526f; } +.dialog-confirm { border: 0; background: #3476f5; color: #fff; } +.dialog-confirm:disabled { opacity: .5; cursor: not-allowed; } +@media (max-width: 720px) { .node-dialog { padding: 24px 18px; } .node-dialog-grid { grid-template-columns: 1fr; gap: 14px; } .mode-column { grid-template-columns: 1fr 1fr; } .mode-column .dialog-column-label, .mode-column .mode-help { grid-column: 1 / -1; } .dialog-line { flex-wrap: wrap; } } diff --git a/client/src/platform.ts b/client/src/platform.ts index 47cddee..9dbee97 100644 --- a/client/src/platform.ts +++ b/client/src/platform.ts @@ -3,9 +3,8 @@ 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 - engine_mode: 'system_tools' | 'unsupported' + app_proxy_available: boolean + engine_mode: 'unsupported' engine_detail: string } @@ -15,6 +14,7 @@ export type Line = { remark: string state: string endpoint: string + routing_mode?: 'split' | 'global_ipv4' } export type SessionView = { @@ -46,7 +46,8 @@ export type DeviceStatus = { detail?: string | null } -export type ProbeResult = { connected: boolean; detail: string; target: string } +export type ProbeResult = { connected: boolean; detail: string; target: string; latency_ms?: number } +export type ClientCapabilities = { password: boolean; oidc: boolean; oidc_provider?: string | null; wireguard: boolean } export type StatusView = { conn: ConnState; server: DeviceStatus | null } @@ -89,10 +90,6 @@ export function clientLines(): Promise { return isDesktop() ? invoke('client_lines') : desktopOnly() } -export function clientConnect(tunnelId: number): Promise { - return isDesktop() ? invoke('client_connect', { tunnelId }) : desktopOnly() -} - export function clientDisconnect(): Promise { return isDesktop() ? invoke('client_disconnect') : desktopOnly() } @@ -105,6 +102,10 @@ export function clientProbe(tunnelId: number): Promise { return isDesktop() ? invoke('client_probe', { tunnelId }) : desktopOnly() } +export function clientCapabilities(baseUrl: string): Promise { + return isDesktop() ? invoke('client_capabilities', { baseUrl }) : desktopOnly() +} + export function clientProxyStart(tunnelId: number): Promise { return isDesktop() ? invoke('client_proxy_start', { tunnelId }) : desktopOnly() } @@ -117,6 +118,18 @@ export function clientProxyStatus(): Promise { return isDesktop() ? invoke('client_proxy_status') : desktopOnly() } +export function clientSystemProxyStatus(): Promise { + return isDesktop() ? invoke('client_system_proxy_status') : desktopOnly() +} + +export function clientSystemProxyEnable(): Promise { + return isDesktop() ? invoke('client_system_proxy_enable') : desktopOnly() +} + +export function clientSystemProxyDisable(): Promise { + return isDesktop() ? invoke('client_system_proxy_disable') : desktopOnly() +} + export function clientDevices(): Promise { return isDesktop() ? invoke('client_devices') : desktopOnly() } diff --git a/client/src/system.css b/client/src/system.css index 8f4982f..ccc5ed1 100644 --- a/client/src/system.css +++ b/client/src/system.css @@ -20,7 +20,7 @@ body { min-width: 0; } .http-warning { background: #fff7e6; color: #a0691b; border: 1px solid #fae5be; border-radius: 8px; padding: 10px; font-size: 12px; line-height: 1.6; } .saved-address { color: #168861; font-size: 12px; margin: 9px 0 0; overflow-wrap: anywhere; } .power-display { border: 0; cursor: pointer; position: relative; z-index: 1; } -.power-display:hover { filter: brightness(1.08); transform: translateY(-2px); } +.power-display:not(:disabled):hover { filter: brightness(1.08); transform: translateY(-2px); } .power-display:focus-visible { outline: 3px solid #144db6; outline-offset: 4px; } .connection-guide { margin: -11px 0 20px; padding: 11px 13px; border: 1px solid #dae7ff; border-radius: 9px; background: #f3f7ff; color: #43618f; font-size: 12px; line-height: 1.6; } .connection-guide button { display: block; padding: 3px 0 0; background: none; border: 0; color: #306eee; font-weight: 600; } @@ -71,7 +71,12 @@ body { min-width: 0; } /* ---- 连接状态 ---- */ .power-display.on { background: linear-gradient(145deg, #2fce88, #17a268); box-shadow: 0 16px 30px #17a26849, inset 0 1px 2px #ffffff66; } .power-display.busy { animation: pulse 1.4s ease-in-out infinite; } -.power-display:disabled { opacity: .6; cursor: wait; } +.power-display:disabled { opacity: .6; cursor: not-allowed; } +.power-display.busy:disabled { cursor: progress; } +.connection-guide .secondary-action { display: inline-flex; align-items: center; margin: 9px 0 0; border: 1px solid #d9e6ff; border-radius: 9px; background: #e8f0ff; color: #285fd0; padding: 9px 12px; cursor: pointer; } +.connection-guide .secondary-action:disabled { cursor: not-allowed; } +.app-proxy-guide p { margin: 6px 0; } +.app-proxy-guide small { display: block; margin-top: 7px; } @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/main.go b/client/tunnel-go/main.go index 1470fb9..5fc0434 100644 --- a/client/tunnel-go/main.go +++ b/client/tunnel-go/main.go @@ -4,6 +4,7 @@ package main import ( + "bufio" "context" "encoding/base64" "encoding/hex" @@ -15,6 +16,7 @@ import ( "net/netip" "os" "os/signal" + "runtime" "strconv" "strings" "syscall" @@ -22,6 +24,7 @@ import ( "golang.zx2c4.com/wireguard/conn" "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun" "golang.zx2c4.com/wireguard/tun/netstack" ) @@ -45,20 +48,80 @@ type result struct { Connected bool `json:"connected"` Detail string `json:"detail"` Target string `json:"target,omitempty"` + LatencyMS *int64 `json:"latency_ms,omitempty"` +} + +// Only these counters may cross the Go/Rust process boundary. IpcGet also +// contains the device private key, so never serialize its raw response. +type tunnelStatus struct { + Status string `json:"status"` + HandshakeUnix uint64 `json:"handshake_unix"` + RXBytes uint64 `json:"rx_bytes"` + TXBytes uint64 `json:"tx_bytes"` +} + +func sanitizedStatus(raw string) (tunnelStatus, error) { + status := tunnelStatus{Status: "device_status"} + seenPeer := false + for _, line := range strings.Split(raw, "\n") { + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + if key == "public_key" { + if seenPeer { + return tunnelStatus{}, errors.New("设备返回多个 Peer") + } + seenPeer = true + } + if !seenPeer { + continue + } + var field *uint64 + switch key { + case "last_handshake_time_sec": + field = &status.HandshakeUnix + case "rx_bytes": + field = &status.RXBytes + case "tx_bytes": + field = &status.TXBytes + default: + continue + } + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return tunnelStatus{}, errors.New("设备状态计数器无效") + } + *field = parsed + } + if !seenPeer { + return tunnelStatus{}, errors.New("设备没有可用的 Peer") + } + return status, nil } func readRecord(path string) (*record, error) { - info, err := os.Lstat(path) + // Open the same inode that we check: Lstat followed by ReadFile would let + // another process replace the path with a symlink between those calls. + file, err := openRecord(path) if err != nil { return nil, fmt.Errorf("无法读取本机设备配置: %w", err) } + defer file.Close() + info, err := file.Stat() + 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) + data, err := io.ReadAll(io.LimitReader(file, 64*1024+1)) if err != nil { return nil, fmt.Errorf("无法读取本机设备配置: %w", err) } + if len(data) > 64*1024 { + return nil, errors.New("本机设备配置超过大小限制") + } var r record if err := json.Unmarshal(data, &r); err != nil { return nil, errors.New("本机设备配置格式无效") @@ -227,6 +290,9 @@ func serveSOCKS(r *record, address string) error { } defer listener.Close() parentPID := os.Getppid() + if parentPID <= 1 { + return errors.New("代理控制进程已退出") + } go func() { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() @@ -261,6 +327,121 @@ func serveSOCKS(r *record, address string) error { } } +// serveTUN creates a native interface. The parent owns IP/routes/DNS and must +// not report a usable connection until those OS changes have succeeded. +func serveTUN(r *record) error { + if runtime.GOOS != "darwin" { + return errors.New("原生 TUN 目前仅实现 macOS 数据面,系统网络配置尚未接入") + } + // Capture before initialization: a parent dying while the device starts + // must not be mistaken for a new, healthy parent (typically launchd). + parentPID := os.Getppid() + if parentPID <= 1 { + return errors.New("隧道控制进程已退出") + } + endpoint, port, err := resolveEndpoint(r.Config.Endpoint) + if err != nil { + return err + } + resolved := *r + resolved.Config.Endpoint = net.JoinHostPort(endpoint.String(), port) + settings, _, err := uapi(&resolved, netip.Addr{}) + if err != nil { + return err + } + mtu := int(r.Config.MTU) + if mtu == 0 { + mtu = 1420 + } + if mtu < 576 || mtu > 9000 { + return errors.New("MTU 无效") + } + // Upstream's macOS CreateTUN optionally writes the assigned interface name + // to WG_TUN_NAME_FILE. Our JSON handshake already returns the name; ignore + // inherited environment so a launcher's variable cannot trigger a file write. + _ = os.Unsetenv("WG_TUN_NAME_FILE") + interfaceDevice, err := tun.CreateTUN("utun", mtu) + if err != nil { + return fmt.Errorf("无法创建本机网络接口: %w", err) + } + name, err := interfaceDevice.Name() + if err != nil { + _ = interfaceDevice.Close() + return fmt.Errorf("无法获取本机网络接口: %w", err) + } + plan, err := planNativeRoutes(r, name, endpoint) + if err != nil { + _ = interfaceDevice.Close() + return err + } + if plan.EndpointBypass { + exit, err := physicalExitFor(endpoint) + if err != nil { + _ = interfaceDevice.Close() + return err + } + plan.PhysicalExit = &exit + } + dev := device.NewDevice(interfaceDevice, conn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, "")) + defer dev.Close() + if err := dev.IpcSet(settings); err != nil { + return errors.New("WireGuard 连接参数无效") + } + if err := dev.Up(); err != nil { + return fmt.Errorf("Go 隧道启动失败: %w", err) + } + // Never include configuration, tokens, or keys in the readiness protocol. + if err := json.NewEncoder(os.Stdout).Encode(struct { + Status string `json:"status"` + Plan networkPlan `json:"plan"` + }{Status: "interface_created", Plan: plan}); err != nil { + return fmt.Errorf("无法向控制面报告本机接口: %w", err) + } + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(stop) + commands := make(chan string) + go func() { + defer close(commands) + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + commands <- scanner.Text() + } + }() + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-stop: + return nil + case command, ok := <-commands: + if !ok || command == "stop" { + return nil + } + if command != "status" { + return errors.New("未知隧道控制指令") + } + raw, err := dev.IpcGet() + if err != nil { + return errors.New("无法读取 WireGuard 状态") + } + status, err := sanitizedStatus(raw) + if err != nil { + return err + } + if err := json.NewEncoder(os.Stdout).Encode(status); err != nil { + return errors.New("无法报告 WireGuard 状态") + } + case <-dev.Wait(): + return errors.New("WireGuard 接口意外关闭") + case <-ticker.C: + if os.Getppid() != parentPID || parentPID <= 1 { + return nil + } + } + } +} + func handleSOCKS(client net.Conn, r *record, stack *netstack.Net) { defer client.Close() _ = client.SetDeadline(time.Now().Add(15 * time.Second)) @@ -375,13 +556,21 @@ func main() { 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 ") + if (len(os.Args) != 4 || (os.Args[1] != "probe" && os.Args[1] != "proxy")) && + (len(os.Args) != 3 || os.Args[1] != "tun") { + fmt.Fprintln(os.Stderr, "usage: opsx-tunnel-go | tun ") 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] == "tun" { + if err = serveTUN(r); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } if err == nil && os.Args[1] == "proxy" { err = serveSOCKS(r, os.Args[3]) if err != nil { @@ -390,12 +579,23 @@ func main() { } return } + var latency *int64 if err == nil { ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() + started := time.Now() err = probe(ctx, r, os.Args[3]) + if err == nil { + elapsed := time.Since(started).Milliseconds() + if elapsed < 1 { + elapsed = 1 + } + // Assignment occurs below, after the result is constructed. + latency = &elapsed + } } res := result{Connected: err == nil, Target: os.Args[3]} + res.LatencyMS = latency if err == nil { res.Detail = "WireGuard 出口 TCP 已连通" } else { diff --git a/client/tunnel-go/main_test.go b/client/tunnel-go/main_test.go index 917e2ed..bb7f6eb 100644 --- a/client/tunnel-go/main_test.go +++ b/client/tunnel-go/main_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/base64" + "encoding/json" "net/netip" "os" "path/filepath" @@ -64,6 +65,93 @@ func TestRejectSymlinkedKeyFile(t *testing.T) { } } +func TestRejectOversizedRecord(t *testing.T) { + path := filepath.Join(t.TempDir(), "record.json") + if err := os.WriteFile(path, []byte(strings.Repeat("x", 64*1024+1)), 0600); err != nil { + t.Fatal(err) + } + if _, err := readRecord(path); err == nil || !strings.Contains(err.Error(), "大小限制") { + t.Fatalf("oversized record accepted: %v", err) + } +} + +func TestStatusReturnsOnlyNonSecretCounters(t *testing.T) { + raw := "private_key=secret\npublic_key=peer\npreshared_key=secret\nendpoint=192.0.2.1:51820\n" + + "last_handshake_time_sec=123\nrx_bytes=456\ntx_bytes=789\nallowed_ip=0.0.0.0/0\n" + status, err := sanitizedStatus(raw) + if err != nil { + t.Fatal(err) + } + if status.Status != "device_status" || status.HandshakeUnix != 123 || status.RXBytes != 456 || status.TXBytes != 789 { + t.Fatalf("unexpected status: %+v", status) + } + data, err := json.Marshal(status) + if err != nil || strings.Contains(string(data), "secret") || strings.Contains(string(data), "192.0.2.1") { + t.Fatalf("status leaked configuration: %s, %v", data, err) + } + for _, malformed := range []string{"private_key=secret", "public_key=peer\nrx_bytes=bad", "public_key=one\npublic_key=two"} { + if _, err := sanitizedStatus(malformed); err == nil { + t.Fatalf("malformed device status accepted: %q", malformed) + } + } +} + +func TestNativeRoutePlanGuardsPhysicalEndpoint(t *testing.T) { + r := testRecord() + ip, port, err := resolveEndpoint(r.Config.Endpoint) + if err != nil || ip.String() != "121.4.111.208" || port != "51820" { + t.Fatalf("endpoint resolution failed: %s, %s, %v", ip, port, err) + } + r.Config.AllowedIPs = "0.0.0.0/0" + plan, err := planNativeRoutes(r, "utun12", ip) + if err != nil || !plan.EndpointBypass || len(plan.Routes) != 2 || + plan.Routes[0].String() != "0.0.0.0/1" || plan.Routes[1].String() != "128.0.0.0/1" { + t.Fatalf("full route missing endpoint bypass: %+v, %v", plan, err) + } + r.Config.AllowedIPs = "10.88.0.0/24, 10.88.0.0/24" + plan, err = planNativeRoutes(r, "utun2", netip.MustParseAddr("121.4.111.208")) + if err != nil || plan.EndpointBypass || len(plan.Routes) != 1 || plan.Routes[0].String() != "10.88.0.0/24" { + t.Fatalf("split route not normalized: %+v, %v", plan, err) + } + r.Config.AllowedIPs = "::/0, 0.0.0.0/0" + if _, err := planNativeRoutes(r, "utun2", netip.MustParseAddr("121.4.111.208")); err == nil { + t.Fatal("unsupported IPv6 route accepted") + } + for _, name := range []string{"utun", "utun1;route", "eth0", "utun-1"} { + if _, err := planNativeRoutes(r, name, netip.MustParseAddr("121.4.111.208")); err == nil { + t.Fatalf("unsafe interface accepted: %q", name) + } + } + for _, endpoint := range []string{"127.0.0.1:51820", "[::1]:51820", "missing-port"} { + if _, _, err := resolveEndpoint(endpoint); err == nil { + t.Fatalf("invalid endpoint accepted: %q", endpoint) + } + } +} + +func TestPhysicalRouteParsingRejectsVPNAndInjection(t *testing.T) { + output := " route to: 121.4.111.208\n gateway: 192.168.80.1\n interface: en0\n" + exit, err := parsePhysicalRoute(output) + if err != nil || exit.Interface != "en0" || exit.Gateway == nil || exit.Gateway.String() != "192.168.80.1" { + t.Fatalf("physical gateway rejected: %+v, %v", exit, err) + } + direct, err := parsePhysicalRoute("interface: en4\n") + if err != nil || direct.Gateway != nil { + t.Fatalf("direct physical link rejected: %+v, %v", direct, err) + } + for _, raw := range []string{ + "interface: utun2\ngateway: 10.0.0.1\n", + "interface: en0;touch /tmp/injected\n", + "interface: lo0\n", + "gateway: 192.168.80.1\n", + "interface: en0\ngateway: invalid\n", + } { + if _, err := parsePhysicalRoute(raw); err == nil { + t.Fatalf("unsafe route accepted: %q", raw) + } + } +} + 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 { diff --git a/client/tunnel-go/network_plan.go b/client/tunnel-go/network_plan.go new file mode 100644 index 0000000..8073a00 --- /dev/null +++ b/client/tunnel-go/network_plan.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "errors" + "net" + "net/netip" + "strings" + "time" +) + +func resolveEndpoint(raw string) (netip.Addr, string, error) { + host, port, err := net.SplitHostPort(raw) + if err != nil || host == "" || port == "" { + return netip.Addr{}, "", errors.New("VPN 公网端点格式无效") + } + if ip, err := netip.ParseAddr(host); err == nil { + if !ip.Is4() || !ip.IsGlobalUnicast() { + return netip.Addr{}, "", errors.New("VPN 公网端点需要有效 IPv4 地址") + } + return ip, port, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip4", host) + if err != nil { + return netip.Addr{}, "", errors.New("无法在连接前解析 VPN 公网端点") + } + for _, address := range addresses { + if address.Is4() && address.IsGlobalUnicast() { + return address, port, nil + } + } + return netip.Addr{}, "", errors.New("VPN 公网端点没有可用 IPv4 地址") +} + +// networkPlan contains only public addressing information. The privileged +// network adapter still needs to discover the physical gateway and persist a +// rollback journal before applying any of these routes. +type networkPlan struct { + Interface string `json:"interface"` + ClientAddress netip.Prefix `json:"client_address"` + Routes []netip.Prefix `json:"routes"` + Endpoint netip.Addr `json:"endpoint"` + EndpointBypass bool `json:"endpoint_bypass"` + PhysicalExit *physicalRoute `json:"physical_exit,omitempty"` +} + +type physicalRoute struct { + Interface string `json:"interface"` + Gateway *netip.Addr `json:"gateway,omitempty"` +} + +// macOS route -n get output is only an observation, never an executable +// command. Reject another VPN's utun as the physical endpoint exit. +func parsePhysicalRoute(output string) (physicalRoute, error) { + var exit physicalRoute + for _, line := range strings.Split(output, "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), ":") + if !ok { + continue + } + value = strings.TrimSpace(value) + switch key { + case "interface": + exit.Interface = value + case "gateway": + gateway, err := netip.ParseAddr(value) + if err != nil || !gateway.Is4() || gateway.IsUnspecified() || gateway.IsLoopback() { + return physicalRoute{}, errors.New("物理网关地址无效") + } + exit.Gateway = &gateway + } + } + if len(exit.Interface) < 2 || len(exit.Interface) > 16 || strings.HasPrefix(exit.Interface, "utun") || exit.Interface == "lo0" { + return physicalRoute{}, errors.New("VPN 网关当前未经过物理网络出口") + } + for _, char := range exit.Interface { + if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) { + return physicalRoute{}, errors.New("物理出口接口名称无效") + } + } + return exit, nil +} + +func planNativeRoutes(r *record, interfaceName string, endpoint netip.Addr) (networkPlan, error) { + if !strings.HasPrefix(interfaceName, "utun") || len(interfaceName) < 5 { + return networkPlan{}, errors.New("本机接口名称无效") + } + for _, digit := range interfaceName[4:] { + if digit < '0' || digit > '9' { + return networkPlan{}, errors.New("本机接口名称无效") + } + } + address, err := netip.ParsePrefix(r.Config.ClientAddress) + if err != nil || !address.Addr().Is4() || address.Addr().IsUnspecified() { + return networkPlan{}, errors.New("本机 IPv4 地址无效") + } + if !endpoint.IsValid() || !endpoint.Is4() || !endpoint.IsGlobalUnicast() { + return networkPlan{}, errors.New("VPN 公网端点必须解析为可达的 IPv4 地址") + } + plan := networkPlan{Interface: interfaceName, ClientAddress: address, Endpoint: endpoint, Routes: []netip.Prefix{}} + seen := make(map[netip.Prefix]bool) + for _, part := range strings.Split(r.Config.AllowedIPs, ",") { + prefix, err := netip.ParsePrefix(strings.TrimSpace(part)) + if err != nil { + return networkPlan{}, errors.New("WireGuard 授权路由无效") + } + if !prefix.Addr().Is4() { + return networkPlan{}, errors.New("IPv6 系统路由尚未实现,请在专线中仅授权 IPv4 路由") + } + prefix = prefix.Masked() + if prefix.Bits() == 0 { + // These /1 routes override the physical default while retaining it + // for explicit endpoint /32 bypass. The helper must install the bypass. + for _, half := range []string{"0.0.0.0/1", "128.0.0.0/1"} { + p := netip.MustParsePrefix(half) + if !seen[p] { + plan.Routes = append(plan.Routes, p) + seen[p] = true + } + } + plan.EndpointBypass = true + continue + } + if !seen[prefix] { + plan.Routes = append(plan.Routes, prefix) + seen[prefix] = true + } + if prefix.Contains(endpoint) { + plan.EndpointBypass = true + } + } + if plan.EndpointBypass { + // A physical /32 must be installed before any matching VPN route. + // Otherwise a full-tunnel VPN can attempt to reach itself through TUN. + if endpoint == address.Addr() { + return networkPlan{}, errors.New("VPN 端点与本机地址冲突") + } + } + return plan, nil +} diff --git a/client/tunnel-go/record_unix.go b/client/tunnel-go/record_unix.go new file mode 100644 index 0000000..1a03387 --- /dev/null +++ b/client/tunnel-go/record_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package main + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func openRecord(path string) (*os.File, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), path), nil +} diff --git a/client/tunnel-go/record_windows.go b/client/tunnel-go/record_windows.go new file mode 100644 index 0000000..663b188 --- /dev/null +++ b/client/tunnel-go/record_windows.go @@ -0,0 +1,32 @@ +//go:build windows + +package main + +import ( + "os" + "syscall" + + "golang.org/x/sys/windows" +) + +func openRecord(path string) (*os.File, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile(name, windows.GENERIC_READ, windows.FILE_SHARE_READ, + nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) + if err != nil { + return nil, err + } + var data windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &data); err != nil { + windows.CloseHandle(handle) + return nil, err + } + if data.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + windows.CloseHandle(handle) + return nil, syscall.EINVAL + } + return os.NewFile(uintptr(handle), path), nil +} diff --git a/client/tunnel-go/route_darwin.go b/client/tunnel-go/route_darwin.go new file mode 100644 index 0000000..5476e15 --- /dev/null +++ b/client/tunnel-go/route_darwin.go @@ -0,0 +1,21 @@ +//go:build darwin + +package main + +import ( + "context" + "errors" + "net/netip" + "os/exec" + "time" +) + +func physicalExitFor(endpoint netip.Addr) (physicalRoute, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "/sbin/route", "-n", "get", endpoint.String()).Output() + if err != nil { + return physicalRoute{}, errors.New("无法读取 VPN 网关的当前物理出口") + } + return parsePhysicalRoute(string(output)) +} diff --git a/client/tunnel-go/route_other.go b/client/tunnel-go/route_other.go new file mode 100644 index 0000000..8640182 --- /dev/null +++ b/client/tunnel-go/route_other.go @@ -0,0 +1,12 @@ +//go:build !darwin + +package main + +import ( + "errors" + "net/netip" +) + +func physicalExitFor(_ netip.Addr) (physicalRoute, error) { + return physicalRoute{}, errors.New("当前平台尚不支持系统隧道网络配置") +} diff --git a/docs/deploy.md b/docs/deploy.md index 41a7f70..d2e4634 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -11,7 +11,7 @@ ## 首次构建与启动 ```bash -git clone https://gitee.com/opsx-tools/opsx-vpn.git +git clone https://gitea.91ai.io/rlwj-lab/opsx-vpn.git cd opsx-vpn cd frontend && npm ci && npm run build && cd .. go build -ldflags="-s -w" -o opsx-vpn ./backend diff --git a/docs/specs/SDD-003-bundled-wireguard-client.md b/docs/specs/SDD-003-bundled-wireguard-client.md index 6c54dd6..cc6772d 100644 --- a/docs/specs/SDD-003-bundled-wireguard-client.md +++ b/docs/specs/SDD-003-bundled-wireguard-client.md @@ -2,27 +2,28 @@ | 字段 | 内容 | |---|---| -| 版本 / 变更级别 | 0.1 / S3(系统隧道、路由、DNS 与特权执行) | +| 版本 / 变更级别 | 0.3 / S3(内置 Go 系统隧道、路由、DNS 与特权执行) | | 设计状态 | Draft,已核对本地实现与 opsxdb / opsxapi 的桌面打包方式 | -| 实现状态 | 已引入可打包 Go 版标准 WireGuard 无特权探针和显式启停的本机 SOCKS5 模式;系统 TUN/路由/DNS 尚未完成 | +| 实现状态 | Go 探针、SOCKS5 与 macOS 原生 `utun` 数据面、Rust 受限子进程桥接代码已实现;系统工具调用路径已移除,内置直连尚不可用;拨号用户可自动登记至代理启动;macOS 系统 SOCKS 开关已实现,真机验收待办;系统接口地址/路由/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 的真实握手仍待验。 +- **当前行为与证据**:`client/src-tauri/src/tunnel.rs` 只管理本机密钥与设备记录;此前的 `wg-quick` 执行和 PATH 检测已按用户要求移除。`system.rs` 在特权网络服务完成前始终报告直连未就绪;Go 探针及应用代理仍可用。`client/src-tauri/tauri.conf.json` 打包 Go sidecar;`../opsxdb/src-tauri/tauri.conf.json` 使用 `externalBin` 打包 Go sidecar。现有 `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 上宣称已实现免依赖系统拨号。 +- **本轮实施边界**:保留可打包的 Go netstack 探针与本机 SOCKS5 应用代理;客户端不再执行系统 WireGuard 命令。完成特权 helper、路由/DNS 回滚和目标 OS 握手验收之前,UI 只报告内置直连未就绪。 - **保留行为**:普通用户认证、设备公钥注册、配置领取及服务端 WireGuard UDP 协议不改;前端不接收私钥,服务端不接收客户端私钥。 ## 2. 需求与约束 -- FR-01:启用免安装分发通道的发布版仅在安装包内存在**正确目标架构、可校验版本**的隧道组件且系统适配就绪时报告“内置引擎就绪”;缺失时应明确报错,绝不悄悄运行 PATH 中同名程序。当前过渡版明确标记为“系统工具模式”,不得作为免安装版分发。 -- FR-02:开发模式仍可使用系统 `wg-quick`,并明确标记为“系统工具模式”;二者的就绪检测和实际连接路径必须一致。 +- FR-01:启用免安装分发通道的发布版仅在安装包内存在**正确目标架构、可校验版本**的隧道组件且系统适配就绪时报告“内置引擎就绪”;缺失时应明确报错,绝不运行 PATH 中同名程序。 +- FR-02:所有模式均不调用本机 `wg` / `wg-quick`;内置网络服务未就绪时直连明确不可用,应用代理单独可用。 - FR-03:随包组件只接受固定动作及受约束的本机配置路径,不接受来自 WebView 的任意命令或路径;日志不可含私钥、令牌、完整配置。 - FR-04:macOS 正式接入必须完成受控提权、TUN、路由与 DNS 原值保存和恢复;授权取消、进程崩溃、升级或断开均须保持可恢复性。系统辅助程序的签名与安装按目标发行证书验收。 - FR-05:Windows/Linux 按各自系统 API 和权限交付;未验收的平台应明确“尚不支持内置隧道”,不能用模拟连接替代。 +- FR-06:无系统工具的用户登录后可从概览启动 Go 应用代理:先校验最新线路授权、自动登记/刷新本机设备、使用实际 WireGuard 参数启动本机 SOCKS5。UI 必须将代理状态与整机 VPN 状态分开;退出、撤销设备、进程停止后不可仍报代理在线。 - NFR-01:正式发布不得从远程地址动态下载可执行文件、不得通过 shell 插入远端配置、不得使用旧飞连魔改 WireGuard 协议。 - NFR-02:非管理员安装包、系统提权、代码签名、升级与卸载必须分别测试;不能将可执行文件放进用户可写目录再让 root 执行。 @@ -30,8 +31,8 @@ ```text React → Tauri invoke → Rust(登录、设备私钥、连接状态) - ├─ 开发过渡:系统 wg-quick - └─ 发布引擎:签名的特权隧道组件 + ├─ 当前:Go 应用代理;直连未就绪 + └─ 目标:签名的特权隧道组件 ├─ macOS:WireGuard Go 引擎 + 受控网络配置/恢复 ├─ Linux:内核 WireGuard + 受控配置服务 └─ Windows:WireGuardNT/官方隧道服务适配 @@ -46,9 +47,30 @@ React → Tauri invoke → Rust(登录、设备私钥、连接状态) ## 4. 接口、数据与配置契约 -- `get_system_readiness` 向前兼容既有 `platform/wg_found/wg_quick_found`,新增 `engine_mode`(`bundled` / `system_tools` / `unsupported`)及可读 `engine_detail`;由 Rust 而非 UI 决定。`bundled` 仅可在实际引擎、权限和系统配置均通过验收后返回。 +### 无 `wg` / `wg-quick` 的 macOS 直连实现 + +- Go 子进程在用户权限下使用上游 `tun.CreateTUN("utun", mtu)` 创建接口、启动 WireGuard,并通过一次性 stdout 消息把系统分配的 `utunN` 回传 Rust;本机配置保持 0600。Go 子进程不能仅因为接口创建成功就报告已联网;Rust 完成 IP、路由、DNS 设置后才允许控制面查询真实握手。 +- 对照 `../demo/clash-verge-rev`:它的 `config/clash.rs` 为 Go 核心 Mihomo 配置 `tun.auto-route/auto-detect-interface/dns-hijack`,`core/service.rs` 由经管理员确认的安装器将核心发布到服务拥有的路径,再用受限 IPC 启动;`feat/tun.rs` 在服务未就绪时停用 TUN。其“rule/global”是 Mihomo 的流量策略,不等于 WireGuard 的 AllowedIPs。OpsX 不直接复用 Mihomo 的代理规则或服务 IPC 协议。 +- 其他参考边界:corplink-rs 的飞连私有协议字段不适用于本项目标准 WireGuard;Tailscale/NetBird 可以参考服务化与网络生命周期,但不能据此假设它们的路由/DNS 实现已经被本项目核对或移植;AmneziaWG-go 的混淆需服务端同时支持,当前服务端未采用该协议。本机只核对过 Clash Verge Rev 的源码,其他项目目前属于候选参考。 +- 已核对本地 `../demo/vpn/wireguard-go`(提交 `2631ce9`),与客户端 `go.mod` 锁定的上游模块版本一致。其 `tun/tun_darwin.go` 中 `CreateTUN("utun", mtu)` 让内核分配真实接口名,且可选环境变量 `WG_TUN_NAME_FILE` 会写文件;本项目禁用这一额外写入,仅通过 JSON 回传真实名称。其 `main.go` 用 `device.Wait`、终止信号和 `device.Close` 管理生命周期,但没有替应用设置地址、路由或 DNS;`device/uapi.go` 的 `IpcGet` 会包含私钥,必须在 Go 进程内过滤后才返回 Rust。 +- Go `tun` 命令仅在 macOS 运行,读取 0600 本机记录,创建接口后只输出 `interface_created` 和系统分配的 `utunN`;其标准输入仅接受 `status`、`stop`,关闭输入、父进程退出或接口关闭时终止。`status` 仅输出握手秒数与收发字节数,不透传包含私钥的 WireGuard UAPI 原文;首次接口事件**不是**握手或路由就绪。普通登录进程不得因为收到这个消息就更新 UI 为已连接。 +- `interface_created` 同时携带不含密钥的 IPv4 路由计划:先在原物理出口固定网关 endpoint `/32`(如果授权路由覆盖它),再按授权网段安装 TUN 路由;`0.0.0.0/0` 展开为两个 `/1`。域名 endpoint 在动路由前解析成一个 IP,WireGuard Device 使用同一个解析结果,防止多个 DNS 地址导致绕行规则与实际发送目标不一致。若需网关绕行,Go 在启动前只读调用 `/sbin/route -n get` 并提取物理接口和网关;已走 `utun`、无接口或格式非法则拒绝。此结果是快照,特权服务实际安装路由前仍须复核物理出口,防止检查后网络切换。当前只规划 IPv4,出现 IPv6 授权立即拒绝,不把未接管的 IPv6 流量误称为全局模式。仍需特权服务安全执行和恢复,此计划本身不会改变系统路由。 +- `client/src-tauri/src/native_tunnel.rs` 已能在 macOS 启动 Go 子进程、限制启动与查询等待时长、解析非机密计划与握手计数,断开或 Rust 对象销毁时终止子进程;字段在 Rust 侧再次校验。该模块先保持脱离 UI 的直连路径,待特权服务完成网络事务与恢复并通过握手/流量验收才启用,防止接口创建事件被 UI 当作可联网状态。 +- Rust 只允许经校验的 IPv4、合法 `utunN`、服务端 `AllowedIPs` 及固定动作传给受控系统服务;服务安装到管理员拥有的固定位置,验签/校验所有权,使用受限本地 IPC 更新路由和 DNS,不能从用户可写路径提权运行 Go 核心,也不通过临时拼接的 AppleScript/shell 命令充当发布版特权服务。专线 endpoint 需为物理网络出口保留路由,防止全局路由自吞。 +- 系统配置事务顺序:解析 endpoint 的实际地址并记录原物理出口与 DNS 状态;先持久化带有接口名和拥有者标识的恢复记录,再设置接口地址和路由、受控 DNS;核对网络栈实际状态、服务端新握手和授权目标连通性后才发布连接状态。全局路由应避开 VPN endpoint,网络切换时重新计算出口。若启动中途失败,按逆序恢复,只撤回自身仍持有的设置;恢复失败保留记录并提示人工修复,不覆盖后续第三方改动。服务重启时扫描自己的未完成事务并恢复。 +- 路由/DNS 变更前写入只属于本应用的恢复记录。部分失败立即回滚;断开、注销、设备撤销、Go 进程意外退出、应用退出或下次启动重试恢复,只清理与当前接口/目标一致的配置。恢复失败时明确报告,并阻止把连接显示为成功。网络切换、休眠唤醒、DNS 原值和抢占冲突需要 macOS 真机验证。 +- 本轮优先 macOS;Linux/Windows 需要独立系统权限与适配,未实现时不得显示内置直连可用。现有 SOCKS5 应用代理保留;任何平台均不回退到系统 WireGuard 工具。 + +- `get_system_readiness` 报告平台、Go 代理组件、`engine_mode` 和可读 `engine_detail`;不再检测或展示系统 WireGuard 命令。`bundled` 仅可在实际引擎、权限和系统配置均通过验收后返回。 - 内置组件的目标 triple 路径和版本由构建产物显式声明;发布流程核对哈希、签名和可执行权限。不能从用户可写数据目录或 PATH 寻找发布 helper。 - 服务端 API、数据库及客户端设备密钥数据模型不变;未来 helper 通过最小化本地 IPC 收到连接参数,不接触用户口令或管理令牌。 +- 本轮 Go 代理继续显式开启,不自动修改系统代理、路由或 DNS。Rust 将设备准备与整机拨号分离;代理路径复用同一设备授权流程,启动时重新查授权,失败时不遗留假在线状态。试验用户的 `AllowedIPs=10.88.0.0/24` 只可代理该网段,不能宣称可经 VPN 浏览公网。 + +### macOS 系统代理扩展(S3,2026-10-21) + +- 用户显式启动应用代理后可单独启用系统 SOCKS 代理。仅当设备配置实际包含 `0.0.0.0/0` 时允许开启;当前私网试验专线应拒绝,避免把普通网页发送到不允许公网访问的隧道。系统代理只影响遵循 macOS 代理设置的应用,不等同于整机 TUN,也不能保证所有 UDP 或 DNS 走隧道。 +- 启用前枚举网络服务、检查 HTTP/HTTPS/SOCKS/PAC/自动发现是否已有代理;已有代理时拒绝接管。持久记录将要更改的服务与原 SOCKS 配置;启用失败逐项回滚。关闭时只恢复仍指向自身监听端口的服务,绝不覆盖其他程序随后修改的代理。进程意外退出或下次启动应尝试清理残留;无法安全恢复时给出错误并保留恢复记录。网络服务新增或切换不能自动把未授权服务纳入代理范围。 +- 启用、关闭、注销、退出与隧道切换均需串行操作。恢复失败不得悄悄关闭仍被系统代理引用的本地监听;手动改动代理后状态显示为冲突。验收包括私网路由拒绝、已有代理拒绝、启停还原、部分失败回滚、异常退出后重启恢复及更换网络服务。正式真机验收前不把此能力描述为跨平台整机 VPN。 ## 5. 风险与验收 @@ -61,16 +83,20 @@ React → Tauri invoke → Rust(登录、设备私钥、连接状态) | AC | 对应需求 | 前置条件与操作 | 预期结果 | 验证方式 | 结果 | |---|---|---|---|---|---| -| AC-01 | FR-01, FR-02 | 无 Homebrew 的 macOS 打开当前过渡版/未来免安装版 | 过渡版明确显示依赖;免安装版仅在组件和权限实际就绪时显示内置可用 | 单测、构建、真机 | 当前过渡版选择器单测通过;免安装版 Not Run | +| AC-01 | FR-01, FR-02 | macOS/Linux 上有或没有系统 WireGuard 命令时打开客户端 | 两种环境均报告内置直连未就绪;客户端不运行系统命令,Go 代理独立可用 | Rust 单测、构建、静态调用核查、真机 | Rust 单测与静态核查通过;真机 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-03a | FR-03/04 | 数据面收到格式错误、过大、符号链接设备记录;模拟全局/分流路由、物理出口与 `utun` 出口;父进程退出或接口意外关闭 | 拒绝错误输入与非物理出口;进程退出并释放接口;接口创建事件不冒充联网完成 | Go 单测、跨平台构建、macOS 故障注入 | 输入与物理出口解析单测、构建通过;macOS 故障注入 Not Run | | AC-04 | FR-05 | Windows/Linux 干净系统安装、拨号、卸载 | 无独立工具安装步骤,未支持平台不假装就绪 | 各平台测试 | Not Run | +| AC-05 | FR-06 | 无 `wg-quick`、有拨号用户授权时点击“应用代理” | 自动登记设备并启动 Go 代理;界面展示应用代理地址且整机 VPN 仍未连接;撤销/退出后代理停止 | Rust/Go 单测、macOS 实机检查 | Rust 模拟服务端首次登记/刷新测试通过;Go 私网探测成功,本机代理监听/私网 SOCKS 握手通过;GUI 拨号用户与撤销验收待运行 | +| AC-06 | macOS 系统代理扩展 | 私网路由/已有代理拒绝;全局路由下启停、断网、异常退出 | 已占用设置不覆盖;仅恢复自身代理;不把 SOCKS 宣称为所有流量经过 VPN | macOS 真机逐项验收 | Rust 构建/静态状态解析通过;系统设置与公网网页真机验收 Not Run | ## 6. 发布与恢复 -- 第一阶段仅交付设计与隔离执行层的基础代码,不默认启用内置模式;持续提供现有 `wg-quick` 开发通道。切换正式引擎前收集目标架构及代码签名资料。 +- 第一阶段关闭系统工具直连,仅交付隔离执行层和 Go 代理;特权网络服务与恢复通过验收后才启用内置直连。切换正式引擎前收集目标架构及代码签名资料。 - 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 恢复完成。 +- 2026-10-21 只读复核运行中的 wg4、IPv4 转发与 NAT:IPv4 转发已开启,POSTROUTING 仅有 OpenVPN `10.10.0.0/24` 规则,WireGuard `10.88.0.0/24` 没有持久出口 NAT。全局线路上线需先通过管理后台启用该专线的出口 NAT 并下发经授权的全局 IPv4 路由,再实测浏览器访问;当前不能标为公网验收通过。 - 先在独立试验机测试安装与签名,再验证路由/DNS 回滚与握手,然后分批发版;不能仅靠 `cargo check` 或浏览器预览宣称可分发。 - 出现网络配置残留时用保存的系统快照恢复并停用有问题的组件;保留旧安装包与开发模式供排障。服务端不变,无数据库回滚。 diff --git a/docs/specs/SDD-004-client-entry-and-node-choice.md b/docs/specs/SDD-004-client-entry-and-node-choice.md new file mode 100644 index 0000000..04fe617 --- /dev/null +++ b/docs/specs/SDD-004-client-entry-and-node-choice.md @@ -0,0 +1,63 @@ +# SDD-004:客户端企业入口与线路选择 + +| 字段 | 内容 | +|---|---| +| 版本 / 级别 | 0.2 / S3(客户端交互、认证配置安全边界与列表展示) | +| 设计状态 | 实施中 | +| 实现状态 | 客户端企业入口、能力读取、路由标签、实测隧道探测与信号格、WireGuard 认证配置边界和专线列表已实现;WireGuard 浏览器 OIDC 仍未实现 | +| 日期 | 2026-10-21 | +| 关联需求 | 企业选择、飞书登录入口、模式和节点延迟;WireGuard 认证方式与专线列表操作 | +| 批准状态 | 用户提供竞品截图并要求按现有项目继续实现 | + +## 1. 背景、目标与范围 + +- 当前 `client/src/App.tsx` 只有服务端地址和密码表单、线路平铺列表。`backend/internal/api/api.go` 的匿名 `/api/client/capabilities` 返回 `oidc: false`;OpenVPN 的 `openvpn-auth-oauth2` 与 WireGuard 客户端会话互不兼容。`backend/internal/api/client.go` 的线路含 `endpoint`,不含节点 RTT;`client_probe` 是经 WireGuard 到固定公网 TCP 目标的诊断。 +- 目标:以 OpsX 自己的品牌做企业入口、认证选择和节点选择弹窗;用户配置的服务器才是“单位”,不预填竞品单位。只给实测线路显示延迟/颜色和信号格。 +- 本次不新增身份映射、全局路由覆盖或系统 DNS 修改。登录方式与网络模式必须与真实服务端/本机能力一致。保留已有 HTTP 地址支持、密码/MFA 登录、登录会话、设备私钥和连接状态语义。 + +## 2. 需求与约束 + +- FR-01:登录页标为“选择接入点”,填写服务端地址并自动带入上次使用的地址;验证真实 `/api/health` 后进入认证界面。切换接入点清除账号口令,不显示历史地址下拉。 +- FR-02:根据服务端 `/api/client/capabilities` 的 `password/oidc` 能力决定展示入口;`password:false` 不呈现密码表单,`oidc:false` 不呈现飞书入口。`oidc:true` 但客户端浏览器回调未实现时明确禁用,不重定向到 OpenVPN 回调;两者均不可用时提示管理员。未来开启浏览器登录须实现授权码+PKCE、回调一次性交换与稳定 `issuer/sub` 绑定 VPNUser,禁止按邮箱自动授予线路。 +- FR-03:线路选择展示服务端真实授权线路;极速/全局用线路下发的路由策略判断。未有可切换策略时模式只是当前策略标记,不保存虚构偏好或擅自修改 AllowedIPs。 +- FR-04:延迟数据必须来自对应线路的实际 Go WireGuard 探针。探测失败/未登记设备时显示不可测,不得用控制面健康请求替代节点 RTT;阈值只用于实测后的色彩映射。 +- NFR-01:无效授权与离线不应误报已登录、已连接或高质量节点;浏览器预览不调用原生接口。 +- FR-05:WireGuard 创建和编辑页展示认证方式:现有“客户端账号 + 设备密钥”可选;第三方 OAuth2/OIDC 显示接入说明且不可选。服务端对显式提交未知模式或 oidc 拒绝保存;旧配置无字段保持原行为。静态 Peer 的已有密钥能力不受此选项保护,页面应如实提示。 +- FR-06:专线列表和详情显示认证方式;列表启动/停止按钮固定可见,并依据实际运行状态切换;其他协议不臆测认证类型。 + +## 3. 架构与方案 + +前端负责向导和显示;Rust 验证服务端地址、取得 `password/oidc` 能力并调用当前已有的客户端 API;Go 组件执行真实 TCP 出口探测。按用户选择的运行中线路拨号,后端继续按 `VPNUser` 授权隔离。节点列表使用现有 `/api/client/lines`,不造地理位置/服务器名称。OpenVPN OIDC 配置不作为客户端 OIDC 能力来源。 + +## 4. 接口、数据与配置契约 + +- 匿名 `/api/client/capabilities` 既有 `password/oidc/wireguard` 布尔值;可选 `oidc_provider` 用于将飞书/Lark 与其他 OIDC 提供商区分,旧服务端缺字段按普通“第三方认证”显示。`oidc:false` 时隐藏第三方认证入口,`password:false` 时隐藏密码表单;老服务端返回 HTML 则不进入登录步骤。 +- `client_probe` 在原结果基础上增加本次成功连接耗时 `latency_ms`(含隧道启动与握手);未成功时不提供延迟。前端只为对应线路显示该值,重新选择单位或重新登录后清除。可选 `latency_probe` 为 VPN 路由内的 IP:端口;未配置时用 `223.5.5.5:443`。线路接口增加由 `AllowedIPs` 推断的 `routing_mode`(`split` / `global_ipv4`);旧服务端缺字段时客户端不推断全局模式。 +- 不修改 SQLite、设备配置或 WireGuard Peer 授权范围。不新增第三方 logo、虚构地区和用户协议链接。 +- WireGuard 配置新增可选 `auth_mode=password`;旧记录缺失时按既有客户端账号+设备密钥行为显示。只有 WireGuard 新增服务端枚举校验,`oidc` 不能写入配置;列表从 JSON 配置读取既有 OpenVPN `auth_mode`,未知配置显示未指定。启动/停止使用原有 API。 + +## 5. 风险与验收 + +| 风险 | 防护 / 恢复 | 可观察信号 | +|---|---|---| +| 将 OpenVPN OAuth 当 WireGuard 登录 | 飞书入口根据客户端能力禁用;文案区分两者 | `oidc:false` 下无法点击浏览器授权 | +| 非法 WireGuard OIDC 配置被误认为安全登录 | 下拉禁选且后端验证拒绝;保留原设备密钥与静态 Peer 流程 | 创建/编辑传 `oidc` 返回校验错误 | +| 将 HTTP 或隧道探针误称为节点 RTT | 清楚标注“隧道 TCP 建链耗时”,未测为中性 | 无数据时没有绿色/黄色毫秒值 | +| 选择全局模式导致错误路由 | 根据专线真实 AllowedIPs 显示当前策略,未具备切换条件则不可切换 | 不更改系统路由与专线配置 | + +| AC | 对应需求 | 操作与预期 | 验证方式 | 结果 | +|---|---|---|---|---| +| AC-01 | FR-01 | 新用户配置地址、检测成功、进入认证;切换单位清空表单 | 客户端 UI 构建、桌面窗口启动;真实登录需账号验收 | 构建/启动通过,真实登录待验收 | +| AC-02 | FR-02 | `oidc:false` 隐藏第三方入口;`password:false` 隐藏密码入口;`oidc_provider=feishu` 显示飞书标签但保持禁用 | 客户端 UI 构建、能力契约单测;实际账号登录待验收 | 构建与契约单测通过,实际登录待验收 | +| AC-03 | FR-03 | 运行中线路可选;全局模式不在私网路由上假激活 | 路由推断 Go 单测、客户端 UI 构建 | 通过 | +| AC-04 | FR-04 | 真实探测显示毫秒数;失败显示不可测 | Go/Rust 测试、真机试验 | 测试通过,真机延迟/线路切换待验收 | +| AC-05 | FR-05 | WireGuard 创建/编辑显示两种方式,其中 OIDC 不可选;手工请求 `auth_mode=oidc` 被拒;无字段旧配置可验证 | 后台构建、Go 单测 | 通过;界面手工验收待进行 | +| AC-06 | FR-06 | 不同协议列表认证标签正确;运行和停止状态有清晰按钮 | 后台构建、代码检查 | 构建通过;界面手工验收待进行 | + +## 6. 发布与恢复 + +先验证 Go 服务端再发布前端及桌面客户端;本轮不修改生产网关。若需回退,恢复原服务端/前端构建与桌面客户端构建;新 `auth_mode=password` 对旧服务端为普通附加字段,旧配置仍可读取。无需迁移数据库或撤销现有授权。 + +## 7. 待决问题 + +正式飞书 OIDC 需要 HTTPS issuer、应用凭据与回调域名,以及管理员确认的飞书 `issuer/sub` 对 VPNUser 授权映射;没有这些配置不得显示可用的一键登录。全局 VPN 需后续系统隧道权限、NAT 与 DNS 恢复验收。 diff --git a/docs/wireguard-api-create.md b/docs/wireguard-api-create.md new file mode 100644 index 0000000..efbd0c4 --- /dev/null +++ b/docs/wireguard-api-create.md @@ -0,0 +1,36 @@ +# 通过管理 API 创建 WireGuard 专线 + +当前后端已经提供 `POST /api/tunnels`,管理后台的“创建专线”向导也调用这个接口;只有 `admin` / `operator` 管理会话有权创建。该接口用于**管理员配置网关**,桌面客户端不能持有管理令牌或直接调用它。创建专线不等于完成终端用户认证、设备授权或桌面拨号。 + +## 已创建的停止状态测试专线 + +2026-10-21 经用户明确授权,通过 SSH 登录服务器本机管理 API,按下列请求创建测试专线 `opsx-client-pilot`(ID `4`)。创建后再次读取 API 确认 `state: stopped`、`auto_start: false`、监听端口 `51820`、公网端点 `121.4.111.208:51820`,并生成托管测试 Peer `client-1`(`10.88.0.2/32`)。只读系统检查显示 UDP `51820` 未监听、`10.88.0.0/24` 未进入路由表;**没有启动专线,也没有完成云安全组或真实客户端握手测试**。启用前需确认云安全组、业务网段路由和服务器网络策略。`allowed_ips` 只包含测试隧道网段,不会默认接管所有客户端流量。 + +```json +{ + "name": "opsx-client-pilot", + "protocol": "wireguard", + "remark": "桌面客户端标准 WireGuard 测试专线", + "auto_start": false, + "config": { + "address": "10.88.0.1/24", + "client_address": "10.88.0.2/32", + "listen_port": 51820, + "public_endpoint": "121.4.111.208:51820", + "allowed_ips": "10.88.0.0/24", + "dns": "223.5.5.5", + "enable_nat": false, + "keepalive": 25 + } +} +``` + +`POST /api/tunnels` 成功返回专线记录(含 `id`、`state`);设为 `auto_start: false` 时返回 `state: stopped`,不会启动网卡。响应含配置但不应含服务器私钥。失败会返回 `error`,例如地址不合法或端口被占用。服务端创建时会生成首个托管 Peer;这是管理端旧流程,下载的客户端配置包含服务端生成的私钥,**不得分发给未来的桌面设备自注册接口**。创建后公开配置中的旧 `client_address` 字段迁移到 `config.peers[0].client_address`,其值为 `10.88.0.2/32`。 + +## 认证、验证与启动边界 + +管理 API 登录使用 `POST /api/login`,请求体为 `username`、`password`、按配置需要的 `mfa_code`;返回的 Bearer token 在 `Authorization` 请求头中发送。只通过可信 HTTPS 或已明确授权的加密管理通道提交凭据,不把密码、token 写入仓库、客户端前端代码或命令示例。`http://121.4.111.208:18080` 是明文 HTTP,**不要向公网该地址发送管理员密码或 token**。 + +创建前,以管理账号调用 `GET /api/tunnels?protocol=wireguard` 核对既有配置与实际 UDP 端口,并检查网段、系统路由及公网 UDP 放行。创建后用 `GET /api/tunnels/:id` 确认 `state`、`public_endpoint` 和 Peer;需要启用时由管理员单独调用 `POST /api/tunnels/:id/start`,再检查 `GET /api/tunnels/:id/status`、系统 WireGuard 握手和业务路由。`POST /api/tunnels/:id/wireguard/peers` 也是 operator 以上权限,当前实现更新运行中 Peer 会停止再启动隧道,不适合作为桌面设备自动注册接口。 + +桌面客户端后续应使用单独的短期用户/设备令牌和客户端生成的公钥,通过专用 API 领取地址、网关公钥、端点与路由;服务器只追加对应 Peer 的公钥,不回传服务器或其他 Peer 的私钥。具体身份映射、撤销和在线更新设计见 [SDD-002](specs/SDD-002-desktop-wireguard-client.md)。 diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 86ab991..1a1ced2 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -129,6 +129,10 @@ "noTunnels": "No tunnels yet", "expireTime": "Expiry Time", "localNode": "Local", + "authMethod": "Authentication", + "authUnknown": "Unspecified", + "wgAuthPassword": "Client account + device key", + "wgAuthHint": "The client account obtains device authorization; WireGuard authenticates using keys. Manually added static peers still connect by key.", "start": "Start", "stop": "Stop", "started": "Started", @@ -768,6 +772,14 @@ "label": "Server VPN Subnet Address", "tip": "Server's address within the VPN subnet (CIDR)" }, + "auth_mode": { + "label": "Client Authentication", + "tip": "Account login grants a device; the tunnel uses key authentication. Static peers still connect by key. Browser OIDC requires a client callback and explicit user grants.", + "options": { + "password": "Client account + device key", + "oidc": "Third-party OAuth2 / OIDC (not available yet)" + } + }, "listen_port": { "label": "Listen Port" }, @@ -786,6 +798,10 @@ "label": "Client AllowedIPs", "tip": "Use 0.0.0.0/0 for full tunnel; specify internal CIDRs for split tunnel" }, + "latency_probe": { + "label": "Tunnel probe target", + "tip": "Reachable IP:port through this line, e.g. 10.88.0.1:22; defaults to 223.5.5.5:443. Measures TCP setup time." + }, "keepalive": { "label": "Keepalive (sec)", "tip": "Recommended 15–25 for clients behind NAT" diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index a3f32a8..b4b01f8 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -129,6 +129,10 @@ "noTunnels": "还没有专线", "expireTime": "到期时间", "localNode": "本机节点", + "authMethod": "认证方式", + "authUnknown": "未指定", + "wgAuthPassword": "客户端账号 + 设备密钥", + "wgAuthHint": "客户端账号用于领取设备授权;WireGuard 用密钥握手。手动添加的静态 Peer 仍凭密钥接入。", "start": "启动", "stop": "停止", "started": "已启动", @@ -768,6 +772,14 @@ "label": "服务端 VPN 网段地址", "tip": "服务端在 VPN 网段中的地址(CIDR)" }, + "auth_mode": { + "label": "客户端认证方式", + "tip": "账号验证后领取设备授权,隧道使用密钥握手。静态 Peer 仍凭密钥接入;飞书/OIDC 需独立实现客户端回调和用户授权映射。", + "options": { + "password": "客户端账号 + 设备密钥", + "oidc": "第三方 OAuth2 / OIDC(尚未接入)" + } + }, "listen_port": { "label": "监听端口" }, @@ -786,6 +798,10 @@ "label": "客户端 AllowedIPs", "tip": "全流量代理用 0.0.0.0/0;仅内网填内网网段" }, + "latency_probe": { + "label": "隧道测速目标", + "tip": "可经该专线访问的 IP:端口,如 10.88.0.1:22;未设置时测试公网 223.5.5.5:443。记录 TCP 建链耗时。" + }, "keepalive": { "label": "Keepalive(秒)", "tip": "NAT 后客户端建议 15–25" diff --git a/frontend/src/protocols.js b/frontend/src/protocols.js index 098f2d7..5a044f4 100644 --- a/frontend/src/protocols.js +++ b/frontend/src/protocols.js @@ -9,6 +9,10 @@ export const PROTOCOL_META = { icon: 'wireguard', scenes: 'protocols.wireguard.scenes', fields: [ + { key: 'auth_mode', label: 'protocols.wireguard.fields.auth_mode.label', type: 'select', options: [ + { value: 'password', label: 'protocols.wireguard.fields.auth_mode.options.password' }, + { value: 'oidc', label: 'protocols.wireguard.fields.auth_mode.options.oidc', disabled: true } + ], default: 'password', tip: 'protocols.wireguard.fields.auth_mode.tip', group: 'basic' }, { key: 'address', label: 'protocols.wireguard.fields.address.label', placeholder: '10.8.0.1/24', required: true, tip: 'protocols.wireguard.fields.address.tip', group: 'basic' }, { key: 'listen_port', label: 'protocols.wireguard.fields.listen_port.label', type: 'number', default: 51820, group: 'basic' }, { key: 'client_address', label: 'protocols.wireguard.fields.client_address.label', placeholder: '10.8.0.2/32', tip: 'protocols.wireguard.fields.client_address.tip', group: 'basic' }, @@ -17,6 +21,7 @@ export const PROTOCOL_META = { { key: 'allowed_ips', label: 'protocols.wireguard.fields.allowed_ips.label', default: '0.0.0.0/0, ::/0', tip: 'protocols.wireguard.fields.allowed_ips.tip', group: 'routing' }, { key: 'keepalive', label: 'protocols.wireguard.fields.keepalive.label', type: 'number', default: 25, tip: 'protocols.wireguard.fields.keepalive.tip', group: 'advanced' }, { key: 'mtu', label: 'protocols.wireguard.fields.mtu.label', type: 'number', default: 0, tip: 'protocols.wireguard.fields.mtu.tip', group: 'advanced' }, + { key: 'latency_probe', label: 'protocols.wireguard.fields.latency_probe.label', placeholder: '10.88.0.1:22', tip: 'protocols.wireguard.fields.latency_probe.tip', group: 'advanced' }, { key: 'enable_nat', label: 'protocols.wireguard.fields.enable_nat.label', type: 'switch', default: false, tip: 'protocols.wireguard.fields.enable_nat.tip', group: 'routing' }, { key: 'egress_interface', label: 'protocols.wireguard.fields.egress_interface.label', placeholder: 'eth0', tip: 'protocols.wireguard.fields.egress_interface.tip', showIf: { enable_nat: true }, group: 'routing' } ] diff --git a/frontend/src/views/TunnelEditor.vue b/frontend/src/views/TunnelEditor.vue index a0a4a44..90f328a 100644 --- a/frontend/src/views/TunnelEditor.vue +++ b/frontend/src/views/TunnelEditor.vue @@ -22,7 +22,7 @@ - + @@ -49,7 +49,7 @@ - + diff --git a/frontend/src/views/TunnelWizard.vue b/frontend/src/views/TunnelWizard.vue index 43df466..c43fd2b 100644 --- a/frontend/src/views/TunnelWizard.vue +++ b/frontend/src/views/TunnelWizard.vue @@ -56,7 +56,7 @@ - + @@ -86,7 +86,7 @@ - + diff --git a/frontend/src/views/Tunnels.vue b/frontend/src/views/Tunnels.vue index bd8cf9b..6d9107e 100644 --- a/frontend/src/views/Tunnels.vue +++ b/frontend/src/views/Tunnels.vue @@ -46,6 +46,14 @@ + + +