feat(client): add authorized WireGuard device enrollment and desktop connection

This commit is contained in:
opsx committed 2026-10-21 14:53:55 +08:00
1 parent d2f1f5040e
commit 6dd3c7dd45
25 files changed
+3274 -131

No files matched your search

+88 -4
View File
@@ -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"
+6
View File
@@ -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"
+295
View File
@@ -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
View File
@@ -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");
+89
View File
@@ -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);
}
}
+348
View File
@@ -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);
}
}
+369 -102
View File
@@ -1,10 +1,16 @@
import { useEffect, useState, type FormEvent } from 'react'
import { checkServer, getSystemReadiness, type SystemReadiness } from './platform'
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import {
checkServer, clientConnect, clientDeviceName, clientDevices, clientDisconnect,
clientLines, clientLogin, clientLogout, clientResume, clientRevokeDevice, clientStatus,
getSystemReadiness,
type ConnState, type DeviceInfo, type Line, type SessionView, type StatusView,
type SystemReadiness,
} from './platform'
import './system.css'
type Page = 'overview' | 'routes' | 'settings'
type CheckState = 'idle' | 'checking' | 'reachable' | 'error'
function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close'; size?: number }) {
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" />,
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c-5 5-5 13 0 18M12 3c5 5 5 13 0 18" /></>,
@@ -14,112 +20,140 @@ function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'powe
server: <><rect x="3" y="3" width="18" height="7" rx="2" /><rect x="3" y="14" width="18" height="7" rx="2" /><path d="M7 6.5h.01M7 17.5h.01" /></>,
check: <path d="m5 12 5 5 9-10" />,
close: <path d="M5 5 19 19M19 5 5 19" />,
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5" /></>,
device: <><rect x="5" y="2" width="14" height="20" rx="2" /><path d="M12 18h.01" /></>,
}
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>
}
function formatBytes(n?: number | null) {
if (n === null || n === undefined) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = n, unit = 0
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++ }
return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
}
function formatDuration(sinceUnix: number, now: number) {
if (!sinceUnix) return '—'
const seconds = Math.max(0, now - sinceUnix)
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
return h > 0 ? `${h} 小时 ${m} 分` : m > 0 ? `${m} 分 ${s} 秒` : `${s} 秒`
}
export default function App() {
const [session, setSession] = useState<SessionView | null>(null)
const [booting, setBooting] = useState(true)
const [page, setPage] = useState<Page>('overview')
const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
const [savedAddress, setSavedAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
const [state, setState] = useState<CheckState>('idle')
const [error, setError] = useState('')
const [checkedAddress, setCheckedAddress] = useState('')
const [responseMs, setResponseMs] = useState<number | null>(null)
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
const [readinessError, setReadinessError] = useState('')
const [connectionGuide, setConnectionGuide] = useState('')
const [conn, setConn] = useState<ConnState | null>(null)
const [status, setStatus] = useState<StatusView | null>(null)
const [selectedLine, setSelectedLine] = useState<number | null>(null)
const [lines, setLines] = useState<Line[]>([])
const [actionError, setActionError] = useState('')
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000))
// 启动时恢复本机会话
useEffect(() => {
if (page !== 'overview' || !savedAddress) return
clientResume()
.then(view => {
if (view) {
setSession(view)
setLines(view.lines)
}
})
.catch(() => undefined)
.finally(() => setBooting(false))
}, [])
// 连接状态轮询:已连接/连接中时每 5 秒以服务端握手为准核对一次
useEffect(() => {
if (!session) return
let active = true
async function refresh() {
const start = performance.now()
setState('checking')
async function poll() {
try {
const health = await checkServer(savedAddress)
const view = await clientStatus()
if (!active) return
setCheckedAddress(health.address)
setResponseMs(Math.round(performance.now() - start))
setState(health.reachable ? 'reachable' : 'error')
} catch (cause) {
if (!active) return
setState('error')
setError(cause instanceof Error ? cause.message : String(cause))
setResponseMs(null)
}
setStatus(view)
setConn(view.conn)
} catch { /* 未连接时无状态可查 */ }
}
void refresh()
const timer = window.setInterval(refresh, 30_000)
void poll()
const timer = window.setInterval(poll, 5000)
return () => { active = false; window.clearInterval(timer) }
}, [page, savedAddress])
}, [session])
// 已连接时刷新时长显示
useEffect(() => {
if (conn?.phase !== 'connected') return
const timer = window.setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000)
return () => window.clearInterval(timer)
}, [conn?.phase])
const refreshLines = useCallback(async () => {
try {
const list = await clientLines()
setLines(list)
} catch { /* 离线时保留缓存 */ }
}, [])
useEffect(() => {
if (page !== 'settings') return
let active = true
getSystemReadiness().then(result => {
if (active) { setReadiness(result); setReadinessError('') }
}).catch(() => {
if (active) setReadinessError('本机检测仅在 Tauri 桌面应用中可用。')
})
return () => { active = false }
}, [page])
if (session && page === 'routes') void refreshLines()
}, [session, page, refreshLines])
async function handleCheck(event: FormEvent) {
event.preventDefault()
setState('checking')
setError('')
setResponseMs(null)
const start = performance.now()
try {
const health = await checkServer(address)
setCheckedAddress(health.address)
setResponseMs(Math.round(performance.now() - start))
if (!health.reachable) throw new Error('服务端未就绪')
localStorage.setItem('opsx_server_address', health.address)
setAddress(health.address)
setSavedAddress(health.address)
setState('reachable')
} catch (cause) {
setState('error')
setError(cause instanceof Error ? cause.message : String(cause))
}
}
function saveAddress() {
try {
const url = new URL(address.trim())
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
throw new Error('请输入 HTTP 或 HTTPS 服务端根地址,不包含路径、账号或参数。')
}
const root = url.href.replace(/\/$/, '')
localStorage.setItem('opsx_server_address', root)
setAddress(root)
setSavedAddress(root)
setError('')
setState('idle')
} catch (cause) {
setError(cause instanceof TypeError ? '请输入完整的 HTTP 或 HTTPS 服务端地址。' : String(cause))
setState('error')
}
}
async function handleConnect() {
if (!savedAddress) {
setConnectionGuide('请先到“设置”检测并保存服务端地址。')
async function handleConnect(tunnelId?: number) {
setActionError('')
const target = tunnelId ?? selectedLine ?? lines.find(l => l.state === 'running')?.tunnel_id ?? lines[0]?.tunnel_id
if (!target) {
setActionError('没有可用线路,请先在“线路”页查看授权。')
return
}
try {
const result = await getSystemReadiness()
setReadiness(result)
setConnectionGuide(result.wg_found && result.wg_quick_found
? '本机已发现 WireGuard 工具。普通用户设备授权和自动下发尚未接入服务端,因此当前不能安全地直接拨号。'
: '本机尚未发现完整的 WireGuard 工具;普通用户设备授权和自动下发也尚未接入。当前不能直接拨号。')
} catch {
setConnectionGuide('当前是浏览器预览,不能修改本机网络。自动连接需要桌面客户端的原生隧道和设备授权接口。')
const next = await clientConnect(target)
setConn(next)
setSelectedLine(target)
setPage('overview')
} catch (cause) {
setActionError(cause instanceof Error ? cause.message : String(cause))
}
}
async function handleDisconnect() {
setActionError('')
try {
await clientDisconnect()
setConn(prev => prev ? { ...prev, phase: 'disconnected' } : prev)
setStatus(null)
} catch (cause) {
setActionError(cause instanceof Error ? cause.message : String(cause))
}
}
async function handleLogout() {
try { await clientLogout() } catch (cause) {
setActionError(cause instanceof Error ? cause.message : String(cause))
return
}
setSession(null)
setConn(null)
setStatus(null)
setLines([])
setSelectedLine(null)
setPage('overview')
}
if (booting) {
return <div className="app-shell"><div className="workspace"><main className="page"><p className="hint">正在启动…</p></main></div></div>
}
if (!session) {
return <LoginView onLoggedIn={view => { setSession(view); setLines(view.lines) }} />
}
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 || ''
return <div className="app-shell">
<aside className="sidebar" aria-label="主导航">
<div className="brand-symbol" aria-label="OpsX VPN"><span /><i /></div>
@@ -130,29 +164,262 @@ export default function App() {
<div className="sidebar-bottom">OX <small>/ VPN</small></div>
</aside>
<div className="workspace">
<header className="topbar"><div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />桌面客户端</div><span className="preview-pill">● &nbsp;客户端 0.1</span></header>
<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>
</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;本客户端未连接</span></div>
<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>
<div className="overview-grid">
<section className="connection-card" aria-label="VPN 连接状态">
<div className="card-top"><div><span className="kicker">安全连接</span><h2>企业网络</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" aria-label="查看 VPN 连接准备步骤" onClick={handleConnect}><Icon name="power" size={42} /></button></div>
<div className="connection-message"><strong>本客户端未连接</strong><span>尚未配置自动设备授权和本机隧道</span></div>{connectionGuide && <div className="connection-guide" role="status">{connectionGuide}<button type="button" onClick={() => setPage('settings')}>查看设置 →</button></div>}
<div className="node-selector"><span className="node-icon"><Icon name="globe" size={19} /></span><span className="node-selector-copy"><small>已授权 VPN 线路</small><strong>尚未获取</strong></span></div>
<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 ? '断开 VPN 连接' : '连接 VPN'}
disabled={connecting}
onClick={() => connected ? void handleDisconnect() : void handleConnect()}>
<Icon name="power" size={42} />
</button>
</div>
<div className="connection-message">
<strong>{connected ? '已连接' : connecting ? '连接中…' : '本客户端未连接'}</strong>
<span>{connected
? `${conn?.client_address ?? ''} · 已连接 ${formatDuration(conn?.since_unix ?? 0, now)}`
: connecting ? (conn?.detail || '正在建立隧道') : (conn?.detail || actionError || '点击上方按钮接入企业网络')}</span>
</div>
{connected && status?.server && <div className="traffic-grid">
<span>下行 {formatBytes(status.server.rx_bytes)}</span>
<span>上行 {formatBytes(status.server.tx_bytes)}</span>
<span>端点 {conn?.endpoint}</span>
</div>}
{actionError && !connecting && <div className="connection-guide" role="alert">{actionError}</div>}
{status?.server?.detail && <div className="connection-guide" role="status">{status.server.detail}</div>}
<div className="node-selector" role="button" tabIndex={0} onClick={() => setPage('routes')} onKeyDown={e => e.key === 'Enter' && setPage('routes')}>
<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>
</div>
</section>
<div className="right-column">
<section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端状态</h2><p>管理服务可达性</p></div></div><p className="server-address">{savedAddress || '尚未设置服务端地址'}</p><span className={`service-state ${state}`}>{state === 'reachable' ? `服务可达 · 控制面往返约 ${responseMs} ms` : state === 'error' ? '检测失败' : state === 'checking' ? '检测中…' : '尚未检测'}</span><p className="hint">控制面检测不代表 VPN 已连接;概览每 30 秒更新一次。</p><button className="secondary-action" onClick={() => setPage('settings')}>在设置中配置 →</button></section>
<section className="info-card progress-card"><div className="info-heading"><span className="icon-tile green"><Icon name="shield" size={20} /></span><div><h2>接入进度</h2><p>桌面端能力逐步开放</p></div></div>
<div className="milestone"><span className={`milestone-mark ${state === 'reachable' ? 'done' : ''}`}>{state === 'reachable' ? '✓' : '1'}</span><span><strong>服务可达性</strong><small>{state === 'reachable' ? checkedAddress : '尚未完成健康检查'}</small></span><em>{state === 'reachable' ? '可达' : '未确认'}</em></div>
<div className="milestone"><span className="milestone-mark">2</span><span><strong>用户与设备授权</strong><small>服务器尚无客户端专用接口</small></span><em>未接入</em></div>
<div className="milestone"><span className="milestone-mark">3</span><span><strong>WireGuard 隧道</strong><small>未创建本机隧道或验证握手</small></span><em>未连接</em></div>
<section className="info-card">
<div className="info-heading"><span className="icon-tile"><Icon name="user" size={20} /></span><div><h2>当前账号</h2><p>拨号用户身份</p></div></div>
<div className="system-grid">
<span>用户名</span><strong>{session.username}</strong>
<span>本机设备</span><strong>{session.device_name}</strong>
<span>授权线路</span><strong>{session.offline ? '服务端暂不可达' : `${lines.length} 条`}</strong>
</div>
<button className="secondary-action" onClick={() => void handleLogout()}>退出登录</button>
</section>
<ServerCard baseUrl={undefined} />
</div>
</div>
<p className="footnote">管理服务健康检查仅反映控制面状态;本客户端尚未建立 VPN 隧道。</p>
<p className="footnote">连接状态由网关侧真实握手确认;控制面检测耗时不等于 VPN 节点延时。</p>
</main>}
{page === 'routes' && <main className="page subpage"><span className="overline">WORKSPACE / ROUTES</span><h1>线路</h1><p className="subpage-intro">这里只显示经当前账户授权、由服务端提供的真实线路。</p><section className="info-card route-preview"><span className="icon-tile large"><Icon name="globe" size={29} /></span><div><h2>暂时无法获取线路</h2><p>服务端尚未提供终端用户身份和设备授权接口,因此无法查询线路或测量真实节点延时。</p></div><button className="secondary-action" onClick={() => setPage('settings')}>检查服务端 →</button></section></main>}
{page === 'settings' && <main className="page subpage"><span className="overline">WORKSPACE / SETTINGS</span><h1>设置</h1><p className="subpage-intro">管理服务端入口并查看本机环境。这里不会保存账号或修改本机网络。</p><section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端地址</h2><p>检测成功后自动保存,下次打开仍可使用</p></div></div><form onSubmit={handleCheck}><label htmlFor="server-url">HTTP / HTTPS 根地址</label><div className="input-row"><input id="server-url" type="url" required placeholder="https://test-link.appx365.com 或 http://121.4.111.208:18080" value={address} onChange={event => { setAddress(event.target.value); setState('idle'); setError(''); setResponseMs(null) }} /><button type="submit" disabled={state === 'checking'}>{state === 'checking' ? '检测中…' : '检测服务'}</button><button className="save-button" type="button" onClick={saveAddress}>保存地址</button></div></form>{savedAddress && <p className="saved-address" role="status">✓ 已保存:{savedAddress}</p>}{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 是明文传输;健康检查可用,但今后登录和设备授权建议改用 HTTPS。</p>}<p className="hint">只填写服务端根地址;检测成功会自动保存,也可直接点击“保存地址”。</p>{state === 'reachable' && <div className="result success" role="status"><Icon name="check" size={17} /><span>服务可达 · 控制面往返约 {responseMs} ms<br /><small>{checkedAddress}。这不是 VPN 节点延时或已连接状态。</small></span></div>}{state === 'error' && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{error || '服务端没有返回有效的健康响应。'}</span></div>}</section><section className="info-card route-preview system-card"><span className="icon-tile large"><Icon name="shield" size={27} /></span><div><h2>安全接入</h2><p>用户认证、设备授权与系统隧道设置将在服务端接口具备后开放。</p></div><span className="status-tag">待接入</span></section><section className="info-card system-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>本机环境</h2><p>只读检查,不代表已经具备 VPN 拨号能力</p></div></div>{readiness ? <div className="system-grid"><span>操作系统</span><strong>{readiness.platform}</strong><span>wg 命令</span><strong>{readiness.wg_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong><span>wg-quick 命令</span><strong>{readiness.wg_quick_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong></div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}<p className="hint">仅检查文件是否存在;没有检查执行权限、驱动、系统授权或 WireGuard 隧道状态。</p></section></main>}
{page === 'routes' && <main className="page subpage">
<span className="overline">WORKSPACE / ROUTES</span><h1>线路</h1>
<p className="subpage-intro">这里只显示当前账号被授权的真实线路。</p>
{lines.length === 0 && <section className="info-card route-preview">
<span className="icon-tile large"><Icon name="globe" size={29} /></span>
<div><h2>{session.offline ? '服务端暂不可达' : '暂无授权线路'}</h2><p>{session.offline ? '恢复网络后自动重试。' : '请联系管理员在 WireGuard 专线中为您创建拨号用户。'}</p></div>
<button className="secondary-action" onClick={() => void refreshLines()}>刷新</button>
</section>}
{lines.map(line => <section key={line.tunnel_id} className="info-card line-card">
<span className="icon-tile"><Icon name="globe" size={20} /></span>
<div className="line-info">
<h2>{line.name} {selectedLine === line.tunnel_id && <em className="line-current">当前</em>}</h2>
<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' || connecting}
onClick={() => { setSelectedLine(line.tunnel_id); void handleConnect(line.tunnel_id) }}>
{connected && conn?.tunnel_name === line.name ? '已连接' : '连接'}
</button>
</section>)}
</main>}
{page === 'settings' && <SettingsPage session={session} onLogout={handleLogout} />}
</div>
</div>
}
// ---- 登录页 ----
function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void }) {
const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [otp, setOtp] = useState('')
const [mfaRequired, setMfaRequired] = useState(false)
const [deviceName, setDeviceName] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [health, setHealth] = useState<'idle' | 'checking' | 'reachable' | 'error'>('idle')
useEffect(() => { clientDeviceName().then(setDeviceName).catch(() => undefined) }, [])
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(/\/$/, ''))
onLoggedIn(view)
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause)
setError(message)
if (message.includes('动态口令')) setMfaRequired(true)
} finally {
setBusy(false)
}
}
async function handleCheck() {
setHealth('checking')
try {
const result = await checkServer(address)
setHealth(result.reachable ? 'reachable' : 'error')
} catch { setHealth('error') }
}
return <div className="app-shell login-shell">
<main className="login-card">
<div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />企业网络接入</div>
<h1>登录</h1>
<p className="hint">使用管理员为您开通的 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>}
<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>
<div className="input-row"><input id="login-password" type="password" required autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} /></div>
{mfaRequired && <>
<label htmlFor="login-otp">动态口令(设置了 PIN 时输入 PIN+动态码)</label>
<div className="input-row"><input id="login-otp" autoComplete="one-time-code" value={otp} onChange={e => setOtp(e.target.value)} /></div>
</>}
<label htmlFor="login-device">设备名(用于在管理端识别本机)</label>
<div className="input-row"><input id="login-device" value={deviceName} onChange={e => setDeviceName(e.target.value)} placeholder="自动填充本机主机名" /></div>
{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>
</form>
</main>
</div>
}
// ---- 服务端健康卡片(概览右栏)----
function ServerCard({ baseUrl }: { baseUrl?: string }) {
const saved = baseUrl || localStorage.getItem('opsx_server_address') || ''
const [state, setState] = useState<'idle' | 'checking' | 'reachable' | 'error'>('idle')
const [responseMs, setResponseMs] = useState<number | null>(null)
useEffect(() => {
if (!saved) return
let active = true
async function refresh() {
const start = performance.now()
setState('checking')
try {
const health = await checkServer(saved)
if (!active) return
setResponseMs(Math.round(performance.now() - start))
setState(health.reachable ? 'reachable' : 'error')
} catch { if (active) { setState('error'); setResponseMs(null) } }
}
void refresh()
const timer = window.setInterval(refresh, 30_000)
return () => { active = false; window.clearInterval(timer) }
}, [saved])
return <section className="info-card">
<div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端状态</h2><p>管理服务可达性</p></div></div>
<p className="server-address">{saved || '未设置'}</p>
<span className={`service-state ${state}`}>{state === 'reachable' ? `服务可达 · 控制面往返约 ${responseMs} ms` : state === 'error' ? '检测失败' : state === 'checking' ? '检测中…' : '尚未检测'}</span>
<p className="hint">控制面检测不代表 VPN 已连接。</p>
</section>
}
// ---- 设置页 ----
function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: () => void }) {
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
const [readinessError, setReadinessError] = useState('')
const [devices, setDevices] = useState<DeviceInfo[]>([])
const [deviceError, setDeviceError] = useState('')
const loadDevices = useCallback(() => {
clientDevices().then(setDevices).catch(err => setDeviceError(String(err?.message ?? err)))
}, [])
useEffect(() => {
let active = true
getSystemReadiness()
.then(result => { if (active) setReadiness(result) })
.catch(() => { if (active) setReadinessError('本机检测仅在 Tauri 桌面应用中可用。') })
loadDevices()
return () => { active = false }
}, [loadDevices])
async function revoke(id: number, name: string) {
if (!window.confirm(`确定撤销设备「${name}」?该设备将立即失去接入能力,需要重新登记。`)) return
setDeviceError('')
try {
await clientRevokeDevice(id)
loadDevices()
} catch (cause) {
setDeviceError(cause instanceof Error ? cause.message : String(cause))
}
}
return <main className="page subpage">
<span className="overline">WORKSPACE / SETTINGS</span><h1>设置</h1>
<p className="subpage-intro">管理本机设备与本机环境。服务端地址在退出登录后可修改。</p>
<section className="info-card">
<div className="info-heading"><span className="icon-tile"><Icon name="user" size={20} /></span><div><h2>账号</h2><p>当前登录的拨号用户</p></div></div>
<div className="system-grid">
<span>用户名</span><strong>{session.username}</strong>
<span>姓名</span><strong>{session.display_name || '—'}</strong>
<span>服务端</span><strong>{localStorage.getItem('opsx_server_address') || '—'}</strong>
</div>
<button className="secondary-action" onClick={onLogout}>退出登录(同时断开 VPN)</button>
</section>
<section className="info-card system-card">
<div className="info-heading"><span className="icon-tile"><Icon name="device" size={20} /></span><div><h2>我的设备</h2><p>撤销后该设备立即无法接入</p></div></div>
{deviceError && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{deviceError}</span></div>}
{devices.length === 0 && <p className="hint">尚未登记设备;首次连接时会自动登记本机。</p>}
{devices.map(d => <div key={d.id} className="milestone">
<span className={`milestone-mark ${d.revoked ? '' : 'done'}`}>{d.revoked ? '×' : '✓'}</span>
<span><strong>{d.name} <small className="device-platform">{d.platform}</small></strong><small>{d.client_address}{d.last_seen_at ? ` · 最近握手 ${new Date(d.last_seen_at).toLocaleString()}` : ''}</small></span>
{d.revoked ? <em>已撤销</em> : <button className="secondary-action revoke-button" onClick={() => void revoke(d.id, d.name)}>撤销</button>}
</div>)}
</section>
<section className="info-card system-card">
<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>wg 命令</span><strong>{readiness.wg_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong>
<span>wg-quick 命令</span><strong>{readiness.wg_quick_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong>
</div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}
<p className="hint">建立隧道时系统会弹出提权授权;Windows 暂请使用 WireGuard 官方客户端导入配置。</p>
</section>
</main>
}
+94 -2
View File
@@ -3,12 +3,104 @@ import { invoke } from '@tauri-apps/api/core'
export type ServerHealth = { address: string; reachable: boolean }
export type SystemReadiness = { platform: string; wg_found: boolean; wg_quick_found: boolean }
export type Line = {
tunnel_id: number
name: string
remark: string
state: string
endpoint: string
}
export type SessionView = {
username: string
display_name: string
device_name: string
lines: Line[]
offline: boolean
}
export type ConnPhase = 'disconnected' | 'connecting' | 'connected'
export type ConnState = {
phase: ConnPhase
detail: string
tunnel_name: string
client_address: string
endpoint: string
device_id: number
since_unix: number
}
export type DeviceStatus = {
running: boolean
connected: boolean
latest_handshake_at?: number | null
rx_bytes?: number | null
tx_bytes?: number | null
detail?: string | null
}
export type StatusView = { conn: ConnState; server: DeviceStatus | null }
export type DeviceInfo = {
id: number
tunnel_id: number
name: string
platform: string
client_address: string
revoked: boolean
last_seen_at?: string | null
}
const isDesktop = () => '__TAURI_INTERNALS__' in window
const desktopOnly = () => Promise.reject(new Error('浏览器预览不支持该操作,请使用桌面客户端。'))
// All desktop-to-native calls pass through this boundary. The WebView never
// handles a management token or a WireGuard private key.
export function checkServer(baseUrl: string): Promise<ServerHealth> {
return invoke<ServerHealth>('check_server', { baseUrl })
return isDesktop() ? invoke<ServerHealth>('check_server', { baseUrl }) : desktopOnly()
}
export function getSystemReadiness(): Promise<SystemReadiness> {
return invoke<SystemReadiness>('get_system_readiness')
return isDesktop() ? invoke<SystemReadiness>('get_system_readiness') : desktopOnly()
}
export function clientLogin(baseUrl: string, username: string, password: string, otp: string, deviceName: string): Promise<SessionView> {
return isDesktop() ? invoke<SessionView>('client_login', { baseUrl, username, password, otp, deviceName }) : desktopOnly()
}
export function clientResume(): Promise<SessionView | null> {
return isDesktop() ? invoke<SessionView | null>('client_resume') : Promise.resolve(null)
}
export function clientLogout(): Promise<void> {
return isDesktop() ? invoke<void>('client_logout') : desktopOnly()
}
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()
}
export function clientStatus(): Promise<StatusView> {
return isDesktop() ? invoke<StatusView>('client_status') : desktopOnly()
}
export function clientDevices(): Promise<DeviceInfo[]> {
return isDesktop() ? invoke<DeviceInfo[]>('client_devices') : desktopOnly()
}
export function clientRevokeDevice(deviceId: number): Promise<void> {
return isDesktop() ? invoke<void>('client_revoke_device', { deviceId }) : desktopOnly()
}
export function clientDeviceName(): Promise<string> {
return isDesktop() ? invoke<string>('client_device_name') : Promise.resolve('')
}
+31
View File
@@ -17,3 +17,34 @@
.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; }
/* ---- 登录页 ---- */
.login-shell { align-items: center; justify-content: center; background: linear-gradient(160deg, #eef4ff, #f8faff 60%, #f0f6ff); }
.login-card { width: 420px; max-width: calc(100vw - 48px); background: #fff; border: 1px solid #e8edf5; border-radius: 20px; box-shadow: 0 18px 48px rgba(29, 72, 137, .08); padding: 36px 38px 30px; }
.login-card h1 { font-size: 24px; margin: 22px 0 6px; }
.login-form { display: grid; gap: 4px; margin-top: 18px; }
.login-form label { font-size: 11px; font-weight: 700; color: #5d6e85; margin: 10px 0 6px; }
.login-submit { margin-top: 22px; height: 44px; border: 0; border-radius: 10px; background: #3476f5; color: #fff; font-size: 14px; font-weight: 700; }
.login-submit:hover { background: #205ed9; }
.login-submit:disabled { opacity: .6; cursor: wait; }
/* ---- 连接状态 ---- */
.power-display.on { background: linear-gradient(145deg, #2fce88, #17a268); box-shadow: 0 16px 30px #17a26849, inset 0 1px 2px #ffffff66; }
.power-display.busy { opacity: .75; cursor: wait; animation: pulse 1.4s ease-in-out infinite; }
@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; }
.node-switch { color: #3977e8; font-size: 11px; white-space: nowrap; }
.node-selector { cursor: pointer; }
.node-selector:hover { background: #f3f7ff; }
/* ---- 线路卡片 ---- */
.line-card { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; }
.line-info { flex: 1; min-width: 0; }
.line-info h2 { display: flex; align-items: center; gap: 8px; }
.line-current { font-size: 10px; font-style: normal; color: #168861; background: #ebf8f2; border-radius: 100px; padding: 3px 8px; }
/* ---- 设备列表 ---- */
.device-platform { color: #93a1b4; font-weight: 400; }
.revoke-button { padding: 7px 12px; font-size: 11px; color: #b34444; border-color: #f3d2d0; background: #fff7f6; }
.revoke-button:hover { background: #ffe9e7; }