feat(client): add authorized WireGuard device enrollment and desktop connection
This commit is contained in:
1 parent
d2f1f5040e
commit
6dd3c7dd45
25 files changed
+3274
-131
No files matched your search
Generated
+88
-4
@@ -330,7 +330,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -486,6 +486,32 @@ version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
@@ -772,6 +798,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1062,7 +1094,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1244,6 +1276,17 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.39.0"
|
||||
@@ -2152,10 +2195,16 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
name = "opsx-vpn-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"hostname",
|
||||
"rand_core 0.6.4",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2514,7 +2563,16 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2529,7 +2587,7 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4573,6 +4631,18 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x25519-dalek"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -4622,6 +4692,20 @@ name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
@@ -15,4 +15,10 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
base64 = "0.22"
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
hostname = "0.4"
|
||||
sha2 = "0.10"
|
||||
@@ -0,0 +1,295 @@
|
||||
// 控制面 API:所有请求在 Rust 侧完成,令牌不进入 WebView。
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::{redirect::Policy, StatusCode, Url};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Line {
|
||||
pub tunnel_id: u64,
|
||||
pub name: String,
|
||||
pub remark: String,
|
||||
pub state: String,
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginResult {
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub lines: Vec<Line>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceConfig {
|
||||
pub device_id: u64,
|
||||
pub device_name: String,
|
||||
pub tunnel_id: u64,
|
||||
pub tunnel_name: String,
|
||||
pub peer_id: String,
|
||||
pub client_address: String,
|
||||
pub server_public_key: String,
|
||||
pub preshared_key: String,
|
||||
pub endpoint: String,
|
||||
pub allowed_ips: String,
|
||||
pub dns: String,
|
||||
pub mtu: u32,
|
||||
pub keepalive: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceInfo {
|
||||
pub id: u64,
|
||||
pub tunnel_id: u64,
|
||||
pub name: String,
|
||||
pub platform: String,
|
||||
pub client_address: String,
|
||||
pub revoked: bool,
|
||||
pub last_seen_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceStatus {
|
||||
pub running: bool,
|
||||
pub connected: bool,
|
||||
pub latest_handshake_at: Option<i64>,
|
||||
pub rx_bytes: Option<i64>,
|
||||
pub tx_bytes: Option<i64>,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
/// 保留 HTTP 状态,只有明确 401 才应清除本地登录。
|
||||
Message(StatusCode, String, bool),
|
||||
Transport(String),
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
ApiError::Message(_, m, _) => m.clone(),
|
||||
ApiError::Transport(m) => m.clone(),
|
||||
}
|
||||
}
|
||||
pub fn mfa_required(&self) -> bool {
|
||||
matches!(self, ApiError::Message(_, _, true))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorBody {
|
||||
error: Option<String>,
|
||||
mfa_required: Option<bool>,
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::Client, ApiError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.map_err(|err| ApiError::Transport(format!("无法创建网络客户端:{err}")))
|
||||
}
|
||||
|
||||
/// 复用健康检查同样的根地址校验,再拼上 API 路径。
|
||||
fn api_url(base_url: &str, path: &str) -> Result<Url, ApiError> {
|
||||
let mut url = Url::parse(base_url.trim())
|
||||
.map_err(|_| ApiError::Transport("服务端地址无效".to_string()))?;
|
||||
if url.scheme() != "https" && url.scheme() != "http" {
|
||||
return Err(ApiError::Transport(
|
||||
"服务端地址须以 http:// 或 https:// 开头".to_string(),
|
||||
));
|
||||
}
|
||||
url.set_path(path);
|
||||
url.set_query(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn parse_response<T: DeserializeOwned>(resp: reqwest::Response) -> Result<T, ApiError> {
|
||||
let status = resp.status();
|
||||
if resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.is_some_and(|v| v.to_str().unwrap_or("").starts_with("text/html"))
|
||||
{
|
||||
return Err(ApiError::Transport("服务端尚未提供客户端登录接口,请先更新 opsx-vpn 服务端;/users 是管理账号,客户端需使用 VPN 拨号用户。".into()));
|
||||
}
|
||||
if status.is_success() {
|
||||
return resp
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|_| ApiError::Transport("服务端响应格式无法解析".to_string()));
|
||||
}
|
||||
let body = resp.json::<ErrorBody>().await.unwrap_or(ErrorBody {
|
||||
error: None,
|
||||
mfa_required: None,
|
||||
});
|
||||
let message = body
|
||||
.error
|
||||
.unwrap_or_else(|| format!("服务端返回 HTTP {status}"));
|
||||
Err(ApiError::Message(
|
||||
status,
|
||||
message,
|
||||
body.mfa_required.unwrap_or(false),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
otp: &str,
|
||||
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) {
|
||||
return Err(ApiError::Transport("服务端尚未启用客户端密码登录".into()));
|
||||
}
|
||||
let resp = client()?
|
||||
.post(api_url(base_url, "/api/client/login")?)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
"otp": otp,
|
||||
"device_name": device_name,
|
||||
"platform": std::env::consts::OS,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
pub async fn logout(base_url: &str, token: &str) -> Result<(), ApiError> {
|
||||
let resp = client()?
|
||||
.post(api_url(base_url, "/api/client/logout")?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
if resp.status() == StatusCode::UNAUTHORIZED {
|
||||
return Ok(()); // 令牌已失效,本地照常清理
|
||||
}
|
||||
let _: serde_json::Value = parse_response(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn lines(base_url: &str, token: &str) -> Result<Vec<Line>, ApiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct Resp {
|
||||
lines: Vec<Line>,
|
||||
}
|
||||
let resp = client()?
|
||||
.get(api_url(base_url, "/api/client/lines")?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
Ok(parse_response::<Resp>(resp).await?.lines)
|
||||
}
|
||||
|
||||
pub async fn register_device(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
tunnel_id: u64,
|
||||
name: &str,
|
||||
public_key: &str,
|
||||
) -> Result<DeviceConfig, ApiError> {
|
||||
let resp = client()?
|
||||
.post(api_url(base_url, "/api/client/devices")?)
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({
|
||||
"tunnel_id": tunnel_id,
|
||||
"name": name,
|
||||
"platform": std::env::consts::OS,
|
||||
"public_key": public_key,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
pub async fn device_config(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
device_id: u64,
|
||||
) -> Result<DeviceConfig, ApiError> {
|
||||
let resp = client()?
|
||||
.get(api_url(
|
||||
base_url,
|
||||
&format!("/api/client/devices/{device_id}/config"),
|
||||
)?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
pub async fn device_status(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
device_id: u64,
|
||||
) -> Result<DeviceStatus, ApiError> {
|
||||
let resp = client()?
|
||||
.get(api_url(
|
||||
base_url,
|
||||
&format!("/api/client/devices/{device_id}/status"),
|
||||
)?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
pub async fn list_devices(base_url: &str, token: &str) -> Result<Vec<DeviceInfo>, ApiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct Resp {
|
||||
devices: Vec<DeviceInfo>,
|
||||
}
|
||||
let resp = client()?
|
||||
.get(api_url(base_url, "/api/client/devices")?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
Ok(parse_response::<Resp>(resp).await?.devices)
|
||||
}
|
||||
|
||||
pub async fn revoke_device(base_url: &str, token: &str, device_id: u64) -> Result<(), ApiError> {
|
||||
let resp = client()?
|
||||
.delete(api_url(
|
||||
base_url,
|
||||
&format!("/api/client/devices/{device_id}"),
|
||||
)?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?;
|
||||
let _: serde_json::Value = parse_response(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api_url_appends_path_to_validated_root() {
|
||||
let url = api_url("https://vpn.example.com:8443/", "/api/client/login").unwrap();
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://vpn.example.com:8443/api/client/login"
|
||||
);
|
||||
assert!(api_url("ftp://vpn.example.com", "/api/client/lines").is_err());
|
||||
}
|
||||
}
|
||||
+480
-4
@@ -1,12 +1,470 @@
|
||||
mod api;
|
||||
mod localstore;
|
||||
mod server;
|
||||
mod system;
|
||||
mod tunnel;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
Manager,
|
||||
AppHandle, Manager,
|
||||
};
|
||||
|
||||
use api::{DeviceConfig, DeviceInfo, DeviceStatus, Line};
|
||||
|
||||
// ---- 全局连接状态(UI 与托盘共用同一事实来源)----
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ConnPhase {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ConnState {
|
||||
phase: ConnPhase,
|
||||
detail: String,
|
||||
tunnel_name: String,
|
||||
client_address: String,
|
||||
endpoint: String,
|
||||
device_id: u64,
|
||||
since_unix: i64,
|
||||
}
|
||||
|
||||
impl Default for ConnState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: ConnPhase::Disconnected,
|
||||
detail: String::new(),
|
||||
tunnel_name: String::new(),
|
||||
client_address: String::new(),
|
||||
endpoint: String::new(),
|
||||
device_id: 0,
|
||||
since_unix: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static CONN: Mutex<Option<ConnState>> = Mutex::new(None);
|
||||
static TRAY_MENU: Mutex<Option<tauri::menu::Menu<tauri::Wry>>> = Mutex::new(None);
|
||||
|
||||
fn conn_state() -> ConnState {
|
||||
CONN.lock()
|
||||
.unwrap()
|
||||
.get_or_insert_with(ConnState::default)
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn set_conn(app: Option<&AppHandle>, state: ConnState) {
|
||||
*CONN.lock().unwrap() = Some(state.clone());
|
||||
if let Some(app) = app {
|
||||
update_tray(app, &state);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_tray(app: &AppHandle, state: &ConnState) {
|
||||
let (label, tip) = match state.phase {
|
||||
ConnPhase::Connected => (
|
||||
format!("VPN:已连接 · {}", state.tunnel_name),
|
||||
format!("OpsX VPN · 已连接 {}", state.tunnel_name),
|
||||
),
|
||||
ConnPhase::Connecting => ("VPN:连接中…".to_string(), "OpsX VPN · 连接中".to_string()),
|
||||
ConnPhase::Disconnected => (
|
||||
"VPN:尚未连接".to_string(),
|
||||
"OpsX VPN · 尚未连接".to_string(),
|
||||
),
|
||||
};
|
||||
if let Some(tray) = app.tray_by_id("tray") {
|
||||
let _ = tray.set_tooltip(Some(&tip));
|
||||
}
|
||||
if let Some(menu) = TRAY_MENU.lock().unwrap().as_ref() {
|
||||
if let Some(tauri::menu::MenuItemKind::MenuItem(item)) = menu.get("status") {
|
||||
let _ = item.set_text(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 辅助 ----
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|err| format!("无法定位应用数据目录:{err}"))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn require_session(app: &AppHandle) -> Result<localstore::Session, String> {
|
||||
localstore::load(&data_dir(app)?).ok_or_else(|| "尚未登录".to_string())
|
||||
}
|
||||
|
||||
fn default_device_name() -> String {
|
||||
hostname::get()
|
||||
.ok()
|
||||
.and_then(|h| h.into_string().ok())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| format!("{} 设备", std::env::consts::OS))
|
||||
}
|
||||
|
||||
// ---- 视图类型(返回给 UI)----
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionView {
|
||||
username: String,
|
||||
display_name: String,
|
||||
device_name: String,
|
||||
lines: Vec<Line>,
|
||||
offline: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusView {
|
||||
conn: ConnState,
|
||||
server: Option<DeviceStatus>,
|
||||
}
|
||||
|
||||
// ---- Tauri 命令 ----
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_login(
|
||||
app: AppHandle,
|
||||
base_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
otp: String,
|
||||
device_name: String,
|
||||
) -> Result<SessionView, String> {
|
||||
let device_name = if device_name.trim().is_empty() {
|
||||
default_device_name()
|
||||
} else {
|
||||
device_name.trim().to_string()
|
||||
};
|
||||
let result = api::login(&base_url, &username, &password, &otp, &device_name)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if err.mfa_required() {
|
||||
"该账号需要动态口令,请输入 PIN+动态码(若未设置 PIN,只输入动态码)".to_string()
|
||||
} else {
|
||||
err.message()
|
||||
}
|
||||
})?;
|
||||
let session = localstore::Session {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token: result.token,
|
||||
username: result.username.clone(),
|
||||
display_name: result.display_name.clone(),
|
||||
device_name: device_name.clone(),
|
||||
lines: result.lines.clone(),
|
||||
};
|
||||
localstore::save(&data_dir(&app)?, &session)?;
|
||||
Ok(SessionView {
|
||||
username: result.username,
|
||||
display_name: result.display_name,
|
||||
device_name,
|
||||
lines: result.lines,
|
||||
offline: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 启动时恢复会话:401 清除本地会话返回 None;网络不可达时离线返回(线路为空)。
|
||||
#[tauri::command]
|
||||
async fn client_resume(app: AppHandle) -> Result<Option<SessionView>, String> {
|
||||
let dir = data_dir(&app)?;
|
||||
let Some(session) = localstore::load(&dir) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match api::lines(&session.base_url, &session.token).await {
|
||||
Ok(lines) => {
|
||||
let view = SessionView {
|
||||
username: session.username.clone(),
|
||||
display_name: session.display_name.clone(),
|
||||
device_name: session.device_name.clone(),
|
||||
lines: lines.clone(),
|
||||
offline: false,
|
||||
};
|
||||
let mut updated = session;
|
||||
updated.lines = lines;
|
||||
let _ = localstore::save(&dir, &updated);
|
||||
Ok(Some(view))
|
||||
}
|
||||
Err(api::ApiError::Message(reqwest::StatusCode::UNAUTHORIZED, _, _)) => {
|
||||
// 令牌失效/被撤销:清除本地会话
|
||||
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,
|
||||
}));
|
||||
}
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
localstore::clear(&dir);
|
||||
Ok(None)
|
||||
}
|
||||
Err(_) => Ok(Some(SessionView {
|
||||
username: session.username,
|
||||
display_name: session.display_name,
|
||||
device_name: session.device_name,
|
||||
lines: vec![],
|
||||
offline: true,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_logout(app: AppHandle) -> Result<(), String> {
|
||||
let dir = data_dir(&app)?;
|
||||
if conn_state().phase != ConnPhase::Disconnected {
|
||||
// 保留记录与会话,确保拆隧道失败后可再次操作。
|
||||
tunnel::down(&dir)?;
|
||||
set_conn(Some(&app), ConnState::default());
|
||||
}
|
||||
if let Some(session) = localstore::load(&dir) {
|
||||
let _ = api::logout(&session.base_url, &session.token).await;
|
||||
}
|
||||
localstore::clear(&dir);
|
||||
tunnel::clear_record(&dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_lines(app: AppHandle) -> Result<Vec<Line>, String> {
|
||||
let session = require_session(&app)?;
|
||||
api::lines(&session.base_url, &session.token)
|
||||
.await
|
||||
.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 do_connect(
|
||||
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 {
|
||||
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
|
||||
}
|
||||
_ => {
|
||||
let (private_key, public_key) = tunnel::generate_keypair()?;
|
||||
let cfg = api::register_device(
|
||||
&session.base_url,
|
||||
&session.token,
|
||||
tunnel_id,
|
||||
device_name,
|
||||
&public_key,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.message())?;
|
||||
tunnel::save_record(
|
||||
&record_dir,
|
||||
&tunnel::TunnelRecord {
|
||||
private_key,
|
||||
config: cfg.clone(),
|
||||
},
|
||||
)?;
|
||||
cfg
|
||||
}
|
||||
};
|
||||
tunnel::up(
|
||||
dir,
|
||||
&tunnel::load_record_for(&record_dir, tunnel_id)?.private_key,
|
||||
&config,
|
||||
)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
/// 连接状态:以服务端观测到的真实握手为准,不凭本机进程猜。
|
||||
#[tauri::command]
|
||||
async fn client_status(app: AppHandle) -> Result<StatusView, String> {
|
||||
let mut conn = conn_state();
|
||||
if conn.phase == ConnPhase::Disconnected {
|
||||
return Ok(StatusView { conn, server: None });
|
||||
}
|
||||
let session = require_session(&app)?;
|
||||
match api::device_status(&session.base_url, &session.token, conn.device_id).await {
|
||||
Ok(status) => {
|
||||
let fresh_handshake = status.connected
|
||||
&& status
|
||||
.latest_handshake_at
|
||||
.is_some_and(|at| at >= conn.since_unix);
|
||||
if fresh_handshake && conn.phase != ConnPhase::Connected {
|
||||
conn.phase = ConnPhase::Connected;
|
||||
conn.detail = "握手已确认".to_string();
|
||||
set_conn(Some(&app), conn.clone());
|
||||
} else if !status.running {
|
||||
conn.phase = ConnPhase::Connecting; // 保留断开按钮,便于恢复本机路由
|
||||
conn.detail = "网关线路未运行".to_string();
|
||||
} else if conn.phase == ConnPhase::Connected && !fresh_handshake {
|
||||
conn.phase = ConnPhase::Connecting;
|
||||
conn.detail = "握手超时,连接可能已断开".to_string();
|
||||
} else if !fresh_handshake && now_unix() - conn.since_unix > 20 {
|
||||
conn.detail = "尚未观测到握手,请检查公网端点与防火墙".to_string();
|
||||
}
|
||||
set_conn(Some(&app), conn.clone());
|
||||
Ok(StatusView {
|
||||
conn,
|
||||
server: Some(status),
|
||||
})
|
||||
}
|
||||
Err(err) => Ok(StatusView {
|
||||
conn,
|
||||
server: Some(DeviceStatus {
|
||||
running: true,
|
||||
connected: false,
|
||||
latest_handshake_at: None,
|
||||
rx_bytes: None,
|
||||
tx_bytes: None,
|
||||
detail: Some(format!("状态查询失败:{}", err.message())),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_devices(app: AppHandle) -> Result<Vec<DeviceInfo>, String> {
|
||||
let session = require_session(&app)?;
|
||||
api::list_devices(&session.base_url, &session.token)
|
||||
.await
|
||||
.map_err(|err| err.message())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn client_revoke_device(app: AppHandle, device_id: u64) -> Result<(), String> {
|
||||
let session = require_session(&app)?;
|
||||
let dir = data_dir(&app)?;
|
||||
let devices = api::list_devices(&session.base_url, &session.token)
|
||||
.await
|
||||
.map_err(|err| err.message())?;
|
||||
let device = devices
|
||||
.iter()
|
||||
.find(|d| d.id == device_id)
|
||||
.ok_or("设备不存在")?;
|
||||
let record_dir = tunnel::device_dir(&dir, &session.base_url, &session.username);
|
||||
let local = tunnel::load_record_for(&record_dir, device.tunnel_id).ok();
|
||||
if local
|
||||
.as_ref()
|
||||
.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)
|
||||
.await
|
||||
.map_err(|err| err.message())?;
|
||||
if local
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.config.device_id == device_id)
|
||||
{
|
||||
tunnel::clear_record_for(&record_dir, device.tunnel_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn client_device_name() -> String {
|
||||
default_device_name()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn client_conn_state() -> ConnState {
|
||||
conn_state()
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -18,7 +476,8 @@ pub fn run() {
|
||||
app,
|
||||
&[&status, &open, &PredefinedMenuItem::separator(app)?, &quit],
|
||||
)?;
|
||||
TrayIconBuilder::new()
|
||||
*TRAY_MENU.lock().unwrap() = Some(menu.clone());
|
||||
TrayIconBuilder::with_id("tray")
|
||||
.icon(app.default_window_icon().expect("desktop app icon").clone())
|
||||
.tooltip("OpsX VPN · 尚未连接")
|
||||
.menu(&menu)
|
||||
@@ -30,7 +489,13 @@ pub fn run() {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => app.exit(0),
|
||||
"quit" => {
|
||||
// 退出前尽力拆除本机隧道,避免残留接口与路由
|
||||
if let Ok(dir) = app.path().app_config_dir() {
|
||||
let _ = tunnel::down(&dir);
|
||||
}
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build(app)?;
|
||||
@@ -38,7 +503,18 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
server::check_server,
|
||||
system::get_system_readiness
|
||||
system::get_system_readiness,
|
||||
client_login,
|
||||
client_resume,
|
||||
client_logout,
|
||||
client_lines,
|
||||
client_connect,
|
||||
client_disconnect,
|
||||
client_status,
|
||||
client_devices,
|
||||
client_revoke_device,
|
||||
client_device_name,
|
||||
client_conn_state,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to start OpsX VPN client");
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// 本机会话持久化:令牌只存本机数据目录(0600),不进入 WebView 或日志。
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::Line;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub base_url: String,
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub device_name: String,
|
||||
pub lines: Vec<Line>,
|
||||
}
|
||||
|
||||
fn session_path(dir: &Path) -> PathBuf {
|
||||
dir.join("session.json")
|
||||
}
|
||||
|
||||
pub fn save(dir: &Path, session: &Session) -> Result<(), String> {
|
||||
std::fs::create_dir_all(dir).map_err(|err| format!("无法创建数据目录:{err}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.map_err(|err| format!("无法限制数据目录权限:{err}"))?;
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(session).map_err(|err| err.to_string())?;
|
||||
write_private(&session_path(dir), raw.as_bytes())
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Option<Session> {
|
||||
let raw = std::fs::read_to_string(session_path(dir)).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
pub fn clear(dir: &Path) {
|
||||
let _ = std::fs::remove_file(session_path(dir));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|err| format!("无法写入会话文件:{err}"))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|err| format!("无法限制会话文件权限:{err}"))?;
|
||||
file.write_all(bytes)
|
||||
.map_err(|err| format!("写入会话文件失败:{err}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
std::fs::write(path, bytes).map_err(|err| format!("无法写入会话文件:{err}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_roundtrip_and_clear() {
|
||||
let dir = std::env::temp_dir().join(format!("opsx-session-test-{}", std::process::id()));
|
||||
let session = Session {
|
||||
base_url: "https://vpn.example.com".to_string(),
|
||||
token: "token-abc".to_string(),
|
||||
username: "alice".to_string(),
|
||||
display_name: "爱丽丝".to_string(),
|
||||
device_name: "alice-mac".to_string(),
|
||||
lines: vec![],
|
||||
};
|
||||
save(&dir, &session).unwrap();
|
||||
let loaded = load(&dir).unwrap();
|
||||
assert_eq!(loaded.token, "token-abc");
|
||||
assert_eq!(loaded.username, "alice");
|
||||
clear(&dir);
|
||||
assert!(load(&dir).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// 本机 WireGuard 隧道:密钥生成、配置渲染与提权启停。
|
||||
//
|
||||
// 私钥只在本机生成、只保存在本机数据目录(0600),绝不上传。
|
||||
// 启停需要系统权限:Linux 走 pkexec,macOS 走 osascript 管理员授权;
|
||||
// Windows 暂无受控提权通道,明确报错而不是静默失败。
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::DeviceConfig;
|
||||
|
||||
/// 生成 WireGuard 密钥对(X25519),返回 (私钥, 公钥),均为 base64。
|
||||
pub fn generate_keypair() -> Result<(String, String), String> {
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
let secret = StaticSecret::random_from_rng(rand_core::OsRng);
|
||||
let public = PublicKey::from(&secret);
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
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,
|
||||
pub config: DeviceConfig,
|
||||
}
|
||||
|
||||
/// 把含私钥的隧道记录写入本机数据目录(0600)。
|
||||
pub fn save_record(dir: &Path, record: &TunnelRecord) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(dir).map_err(|err| format!("无法创建数据目录:{err}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.map_err(|err| format!("无法限制数据目录权限:{err}"))?;
|
||||
}
|
||||
let path = record_path(dir, record.config.tunnel_id);
|
||||
let raw = serde_json::to_string_pretty(record).map_err(|err| err.to_string())?;
|
||||
write_private(&path, raw.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn record_path(dir: &Path, tunnel_id: u64) -> PathBuf {
|
||||
dir.join(format!("tunnel-{tunnel_id}.json"))
|
||||
}
|
||||
|
||||
/// 不同服务器、用户的设备密钥各自保存,切换线路不会遗失原设备登记。
|
||||
pub fn device_dir(dir: &Path, base_url: &str, username: &str) -> PathBuf {
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(format!("{base_url}\0{username}").as_bytes());
|
||||
let key: String = digest.iter().map(|b| format!("{b:02x}")).collect();
|
||||
dir.join("devices").join(key)
|
||||
}
|
||||
|
||||
pub fn load_record_for(dir: &Path, tunnel_id: u64) -> Result<TunnelRecord, String> {
|
||||
let raw = std::fs::read_to_string(record_path(dir, tunnel_id))
|
||||
.map_err(|_| "本机没有已登记的隧道记录".to_string())?;
|
||||
serde_json::from_str(&raw).map_err(|err| format!("本机隧道记录损坏:{err}"))
|
||||
}
|
||||
|
||||
pub fn clear_record_for(dir: &Path, tunnel_id: u64) {
|
||||
let _ = std::fs::remove_file(record_path(dir, tunnel_id));
|
||||
}
|
||||
|
||||
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)]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|err| format!("无法写入 {}:{err}", path.display()))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|err| format!("无法限制文件权限:{err}"))?;
|
||||
use std::io::Write;
|
||||
file.write_all(bytes)
|
||||
.map_err(|err| format!("写入失败:{err}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
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
|
||||
.decode(value)
|
||||
.is_ok_and(|v| v.len() == 32)
|
||||
}
|
||||
|
||||
fn valid_cidrs(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.split(',').all(|part| {
|
||||
let Some((ip, mask)) = part.trim().split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(address) = ip.parse::<std::net::IpAddr>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = mask.parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
prefix <= if address.is_ipv4() { 32 } else { 128 }
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_config(private_key: &str, cfg: &DeviceConfig) -> Result<(), String> {
|
||||
if !valid_key(private_key)
|
||||
|| !valid_key(&cfg.server_public_key)
|
||||
|| !valid_key(&cfg.preshared_key)
|
||||
{
|
||||
return Err("WireGuard 密钥格式无效".into());
|
||||
}
|
||||
if !valid_cidrs(&cfg.client_address) || !valid_cidrs(&cfg.allowed_ips) {
|
||||
return Err("服务端下发的地址或路由无效".into());
|
||||
}
|
||||
if !cfg.dns.is_empty()
|
||||
&& !cfg
|
||||
.dns
|
||||
.split(',')
|
||||
.all(|ip| ip.trim().parse::<std::net::IpAddr>().is_ok())
|
||||
{
|
||||
return Err("服务端下发的 DNS 地址无效".into());
|
||||
}
|
||||
let Some((host, port)) = cfg.endpoint.rsplit_once(':') else {
|
||||
return Err("缺少 VPN 公网端点".into());
|
||||
};
|
||||
if host.is_empty()
|
||||
|| host
|
||||
.chars()
|
||||
.any(|c| !(c.is_ascii_alphanumeric() || ".-[]:".contains(c)))
|
||||
|| port.parse::<u16>().unwrap_or(0) == 0
|
||||
{
|
||||
return Err("VPN 公网端点格式无效".into());
|
||||
}
|
||||
if (cfg.mtu != 0 && !(576..=9000).contains(&cfg.mtu)) || cfg.keepalive > 65535 {
|
||||
return Err("服务端下发的 MTU 或保活参数无效".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_binary(name: &str) -> Result<String, String> {
|
||||
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
|
||||
.map(|dir| dir.join(name))
|
||||
.find(|p| p.is_file())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.ok_or_else(|| format!("未在本机 PATH 找到 {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!(
|
||||
"{} {} {}",
|
||||
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}"))?;
|
||||
return 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('\'', "'\\''"))
|
||||
}
|
||||
|
||||
/// 建立隧道:渲染配置 → 0600 落盘 → 提权 wg-quick up。
|
||||
pub fn up(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)
|
||||
}
|
||||
|
||||
/// 拆除隧道。配置文件可能已被清理,尽力而为。
|
||||
pub fn down(dir: &Path) -> Result<(), String> {
|
||||
let path = dir.join("opsx-wg.conf");
|
||||
if !path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
run_wg_quick("down", &path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_config() -> DeviceConfig {
|
||||
DeviceConfig {
|
||||
device_id: 1,
|
||||
device_name: "测试设备".to_string(),
|
||||
tunnel_id: 4,
|
||||
tunnel_name: "测试专线".to_string(),
|
||||
peer_id: "dev-1".to_string(),
|
||||
client_address: "10.8.0.7/32".to_string(),
|
||||
server_public_key: "serverpub".to_string(),
|
||||
preshared_key: "psk".to_string(),
|
||||
endpoint: "vpn.example.com:51820".to_string(),
|
||||
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
|
||||
dns: "223.5.5.5".to_string(),
|
||||
mtu: 1420,
|
||||
keepalive: 25,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_keypair_is_base64_x25519() {
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let (private_key, public_key) = generate_keypair().unwrap();
|
||||
assert_eq!(b64.decode(&private_key).unwrap().len(), 32);
|
||||
assert_eq!(b64.decode(&public_key).unwrap().len(), 32);
|
||||
// 同一私钥每次派生相同公钥,且两把私钥的公钥不同
|
||||
let (private2, public2) = generate_keypair().unwrap();
|
||||
assert_ne!(public_key, public2);
|
||||
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]);
|
||||
let mut cfg = sample_config();
|
||||
cfg.server_public_key = key.clone();
|
||||
cfg.preshared_key = key.clone();
|
||||
assert!(validate_config(&key, &cfg).is_ok());
|
||||
cfg.dns = "1.1.1.1\nPostUp = touch /tmp/unsafe".into();
|
||||
assert!(validate_config(&key, &cfg).is_err());
|
||||
cfg.dns = "1.1.1.1".into();
|
||||
cfg.allowed_ips = "0.0.0.0/0\nPostUp = touch /tmp/unsafe".into();
|
||||
assert!(validate_config(&key, &cfg).is_err());
|
||||
cfg.allowed_ips = "0.0.0.0/0".into();
|
||||
cfg.endpoint = "vpn.example.com:51820\n[Interface]".into();
|
||||
assert!(validate_config(&key, &cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_roundtrip_and_clear() {
|
||||
let dir = std::env::temp_dir().join(format!("opsx-test-{}", std::process::id()));
|
||||
let record = TunnelRecord {
|
||||
private_key: "private".to_string(),
|
||||
config: sample_config(),
|
||||
};
|
||||
save_record(&dir, &record).unwrap();
|
||||
let loaded = load_record_for(&dir, sample_config().tunnel_id).unwrap();
|
||||
assert_eq!(loaded.private_key, "private");
|
||||
assert_eq!(loaded.config.client_address, "10.8.0.7/32");
|
||||
clear_record_for(&dir, sample_config().tunnel_id);
|
||||
assert!(load_record_for(&dir, sample_config().tunnel_id).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user