Add bundled WireGuard diagnostics and application proxy

This commit is contained in:
opsx committed 2026-10-21 18:00:21 +08:00
1 parent 6dd3c7dd45
commit eabb3e4472
20 files changed
+1129 -76

No files matched your search

+148
View File
@@ -52,6 +52,38 @@ impl Default for ConnState {
static CONN: Mutex<Option<ConnState>> = Mutex::new(None);
static TRAY_MENU: Mutex<Option<tauri::menu::Menu<tauri::Wry>>> = Mutex::new(None);
static PROXY: Mutex<Option<std::process::Child>> = Mutex::new(None);
// Serialize launches and teardown: two simultaneous clicks must never orphan a sidecar.
static PROXY_OPERATION: Mutex<()> = Mutex::new(());
const PROXY_ADDRESS: &str = "127.0.0.1:18743";
fn stop_proxy_inner() {
if let Some(mut child) = PROXY.lock().unwrap().take() {
let _ = child.kill();
let _ = child.wait();
}
}
fn stop_proxy() {
let _operation = PROXY_OPERATION.lock().unwrap();
stop_proxy_inner();
}
#[tauri::command]
fn client_proxy_status() -> bool {
let _operation = PROXY_OPERATION.lock().unwrap();
let mut guard = PROXY.lock().unwrap();
if let Some(child) = guard.as_mut() {
if child.try_wait().ok().flatten().is_none() { return true; }
}
*guard = None;
false
}
#[tauri::command]
fn client_proxy_stop() {
stop_proxy();
}
fn conn_state() -> ConnState {
CONN.lock()
@@ -128,6 +160,113 @@ struct StatusView {
server: Option<DeviceStatus>,
}
#[derive(serde::Deserialize, Serialize)]
struct GoProbeResult {
connected: bool,
detail: String,
target: String,
}
fn go_probe_binary(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let binary_name = if cfg!(windows) { "opsx-tunnel-go.exe" } else { "opsx-tunnel-go" };
let mut candidates = Vec::new();
if let Ok(dir) = app.path().resource_dir() {
candidates.push(dir.join(binary_name));
candidates.push(dir.join("binaries").join(binary_name));
}
if let Ok(dir) = app.path().executable_dir() {
candidates.push(dir.join(binary_name));
}
#[cfg(debug_assertions)]
{
let triple = match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => "aarch64-apple-darwin",
("macos", "x86_64") => "x86_64-apple-darwin",
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
("linux", "aarch64") => "aarch64-unknown-linux-gnu",
_ => "unsupported",
};
candidates.push(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("binaries")
.join(format!("opsx-tunnel-go-{triple}")));
}
candidates.into_iter().find(|p| p.is_file())
.ok_or_else(|| "安装包缺少 Go 隧道组件,请重新安装客户端".into())
}
/// 独立探测 WireGuard 数据面及公网 TCP,不更改系统路由/DNS。
/// 只接收已登录账号的设备记录,探测目标固定为公共地址。
#[tauri::command]
async fn client_probe(app: AppHandle, tunnel_id: u64) -> Result<GoProbeResult, String> {
let session = require_session(&app)?;
let dir = data_dir(&app)?;
let record_dir = tunnel::device_dir(&dir, &session.base_url, &session.username);
let mut record = tunnel::load_record_for(&record_dir, tunnel_id)?;
if record.config.tunnel_id != tunnel_id {
return Err("本机设备线路不匹配".into());
}
// 管理员可能已更新公网路由或撤销设备;探测前始终拉取最新授权。
record.config = api::device_config(&session.base_url, &session.token, record.config.device_id)
.await.map_err(|err| err.message())?;
if record.config.tunnel_id != tunnel_id {
return Err("服务端设备线路不匹配".into());
}
let path = tunnel::save_record(&record_dir, &record)?;
let helper = go_probe_binary(&app)?;
let output = tauri::async_runtime::spawn_blocking(move || {
std::process::Command::new(helper)
.arg("probe").arg(path).arg("223.5.5.5:443")
.output()
.map_err(|err| format!("无法启动 Go 隧道探针:{err}"))
}).await.map_err(|err| format!("Go 隧道探针执行失败:{err}"))??;
serde_json::from_slice::<GoProbeResult>(&output.stdout)
.map_err(|_| "Go 隧道探针未返回有效状态".into())
}
/// Explicit opt-in local proxy. This has no system routing or DNS side effects.
#[tauri::command]
async fn client_proxy_start(app: AppHandle, tunnel_id: u64) -> Result<String, String> {
let session = require_session(&app)?;
let dir = tunnel::device_dir(&data_dir(&app)?, &session.base_url, &session.username);
let mut record = tunnel::load_record_for(&dir, tunnel_id)?;
record.config = api::device_config(&session.base_url, &session.token, record.config.device_id)
.await.map_err(|err| err.message())?;
if record.config.tunnel_id != tunnel_id { return Err("服务端设备线路不匹配".into()); }
tunnel::validate_config(&record.private_key, &record.config)?;
let path = tunnel::save_record(&dir, &record)?;
let helper = go_probe_binary(&app)?;
tauri::async_runtime::spawn_blocking(move || {
use std::io::BufRead;
let _operation = PROXY_OPERATION.lock().unwrap();
stop_proxy_inner();
let mut child = std::process::Command::new(helper)
.arg("proxy").arg(path).arg(PROXY_ADDRESS)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn().map_err(|err| format!("启动本地代理失败:{err}"))?;
let stdout = child.stdout.take().ok_or("代理未返回状态")?;
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut line = String::new();
let result = std::io::BufReader::new(stdout).read_line(&mut line);
let _ = sender.send((result, line));
});
match receiver.recv_timeout(std::time::Duration::from_secs(5)) {
Ok((Ok(_), line)) if serde_json::from_str::<serde_json::Value>(&line)
.ok().and_then(|v| v.get("status")?.as_str().map(str::to_owned)).as_deref() == Some("ready") => {
*PROXY.lock().unwrap() = Some(child);
Ok(format!("socks5h://{PROXY_ADDRESS}"))
}
_ => {
let _ = child.kill();
let _ = child.wait();
Err("本机代理启动失败或端口被占用".into())
}
}
}).await.map_err(|err| format!("代理启动任务失败:{err}"))?
}
// ---- Tauri 命令 ----
#[tauri::command]
@@ -161,6 +300,7 @@ async fn client_login(
device_name: device_name.clone(),
lines: result.lines.clone(),
};
stop_proxy();
localstore::save(&data_dir(&app)?, &session)?;
Ok(SessionView {
username: result.username,
@@ -194,6 +334,7 @@ async fn client_resume(app: AppHandle) -> Result<Option<SessionView>, String> {
}
Err(api::ApiError::Message(reqwest::StatusCode::UNAUTHORIZED, _, _)) => {
// 令牌失效/被撤销:清除本地会话
stop_proxy();
if let Err(err) = tunnel::down(&dir) {
let mut state = conn_state();
state.phase = ConnPhase::Connecting; // 保留 UI 的断开入口
@@ -223,6 +364,7 @@ async fn client_resume(app: AppHandle) -> Result<Option<SessionView>, String> {
#[tauri::command]
async fn client_logout(app: AppHandle) -> Result<(), String> {
stop_proxy();
let dir = data_dir(&app)?;
if conn_state().phase != ConnPhase::Disconnected {
// 保留记录与会话,确保拆隧道失败后可再次操作。
@@ -443,6 +585,7 @@ async fn client_revoke_device(app: AppHandle, device_id: u64) -> Result<(), Stri
.as_ref()
.is_some_and(|r| r.config.device_id == device_id)
{
stop_proxy();
tunnel::clear_record_for(&record_dir, device.tunnel_id);
}
Ok(())
@@ -490,6 +633,7 @@ pub fn run() {
}
}
"quit" => {
stop_proxy();
// 退出前尽力拆除本机隧道,避免残留接口与路由
if let Ok(dir) = app.path().app_config_dir() {
let _ = tunnel::down(&dir);
@@ -511,6 +655,10 @@ pub fn run() {
client_connect,
client_disconnect,
client_status,
client_probe,
client_proxy_start,
client_proxy_stop,
client_proxy_status,
client_devices,
client_revoke_device,
client_device_name,
+64 -11
View File
@@ -5,28 +5,81 @@ pub struct SystemReadiness {
platform: &'static str,
wg_found: bool,
wg_quick_found: bool,
engine_mode: EngineMode,
engine_detail: &'static str,
}
fn on_path(name: &str) -> bool {
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).any(|directory| {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum EngineMode {
SystemTools,
Unsupported,
}
fn engine_for(platform: &str, wg: bool, wg_quick: bool) -> (EngineMode, &'static str) {
match platform {
"windows" => (EngineMode::Unsupported, "Windows 内置隧道尚未实现"),
"macos" | "linux" if wg && wg_quick => (
EngineMode::SystemTools,
"当前使用系统 WireGuard 工具;免安装引擎尚未交付",
),
"macos" | "linux" => (
EngineMode::Unsupported,
"当前版本需要本机 WireGuard 工具;免安装引擎尚未交付",
),
_ => (EngineMode::Unsupported, "当前系统尚不支持连接"),
}
}
pub(crate) fn active_engine() -> Result<EngineMode, &'static str> {
let (mode, detail) = engine_for(
std::env::consts::OS,
binary_path("wg").is_some(),
binary_path("wg-quick").is_some(),
);
if mode == EngineMode::Unsupported { Err(detail) } else { Ok(mode) }
}
pub(crate) fn binary_path(name: &str) -> Option<std::path::PathBuf> {
let mut directories: Vec<_> = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
// Finder/Dock 启动的 macOS 应用通常没有 Homebrew 的 PATH。
#[cfg(target_os = "macos")]
directories.extend(["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].map(std::path::PathBuf::from));
directories.into_iter().find_map(|directory| {
let path = directory.join(name);
if path.is_file() { return Some(path); }
#[cfg(windows)]
{
[name.to_string(), format!("{name}.exe")]
.iter()
.any(|candidate| directory.join(candidate).is_file())
}
#[cfg(not(windows))]
{
directory.join(name).is_file()
let exe = directory.join(format!("{name}.exe"));
if exe.is_file() { return Some(exe); }
}
None
})
}
#[tauri::command]
pub fn get_system_readiness() -> SystemReadiness {
let wg_found = binary_path("wg").is_some();
let wg_quick_found = binary_path("wg-quick").is_some();
let (engine_mode, engine_detail) = engine_for(std::env::consts::OS, wg_found, wg_quick_found);
SystemReadiness {
platform: std::env::consts::OS,
wg_found: on_path("wg"),
wg_quick_found: on_path("wg-quick"),
wg_found,
wg_quick_found,
engine_mode,
engine_detail,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_tools_never_report_a_bundled_engine() {
assert_eq!(engine_for("macos", false, false).0, EngineMode::Unsupported);
assert_eq!(engine_for("linux", true, false).0, EngineMode::Unsupported);
assert_eq!(engine_for("macos", true, true).0, EngineMode::SystemTools);
assert_eq!(engine_for("windows", true, true).0, EngineMode::Unsupported);
}
}
+41 -22
View File
@@ -139,7 +139,7 @@ fn valid_cidrs(value: &str) -> bool {
})
}
fn validate_config(private_key: &str, cfg: &DeviceConfig) -> Result<(), String> {
pub(crate) fn validate_config(private_key: &str, cfg: &DeviceConfig) -> Result<(), String> {
if !valid_key(private_key)
|| !valid_key(&cfg.server_public_key)
|| !valid_key(&cfg.preshared_key)
@@ -175,11 +175,9 @@ fn validate_config(private_key: &str, cfg: &DeviceConfig) -> Result<(), String>
}
fn find_binary(name: &str) -> Result<String, String> {
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
.map(|dir| dir.join(name))
.find(|p| p.is_file())
crate::system::binary_path(name)
.map(|p| p.to_string_lossy().to_string())
.ok_or_else(|| format!("未在本机 PATH 找到 {name},请先安装 wireguard-tools"))
.ok_or_else(|| format!("未找到 {name},请安装 wireguard-tools 后重试(在设置页查看安装提示)"))
}
/// 以系统授权方式执行 wg-quick up/down。
@@ -210,7 +208,7 @@ fn run_wg_quick(action: &str, conf: &Path) -> Result<(), String> {
return Err("配置路径包含非法字符".into());
}
let command = format!(
"{} {} {}",
"PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH; export PATH; {} {} {}",
shell_escape(&wg_quick),
action,
shell_escape(&conf_str)
@@ -221,11 +219,11 @@ fn run_wg_quick(action: &str, conf: &Path) -> Result<(), String> {
.args(["-e", &script])
.status()
.map_err(|err| format!("无法请求管理员授权:{err}"))?;
return if status.success() {
if status.success() {
Ok(())
} else {
Err(format!("wg-quick {action} 执行失败(可能被取消授权)"))
};
}
}
#[cfg(target_os = "windows")]
{
@@ -242,23 +240,44 @@ fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
/// 建立隧道:渲染配置 → 0600 落盘 → 提权 wg-quick up。
pub fn up(dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result<PathBuf, String> {
validate_config(private_key, cfg)?;
let path = write_conf(dir, &render_config(private_key, cfg))?;
// 重复连接时先尽力清理旧接口,忽略失败(接口可能本就不存在)
let _ = run_wg_quick("down", &path);
run_wg_quick("up", &path)?;
Ok(path)
/// 所有引擎必须提供同一启停契约;以后内置引擎在此实现而非改动控制面。
trait TunnelEngine {
fn up(&self, dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result<PathBuf, String>;
fn down(&self, dir: &Path) -> Result<(), String>;
}
/// 拆除隧道。配置文件可能已被清理,尽力而为。
pub fn down(dir: &Path) -> Result<(), String> {
let path = dir.join("opsx-wg.conf");
if !path.is_file() {
return Ok(());
struct SystemToolsEngine;
impl TunnelEngine for SystemToolsEngine {
fn up(&self, dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result<PathBuf, String> {
validate_config(private_key, cfg)?;
let path = write_conf(dir, &render_config(private_key, cfg))?;
// 重复连接时先尽力清理旧接口,忽略失败(接口可能本就不存在)
let _ = run_wg_quick("down", &path);
run_wg_quick("up", &path)?;
Ok(path)
}
run_wg_quick("down", &path)
fn down(&self, dir: &Path) -> Result<(), String> {
let path = dir.join("opsx-wg.conf");
if !path.is_file() {
return Ok(());
}
run_wg_quick("down", &path)
}
}
/// 建立隧道:仅使用检测到的执行层,不能回退到 PATH 中的其他二进制。
pub fn up(dir: &Path, private_key: &str, cfg: &DeviceConfig) -> Result<PathBuf, String> {
match crate::system::active_engine().map_err(str::to_string)? {
crate::system::EngineMode::SystemTools => SystemToolsEngine.up(dir, private_key, cfg),
crate::system::EngineMode::Unsupported => Err("当前系统不支持连接".into()),
}
}
/// 拆除已建立的接口,即使连接后本机工具被移走也要尝试清理。
pub fn down(dir: &Path) -> Result<(), String> {
SystemToolsEngine.down(dir)
}
#[cfg(test)]