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