118 lines
3.9 KiB
Rust
118 lines
3.9 KiB
Rust
use std::time::Duration;
|
||
|
||
use reqwest::{redirect::Policy, Url};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Deserialize)]
|
||
struct HealthResponse {
|
||
ok: bool,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct ServerHealth {
|
||
address: String,
|
||
reachable: bool,
|
||
}
|
||
|
||
fn health_url(input: &str) -> Result<Url, String> {
|
||
let mut url = Url::parse(input.trim())
|
||
.map_err(|_| "请输入完整的服务端地址(含 http:// 或 https://)".to_string())?;
|
||
if url.scheme() != "https" && url.scheme() != "http" {
|
||
return Err("服务端地址须以 http:// 或 https:// 开头".to_string());
|
||
}
|
||
if url.host_str().is_none()
|
||
|| !url.username().is_empty()
|
||
|| url.password().is_some()
|
||
|| url.query().is_some()
|
||
|| url.fragment().is_some()
|
||
|| url.path() != "/"
|
||
{
|
||
return Err("请填写服务端根地址,不包含账号、路径、查询参数或片段".to_string());
|
||
}
|
||
url.set_path("/api/health");
|
||
Ok(url)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn check_server(base_url: String) -> Result<ServerHealth, String> {
|
||
let url = health_url(&base_url)?;
|
||
let client = reqwest::Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.redirect(Policy::none())
|
||
.build()
|
||
.map_err(|err| format!("无法创建网络客户端:{err}"))?;
|
||
let response = client
|
||
.get(url.clone())
|
||
.send()
|
||
.await
|
||
.map_err(|err| format!("连接服务端失败:{err}"))?;
|
||
if !response.status().is_success() {
|
||
return Err(format!("服务端健康检查返回 HTTP {}", response.status()));
|
||
}
|
||
let body: HealthResponse = response
|
||
.json()
|
||
.await
|
||
.map_err(|_| "服务端响应不是有效的 opsx-vpn 健康结果".to_string())?;
|
||
if !body.ok {
|
||
return Err("服务端报告未就绪".to_string());
|
||
}
|
||
let mut root = url;
|
||
root.set_path("");
|
||
Ok(ServerHealth {
|
||
address: root.as_str().trim_end_matches('/').to_string(),
|
||
reachable: true,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{check_server, health_url};
|
||
use std::io::{Read, Write};
|
||
use std::net::TcpListener;
|
||
|
||
#[test]
|
||
fn accepts_http_and_https_server_addresses() {
|
||
assert_eq!(
|
||
health_url("https://vpn.example.com:443").unwrap().as_str(),
|
||
"https://vpn.example.com/api/health"
|
||
);
|
||
assert!(health_url("http://127.0.0.1:18080/").is_ok());
|
||
assert!(health_url("http://localhost:18080").is_ok());
|
||
assert_eq!(
|
||
health_url("http://121.4.111.208:18080").unwrap().as_str(),
|
||
"http://121.4.111.208:18080/api/health"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_unsupported_schemes_and_non_root_addresses() {
|
||
for input in [
|
||
"ftp://vpn.example.com",
|
||
"https://user:pass@vpn.example.com",
|
||
"https://vpn.example.com/admin",
|
||
"https://vpn.example.com/?token=abc",
|
||
"file:///etc/passwd",
|
||
] {
|
||
assert!(health_url(input).is_err(), "accepted {input}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn checks_the_existing_health_contract_over_http() {
|
||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener");
|
||
let address = listener.local_addr().unwrap();
|
||
let server = std::thread::spawn(move || {
|
||
let (mut stream, _) = listener.accept().unwrap();
|
||
let mut request = [0; 512];
|
||
let read = stream.read(&mut request).unwrap();
|
||
assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /api/health "));
|
||
stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}").unwrap();
|
||
});
|
||
let result =
|
||
tauri::async_runtime::block_on(check_server(format!("http://{address}"))).unwrap();
|
||
assert!(result.reachable);
|
||
assert_eq!(result.address, format!("http://{address}"));
|
||
server.join().unwrap();
|
||
}
|
||
}
|