feat(gui): privileged TUN helper architecture - GUI runs unprivileged, UAC prompt shown only for TUN mode via ostp-tun-helper.exe IPC

This commit is contained in:
ospab 2026-05-15 23:08:14 +03:00
parent 5d9034ca1e
commit b0491e14e3
9 changed files with 849 additions and 110 deletions

31
Cargo.lock generated
View File

@ -2687,6 +2687,19 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "ostp-tun-helper"
version = "0.1.43"
dependencies = [
"anyhow",
"ostp-client",
"portable-atomic",
"serde",
"serde_json",
"tokio",
"winres",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@ -4169,6 +4182,15 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.8.2" version = "0.8.2"
@ -5204,6 +5226,15 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"

View File

@ -4,7 +4,8 @@ members = [
"ostp-client", "ostp-client",
"ostp-server", "ostp-server",
"ostp-jni", "ostp", "ostp-jni", "ostp",
"ostp-gui/src-tauri" "ostp-gui/src-tauri",
"ostp-tun-helper"
] ]
resolver = "2" resolver = "2"

232
ostp-gui/package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

View File

@ -1,30 +1,3 @@
fn main() { fn main() {
let mut windows = tauri_build::WindowsAttributes::new(); tauri_build::build()
// Define the manifest with requireAdministrator to allow TUN mode without terminal
// and include Common-Controls v6 for modern UI elements/dialogs.
let manifest = r#"
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*" />
</dependentAssembly>
</dependency>
</assembly>
"#;
windows = windows.app_manifest(manifest);
tauri_build::try_build(
tauri_build::Attributes::new()
.windows_attributes(windows)
)
.expect("failed to run build script");
} }

View File

