feat(tls): manage ACME domain and add desktop client settings
No files matched your search
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "opsx-vpn-client"
|
||||
version = "0.1.0"
|
||||
description = "OpsX VPN desktop client"
|
||||
authors = ["OpsX"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "opsx_vpn_client_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "OpsX VPN desktop window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 845 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
opsx_vpn_client_lib::run();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpsX VPN",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.opsx.vpn.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:ui",
|
||||
"devUrl": "http://127.0.0.1:5176",
|
||||
"beforeBuildCommand": "npm run build:ui",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [{ "label": "main", "title": "OpsX VPN", "width": 1080, "height": 740, "minWidth": 800, "minHeight": 590 }],
|
||||
"security": { "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:5176 ws://127.0.0.1:5176; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||
"category": "Utility"
|
||||
}
|
||||
}
|
||||