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

+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]);