@ -7,11 +7,12 @@ use anyhow::Result;
use ostp_client::bridge::BridgeMetrics; use ostp_client::bridge::BridgeMetrics;
use portable_atomic::Ordering; use portable_atomic::Ordering;
// Config deserialization matching ostp core // ── Config types ─────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(tag = "mode", rename_all = "lowercase")] #[serde(tag = "mode", rename_all = "lowercase")]
enum AppMode { enum AppMode {
Server(serde_json::Value), // We ignore server config in GUI Server(serde_json::Value),
Client(ClientConfigRaw), Client(ClientConfigRaw),
} }
@ -69,26 +70,58 @@ struct UIMetrics {
bytes_recv: u64, 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<watch::Sender<bool>>, shutdown_tx: Option<watch::Sender<bool>>,
metrics: Option<Arc<BridgeMetrics>>, metrics: Arc<BridgeMetrics>,
handle: Option<JoinHandle<Result<(), String>>>, handle: JoinHandle<Result<(), String>>,
}
// For TUN mode: communicates with the privileged helper via named pipe.
struct HelperState {
/// Shared state updated by pipe reader task
pipe_state: Arc<Mutex<HelperPipeState>>,
/// Send commands to helper over named pipe
cmd_tx: tokio::sync::mpsc::Sender<String>,
}
enum TunnelHandle {
InProcess(InProcessState),
Helper(HelperState),
}
struct AppStateInner {
tunnel: Option<TunnelHandle>,
} }
impl Drop for AppStateInner { impl Drop for AppStateInner {
fn drop(&mut self) { fn drop(&mut self) {
// Send final signal to ensure the core background threads exit immediately if let Some(TunnelHandle::InProcess(ref mut s)) = self.tunnel {
// and activate Wintun routing cleanup Drop routines. if let Some(tx) = s.shutdown_tx.take() {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(true); let _ = tx.send(true);
} }
} }
}
} }
struct AppState(Mutex<AppStateInner>); struct AppState(Mutex<AppStateInner>);
// ── Config helpers ────────────────────────────────────────────────────────────
fn get_config_path() -> PathBuf { 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 Ok(exe_path) = std::env::current_exe() {
if let Some(parent) = exe_path.parent() { if let Some(parent) = exe_path.parent() {
let path = parent.join("config.json"); let path = parent.join("config.json");
@ -100,11 +133,12 @@ fn get_config_path() -> PathBuf {
PathBuf::from("config.json") PathBuf::from("config.json")
} }
// ── Tauri commands ────────────────────────────────────────────────────────────
#[tauri::command] #[tauri::command]
async fn get_config() -> Result<String, String> { async fn get_config() -> Result<String, String> {
let path = get_config_path(); let path = get_config_path();
if !path.exists() { if !path.exists() {
// Return default template if file missing
return Ok(r#"{ return Ok(r#"{
"mode": "client", "mode": "client",
"log_level": "info", "log_level": "info",
@ -124,10 +158,8 @@ async fn get_config() -> Result<String, String> {
#[tauri::command] #[tauri::command]
async fn save_config(json_content: String) -> Result<bool, String> { async fn save_config(json_content: String) -> Result<bool, String> {
// Validate formatting
let _parsed: UnifiedConfig = serde_json::from_str(&json_content) let _parsed: UnifiedConfig = serde_json::from_str(&json_content)
.map_err(|e| format!("Invalid OSTP config JSON: {}", e))?; .map_err(|e| format!("Invalid OSTP config JSON: {}", e))?;
let path = get_config_path(); let path = get_config_path();
std::fs::write(path, json_content).map_err(|e| format!("Write error: {}", e))?; std::fs::write(path, json_content).map_err(|e| format!("Write error: {}", e))?;
Ok(true) Ok(true)
@ -136,74 +168,105 @@ async fn save_config(json_content: String) -> Result<bool, String> {
#[tauri::command] #[tauri::command]
async fn get_tunnel_status(state: tauri::State<'_, AppState>) -> Result<u8, String> { async fn get_tunnel_status(state: tauri::State<'_, AppState>) -> Result<u8, String> {
let guard = state.0.lock().await; let guard = state.0.lock().await;
if let Some(ref handle) = guard.handle { match &guard.tunnel {
if handle.is_finished() { None => Ok(0),
Some(TunnelHandle::InProcess(s)) => {
if s.handle.is_finished() {
return Ok(0); return Ok(0);
} }
if let Some(ref metrics) = guard.metrics { Ok(s.metrics.connection_state.load(Ordering::Relaxed))
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] #[tauri::command]
async fn get_metrics(state: tauri::State<'_, AppState>) -> Result<Option<UIMetrics>, String> { async fn get_metrics(state: tauri::State<'_, AppState>) -> Result<Option<UIMetrics>, String> {
let guard = state.0.lock().await; let guard = state.0.lock().await;
if let Some(ref metrics) = guard.metrics { 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 { Ok(Some(UIMetrics {
bytes_sent: metrics.bytes_sent.load(Ordering::Relaxed), bytes_sent: ps.bytes_sent,
bytes_recv: metrics.bytes_recv.load(Ordering::Relaxed), bytes_recv: ps.bytes_recv,
})) }))
} else { }
Ok(None)
} }
} }
#[tauri::command] #[tauri::command]
async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String> { async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String> {
let mut guard = state.0.lock().await; let mut guard = state.0.lock().await;
if let Some(tx) = guard.shutdown_tx.take() { match guard.tunnel.take() {
None => {}
Some(TunnelHandle::InProcess(mut s)) => {
if let Some(tx) = s.shutdown_tx.take() {
let _ = tx.send(true); let _ = tx.send(true);
} }
if let Some(handle) = guard.handle.take() { drop(s.handle);
let _ = handle.await; }
Some(TunnelHandle::Helper(h)) => {
let _ = h.cmd_tx.send("{\"cmd\":\"stop\"}\n".to_string()).await;
}
} }
guard.metrics = None;
Ok(true) Ok(true)
} }
#[tauri::command] #[tauri::command]
async fn start_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String> { async fn start_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String> {
// Ensure it's stopped first
let mut guard = state.0.lock().await; let mut guard = state.0.lock().await;
if let Some(ref h) = guard.handle {
if !h.is_finished() { // Already running?
return Ok(true); // 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(); let path = get_config_path();
if !path.exists() { 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 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 { let client_cfg = match unified.mode {
AppMode::Client(c) => c, 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); let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false);
let turn_cfg = client_cfg.turn.as_ref();
let mapped_config = ostp_client::config::ClientConfig { if is_tun_enabled {
mode: if is_tun_enabled { "tun".to_string() } else { "proxy".to_string() }, // ── 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<bool, String> {
let turn_cfg = client_cfg.turn.as_ref();
let mapped = ostp_client::config::ClientConfig {
mode: "proxy".to_string(),
debug: client_cfg.debug.unwrap_or(false), debug: client_cfg.debug.unwrap_or(false),
ostp: ostp_client::config::OstpConfig { ostp: ostp_client::config::OstpConfig {
server_addr: client_cfg.server.clone(), server_addr: client_cfg.server.clone(),
@ -231,14 +294,9 @@ async fn start_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String>
enabled: client_cfg.mux.as_ref().and_then(|m| m.enabled).unwrap_or(false), 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), 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 { let metrics = Arc::new(BridgeMetrics {
bytes_sent: portable_atomic::AtomicU64::new(0), bytes_sent: portable_atomic::AtomicU64::new(0),
bytes_recv: portable_atomic::AtomicU64::new(0), bytes_recv: portable_atomic::AtomicU64::new(0),
@ -246,44 +304,209 @@ async fn start_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String>
}); });
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (shutdown_tx, shutdown_rx) = watch::channel(false);
let metrics_clone = metrics.clone(); let metrics_clone = metrics.clone();
let engine_handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
match ostp_client::runner::run_client_core(mapped_config, metrics_clone, shutdown_rx).await { match ostp_client::runner::run_client_core(mapped, metrics_clone, shutdown_rx).await {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => Err(e.to_string()), Err(e) => Err(e.to_string()),
} }
}); });
guard.shutdown_tx = Some(shutdown_tx); guard.tunnel = Some(TunnelHandle::InProcess(InProcessState {
guard.metrics = Some(metrics); shutdown_tx: Some(shutdown_tx),
guard.handle = Some(engine_handle); 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<bool, String> {
// 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::<serde_json::Value>(&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::<String>(16);
// Spawn a task that manages the pipe I/O
let pipe_state: Arc<Mutex<HelperPipeState>> = 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::<HelperMsg>(&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) Ok(true)
} }
#[cfg(target_os = "windows")] struct HelperPipeState {
fn apply_webview_loopback_exemption() { connection_state: u8,
use std::os::windows::process::CommandExt; bytes_sent: u64,
if ostp_client::runner::is_admin() { bytes_recv: u64,
// 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();
}
} }
fn find_helper_exe() -> Option<PathBuf> {
// 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<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
let verb_wstr: Vec<u16> = 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
#[cfg(target_os = "windows")] let state = AppState(Mutex::new(AppStateInner { tunnel: None }));
apply_webview_loopback_exemption();
let state = AppState(Mutex::new(AppStateInner {
shutdown_tx: None,
metrics: None,
handle: None,
}));
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())

View File

@ -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"

31
ostp-tun-helper/build.rs Normal file
View File

@ -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#"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls"
version="6.0.0.0" processorArchitecture="*"
publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
</assembly>
"#);
res.compile().expect("failed to compile Windows resources");
}
}

226
ostp-tun-helper/src/main.rs Normal file
View File

@ -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":<config json string>}
// 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<watch::Sender<bool>>,
metrics: Option<Arc<ostp_client::bridge::BridgeMetrics>>,
}
#[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(())
}

Binary file not shown.