328 lines
9.7 KiB
Rust
328 lines
9.7 KiB
Rust
// 控制面 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<Line>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ClientCapabilities {
|
|
pub password: bool,
|
|
pub oidc: bool,
|
|
#[serde(default)]
|
|
pub oidc_provider: Option<String>,
|
|
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<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 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<ClientCapabilities, ApiError> {
|
|
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<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());
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|