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

+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());
}
}