diff --git a/Cargo.lock b/Cargo.lock index 9ea7d9f..b74f296 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2687,6 +2687,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "ostp-tun-helper" +version = "0.1.43" +dependencies = [ + "anyhow", + "ostp-client", + "portable-atomic", + "serde", + "serde_json", + "tokio", + "winres", +] + [[package]] name = "pango" version = "0.18.3" @@ -4169,6 +4182,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.8.2" @@ -5204,6 +5226,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winres" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 09565b6..6c891d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,8 @@ members = [ "ostp-client", "ostp-server", "ostp-jni", "ostp", - "ostp-gui/src-tauri" + "ostp-gui/src-tauri", + "ostp-tun-helper" ] resolver = "2" diff --git a/ostp-gui/package-lock.json b/ostp-gui/package-lock.json new file mode 100644 index 0000000..7c2beac --- /dev/null +++ b/ostp-gui/package-lock.json @@ -0,0 +1,232 @@ +{ + "name": "ostp-gui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ostp-gui", + "version": "0.1.0", + "devDependencies": { + "@tauri-apps/cli": "^2" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz", + "integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.1", + "@tauri-apps/cli-darwin-x64": "2.11.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.1", + "@tauri-apps/cli-linux-arm64-musl": "2.11.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.1", + "@tauri-apps/cli-linux-x64-gnu": "2.11.1", + "@tauri-apps/cli-linux-x64-musl": "2.11.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.1", + "@tauri-apps/cli-win32-x64-msvc": "2.11.1" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz", + "integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz", + "integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz", + "integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz", + "integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz", + "integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz", + "integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz", + "integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz", + "integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz", + "integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz", + "integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz", + "integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/ostp-gui/src-tauri/build.rs b/ostp-gui/src-tauri/build.rs index b36ee7a..d860e1e 100644 --- a/ostp-gui/src-tauri/build.rs +++ b/ostp-gui/src-tauri/build.rs @@ -1,30 +1,3 @@ fn main() { - let mut windows = tauri_build::WindowsAttributes::new(); - - // Define the manifest with requireAdministrator to allow TUN mode without terminal - // and include Common-Controls v6 for modern UI elements/dialogs. - let manifest = r#" - - - - - - - - - - - - - - - "#; - - windows = windows.app_manifest(manifest); - - tauri_build::try_build( - tauri_build::Attributes::new() - .windows_attributes(windows) - ) - .expect("failed to run build script"); + tauri_build::build() } diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index fd9f317..57a780a 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -7,11 +7,12 @@ use anyhow::Result; use ostp_client::bridge::BridgeMetrics; use portable_atomic::Ordering; -// Config deserialization matching ostp core +// ── Config types ───────────────────────────────────────────────────────────── + #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(tag = "mode", rename_all = "lowercase")] enum AppMode { - Server(serde_json::Value), // We ignore server config in GUI + Server(serde_json::Value), Client(ClientConfigRaw), } @@ -69,26 +70,58 @@ struct UIMetrics { bytes_recv: u64, } -struct AppStateInner { +// ── Messages exchanged with the privileged helper ──────────────────────────── + +#[derive(Deserialize, Clone)] +#[serde(tag = "type", rename_all = "lowercase")] +enum HelperMsg { + Status { value: u8 }, + Log { message: String }, + Metrics { bytes_sent: u64, bytes_recv: u64 }, + Error { message: String }, +} + +// ── Application state ───────────────────────────────────────────────────────── + +// For proxy (non-TUN) mode: runs in-process. +struct InProcessState { shutdown_tx: Option>, - metrics: Option>, - handle: Option>>, + metrics: Arc, + handle: JoinHandle>, +} + +// For TUN mode: communicates with the privileged helper via named pipe. +struct HelperState { + /// Shared state updated by pipe reader task + pipe_state: Arc>, + /// Send commands to helper over named pipe + cmd_tx: tokio::sync::mpsc::Sender, +} + +enum TunnelHandle { + InProcess(InProcessState), + Helper(HelperState), +} + +struct AppStateInner { + tunnel: Option, } impl Drop for AppStateInner { fn drop(&mut self) { - // Send final signal to ensure the core background threads exit immediately - // and activate Wintun routing cleanup Drop routines. - if let Some(tx) = self.shutdown_tx.take() { - let _ = tx.send(true); + if let Some(TunnelHandle::InProcess(ref mut s)) = self.tunnel { + if let Some(tx) = s.shutdown_tx.take() { + let _ = tx.send(true); + } } } } struct AppState(Mutex); +// ── Config helpers ──────────────────────────────────────────────────────────── + fn get_config_path() -> PathBuf { - // Standard behavior: same dir as current exe, or fall back to current working dir if let Ok(exe_path) = std::env::current_exe() { if let Some(parent) = exe_path.parent() { let path = parent.join("config.json"); @@ -100,11 +133,12 @@ fn get_config_path() -> PathBuf { PathBuf::from("config.json") } +// ── Tauri commands ──────────────────────────────────────────────────────────── + #[tauri::command] async fn get_config() -> Result { let path = get_config_path(); if !path.exists() { - // Return default template if file missing return Ok(r#"{ "mode": "client", "log_level": "info", @@ -124,10 +158,8 @@ async fn get_config() -> Result { #[tauri::command] async fn save_config(json_content: String) -> Result { - // Validate formatting let _parsed: UnifiedConfig = serde_json::from_str(&json_content) .map_err(|e| format!("Invalid OSTP config JSON: {}", e))?; - let path = get_config_path(); std::fs::write(path, json_content).map_err(|e| format!("Write error: {}", e))?; Ok(true) @@ -136,74 +168,105 @@ async fn save_config(json_content: String) -> Result { #[tauri::command] async fn get_tunnel_status(state: tauri::State<'_, AppState>) -> Result { let guard = state.0.lock().await; - if let Some(ref handle) = guard.handle { - if handle.is_finished() { - return Ok(0); + match &guard.tunnel { + None => Ok(0), + Some(TunnelHandle::InProcess(s)) => { + if s.handle.is_finished() { + return Ok(0); + } + Ok(s.metrics.connection_state.load(Ordering::Relaxed)) } - if let Some(ref metrics) = guard.metrics { - return Ok(metrics.connection_state.load(Ordering::Relaxed)); + Some(TunnelHandle::Helper(h)) => { + let ps = h.pipe_state.lock().await; + Ok(ps.connection_state) } - Ok(0) - } else { - Ok(0) } } #[tauri::command] async fn get_metrics(state: tauri::State<'_, AppState>) -> Result, String> { let guard = state.0.lock().await; - if let Some(ref metrics) = guard.metrics { - Ok(Some(UIMetrics { - bytes_sent: metrics.bytes_sent.load(Ordering::Relaxed), - bytes_recv: metrics.bytes_recv.load(Ordering::Relaxed), - })) - } else { - Ok(None) + match &guard.tunnel { + None => Ok(None), + Some(TunnelHandle::InProcess(s)) => Ok(Some(UIMetrics { + bytes_sent: s.metrics.bytes_sent.load(Ordering::Relaxed), + bytes_recv: s.metrics.bytes_recv.load(Ordering::Relaxed), + })), + Some(TunnelHandle::Helper(h)) => { + let ps = h.pipe_state.lock().await; + Ok(Some(UIMetrics { + bytes_sent: ps.bytes_sent, + bytes_recv: ps.bytes_recv, + })) + } } } #[tauri::command] async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result { let mut guard = state.0.lock().await; - if let Some(tx) = guard.shutdown_tx.take() { - let _ = tx.send(true); + match guard.tunnel.take() { + None => {} + Some(TunnelHandle::InProcess(mut s)) => { + if let Some(tx) = s.shutdown_tx.take() { + let _ = tx.send(true); + } + drop(s.handle); + } + Some(TunnelHandle::Helper(h)) => { + let _ = h.cmd_tx.send("{\"cmd\":\"stop\"}\n".to_string()).await; + } } - if let Some(handle) = guard.handle.take() { - let _ = handle.await; - } - guard.metrics = None; Ok(true) } #[tauri::command] async fn start_tunnel(state: tauri::State<'_, AppState>) -> Result { - // Ensure it's stopped first let mut guard = state.0.lock().await; - if let Some(ref h) = guard.handle { - if !h.is_finished() { - return Ok(true); // Already running - } + + // Already running? + match &guard.tunnel { + Some(TunnelHandle::InProcess(s)) if !s.handle.is_finished() => return Ok(true), + Some(TunnelHandle::Helper(_)) => return Ok(true), + _ => {} } + // Clean up finished handle + guard.tunnel = None; let path = get_config_path(); if !path.exists() { - return Err("config.json not found. Go to Settings and configure your key first.".into()); + return Err("config.json not found. Go to Settings and configure your connection first.".into()); } let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; - let unified: UnifiedConfig = serde_json::from_str(&content).map_err(|e| format!("Config parse error: {}", e))?; - + let unified: UnifiedConfig = serde_json::from_str(&content) + .map_err(|e| format!("Config parse error: {}", e))?; + let client_cfg = match unified.mode { AppMode::Client(c) => c, - AppMode::Server(_) => return Err("Configuration is in Server mode. GUI only supports Client configurations.".into()), + AppMode::Server(_) => return Err("Configuration is in Server mode. GUI only supports Client mode.".into()), }; - // Translate to ostp_client domain struct let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false); + + if is_tun_enabled { + // ── TUN mode: launch privileged helper ──────────────────────────────── + start_tun_via_helper(&mut guard, client_cfg, content).await + } else { + // ── Proxy mode: run in-process ──────────────────────────────────────── + start_proxy_in_process(&mut guard, client_cfg).await + } +} + +// ── In-process proxy tunnel ────────────────────────────────────────────────── + +async fn start_proxy_in_process( + guard: &mut AppStateInner, + client_cfg: ClientConfigRaw, +) -> Result { let turn_cfg = client_cfg.turn.as_ref(); - - let mapped_config = ostp_client::config::ClientConfig { - mode: if is_tun_enabled { "tun".to_string() } else { "proxy".to_string() }, + let mapped = ostp_client::config::ClientConfig { + mode: "proxy".to_string(), debug: client_cfg.debug.unwrap_or(false), ostp: ostp_client::config::OstpConfig { server_addr: client_cfg.server.clone(), @@ -231,67 +294,227 @@ async fn start_tunnel(state: tauri::State<'_, AppState>) -> Result enabled: client_cfg.mux.as_ref().and_then(|m| m.enabled).unwrap_or(false), sessions: client_cfg.mux.as_ref().and_then(|m| m.sessions).unwrap_or(1), }, - dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()), + dns_server: None, }; - #[cfg(target_os = "windows")] - if mapped_config.mode == "tun" && !ostp_client::runner::is_admin() { - return Err("Administrator privileges are required to initialize TUN mode. Please run the application as Administrator.".to_string()); - } - let metrics = Arc::new(BridgeMetrics { bytes_sent: portable_atomic::AtomicU64::new(0), bytes_recv: portable_atomic::AtomicU64::new(0), connection_state: portable_atomic::AtomicU8::new(0), }); - + let (shutdown_tx, shutdown_rx) = watch::channel(false); - let metrics_clone = metrics.clone(); - let engine_handle = tokio::spawn(async move { - match ostp_client::runner::run_client_core(mapped_config, metrics_clone, shutdown_rx).await { + let handle = tokio::spawn(async move { + match ostp_client::runner::run_client_core(mapped, metrics_clone, shutdown_rx).await { Ok(_) => Ok(()), Err(e) => Err(e.to_string()), } }); - guard.shutdown_tx = Some(shutdown_tx); - guard.metrics = Some(metrics); - guard.handle = Some(engine_handle); + guard.tunnel = Some(TunnelHandle::InProcess(InProcessState { + shutdown_tx: Some(shutdown_tx), + metrics, + handle, + })); + Ok(true) +} + +// ── Privileged TUN helper via named pipe ───────────────────────────────────── + +const PIPE_NAME: &str = r"\\.\pipe\ostp-tun-helper"; + +async fn start_tun_via_helper( + guard: &mut AppStateInner, + _client_cfg: ClientConfigRaw, + raw_config_json: String, +) -> Result { + // Find the helper binary next to our exe + let helper_exe = find_helper_exe().ok_or_else(|| { + "ostp-tun-helper.exe not found next to the application. Please reinstall.".to_string() + })?; + + // Launch with UAC elevation via ShellExecuteW("runas") + launch_as_admin(&helper_exe).map_err(|e| format!("Failed to launch helper: {}", e))?; + + // Give the helper time to start and create the pipe + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + + // Connect to the helper's named pipe + let pipe = tokio::time::timeout( + std::time::Duration::from_secs(10), + async { + loop { + match tokio::net::windows::named_pipe::ClientOptions::new().open(PIPE_NAME) { + Ok(p) => return Ok::<_, std::io::Error>(p), + Err(_) => tokio::time::sleep(std::time::Duration::from_millis(200)).await, + } + } + } + ).await.map_err(|_| "Timed out connecting to TUN helper. It may have been denied by UAC.".to_string())? + .map_err(|e| format!("Pipe connection error: {}", e))?; + + // Build the config JSON and send start command + let mut mapped_config = serde_json::from_str::(&raw_config_json) + .map_err(|e| e.to_string())?; + // Ensure mode is set + if let Some(obj) = mapped_config.as_object_mut() { + obj.insert("mode".to_string(), serde_json::Value::String("tun".to_string())); + } + let start_cmd = serde_json::json!({ + "cmd": "start", + "config": serde_json::to_string(&mapped_config).unwrap_or_default() + }).to_string(); + + // Set up channel for sending commands to helper task + let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel::(16); + + // Spawn a task that manages the pipe I/O + let pipe_state: Arc> = Arc::new(Mutex::new(HelperPipeState { + connection_state: 1, + bytes_sent: 0, + bytes_recv: 0, + })); + let state_for_task = pipe_state.clone(); + + tokio::spawn(async move { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::io::split; + + let (reader_half, mut writer_half) = split(pipe); + let mut reader = BufReader::new(reader_half); + + // Send the start command + let _ = writer_half.write_all(format!("{}\n", start_cmd).as_bytes()).await; + + // Concurrently: read from pipe, write commands from channel + let mut line = String::new(); + loop { + tokio::select! { + result = reader.read_line(&mut line) => { + let n = result.unwrap_or(0); + if n == 0 { break; } // Helper disconnected + let trimmed = line.trim().to_string(); + line.clear(); + if trimmed.is_empty() { continue; } + if let Ok(msg) = serde_json::from_str::(&trimmed) { + let mut s = state_for_task.lock().await; + match msg { + HelperMsg::Status { value } => s.connection_state = value, + HelperMsg::Metrics { bytes_sent, bytes_recv } => { + s.bytes_sent = bytes_sent; + s.bytes_recv = bytes_recv; + } + HelperMsg::Error { .. } => s.connection_state = 0, + HelperMsg::Log { .. } => {} + } + } + } + cmd = cmd_rx.recv() => { + match cmd { + Some(c) => { let _ = writer_half.write_all(c.as_bytes()).await; } + None => break, + } + } + } + } + // Mark stopped + let mut s = state_for_task.lock().await; + s.connection_state = 0; + }); + + guard.tunnel = Some(TunnelHandle::Helper(HelperState { + pipe_state, + cmd_tx, + })); Ok(true) } -#[cfg(target_os = "windows")] -fn apply_webview_loopback_exemption() { - use std::os::windows::process::CommandExt; - if ostp_client::runner::is_admin() { - // Silently whitelist the standard WebView2 sandbox to communicate with elevated localhost/dev server - let _ = std::process::Command::new("CheckNetIsolation.exe") - .args(["LoopbackExempt", "-a", "-n=Microsoft.Win32WebView2Sandbox_cw5n1h2txyewy"]) - .creation_flags(0x08000000) - .output(); - } +struct HelperPipeState { + connection_state: u8, + bytes_sent: u64, + bytes_recv: u64, } +fn find_helper_exe() -> Option { + // First look next to current exe + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join("ostp-tun-helper.exe"); + if candidate.exists() { + return Some(candidate); + } + } + } + // Dev: look in target/debug next to workspace root + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join("ostp-tun-helper.exe"); + if candidate.exists() { + return Some(candidate); + } + } + } + None +} + +#[cfg(target_os = "windows")] +fn launch_as_admin(exe: &PathBuf) -> Result<()> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::ptr::null_mut; + + let exe_wstr: Vec = exe.as_os_str().encode_wide().chain(Some(0)).collect(); + let verb_wstr: Vec = OsStr::new("runas").encode_wide().chain(Some(0)).collect(); + + #[link(name = "shell32")] + extern "system" { + fn ShellExecuteW( + hwnd: *mut std::ffi::c_void, + lpOperation: *const u16, + lpFile: *const u16, + lpParameters: *const u16, + lpDirectory: *const u16, + nShowCmd: i32, + ) -> isize; + } + + let ret = unsafe { + ShellExecuteW( + null_mut(), + verb_wstr.as_ptr(), + exe_wstr.as_ptr(), + null_mut(), + null_mut(), + 0, // SW_HIDE + ) + }; + + if ret <= 32 { + anyhow::bail!("ShellExecuteW failed (code {}). UAC was denied or helper not found.", ret); + } + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +fn launch_as_admin(_exe: &PathBuf) -> Result<()> { + anyhow::bail!("TUN mode via helper is only supported on Windows"); +} + +// ── Tauri setup ─────────────────────────────────────────────────────────────── + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - #[cfg(target_os = "windows")] - apply_webview_loopback_exemption(); - - let state = AppState(Mutex::new(AppStateInner { - shutdown_tx: None, - metrics: None, - handle: None, - })); + let state = AppState(Mutex::new(AppStateInner { tunnel: None })); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .manage(state) .invoke_handler(tauri::generate_handler![ - start_tunnel, - stop_tunnel, - get_tunnel_status, + start_tunnel, + stop_tunnel, + get_tunnel_status, get_metrics, get_config, save_config diff --git a/ostp-tun-helper/Cargo.toml b/ostp-tun-helper/Cargo.toml new file mode 100644 index 0000000..26662c3 --- /dev/null +++ b/ostp-tun-helper/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ostp-tun-helper" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "ostp-tun-helper" +path = "src/main.rs" + +[dependencies] +ostp-client = { path = "../ostp-client" } +tokio = { workspace = true } +anyhow = { workspace = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +portable-atomic = { workspace = true } + +[build-dependencies] +# no extra build deps needed; manifest is embedded via build.rs + +[target.'cfg(windows)'.build-dependencies] +winres = "0.1" diff --git a/ostp-tun-helper/build.rs b/ostp-tun-helper/build.rs new file mode 100644 index 0000000..2426eb1 --- /dev/null +++ b/ostp-tun-helper/build.rs @@ -0,0 +1,31 @@ +// build.rs for ostp-tun-helper +// Embeds a Windows manifest that requests Administrator privileges. +// This makes Windows show a UAC prompt when the binary is double-clicked +// or launched via ShellExecuteW("runas"). + +fn main() { + #[cfg(windows)] + { + let mut res = winres::WindowsResource::new(); + res.set_manifest(r#" + + + + + + + + + + + + + + + +"#); + res.compile().expect("failed to compile Windows resources"); + } +} diff --git a/ostp-tun-helper/src/main.rs b/ostp-tun-helper/src/main.rs new file mode 100644 index 0000000..671c550 --- /dev/null +++ b/ostp-tun-helper/src/main.rs @@ -0,0 +1,226 @@ +// ostp-tun-helper/src/main.rs +// +// Privileged helper for TUN mode. Runs with Administrator rights. +// Communicates with ostp-gui via a named pipe IPC channel. +// +// Protocol over the named pipe (newline-delimited JSON): +// GUI -> Helper: {"cmd":"start","config":} +// GUI -> Helper: {"cmd":"stop"} +// Helper -> GUI: {"type":"status","value":0|1|2} (0=stopped,1=connecting,2=connected) +// Helper -> GUI: {"type":"log","message":"..."} +// Helper -> GUI: {"type":"metrics","bytes_sent":N,"bytes_recv":N} + +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{watch, Mutex}; +use portable_atomic::Ordering; + +const PIPE_NAME: &str = r"\\.\pipe\ostp-tun-helper"; + +#[derive(Deserialize)] +#[serde(tag = "cmd", rename_all = "lowercase")] +enum GuiCmd { + Start { config: String }, + Stop, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +enum HelperMsg { + Status { value: u8 }, + Log { message: String }, + Metrics { bytes_sent: u64, bytes_recv: u64 }, + Error { message: String }, +} + +struct TunnelState { + shutdown_tx: Option>, + metrics: Option>, +} + +#[tokio::main] +async fn main() -> Result<()> { + // The helper is always launched by the GUI. If no client connects within + // 60 seconds, exit to avoid lingering admin processes. + run_pipe_server().await +} + +async fn run_pipe_server() -> Result<()> { + use tokio::net::windows::named_pipe::{ServerOptions}; + + let state = Arc::new(Mutex::new(TunnelState { + shutdown_tx: None, + metrics: None, + })); + + // Create the named pipe server + let server = ServerOptions::new() + .first_pipe_instance(true) + .create(PIPE_NAME)?; + + // Wait for GUI to connect (60 second timeout) + let connect_timeout = tokio::time::timeout( + Duration::from_secs(60), + server.connect() + ).await; + + let pipe = match connect_timeout { + Ok(Ok(())) => server, + _ => { + // No client connected — exit silently + return Ok(()); + } + }; + + let (reader_half, writer_half) = tokio::io::split(pipe); + let writer = Arc::new(Mutex::new(writer_half)); + let mut reader = BufReader::new(reader_half); + + // Helper to send a message back to GUI + let send_msg = { + let writer = writer.clone(); + move |msg: HelperMsg| { + let writer = writer.clone(); + let json = serde_json::to_string(&msg).unwrap_or_default(); + tokio::spawn(async move { + let mut w = writer.lock().await; + let _ = w.write_all(format!("{}\n", json).as_bytes()).await; + }); + } + }; + + // Read commands from GUI + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line).await.unwrap_or(0); + if n == 0 { + // GUI disconnected — stop tunnel and exit + let mut st = state.lock().await; + if let Some(tx) = st.shutdown_tx.take() { + let _ = tx.send(true); + } + break; + } + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let cmd: GuiCmd = match serde_json::from_str(trimmed) { + Ok(c) => c, + Err(e) => { + send_msg(HelperMsg::Error { message: format!("Bad command: {}", e) }); + continue; + } + }; + + match cmd { + GuiCmd::Start { config } => { + // Stop any existing tunnel first + { + let mut st = state.lock().await; + if let Some(tx) = st.shutdown_tx.take() { + let _ = tx.send(true); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Parse config + let cfg: ostp_client::config::ClientConfig = match serde_json::from_str(&config) { + Ok(c) => c, + Err(e) => { + send_msg(HelperMsg::Error { message: format!("Config parse error: {}", e) }); + continue; + } + }; + + let metrics = Arc::new(ostp_client::bridge::BridgeMetrics { + bytes_sent: portable_atomic::AtomicU64::new(0), + bytes_recv: portable_atomic::AtomicU64::new(0), + connection_state: portable_atomic::AtomicU8::new(0), + }); + + let (shutdown_tx, shutdown_rx) = watch::channel(false); + + { + let mut st = state.lock().await; + st.shutdown_tx = Some(shutdown_tx); + st.metrics = Some(metrics.clone()); + } + + // Spawn the tunnel + let metrics_for_runner = metrics.clone(); + let send_log = { + let writer = writer.clone(); + move |msg: String| { + let writer = writer.clone(); + let json = serde_json::to_string(&HelperMsg::Log { message: msg }).unwrap_or_default(); + tokio::spawn(async move { + let mut w = writer.lock().await; + let _ = w.write_all(format!("{}\n", json).as_bytes()).await; + }); + } + }; + + let writer_for_tick = writer.clone(); + let metrics_for_tick = metrics.clone(); + + tokio::spawn(async move { + match ostp_client::runner::run_client_core(cfg, metrics_for_runner, shutdown_rx).await { + Ok(_) => {} + Err(e) => { + let json = serde_json::to_string(&HelperMsg::Error { message: e.to_string() }).unwrap_or_default(); + let mut w = writer_for_tick.lock().await; + let _ = w.write_all(format!("{}\n", json).as_bytes()).await; + } + } + }); + + // Spawn a tick that forwards status + metrics to GUI every second + let writer_tick = writer.clone(); + let metrics_tick = metrics_for_tick.clone(); + tokio::spawn(async move { + let mut last_state = 99u8; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let cs = metrics_tick.connection_state.load(Ordering::Relaxed); + let sent = metrics_tick.bytes_sent.load(Ordering::Relaxed); + let recv = metrics_tick.bytes_recv.load(Ordering::Relaxed); + + let mut w = writer_tick.lock().await; + // Only send status change events + if cs != last_state { + last_state = cs; + let json = serde_json::to_string(&HelperMsg::Status { value: cs }).unwrap_or_default(); + if w.write_all(format!("{}\n", json).as_bytes()).await.is_err() { break; } + } + // Always send metrics + let json = serde_json::to_string(&HelperMsg::Metrics { bytes_sent: sent, bytes_recv: recv }).unwrap_or_default(); + if w.write_all(format!("{}\n", json).as_bytes()).await.is_err() { break; } + drop(w); + } + }); + + send_msg(HelperMsg::Status { value: 1 }); + } + + GuiCmd::Stop => { + let mut st = state.lock().await; + if let Some(tx) = st.shutdown_tx.take() { + let _ = tx.send(true); + } + st.metrics = None; + send_msg(HelperMsg::Status { value: 0 }); + } + } + } + + Ok(()) +} diff --git a/test_tun/tun2socks-linux-amd64 b/test_tun/tun2socks-linux-amd64 new file mode 100644 index 0000000..49dc4c6 Binary files /dev/null and b/test_tun/tun2socks-linux-amd64 differ