Refine WireGuard client routing and authentication UI

This commit is contained in:
opsx committed 2026-10-21 21:10:53 +08:00
1 parent eabb3e4472
commit 41fb9d5631
33 files changed
+1709 -469

No files matched your search

+9 -3
View File
@@ -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`、无额外口令,其他本机进程也能使用;切换账号、撤销本机设备、退出登录或退出应用时会停止代理。客户端意外退出后代理最多约两秒内检测到父进程消失并退出。
+39 -7
View File
@@ -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<Line>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientCapabilities {
pub password: bool,
pub oidc: bool,
#[serde(default)]
pub oidc_provider: Option<String>,
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<LoginResult, ApiError> {
// 旧版服务端会把未知 /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<ClientCapabilities, ApiError> {
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"));
}
}
+196 -125
View File
@@ -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<Option<ConnState>> = Mutex::new(None);
static TRAY_MENU: Mutex<Option<tauri::menu::Menu<tauri::Wry>>> = Mutex::new(None);
static PROXY: Mutex<Option<std::process::Child>> = Mutex::new(None);
static PROXY_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
static PROXY_ROUTE: Mutex<Option<String>> = 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<std::path::PathBuf, String> {
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<bool, String> {
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<i64>,
}
#[tauri::command]
async fn client_capabilities(base_url: String) -> Result<api::ClientCapabilities, String> {
api::capabilities(&base_url).await.map_err(|err| err.message())
}
fn go_probe_binary(app: &AppHandle) -> Result<std::path::PathBuf, String> {
@@ -194,8 +245,8 @@ fn go_probe_binary(app: &AppHandle) -> Result<std::path::PathBuf, String> {
.ok_or_else(|| "安装包缺少 Go 隧道组件,请重新安装客户端".into())
}
/// 独立探测 WireGuard 数据面及公网 TCP,不更改系统路由/DNS。
/// 只接收已登录账号的设备记录,探测目标固定为公共地址。
/// 独立探测 WireGuard 数据面及已配置目标的 TCP,不更改系统路由/DNS。
/// 只接收已登录账号的设备记录;目标由服务端下发,否则使用默认公网地址。
#[tauri::command]
async fn client_probe(app: AppHandle, tunnel_id: u64) -> Result<GoProbeResult, String> {
let session = require_session(&app)?;
@@ -213,9 +264,14 @@ async fn client_probe(app: AppHandle, tunnel_id: u64) -> Result<GoProbeResult, S
}
let path = tunnel::save_record(&record_dir, &record)?;
let helper = go_probe_binary(&app)?;
let target = if record.config.latency_probe.is_empty() {
"223.5.5.5:443".to_string()
} else {
record.config.latency_probe.clone()
};
let output = tauri::async_runtime::spawn_blocking(move || {
std::process::Command::new(helper)
.arg("probe").arg(path).arg("223.5.5.5:443")
.arg("probe").arg(path).arg(target)
.output()
.map_err(|err| format!("无法启动 Go 隧道探针:{err}"))
}).await.map_err(|err| format!("Go 隧道探针执行失败:{err}"))??;
@@ -226,19 +282,28 @@ async fn client_probe(app: AppHandle, tunnel_id: u64) -> Result<GoProbeResult, S
/// Explicit opt-in local proxy. This has no system routing or DNS side effects.
#[tauri::command]
async fn client_proxy_start(app: AppHandle, tunnel_id: u64) -> Result<String, String> {
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<String, St
Ok((Ok(_), line)) if serde_json::from_str::<serde_json::Value>(&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<Option<SessionView>, 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<Option<SessionView>, 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<Vec<Line>, String> {
.map_err(|err| err.message())
}
/// 一键连接:已登记设备则刷新参数,否则生成密钥对并注册;随后提权起隧道。
/// 直连路径尚未接入特权网络服务;禁止回退到系统工具。
#[tauri::command]
async fn client_connect(app: AppHandle, tunnel_id: u64) -> Result<ConnState, String> {
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<ConnState, String> {
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<DeviceConfig, String> {
// 已登记同线路设备:刷新连接参数;否则生成新密钥对并注册
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<tunnel::TunnelRecord, String> {
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();
}
}
+212
View File
@@ -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<String>,
pub endpoint: String,
pub endpoint_bypass: bool,
pub physical_exit: Option<PhysicalExit>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct PhysicalExit {
pub interface: String,
pub gateway: Option<String>,
}
#[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<NativePlan>,
handshake_unix: Option<u64>,
rx_bytes: Option<u64>,
tx_bytes: Option<u64>,
}
pub(crate) struct NativeProcess {
child: Child,
input: ChildStdin,
events: Receiver<String>,
}
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<NativeCounters, String> {
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<NativePlan, String> {
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::<std::net::Ipv4Addr>().is_ok()
}
fn ipv4_cidr(value: &str) -> bool {
let Some((ip, prefix)) = value.split_once('/') else {
return false;
};
ipv4(ip) && prefix.parse::<u8>().is_ok_and(|bits| bits <= 32)
}
fn decode_counters(line: &str) -> Result<NativeCounters, String> {
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());
}
}
+10 -46
View File
@@ -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<EngineMode, &'static str> {
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<std::path::PathBuf> {
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);
}
}
}
+121
View File
@@ -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<String> }
fn networksetup(args: &[&str]) -> Result<String, String> {
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<Vec<String>, 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<bool, String> {
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<bool, String> {
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(&current) || (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<bool, String> { 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()) }
+2 -156
View File
@@ -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<PathBuf, String> {
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<String, String> {
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<PathBuf, String>;
fn down(&self, dir: &Path) -> Result<(), String>;
}
struct SystemToolsEngine;
impl TunnelEngine for SystemToolsEngine {
fn up(&self, dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result<PathBuf, String> {
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<PathBuf, String> {
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]);
+234 -91
View File
@@ -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 <span className={`node-signal ${quality}`} aria-label={ms === undefined ? '尚未测速' : `VPN 隧道 TCP 建链 ${ms} 毫秒`}>
<span className="signal-bars" aria-hidden="true"><i /><i /><i /></span>
<span>{ms === undefined ? '未测速' : `${ms} ms`}</span>
</span>
}
function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close' | 'user' | 'device'; size?: number }) {
const paths = {
home: <path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z" />,
@@ -48,12 +61,22 @@ export default function App() {
const [conn, setConn] = useState<ConnState | null>(null)
const [status, setStatus] = useState<StatusView | null>(null)
const [selectedLine, setSelectedLine] = useState<number | null>(null)
const [selectedMode, setSelectedMode] = useState<'split' | 'global_ipv4'>('split')
const [lines, setLines] = useState<Line[]>([])
const [actionError, setActionError] = useState('')
const [actionBusy, setActionBusy] = useState(false)
const actionLock = useRef(false)
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000))
const [nodePickerOpen, setNodePickerOpen] = useState(false)
const [latencies, setLatencies] = useState<Record<number, number>>({})
const [probeBusyLine, setProbeBusyLine] = useState<number | null>(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 <LoginView onLoggedIn={view => { setSession(view); setLines(view.lines) }} />
return <LoginView onLoggedIn={view => { 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 <div className="app-shell">
<aside className="sidebar" aria-label="主导航">
@@ -192,37 +273,42 @@ export default function App() {
<div className="workspace">
<header className="topbar">
<div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />{session.display_name || session.username}</div>
<span className={`preview-pill ${connected ? 'online' : ''}`}>● &nbsp;{connected ? '已连接' : connecting ? '连接中' : '未连接'}</span>
<span className={`preview-pill ${connected ? 'online' : ''}`}>● &nbsp;{connected ? '已连接' : connecting ? '连接中' : proxyRunning ? systemProxyOn ? '系统代理已启用' : '应用代理已启动' : '本客户端未连接'}</span>
</header>
{page === 'overview' && <main className="page">
<div className="section-intro">
<div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>一键接入企业网络;连接状态以网关真实握手为准。</p></div>
<span className="status-tag">●&nbsp;{connected ? `已连接 · ${currentLineName}` : connecting ? '连接中…' : '本客户端未连接'}</span>
<div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>选择线路,启动节点代理。</p></div>
<span className="status-tag">●&nbsp;{connected ? `已连接 · ${currentLineName}` : connecting ? '连接中…' : proxyRunning ? systemProxyOn ? '系统代理已启用' : '应用代理已启动' : '本客户端未连接'}</span>
</div>
<div className="overview-grid">
<section className="connection-card" aria-label="VPN 连接状态">
<div className="card-top"><div><span className="kicker">安全连接</span><h2>{currentLineName || '企业网络'}</h2></div><span className="protocol"><Icon name="shield" size={15} /> WIREGUARD</span></div>
<div className="connect-visual">
<div className="orbit outer" /><div className="orbit inner" />
<button type="button" className={`power-display ${connected ? 'on' : ''} ${connecting ? 'busy' : ''}`}
aria-label={connected || connecting ? '断开 VPN 连接' : '连接 VPN'}
disabled={actionBusy || (!connected && !connecting && readiness?.engine_mode === 'unsupported')}
onClick={() => connected || connecting ? void handleDisconnect() : void handleConnect()}>
<button type="button" className={`power-display ${connected || proxyRunning ? 'on' : ''} ${connecting ? 'busy' : ''}`}
aria-label={connected || connecting ? '断开连接' : proxyRunning ? '停止节点代理' : '启动节点代理'}
disabled={actionBusy || proxyBusy || (!connected && !connecting && !proxyRunning && !readiness?.app_proxy_available)}
onClick={() => connected || connecting ? void handleDisconnect() : void toggleProxy()}>
<Icon name="power" size={42} />
</button>
</div>
<div className="connection-message">
<strong>{connected ? '已连接' : connecting ? '连接中…' : '本客户端未连接'}</strong>
<strong>{connected ? '已连接' : connecting ? '连接中…' : proxyRunning ? systemProxyOn ? '系统代理已启用' : '应用代理已启动' : '本客户端未连接'}</strong>
<span>{connected
? `${conn?.client_address ?? ''} · 已连接 ${formatDuration(conn?.since_unix ?? 0, now)}`
: connecting ? (conn?.detail || '正在建立隧道') : readiness?.engine_mode === 'unsupported'
? '本机隧道环境尚未就绪'
? '点击按钮启动节点代理'
: (actionError || conn?.detail || '点击上方按钮接入企业网络')}</span>
</div>
{readiness?.engine_mode === 'unsupported' && <div className="connection-guide" role="status">
{readiness.engine_detail}
<button type="button" onClick={() => setPage('settings')}>查看本机环境与安装方法 →</button>
{readiness?.engine_mode === 'unsupported' && <div className="connection-guide app-proxy-guide">
{currentLine?.routing_mode === 'split' && <p>当前线路只允许授权网段,应用代理无法访问未授权的公网地址。</p>}
<button type="button" className="secondary-action" disabled={proxyBusy || actionBusy || (!proxyRunning && (!readiness.app_proxy_available || !lines.some(line => line.state === 'running')))} onClick={() => void toggleProxy()}>{proxyBusy ? '处理中…' : proxyRunning ? '停止应用代理' : '启动应用代理'}</button>
{!readiness.app_proxy_available && <small role="status">安装包缺少 Go 隧道组件,请重新安装桌面客户端。</small>}
{proxyRunning && <small>代理地址:socks5h://127.0.0.1:18743</small>}
{(proxyRunning || systemProxyOn) && <button type="button" className="secondary-action" disabled={proxyBusy || readiness.platform !== 'macos' || (!systemProxyOn && currentLine?.routing_mode !== 'global_ipv4')} onClick={() => void toggleSystemProxy()}>{systemProxyOn ? '关闭系统代理' : '启用系统代理'}</button>}
{systemProxyOn && <small>已启用 macOS 系统 SOCKS 设置;遵循系统代理的应用会通过节点访问网络。</small>}
{proxyError && <small role="alert">{proxyError}</small>}
</div>}
{connected && status?.server && <div className="traffic-grid">
<span>下行 {formatBytes(status.server.rx_bytes)}</span>
@@ -231,10 +317,11 @@ export default function App() {
</div>}
{actionError && !connecting && readiness?.engine_mode !== 'unsupported' && <div className="connection-guide" role="alert">{actionError}</div>}
{status?.server?.detail && <div className="connection-guide" role="status">{status.server.detail}</div>}
<button type="button" className="node-selector" onClick={() => setPage('routes')}>
<button type="button" className="node-selector" onClick={() => { if (currentLine) { setSelectedLine(currentLine.tunnel_id); setSelectedMode(currentLine.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split') } setNodePickerOpen(true) }}>
<span className="node-icon"><Icon name="globe" size={19} /></span>
<span className="node-selector-copy"><small>当前线路</small><strong>{currentLineName || (lines[0]?.name ?? '暂无授权线路')}</strong></span>
<small className="node-switch">切换 →</small>
<Signal ms={currentLine ? latencies[currentLine.tunnel_id] : undefined} />
<small className="node-switch">选择 →</small>
</button>
</section>
<div className="right-column">
@@ -268,15 +355,50 @@ export default function App() {
<p>{line.remark || line.endpoint || '—'}</p>
</div>
<span className={`service-state ${line.state === 'running' ? 'reachable' : 'error'}`}>{line.state === 'running' ? '运行中' : '未运行'}</span>
<button className="secondary-action" disabled={line.state !== 'running' || actionBusy}
onClick={() => { setSelectedLine(line.tunnel_id); void handleConnect(line.tunnel_id) }}>
{connected && conn?.tunnel_name === line.name ? '已连接' : '连接'}
<Signal ms={latencies[line.tunnel_id]} />
<button type="button" className="latency-action" disabled={probeBusyLine !== null || line.state !== 'running'} onClick={() => void measureLine(line)}>{probeBusyLine === line.tunnel_id ? '探测中…' : '测隧道'}</button>
<button className="secondary-action" disabled={line.state !== 'running' || !readiness?.app_proxy_available || proxyBusy || actionBusy}
onClick={() => { setSelectedLine(line.tunnel_id); void toggleProxy(line.tunnel_id) }}>
{proxyRunning && selectedLine === line.tunnel_id ? '切换代理线路' : '启动节点代理'}
</button>
</section>)}
{probeMessage && <p className="hint" role="status">{probeMessage}</p>}
{proxyRunning && <div className="connection-guide">节点代理已启动:socks5h://127.0.0.1:18743。{systemProxyOn ? 'macOS 系统代理已启用。' : '需要使用它的应用手动设置代理。'}<button type="button" disabled={proxyBusy} onClick={() => void toggleProxy()}>停止代理</button></div>}
{proxyError && <p className="hint" role="alert">{proxyError}</p>}
</main>}
{page === 'settings' && <SettingsPage session={session} lines={lines} onLogout={handleLogout} actionBusy={actionBusy} onReadinessChange={setReadiness} />}
{page === 'settings' && <SettingsPage session={session} lines={lines} onLogout={handleLogout} actionBusy={actionBusy} onReadinessChange={setReadiness} proxyRunning={proxyRunning} proxyBusy={proxyBusy} proxyError={proxyError} onToggleProxy={toggleProxy} onProxyStatusChange={setProxyRunning} systemProxyOn={systemProxyOn} onToggleSystemProxy={toggleSystemProxy} />}
</div>
{nodePickerOpen && <div className="node-dialog-backdrop" onClick={() => setNodePickerOpen(false)}>
<section className="node-dialog" role="dialog" aria-modal="true" aria-labelledby="node-dialog-title" onClick={event => event.stopPropagation()}>
<button type="button" className="dialog-close" aria-label="关闭" onClick={() => setNodePickerOpen(false)}>×</button>
<span className="overline">VPN / LINES</span><h2 id="node-dialog-title">选择服务器节点</h2>
<p className="node-dialog-intro">只显示当前账号获授权的真实线路。耗时为经该线路到管理员设置的目标(默认公网)的 TCP 建链时间,包含隧道启动与握手。</p>
<div className="node-dialog-grid">
<div className="mode-column"><span className="dialog-column-label">网络模式</span>
{(['split', 'global_ipv4'] as const).map(mode => {
const available = lines.find(line => line.state === 'running' && (line.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split') === mode)
return <button type="button" key={mode} className={`mode-option ${selectedMode === mode ? 'selected' : ''}`} disabled={!available} onClick={() => { setSelectedMode(mode); setSelectedLine(available!.tunnel_id) }}><span className={`mode-mark ${mode === 'global_ipv4' ? 'globe' : ''}`}>{mode === 'split' ? '↗' : '◎'}</span><span><strong>{mode === 'split' ? '极速模式' : '全局模式'}</strong><small>{available ? mode === 'split' ? '选择授权内网线路' : '选择提供公网出口的线路' : '暂无此模式的授权线路'}</small></span></button>
})}
<p className="mode-help">模式随授权线路切换;同一线路的路由由服务端下发。</p>
</div>
<div className="dialog-lines"><div className="dialog-list-head"><span>接入服务器</span><span>隧道探测</span></div>
{lines.length === 0 && <p className="hint">当前没有获授权线路。</p>}
{lines.map(line => <div key={line.tunnel_id} className={`dialog-line ${selectedLine === line.tunnel_id ? 'picked' : ''}`}>
<button type="button" className="dialog-line-select" disabled={line.state !== 'running'} onClick={() => { setSelectedLine(line.tunnel_id); setSelectedMode(line.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split') }}>
<span className="selection-radio" aria-hidden="true">{selectedLine === line.tunnel_id ? '✓' : ''}</span>
<span className="dialog-line-text"><strong>{line.name}</strong><small>{line.state === 'running' ? (line.routing_mode === 'global_ipv4' ? '全局 IPv4' : '按线路分流') : '暂未运行'}</small></span>
</button>
<Signal ms={latencies[line.tunnel_id]} />
<button type="button" className="latency-action" disabled={probeBusyLine !== null || line.state !== 'running'} onClick={() => void measureLine(line)}>{probeBusyLine === line.tunnel_id ? '检测中' : '测速'}</button>
</div>)}
{probeMessage && <p className="hint" role="status">{probeMessage}</p>}
</div>
</div>
<div className="dialog-actions"><button type="button" className="dialog-cancel" onClick={() => setNodePickerOpen(false)}>取消</button><button type="button" className="dialog-confirm" disabled={!lines.some(line => line.tunnel_id === selectedLine && line.state === 'running') || actionBusy || proxyBusy || !readiness?.app_proxy_available} onClick={() => { setNodePickerOpen(false); void toggleProxy(selectedLine ?? undefined) }}>启动节点代理</button></div>
{readiness?.engine_mode === 'unsupported' && <p className="mode-help">当前使用内置节点代理;全局线路可在概览启用系统代理。</p>}
</section>
</div>}
</div>
}
@@ -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<ClientCapabilities | null>(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 })
<div className="login-intro-content">
<span className="login-eyebrow">SECURE ACCESS / 企业网络</span>
<h2>连接工作网络,<br />从这里开始。</h2>
<p>选择您的企业服务端,用已授权的 VPN 账号安全接入。</p>
<p>选择您所属企业的服务端,用管理员开通的账号安全接入。</p>
<div className="login-feature"><Icon name="shield" size={19} /><span>独立设备身份 · WireGuard 加密隧道</span></div>
</div>
<small className="login-intro-foot">OPSX / PRIVATE NETWORK</small>
</div>
<main className="login-card">
<div className="login-card-head"><span className="login-step">账号登录</span><span className="login-card-icon"><Icon name="shield" size={21} /></span></div>
<h1>欢迎使用 OpsX VPN</h1>
<p className="login-description">请使用管理员开通的 VPN 拨号账号登录</p>
<form onSubmit={handleSubmit} className="login-form">
<label htmlFor="login-server">服务端地址</label>
<div className="input-row">
<input id="login-server" type="url" required placeholder="https://vpn.example.com" value={address}
onChange={e => { setAddress(e.target.value); setHealth('idle') }} />
<button type="button" className="save-button" onClick={() => void handleCheck()} disabled={health === 'checking'}>
{health === 'checking' ? '检测中…' : '检测'}
</button>
</div>
{health === 'reachable' && <p className="saved-address">✓ 服务可达</p>}
{health === 'error' && <p className="http-warning">服务不可达,请确认地址与网络。</p>}
{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 为明文传输,建议使用可信 HTTPS 域名。</p>}
<div className="login-card-head"><span className="login-step">{stage === 'unit' ? '01 / 选择接入点' : '02 / 身份认证'}</span><span className="login-card-icon"><Icon name="shield" size={21} /></span></div>
{stage === 'unit' ? <>
<h1>选择接入点</h1>
<p className="login-description">输入企业提供的服务器地址,继续使用 VPN 拨号用户登录。</p>
<form onSubmit={handleContinue} className="login-form">
<label htmlFor="login-server">企业服务端地址</label>
<div className="input-row"><input id="login-server" type="url" required placeholder="https://vpn.example.com" value={address} onChange={event => { setAddress(event.target.value); setHealth('idle') }} />
<button type="button" className="save-button" onClick={() => void handleCheck()} disabled={health === 'checking'}>{health === 'checking' ? '检测中…' : '检测'}</button></div>
{health === 'reachable' && <p className="saved-address">✓ 服务可达</p>}
{health === 'error' && <p className="http-warning">服务不可达,请确认地址与网络。</p>}
{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 为明文传输,建议使用可信 HTTPS 域名。</p>}
{error && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{error}</span></div>}
<button className="login-submit" type="submit" disabled={busy}>{busy ? '正在验证…' : '继续认证'}</button>
<p className="login-footnote">浏览器预览无法执行本机认证;请使用桌面客户端继续。</p>
</form>
</> : <>
<button type="button" className="login-back" onClick={changeUnit}>← 切换接入点</button>
<h1>登录 {unitLabel(address)}</h1>
<p className="login-description">使用该接入点提供的认证方式登录 VPN 拨号用户</p>
{capabilities?.oidc && <div className="lark-login-card"><span className="lark-mark" aria-hidden="true"><i /><i /><i /></span><div><strong>{['feishu', 'lark'].includes(capabilities.oidc_provider?.toLowerCase() || '') ? '飞书 / Lark · ' : '第三方认证 · '}OAuth2 / OIDC</strong><small>服务端支持 OIDC;客户端浏览器回调与拨号用户授权映射尚未接通</small></div><button type="button" disabled aria-label="第三方浏览器登录尚未开放">待接入</button></div>}
{capabilities?.password && <>
{capabilities.oidc && <div className="login-divider"><span>使用账号密码登录</span></div>}
<form onSubmit={handleSubmit} className="login-form">
{address.startsWith('http://') && <p className="http-warning">HTTP 为明文传输,建议使用可信 HTTPS 域名。</p>}
<label htmlFor="login-username">用户名</label>
<div className="input-row"><input id="login-username" required autoComplete="username" value={username} onChange={e => setUsername(e.target.value)} /></div>
<label htmlFor="login-password">密码</label>
@@ -361,6 +523,9 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
<button className="login-submit" type="submit" disabled={busy}>{busy ? '登录中…' : '登录'}</button>
<p className="login-footnote">首次连接时会自动登记本机设备</p>
</form>
</>}
{!capabilities?.password && <p className="login-description" role="status">{capabilities?.oidc ? '此接入点暂未开放客户端浏览器登录,请联系管理员启用密码登录。' : '此接入点暂未提供可用的客户端认证方式。'}</p>}
</>}
</main>
</div>
}
@@ -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<void>; onProxyStatusChange: (running: boolean) => void; systemProxyOn: boolean; onToggleSystemProxy: () => Promise<void> }) {
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
const [readinessError, setReadinessError] = useState('')
const [devices, setDevices] = useState<DeviceInfo[]>([])
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
<div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>本机环境</h2><p>只读检查,不代表已具备拨号能力</p></div></div>
{readiness ? <div className="system-grid">
<span>操作系统</span><strong>{readiness.platform}</strong>
<span>隧道引擎</span><strong>{readiness.engine_mode === 'system_tools' ? '系统工具模式' : '当前不可用'}</strong>
<span>wg 命令</span><strong>{readiness.wg_found ? '已找到' : '未找到'}</strong>
<span>wg-quick 命令</span><strong>{readiness.wg_quick_found ? '已找到' : '未找到'}</strong>
<span>内置直连</span><strong>尚未就绪</strong>
<span>Go 应用代理组件</span><strong>{readiness.app_proxy_available ? '已找到' : '缺失'}</strong>
</div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}
{readiness && <p className="hint" role="status">{readiness.engine_detail}</p>}
{readiness && (!readiness.wg_found || !readiness.wg_quick_found) && readiness.platform !== 'windows' && <div className="install-guide">
<strong>安装 WireGuard 工具</strong>
<p>{readiness.platform === 'macos' ? '在终端运行 brew install wireguard-tools。' : '请按发行版安装 wireguard-tools(Ubuntu / Debian:sudo apt install wireguard-tools)。'}</p>
<button type="button" className="secondary-action" onClick={() => { setReadiness(null); getSystemReadiness().then(result => { setReadiness(result); onReadinessChange(result) }).catch(() => setReadinessError('本机环境检测失败')) }}>重新检测</button>
</div>}
<p className="hint">建立隧道时系统会弹出提权授权;Windows 桌面隧道暂不支持,请使用 WireGuard 官方客户端。</p>
<p className="hint">内置直连尚需系统路由与 DNS 配置;当前可使用下方 Go 节点代理。</p>
<div className="install-guide probe-guide">
<strong>Go 隧道出口诊断</strong>
<p>通过当前设备的 WireGuard 参数连接公网 TCP,不修改本机路由或 DNS;这项检查不代表系统 VPN 已连接。测试线路需授权公网路由,网关需开启出口 NAT。</p>
<p>通过当前设备的 WireGuard 参数连接管理员设置的探测目标(默认公网 TCP),不修改本机路由或 DNS;这项检查不代表系统 VPN 已连接。目标必须在授权路由内。</p>
<button type="button" className="secondary-action" disabled={probeBusy} onClick={() => void runProbe()}>{probeBusy ? '检测中…' : '测试 VPN 出口'}</button>
{probeResult && <p role="status">{probeResult}</p>}
</div>
<div className="install-guide probe-guide">
<strong>本地 SOCKS5 代理(试用)</strong>
<p>通过内置 Go WireGuard 隧道为已配置代理的应用提供连接,不修改系统路由或 DNS。应用代理地址:socks5h://127.0.0.1:18743;使用 socks5h 才会经 VPN 解析域名。线路须允许目标公网路由并开启网关 NAT。</p>
<button type="button" className="secondary-action" disabled={proxyBusy} onClick={() => void toggleProxy()}>{proxyBusy ? '处理中…' : proxyRunning ? '停止本地代理' : '启动本地代理'}</button>
{proxyRunning && <p role="status">本地代理正在运行;只有主动配置此代理的应用会走 VPN。</p>}
<p>通过内置 Go WireGuard 提供 SOCKS5 连接,不修改系统路由或 DNS。手动配置地址:socks5h://127.0.0.1:18743;使用 socks5h 才会经 VPN 解析域名。目标地址必须在专线授权路由内;公网还需网关转发和 NAT。</p>
<button type="button" className="secondary-action" disabled={proxyBusy || (!proxyRunning && (!readiness?.app_proxy_available || !lines.some(line => line.state === 'running')))} onClick={() => void onToggleProxy()}>{proxyBusy ? '处理中…' : proxyRunning ? '停止本地代理' : '启动本地代理'}</button>
{proxyRunning && <p role="status">本地代理正在运行;可手动配置应用,也可在 macOS 上启用系统代理。</p>}
{(proxyRunning || systemProxyOn) && <button type="button" className="secondary-action" disabled={proxyBusy || (readiness?.platform !== 'macos' && !systemProxyOn)} onClick={() => void onToggleSystemProxy()}>{systemProxyOn ? '关闭系统代理' : '启用 macOS 系统代理'}</button>}
{proxyRunning && <p>系统代理只适用于遵循 macOS 设置的应用,需要线路允许全局 IPv4;部分软件和 UDP 流量不会使用它。</p>}
{systemProxyOn && <p role="status">系统 SOCKS 代理已启用。</p>}
{proxyError && <p role="alert">{proxyError}</p>}
</div>
</section>
+67
View File
@@ -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; } }
+21 -8
View File
@@ -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<Line[]> {
return isDesktop() ? invoke<Line[]>('client_lines') : desktopOnly()
}
export function clientConnect(tunnelId: number): Promise<ConnState> {
return isDesktop() ? invoke<ConnState>('client_connect', { tunnelId }) : desktopOnly()
}
export function clientDisconnect(): Promise<void> {
return isDesktop() ? invoke<void>('client_disconnect') : desktopOnly()
}
@@ -105,6 +102,10 @@ export function clientProbe(tunnelId: number): Promise<ProbeResult> {
return isDesktop() ? invoke<ProbeResult>('client_probe', { tunnelId }) : desktopOnly()
}
export function clientCapabilities(baseUrl: string): Promise<ClientCapabilities> {
return isDesktop() ? invoke<ClientCapabilities>('client_capabilities', { baseUrl }) : desktopOnly()
}
export function clientProxyStart(tunnelId: number): Promise<string> {
return isDesktop() ? invoke<string>('client_proxy_start', { tunnelId }) : desktopOnly()
}
@@ -117,6 +118,18 @@ export function clientProxyStatus(): Promise<boolean> {
return isDesktop() ? invoke<boolean>('client_proxy_status') : desktopOnly()
}
export function clientSystemProxyStatus(): Promise<boolean> {
return isDesktop() ? invoke<boolean>('client_system_proxy_status') : desktopOnly()
}
export function clientSystemProxyEnable(): Promise<void> {
return isDesktop() ? invoke<void>('client_system_proxy_enable') : desktopOnly()
}
export function clientSystemProxyDisable(): Promise<void> {
return isDesktop() ? invoke<void>('client_system_proxy_disable') : desktopOnly()
}
export function clientDevices(): Promise<DeviceInfo[]> {
return isDesktop() ? invoke<DeviceInfo[]>('client_devices') : desktopOnly()
}
+7 -2
View File
@@ -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; }
+204 -4
View File
@@ -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 <probe|proxy> <local-record> <IP:port|loopback:port>")
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 <probe|proxy> <local-record> <IP:port|loopback:port> | tun <local-record>")
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 {
+88
View File
@@ -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 {
+142
View File
@@ -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
}
+17
View File
@@ -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
}
+32
View File
@@ -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
}
+21
View File
@@ -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))
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !darwin
package main
import (
"errors"
"net/netip"
)
func physicalExitFor(_ netip.Addr) (physicalRoute, error) {
return physicalRoute{}, errors.New("当前平台尚不支持系统隧道网络配置")
}