// 控制面 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, #[serde(default)] pub routing_mode: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoginResult { pub token: String, pub username: String, pub display_name: String, pub lines: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClientCapabilities { pub password: bool, pub oidc: bool, #[serde(default)] pub oidc_provider: Option, pub wireguard: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceConfig { pub device_id: u64, 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, #[serde(default)] pub latency_probe: 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, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceStatus { pub running: bool, pub connected: bool, pub latest_handshake_at: Option, pub rx_bytes: Option, pub tx_bytes: Option, pub detail: Option, } #[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, mfa_required: Option, } fn client() -> Result { 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 { 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(resp: reqwest::Response) -> Result { 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::() .await .map_err(|_| ApiError::Transport("服务端响应格式无法解析".to_string())); } let body = resp.json::().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 { // 旧版服务端会把未知 /api/client/* 路径返回 200 HTML;先确认能力,避免发送密码。 let supported = capabilities(base_url).await?; if !supported.password { 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 capabilities(base_url: &str) -> Result { let response = client()? .get(api_url(base_url, "/api/client/capabilities")?) .send() .await .map_err(|err| ApiError::Transport(format!("连接服务端失败:{err}")))?; parse_response(response).await } pub async fn logout(base_url: &str, token: &str) -> Result<(), ApiError> { let resp = client()? .post(api_url(base_url, "/api/client/logout")?) .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, ApiError> { #[derive(Deserialize)] struct Resp { lines: Vec, } 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).await?.lines) } pub async fn register_device( base_url: &str, token: &str, tunnel_id: u64, name: &str, public_key: &str, ) -> Result { 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 { 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 { 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, ApiError> { #[derive(Deserialize)] struct Resp { devices: Vec, } 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).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()); } #[test] fn client_auth_capabilities_accept_legacy_and_provider_specific_responses() { let current: ClientCapabilities = serde_json::from_str( r#"{"password":true,"oidc":false,"wireguard":true}"#, ).unwrap(); assert!(current.password); assert!(!current.oidc); assert!(current.oidc_provider.is_none()); let future: ClientCapabilities = serde_json::from_str( r#"{"password":false,"oidc":true,"oidc_provider":"feishu","wireguard":true}"#, ).unwrap(); assert!(!future.password); assert_eq!(future.oidc_provider.as_deref(), Some("feishu")); } }