Add bundled WireGuard diagnostics and application proxy
This commit is contained in:
1 parent
6dd3c7dd45
commit
eabb3e4472
20 files changed
+1129
-76
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||
"category": "Utility"
|
||||
"category": "Utility",
|
||||
"externalBin": ["binaries/opsx-tunnel-go"]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user