feat(tls): manage ACME domain and add desktop client settings

This commit is contained in:
opsx committed 2026-10-21 10:25:55 +08:00
1 parent 003f755b37
commit 00f6c876cf
85 files changed
+7276 -1

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
mod server;
mod system;
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::TrayIconBuilder,
Manager,
};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let status = MenuItem::with_id(app, "status", "VPN:尚未连接", false, None::<&str>)?;
let open = MenuItem::with_id(app, "open", "打开 OpsX VPN", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(
app,
&[&status, &open, &PredefinedMenuItem::separator(app)?, &quit],
)?;
TrayIconBuilder::new()
.icon(app.default_window_icon().expect("desktop app icon").clone())
.tooltip("OpsX VPN · 尚未连接")
.menu(&menu)
.on_menu_event(|app, event| match event.id().as_ref() {
"open" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
"quit" => app.exit(0),
_ => {}
})
.build(app)?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
server::check_server,
system::get_system_readiness
])
.run(tauri::generate_context!())
.expect("failed to start OpsX VPN client");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
opsx_vpn_client_lib::run();
}
+117
View File
@@ -0,0 +1,117 @@
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();
}
}
+32
View File
@@ -0,0 +1,32 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct SystemReadiness {
platform: &'static str,
wg_found: bool,
wg_quick_found: bool,
}
fn on_path(name: &str) -> bool {
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).any(|directory| {
#[cfg(windows)]
{
[name.to_string(), format!("{name}.exe")]
.iter()
.any(|candidate| directory.join(candidate).is_file())
}
#[cfg(not(windows))]
{
directory.join(name).is_file()
}
})
}
#[tauri::command]
pub fn get_system_readiness() -> SystemReadiness {
SystemReadiness {
platform: std::env::consts::OS,
wg_found: on_path("wg"),
wg_quick_found: on_path("wg-quick"),
}
}