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
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user