From 48883ddd03b5d344f7b1d1051dcea3657a3b7cd4 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 4 Apr 2026 03:16:04 +0400 Subject: [PATCH] refactor: more robust vpn handling --- .github/workflows/lint-rs.yml | 12 +- package.json | 2 +- src-tauri/src/ip_utils.rs | 23 +- src-tauri/src/lib.rs | 133 ++++-- src-tauri/src/proxy_runner.rs | 151 +++++- src-tauri/src/traffic_stats.rs | 8 +- src-tauri/src/vpn/openvpn_socks5.rs | 699 +++++++++++++++++++++++++--- src-tauri/src/vpn/socks5_server.rs | 59 ++- src-tauri/src/vpn_worker_runner.rs | 162 +++++-- src-tauri/tests/test_harness/mod.rs | 201 ++++++-- src-tauri/tests/vpn_integration.rs | 539 ++++++++++++++++++++- src/components/vpn-form-dialog.tsx | 80 ++++ src/i18n/locales/en.json | 10 + src/i18n/locales/es.json | 10 + src/i18n/locales/fr.json | 10 + src/i18n/locales/ja.json | 10 + src/i18n/locales/pt.json | 10 + src/i18n/locales/ru.json | 10 + src/i18n/locales/zh.json | 10 + 19 files changed, 1936 insertions(+), 203 deletions(-) diff --git a/.github/workflows/lint-rs.yml b/.github/workflows/lint-rs.yml index 5f9bedb..6c1cb38 100644 --- a/.github/workflows/lint-rs.yml +++ b/.github/workflows/lint-rs.yml @@ -67,7 +67,7 @@ jobs: if: matrix.os == 'ubuntu-22.04' run: | sudo apt-get update - sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev + sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev openvpn - name: Install frontend dependencies run: pnpm install --frozen-lockfile @@ -117,6 +117,16 @@ jobs: run: cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration working-directory: src-tauri + - name: Run OpenVPN e2e test (Ubuntu only) + if: matrix.os == 'ubuntu-22.04' + shell: bash + working-directory: src-tauri + env: + DONUTBROWSER_RUN_OPENVPN_E2E: "1" + run: | + sudo --preserve-env=PATH,CARGO_HOME,RUSTUP_HOME,DONUTBROWSER_RUN_OPENVPN_E2E \ + cargo test --test vpn_integration test_openvpn_traffic_flows_through_donut_proxy -- --nocapture + - name: Run Rust sync e2e tests run: node scripts/sync-test-harness.mjs diff --git a/package.json b/package.json index 38ccf3f..d894be6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "start": "next start", "test": "pnpm test:rust:unit && pnpm test:sync-e2e", "test:rust": "cd src-tauri && cargo test", - "test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration", + "test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration", "test:sync-e2e": "node scripts/sync-test-harness.mjs", "lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell", "lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit", diff --git a/src-tauri/src/ip_utils.rs b/src-tauri/src/ip_utils.rs index 864de89..3e35c44 100644 --- a/src-tauri/src/ip_utils.rs +++ b/src-tauri/src/ip_utils.rs @@ -55,6 +55,7 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result { let proxy = reqwest::Proxy::all(proxy_url) .map_err(|e| IpError::Network(format!("Invalid proxy: {}", e)))?; client_builder + .no_proxy() .proxy(proxy) .build() .map_err(|e| IpError::Network(e.to_string()))? @@ -64,7 +65,7 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result { .map_err(|e| IpError::Network(e.to_string()))? }; - let mut last_error = None; + let mut errors = Vec::new(); for url in &urls { match client.get(*url).send().await { @@ -76,21 +77,29 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result { } } Err(e) => { - last_error = Some(format!("Failed to read response from {}: {}", url, e)); + errors.push(format!("{}: {}", url, e)); } }, Ok(response) => { - last_error = Some(format!("HTTP {} from {}", response.status(), url)); + errors.push(format!("{}: HTTP {}", url, response.status())); } Err(e) => { - last_error = Some(format!("Request to {} failed: {}", url, e)); + errors.push(format!("{}: {}", url, e)); } } } - Err(IpError::Network(last_error.unwrap_or_else(|| { - "Failed to fetch public IP from any endpoint".to_string() - }))) + if errors.is_empty() { + Err(IpError::Network( + "Failed to fetch public IP from any endpoint".to_string(), + )) + } else { + Err(IpError::Network(format!( + "All {} endpoints failed: {}", + errors.len(), + errors.join("; ") + ))) + } } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d98832..aa86e3f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -813,6 +813,42 @@ async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), Str } // VPN commands +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct VpnDependencyStatus { + is_available: bool, + requires_external_install: bool, + missing_binary: bool, + missing_windows_adapter: bool, + dependency_check_failed: bool, +} + +#[tauri::command] +async fn get_vpn_dependency_status(vpn_type: vpn::VpnType) -> Result { + match vpn_type { + vpn::VpnType::WireGuard => Ok(VpnDependencyStatus { + is_available: true, + requires_external_install: false, + missing_binary: false, + missing_windows_adapter: false, + dependency_check_failed: false, + }), + vpn::VpnType::OpenVPN => { + let status = crate::vpn::openvpn_socks5::OpenVpnSocks5Server::dependency_status(); + let is_available = + status.binary_found && !status.missing_windows_adapter && !status.dependency_check_failed; + + Ok(VpnDependencyStatus { + is_available, + requires_external_install: true, + missing_binary: !status.binary_found, + missing_windows_adapter: status.missing_windows_adapter, + dependency_check_failed: status.dependency_check_failed, + }) + } + } +} + #[tauri::command] async fn import_vpn_config( content: String, @@ -986,45 +1022,81 @@ async fn check_vpn_validity( .unwrap_or_default() .as_secs(); - // Start a temporary VPN worker to send real traffic + let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id).is_some(); + let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id) .await .map_err(|e| format!("Failed to start VPN worker: {e}"))?; - let socks_url = format!("socks5://127.0.0.1:{}", vpn_worker.local_port.unwrap_or(0)); + let socks_url = format!( + "socks5://127.0.0.1:{}", + vpn_worker.local_port.unwrap_or_default() + ); - // Fetch public IP through the VPN SOCKS5 proxy - let result = match ip_utils::fetch_public_ip(Some(&socks_url)).await { - Ok(ip) => { - let (city, country, country_code) = - crate::proxy_manager::ProxyManager::get_ip_geolocation(&ip) - .await - .unwrap_or_default(); - - crate::proxy_manager::ProxyCheckResult { - ip, - city, - country, - country_code, - timestamp: now, - is_valid: true, - } - } - Err(e) => { - log::warn!("VPN check failed to fetch public IP: {e}"); - crate::proxy_manager::ProxyCheckResult { - ip: String::new(), - city: None, - country: None, - country_code: None, - timestamp: now, - is_valid: false, + let local_proxy = crate::proxy_runner::start_proxy_process(Some(socks_url), None) + .await + .map_err(|error| error.to_string()); + let local_proxy = match local_proxy { + Ok(proxy) => proxy, + Err(error_message) => { + if !had_existing_worker { + let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await; } + return Err(format!("Failed to start validation proxy: {error_message}")); } }; - // Stop the temporary VPN worker - let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await; + let local_proxy_url = format!( + "http://127.0.0.1:{}", + local_proxy.local_port.unwrap_or_default() + ); + + let mut result = None; + for attempt in 0..3 { + if attempt > 0 { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + + match ip_utils::fetch_public_ip(Some(&local_proxy_url)).await { + Ok(ip) => { + let (city, country, country_code) = + crate::proxy_manager::ProxyManager::get_ip_geolocation(&ip) + .await + .unwrap_or_default(); + + result = Some(crate::proxy_manager::ProxyCheckResult { + ip, + city, + country, + country_code, + timestamp: now, + is_valid: true, + }); + break; + } + Err(error) => { + log::warn!( + "VPN validation attempt {} failed to fetch public IP through donut-proxy: {}", + attempt + 1, + error + ); + } + } + } + + let _ = crate::proxy_runner::stop_proxy_process(&local_proxy.id).await; + if !had_existing_worker { + let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await; + } + + let result = result.unwrap_or(crate::proxy_manager::ProxyCheckResult { + ip: String::new(), + city: None, + country: None, + country_code: None, + timestamp: now, + is_valid: false, + }); Ok(result) } @@ -1932,6 +2004,7 @@ pub fn run() { add_mcp_to_claude_code, remove_mcp_from_claude_code, // VPN commands + get_vpn_dependency_status, import_vpn_config, list_vpn_configs, get_vpn_config, diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index bf016b3..e3095f2 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -2,12 +2,161 @@ use crate::proxy_storage::{ delete_proxy_config, generate_proxy_id, get_proxy_config, is_process_running, list_proxy_configs, save_proxy_config, ProxyConfig, }; +use std::path::{Path, PathBuf}; use std::process::Stdio; lazy_static::lazy_static! { static ref PROXY_PROCESSES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::HashMap::new()); } +fn target_binary_name(base_name: &str) -> Option { + let target = std::env::var("TARGET").ok()?; + + #[cfg(windows)] + { + Some(format!("{base_name}-{target}.exe")) + } + + #[cfg(not(windows))] + { + Some(format!("{base_name}-{target}")) + } +} + +fn unsuffixed_binary_name(base_name: &str) -> String { + #[cfg(windows)] + { + match base_name { + "donut-proxy" => "donut-proxy.exe".to_string(), + "donut-daemon" => "donut-daemon.exe".to_string(), + _ => String::new(), + } + } + + #[cfg(not(windows))] + { + base_name.to_string() + } +} + +fn binary_matches_prefix(path: &Path, base_name: &str) -> bool { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + + #[cfg(windows)] + { + file_name.starts_with(&format!("{base_name}-")) && file_name.ends_with(".exe") + } + + #[cfg(not(windows))] + { + file_name.starts_with(&format!("{base_name}-")) + } +} + +fn push_candidate_dir(dirs: &mut Vec, dir: Option) { + if let Some(dir) = dir { + if !dirs.iter().any(|existing| existing == &dir) { + dirs.push(dir); + } + } +} + +pub(crate) fn find_sidecar_executable( + base_name: &str, +) -> Result> { + let current_exe = std::env::current_exe()?; + let current_dir = current_exe + .parent() + .ok_or("Failed to get parent directory of current executable")?; + + if current_exe + .file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| stem == base_name) + { + return Ok(current_exe); + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut search_dirs = Vec::new(); + + push_candidate_dir(&mut search_dirs, Some(current_dir.to_path_buf())); + push_candidate_dir( + &mut search_dirs, + current_dir.parent().map(std::path::Path::to_path_buf), + ); + push_candidate_dir( + &mut search_dirs, + current_dir + .parent() + .and_then(|parent| parent.parent()) + .map(Path::to_path_buf), + ); + push_candidate_dir(&mut search_dirs, Some(current_dir.join("binaries"))); + push_candidate_dir( + &mut search_dirs, + current_dir.parent().map(|parent| parent.join("binaries")), + ); + push_candidate_dir( + &mut search_dirs, + current_dir + .parent() + .and_then(|parent| parent.parent()) + .map(|parent| parent.join("binaries")), + ); + push_candidate_dir(&mut search_dirs, Some(manifest_dir.join("binaries"))); + push_candidate_dir( + &mut search_dirs, + Some(manifest_dir.join("target").join("debug")), + ); + push_candidate_dir( + &mut search_dirs, + Some(manifest_dir.join("target").join("release")), + ); + + let mut exact_names = vec![unsuffixed_binary_name(base_name)]; + if let Some(target_name) = target_binary_name(base_name) { + exact_names.push(target_name); + } + + for dir in &search_dirs { + for name in &exact_names { + if name.is_empty() { + continue; + } + + let candidate = dir.join(name); + if candidate.exists() { + return Ok(candidate); + } + } + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && binary_matches_prefix(&path, base_name) { + return Ok(path); + } + } + } + } + + Err( + format!( + "Failed to locate '{}' executable. Searched in: {}", + base_name, + search_dirs + .iter() + .map(|dir| dir.display().to_string()) + .collect::>() + .join(", ") + ) + .into(), + ) +} + pub async fn start_proxy_process( upstream_url: Option, port: Option, @@ -47,7 +196,7 @@ pub async fn start_proxy_process_with_profile( // Spawn proxy worker process in the background using std::process::Command // This ensures proper process detachment on Unix systems - let exe = std::env::current_exe()?; + let exe = find_sidecar_executable("donut-proxy")?; #[cfg(unix)] { diff --git a/src-tauri/src/traffic_stats.rs b/src-tauri/src/traffic_stats.rs index e7c52a1..55d5ce5 100644 --- a/src-tauri/src/traffic_stats.rs +++ b/src-tauri/src/traffic_stats.rs @@ -580,7 +580,9 @@ impl LiveTrafficTracker { .profile_id .clone() .unwrap_or_else(|| self.proxy_id.clone()); - let session_file = get_traffic_stats_dir().join(format!("{}.session.json", storage_key)); + let storage_dir = get_traffic_stats_dir(); + fs::create_dir_all(&storage_dir)?; + let session_file = storage_dir.join(format!("{}.session.json", storage_key)); // Write atomically using a temp file let temp_file = session_file.with_extension("tmp"); @@ -761,9 +763,11 @@ impl LiveTrafficTracker { .profile_id .clone() .unwrap_or_else(|| self.proxy_id.clone()); + let storage_dir = get_traffic_stats_dir(); + fs::create_dir_all(&storage_dir)?; // Use file locking to prevent concurrent writes from multiple proxy processes - let lock_path = get_traffic_stats_dir().join(format!("{}.lock", storage_key)); + let lock_path = storage_dir.join(format!("{}.lock", storage_key)); let _lock = match acquire_file_lock(&lock_path) { Ok(lock) => lock, Err(e) => { diff --git a/src-tauri/src/vpn/openvpn_socks5.rs b/src-tauri/src/vpn/openvpn_socks5.rs index 3c954b1..216e73b 100644 --- a/src-tauri/src/vpn/openvpn_socks5.rs +++ b/src-tauri/src/vpn/openvpn_socks5.rs @@ -1,8 +1,24 @@ use super::config::{OpenVpnConfig, VpnError}; -use std::path::PathBuf; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{lookup_host, TcpListener, TcpSocket, TcpStream}; + +const OPENVPN_CONNECT_TIMEOUT_SECS: u64 = 90; + +enum SocksTarget { + Address(SocketAddr), + Domain(String, u16), +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct OpenVpnDependencyStatus { + pub binary_found: bool, + pub missing_windows_adapter: bool, + pub dependency_check_failed: bool, +} pub struct OpenVpnSocks5Server { config: OpenVpnConfig, @@ -14,7 +30,167 @@ impl OpenVpnSocks5Server { Self { config, port } } - fn find_openvpn_binary() -> Result { + fn read_log_tail(path: &Path, lines: usize) -> String { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .rev() + .take(lines) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n") + } + + fn extract_vpn_ip(line: &str) -> Option { + for field in line.split(',') { + let trimmed = field.trim(); + if let Ok(ip) = trimmed.parse::() { + if ip.is_private() && !ip.is_loopback() { + return Some(ip); + } + } + } + + None + } + + fn log_indicates_connected(log_content: &str) -> bool { + log_content.contains("Initialization Sequence Completed") + } + + fn log_indicates_failure(log_content: &str) -> bool { + log_content.contains("AUTH_FAILED") + || log_content.contains("Exiting due to fatal error") + || log_content.contains("Fatal error") + || log_content.contains("Options error") + || log_content.contains("Exiting") + } + + fn has_config_directive(config: &str, directive: &str) -> bool { + config.lines().any(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() + && !trimmed.starts_with('#') + && !trimmed.starts_with(';') + && trimmed.starts_with(directive) + }) + } + + fn strip_config_directive(config: &str, directive: &str) -> String { + config + .lines() + .filter(|line| { + let trimmed = line.trim(); + trimmed.is_empty() + || trimmed.starts_with('#') + || trimmed.starts_with(';') + || !trimmed.starts_with(directive) + }) + .collect::>() + .join("\n") + } + + fn build_runtime_config(&self) -> String { + let mut runtime_config = self.config.raw_config.clone(); + + runtime_config = Self::strip_config_directive(&runtime_config, "redirect-gateway"); + runtime_config = Self::strip_config_directive(&runtime_config, "block-outside-dns"); + runtime_config = Self::strip_config_directive(&runtime_config, "dhcp-option"); + + if !runtime_config.contains("pull-filter ignore \"redirect-gateway\"") { + runtime_config.push_str("\npull-filter ignore \"redirect-gateway\"\n"); + } + if !runtime_config.contains("pull-filter ignore \"block-outside-dns\"") { + runtime_config.push_str("pull-filter ignore \"block-outside-dns\"\n"); + } + if !runtime_config.contains("pull-filter ignore \"dhcp-option\"") { + runtime_config.push_str("pull-filter ignore \"dhcp-option\"\n"); + } + + if !Self::has_config_directive(&runtime_config, "route 0.0.0.0") { + runtime_config.push_str("\nroute 0.0.0.0 0.0.0.0 vpn_gateway 9999\n"); + } + + #[cfg(windows)] + { + if Self::has_config_directive(&runtime_config, "dev-node") { + runtime_config = runtime_config + .lines() + .filter(|line| { + let trimmed = line.trim(); + trimmed.is_empty() + || trimmed.starts_with('#') + || trimmed.starts_with(';') + || !trimmed.starts_with("dev-node") + }) + .collect::>() + .join("\n"); + } + + if !Self::has_config_directive(&runtime_config, "disable-dco") { + runtime_config.push_str("\ndisable-dco\n"); + } + + if self.config.dev_type.starts_with("tun") + && !Self::has_config_directive(&runtime_config, "windows-driver") + { + runtime_config.push_str("\nwindows-driver wintun\n"); + } + } + + runtime_config + } + + pub(crate) fn dependency_status() -> OpenVpnDependencyStatus { + let Ok(openvpn_bin) = Self::find_openvpn_binary() else { + return OpenVpnDependencyStatus { + binary_found: false, + missing_windows_adapter: false, + dependency_check_failed: false, + }; + }; + + #[cfg(windows)] + { + match Self::windows_openvpn_has_adapter(&openvpn_bin) { + Ok(has_adapter) => OpenVpnDependencyStatus { + binary_found: true, + missing_windows_adapter: !has_adapter, + dependency_check_failed: false, + }, + Err(_) => OpenVpnDependencyStatus { + binary_found: true, + missing_windows_adapter: false, + dependency_check_failed: true, + }, + } + } + + #[cfg(not(windows))] + { + OpenVpnDependencyStatus { + binary_found: true, + missing_windows_adapter: false, + dependency_check_failed: false, + } + } + } + + pub(crate) fn find_openvpn_binary() -> Result { + if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") { + let path = PathBuf::from(path); + if path.exists() { + return Ok(path); + } + + return Err(VpnError::Connection(format!( + "Configured OpenVPN binary does not exist: {}", + path.display() + ))); + } + let locations = [ "/usr/sbin/openvpn", "/usr/local/sbin/openvpn", @@ -71,12 +247,300 @@ impl OpenVpnSocks5Server { )) } + fn openvpn_supports_management(openvpn_bin: &Path) -> bool { + let mut command = Command::new(openvpn_bin); + command.arg("--version"); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + command.creation_flags(CREATE_NO_WINDOW); + } + + let Ok(output) = command.output() else { + return true; + }; + + let version_text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + !version_text.contains("enable_management=no") + } + + #[cfg(windows)] + pub(crate) fn windows_openvpn_has_adapter(openvpn_bin: &Path) -> Result { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + + let output = Command::new(openvpn_bin) + .arg("--show-adapters") + .creation_flags(CREATE_NO_WINDOW) + .output() + .map_err(|e| VpnError::Connection(format!("Failed to inspect OpenVPN adapters: {e}")))?; + + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + Ok( + text + .lines() + .map(str::trim) + .any(|line| !line.is_empty() && !line.starts_with("Available adapters")), + ) + } + + fn extract_vpn_ip_from_log(log_content: &str) -> Option { + for line in log_content.lines() { + if let Some(ip) = Self::extract_vpn_ip(line) { + return Some(ip); + } + + if let Some(position) = line.find("ifconfig ") { + let after = &line[position + "ifconfig ".len()..]; + if let Some(ip_str) = after + .split_whitespace() + .next() + .or_else(|| after.split(',').next()) + { + if let Ok(ip) = ip_str.parse::() { + if ip.is_private() && !ip.is_loopback() { + return Some(ip); + } + } + } + } + } + + None + } + + async fn wait_for_openvpn_ready_via_management( + child: &mut std::process::Child, + mgmt_port: u16, + log_path: &Path, + ) -> Result, VpnError> { + let deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS); + + let mgmt_stream = loop { + if tokio::time::Instant::now() >= deadline { + return Err(VpnError::Connection(format!( + "Timed out connecting to OpenVPN management interface. Last OpenVPN output:\n{}", + Self::read_log_tail(log_path, 20) + ))); + } + + if let Ok(Some(status)) = child.try_wait() { + return Err(VpnError::Connection(format!( + "OpenVPN exited (status: {}) before the tunnel was established. Last output:\n{}", + status, + Self::read_log_tail(log_path, 20) + ))); + } + + match TcpStream::connect(("127.0.0.1", mgmt_port)).await { + Ok(stream) => break stream, + Err(_) => tokio::time::sleep(tokio::time::Duration::from_millis(500)).await, + } + }; + + let (mgmt_reader, mut mgmt_writer) = mgmt_stream.into_split(); + let _ = mgmt_writer.write_all(b"state on\nstate\n").await; + + let mut lines = BufReader::new(mgmt_reader).lines(); + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1)); + interval.tick().await; + + let mut vpn_ip = None; + + loop { + if tokio::time::Instant::now() >= deadline { + return Err(VpnError::Connection(format!( + "Timed out waiting for OpenVPN to reach CONNECTED state. Last OpenVPN output:\n{}", + Self::read_log_tail(log_path, 20) + ))); + } + + if let Ok(Some(status)) = child.try_wait() { + return Err(VpnError::Connection(format!( + "OpenVPN exited (status: {}) before connecting. Last output:\n{}", + status, + Self::read_log_tail(log_path, 20) + ))); + } + + tokio::select! { + line_result = lines.next_line() => { + match line_result { + Ok(Some(line)) => { + if let Some(ip) = Self::extract_vpn_ip(&line) { + vpn_ip = Some(ip); + } + + if line.contains(",CONNECTED,") { + break; + } + + if line.contains("AUTH_FAILED") { + return Err(VpnError::Connection(format!( + "OpenVPN authentication failed. Last output:\n{}", + Self::read_log_tail(log_path, 20) + ))); + } + + if line.contains(",EXITING,") || line.contains(">FATAL:") { + return Err(VpnError::Connection(format!( + "OpenVPN is exiting. Last output:\n{}", + Self::read_log_tail(log_path, 20) + ))); + } + } + Ok(None) => { + return Err(VpnError::Connection(format!( + "OpenVPN management connection closed before CONNECTED state. Last output:\n{}", + Self::read_log_tail(log_path, 20) + ))); + } + Err(_) => {} + } + } + _ = interval.tick() => { + let _ = mgmt_writer.write_all(b"state\n").await; + + let log_path = log_path.to_path_buf(); + let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path)) + .await + .ok() + .and_then(Result::ok); + + if let Some(content) = log_content { + if Self::log_indicates_connected(&content) { + break; + } + } + } + } + } + + if vpn_ip.is_none() { + if let Ok(log_content) = std::fs::read_to_string(log_path) { + vpn_ip = Self::extract_vpn_ip_from_log(&log_content); + } + } + + Ok(vpn_ip) + } + + async fn wait_for_openvpn_ready_via_log( + child: &mut std::process::Child, + log_path: &Path, + ) -> Result, VpnError> { + let deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS); + + loop { + if tokio::time::Instant::now() >= deadline { + return Err(VpnError::Connection(format!( + "Timed out waiting for OpenVPN to connect. Last OpenVPN output:\n{}", + Self::read_log_tail(log_path, 40) + ))); + } + + if let Ok(Some(status)) = child.try_wait() { + return Err(VpnError::Connection(format!( + "OpenVPN exited (status: {}) before connecting. Last output:\n{}", + status, + Self::read_log_tail(log_path, 40) + ))); + } + + let log_path_buf = log_path.to_path_buf(); + let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path_buf)) + .await + .ok() + .and_then(Result::ok) + .unwrap_or_default(); + + if Self::log_indicates_connected(&log_content) { + return Ok(Self::extract_vpn_ip_from_log(&log_content)); + } + + if Self::log_indicates_failure(&log_content) { + return Err(VpnError::Connection(format!( + "OpenVPN reported a fatal error while connecting. Last output:\n{}", + Self::read_log_tail(log_path, 40) + ))); + } + + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + } + + async fn connect_target( + target: SocksTarget, + vpn_bind_ip: Ipv4Addr, + ) -> Result<(TcpStream, SocketAddr), Box> { + let mut addresses = match target { + SocksTarget::Address(addr) => vec![addr], + SocksTarget::Domain(host, port) => { + let mut resolved = lookup_host((host.as_str(), port)) + .await? + .collect::>(); + resolved.sort_by_key(|addr| if addr.is_ipv4() { 0 } else { 1 }); + resolved + } + }; + + if addresses.is_empty() { + return Err("No addresses resolved for SOCKS5 target".into()); + } + + let mut last_error = None; + + for address in addresses.drain(..) { + let socket = if address.is_ipv4() { + let socket = TcpSocket::new_v4()?; + if !vpn_bind_ip.is_unspecified() { + socket.bind(SocketAddr::new(IpAddr::V4(vpn_bind_ip), 0))?; + } + socket + } else { + TcpSocket::new_v6()? + }; + + match socket.connect(address).await { + Ok(stream) => return Ok((stream, address)), + Err(error) => last_error = Some(error), + } + } + + Err( + last_error + .map(|error| error.into()) + .unwrap_or_else(|| "Failed to connect to any resolved SOCKS5 target".into()), + ) + } + pub async fn run(self, config_id: String) -> Result<(), VpnError> { let openvpn_bin = Self::find_openvpn_binary()?; + let supports_management = Self::openvpn_supports_management(&openvpn_bin); + + #[cfg(windows)] + if !Self::windows_openvpn_has_adapter(&openvpn_bin)? { + return Err(VpnError::Connection( + "OpenVPN requires a TAP/Wintun/ovpn-dco adapter on Windows, but none were found. Install or provision an adapter before connecting.".to_string(), + )); + } - // Write config to temp file let config_path = std::env::temp_dir().join(format!("openvpn_{}.ovpn", config_id)); - std::fs::write(&config_path, &self.config.raw_config).map_err(VpnError::Io)?; + std::fs::write(&config_path, self.build_runtime_config()).map_err(VpnError::Io)?; #[cfg(unix)] { @@ -84,43 +548,74 @@ impl OpenVpnSocks5Server { let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600)); } - // Find a management port - let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0") - .map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?; - let mgmt_port = mgmt_listener - .local_addr() - .map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))? - .port(); - drop(mgmt_listener); + let mgmt_port = if supports_management { + let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0") + .map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?; + let port = mgmt_listener + .local_addr() + .map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))? + .port(); + drop(mgmt_listener); + Some(port) + } else { + log::info!( + "[vpn-worker] OpenVPN build does not support management; using log-based readiness" + ); + None + }; + + let openvpn_log_path = std::env::temp_dir().join(format!("openvpn-{}.log", config_id)); + let log_file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&openvpn_log_path) + .map_err(VpnError::Io)?; - // Start OpenVPN with SOCKS proxy mode let mut cmd = Command::new(&openvpn_bin); + cmd.arg("--config").arg(&config_path); + if let Some(mgmt_port) = mgmt_port { + cmd + .arg("--management") + .arg("127.0.0.1") + .arg(mgmt_port.to_string()); + } cmd - .arg("--config") - .arg(&config_path) - .arg("--management") - .arg("127.0.0.1") - .arg(mgmt_port.to_string()) - .arg("--socks-proxy") - .arg("127.0.0.1") - .arg(self.port.to_string()) .arg("--verb") .arg("3") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + .stdout( + log_file + .try_clone() + .map(Stdio::from) + .map_err(VpnError::Io)?, + ) + .stderr(Stdio::from(log_file)); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + + cmd.arg("--disable-dco"); + if self.config.dev_type.starts_with("tun") { + cmd.arg("--windows-driver").arg("wintun"); + } + cmd.creation_flags(CREATE_NO_WINDOW); + } let mut child = cmd .spawn() .map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?; - // Wait for OpenVPN to start - tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; match child.try_wait() { Ok(Some(status)) => { let _ = std::fs::remove_file(&config_path); return Err(VpnError::Connection(format!( - "OpenVPN exited early with status: {status}. OpenVPN requires elevated privileges (sudo/admin)." + "OpenVPN exited immediately (status: {}). Last output:\n{}", + status, + Self::read_log_tail(&openvpn_log_path, 20) ))); } Ok(None) => {} @@ -132,8 +627,15 @@ impl OpenVpnSocks5Server { } } - // Start a basic SOCKS5 proxy that tunnels through the OpenVPN TUN interface - let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port)) + let vpn_bind_ip = if let Some(mgmt_port) = mgmt_port { + Self::wait_for_openvpn_ready_via_management(&mut child, mgmt_port, &openvpn_log_path).await? + } else { + Self::wait_for_openvpn_ready_via_log(&mut child, &openvpn_log_path).await? + } + .unwrap_or(Ipv4Addr::UNSPECIFIED); + let vpn_bind_ip = Arc::new(vpn_bind_ip); + + let listener = TcpListener::bind(("127.0.0.1", self.port)) .await .map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5: {e}")))?; @@ -142,10 +644,10 @@ impl OpenVpnSocks5Server { .map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))? .port(); - if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) { - wc.local_port = Some(actual_port); - wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port)); - let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc); + if let Some(mut worker_config) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) { + worker_config.local_port = Some(actual_port); + worker_config.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port)); + let _ = crate::vpn_worker_storage::save_vpn_worker_config(&worker_config); } log::info!( @@ -156,10 +658,13 @@ impl OpenVpnSocks5Server { loop { match listener.accept().await { Ok((client, _)) => { - tokio::spawn(Self::handle_socks5_client(client)); + let bind_ip = vpn_bind_ip.clone(); + tokio::spawn(async move { + let _ = Self::handle_socks5_client(client, bind_ip).await; + }); } - Err(e) => { - log::warn!("[vpn-worker] Accept error: {e}"); + Err(error) => { + log::warn!("[vpn-worker] Accept error: {error}"); } } } @@ -167,53 +672,119 @@ impl OpenVpnSocks5Server { async fn handle_socks5_client( mut client: TcpStream, + vpn_bind_ip: Arc, ) -> Result<(), Box> { - // SOCKS5 greeting - let mut buf = [0u8; 256]; - let n = client.read(&mut buf).await?; - if n < 3 || buf[0] != 0x05 { + let mut greeting = [0u8; 2]; + if let Err(error) = client.read_exact(&mut greeting).await { + if error.kind() != std::io::ErrorKind::UnexpectedEof { + log::debug!("[socks5] Failed to read greeting header: {}", error); + } return Ok(()); } + + if greeting[0] != 0x05 { + return Ok(()); + } + + let mut methods = vec![0u8; greeting[1] as usize]; + if let Err(error) = client.read_exact(&mut methods).await { + if error.kind() != std::io::ErrorKind::UnexpectedEof { + log::debug!("[socks5] Failed to read methods list: {}", error); + } + return Ok(()); + } + client.write_all(&[0x05, 0x00]).await?; - // SOCKS5 connect request - let n = client.read(&mut buf).await?; - if n < 10 || buf[0] != 0x05 || buf[1] != 0x01 { + let mut request_header = [0u8; 4]; + if let Err(error) = client.read_exact(&mut request_header).await { + if error.kind() != std::io::ErrorKind::UnexpectedEof { + log::debug!("[socks5] Failed to read request header: {}", error); + } return Ok(()); } - let dest_addr = match buf[3] { + if request_header[0] != 0x05 { + return Ok(()); + } + + if request_header[1] != 0x01 { + let _ = client + .write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + .await; + return Ok(()); + } + + let target = match request_header[3] { 0x01 => { - let ip = std::net::Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]); - let port = u16::from_be_bytes([buf[8], buf[9]]); - format!("{}:{}", ip, port) + let mut addr_port = [0u8; 6]; + client.read_exact(&mut addr_port).await?; + SocksTarget::Address(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new( + addr_port[0], + addr_port[1], + addr_port[2], + addr_port[3], + )), + u16::from_be_bytes([addr_port[4], addr_port[5]]), + )) } 0x03 => { - let domain_len = buf[4] as usize; - let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).to_string(); - let port_start = 5 + domain_len; - let port = u16::from_be_bytes([buf[port_start], buf[port_start + 1]]); - format!("{}:{}", domain, port) + let mut len = [0u8; 1]; + client.read_exact(&mut len).await?; + if len[0] == 0 { + return Ok(()); + } + + let mut domain = vec![0u8; len[0] as usize]; + client.read_exact(&mut domain).await?; + + let mut port = [0u8; 2]; + client.read_exact(&mut port).await?; + + SocksTarget::Domain( + String::from_utf8_lossy(&domain).to_string(), + u16::from_be_bytes(port), + ) + } + 0x04 => { + let mut addr_port = [0u8; 18]; + client.read_exact(&mut addr_port).await?; + + let mut octets = [0u8; 16]; + octets.copy_from_slice(&addr_port[..16]); + + SocksTarget::Address(SocketAddr::new( + IpAddr::V6(std::net::Ipv6Addr::from(octets)), + u16::from_be_bytes([addr_port[16], addr_port[17]]), + )) + } + _ => { + let _ = client + .write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + .await; + return Ok(()); } - _ => return Ok(()), }; - // Connect to destination through OpenVPN tunnel (OS routing handles it) - match TcpStream::connect(&dest_addr).await { - Ok(upstream) => { + match Self::connect_target(target, *vpn_bind_ip).await { + Ok((upstream, _address)) => { client .write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0]) .await?; - let (mut cr, mut cw) = client.into_split(); - let (mut ur, mut uw) = upstream.into_split(); + let (mut client_read, mut client_write) = client.into_split(); + let (mut upstream_read, mut upstream_write) = upstream.into_split(); - let c2u = tokio::io::copy(&mut cr, &mut uw); - let u2c = tokio::io::copy(&mut ur, &mut cw); - - let _ = tokio::try_join!(c2u, u2c); + let client_to_upstream = tokio::io::copy(&mut client_read, &mut upstream_write); + let upstream_to_client = tokio::io::copy(&mut upstream_read, &mut client_write); + let _ = tokio::try_join!(client_to_upstream, upstream_to_client)?; } - Err(_) => { + Err(error) => { + log::debug!( + "[socks5] Failed to connect through OpenVPN tunnel: {}", + error + ); client .write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) .await?; diff --git a/src-tauri/src/vpn/socks5_server.rs b/src-tauri/src/vpn/socks5_server.rs index b2ccd5a..bd366df 100644 --- a/src-tauri/src/vpn/socks5_server.rs +++ b/src-tauri/src/vpn/socks5_server.rs @@ -370,6 +370,8 @@ impl WireGuardSocks5Server { smol_handle: SocketHandle, tcp_stream: TcpStream, socks_done: bool, + connecting: bool, + greeting_done: bool, read_buf: Vec, dest_addr: Option, } @@ -391,6 +393,8 @@ impl WireGuardSocks5Server { smol_handle: handle, tcp_stream: stream, socks_done: false, + connecting: false, + greeting_done: false, read_buf: Vec::new(), dest_addr: None, }); @@ -409,7 +413,30 @@ impl WireGuardSocks5Server { // Process each connection let mut completed = Vec::new(); for (idx, conn) in connections.iter_mut().enumerate() { - if !conn.socks_done { + if conn.connecting { + let socket = sockets.get_mut::(conn.smol_handle); + if socket.may_send() { + let _ = conn.tcp_stream.try_write(&[ + 0x05, + 0x00, + 0x00, + 0x01, + 127, + 0, + 0, + 1, + (actual_port >> 8) as u8, + (actual_port & 0xff) as u8, + ]); + conn.connecting = false; + conn.socks_done = true; + } else if !socket.is_open() { + let _ = conn + .tcp_stream + .try_write(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + completed.push(idx); + } + } else if !conn.socks_done { // Handle SOCKS5 handshake let mut buf = [0u8; 512]; match conn.tcp_stream.try_read(&mut buf) { @@ -427,19 +454,26 @@ impl WireGuardSocks5Server { } } - if conn.dest_addr.is_none() && conn.read_buf.len() >= 3 { + if !conn.greeting_done && conn.read_buf.len() >= 3 { // SOCKS5 greeting: version, nmethods, methods if conn.read_buf[0] != 0x05 { completed.push(idx); continue; } - // Reply: no auth required - let _ = conn.tcp_stream.try_write(&[0x05, 0x00]); let nmethods = conn.read_buf[1] as usize; + if conn.read_buf.len() < 2 + nmethods { + continue; + } + // Reply: no auth required + if conn.tcp_stream.try_write(&[0x05, 0x00]).is_err() { + completed.push(idx); + continue; + } conn.read_buf.drain(..2 + nmethods); + conn.greeting_done = true; } - if conn.dest_addr.is_none() && conn.read_buf.len() >= 10 { + if conn.greeting_done && conn.dest_addr.is_none() && conn.read_buf.len() >= 10 { // SOCKS5 connect request if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 { completed.push(idx); @@ -539,20 +573,7 @@ impl WireGuardSocks5Server { continue; } - // Send SOCKS5 success reply - let _ = conn.tcp_stream.try_write(&[ - 0x05, - 0x00, - 0x00, - 0x01, - 127, - 0, - 0, - 1, - (actual_port >> 8) as u8, - (actual_port & 0xff) as u8, - ]); - conn.socks_done = true; + conn.connecting = true; } } else { // Data relay between SOCKS5 client and smoltcp socket diff --git a/src-tauri/src/vpn_worker_runner.rs b/src-tauri/src/vpn_worker_runner.rs index 3b43127..fc7d74d 100644 --- a/src-tauri/src/vpn_worker_runner.rs +++ b/src-tauri/src/vpn_worker_runner.rs @@ -1,3 +1,4 @@ +use crate::proxy_runner::find_sidecar_executable; use crate::proxy_storage::is_process_running; use crate::vpn_worker_storage::{ delete_vpn_worker_config, find_vpn_worker_by_vpn_id, generate_vpn_worker_id, @@ -5,12 +6,124 @@ use crate::vpn_worker_storage::{ }; use std::process::Stdio; +const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100; +const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 10_000; +const OPENVPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 100_000; + +async fn vpn_worker_accepting_connections(config: &VpnWorkerConfig) -> bool { + let Some(port) = config.local_port else { + return false; + }; + + if config + .local_url + .as_ref() + .is_none_or(|local_url| local_url.is_empty()) + { + return false; + } + + matches!( + tokio::time::timeout( + tokio::time::Duration::from_millis(VPN_WORKER_POLL_INTERVAL_MS), + tokio::net::TcpStream::connect(("127.0.0.1", port)), + ) + .await, + Ok(Ok(_)) + ) +} + +fn worker_log_path(id: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("donut-vpn-{}.log", id)) +} + +fn read_worker_log(id: &str) -> String { + std::fs::read_to_string(worker_log_path(id)).unwrap_or_else(|_| "No log available".to_string()) +} + +async fn wait_for_vpn_worker_ready( + id: &str, + vpn_type: &str, +) -> Result> { + let startup_timeout = if vpn_type == "openvpn" { + tokio::time::Duration::from_millis(OPENVPN_WORKER_STARTUP_TIMEOUT_MS) + } else { + tokio::time::Duration::from_millis(VPN_WORKER_STARTUP_TIMEOUT_MS) + }; + let startup_deadline = tokio::time::Instant::now() + startup_timeout; + + tokio::time::sleep(tokio::time::Duration::from_millis( + VPN_WORKER_POLL_INTERVAL_MS, + )) + .await; + + let mut attempts = 0u32; + + loop { + tokio::time::sleep(tokio::time::Duration::from_millis( + VPN_WORKER_POLL_INTERVAL_MS, + )) + .await; + + if let Some(updated_config) = get_vpn_worker_config(id) { + let process_running = updated_config.pid.map(is_process_running).unwrap_or(false); + + if !process_running && attempts > 2 { + let log_output = read_worker_log(id); + delete_vpn_worker_config(id); + return Err(format!("VPN worker process crashed. Log output:\n{}", log_output).into()); + } + + if vpn_worker_accepting_connections(&updated_config).await { + return Ok(updated_config); + } + } + + attempts += 1; + if tokio::time::Instant::now() >= startup_deadline { + if let Some(config) = get_vpn_worker_config(id) { + let process_running = config.pid.map(is_process_running).unwrap_or(false); + let log_output = read_worker_log(id); + delete_vpn_worker_config(id); + return Err( + format!( + "VPN worker failed to start within {:.1}s. pid={:?}, process_running={}, local_url={:?}\n\nVPN worker log:\n{}", + startup_timeout.as_secs_f32(), + config.pid, + process_running, + config.local_url, + log_output + ) + .into(), + ); + } + + delete_vpn_worker_config(id); + return Err("VPN worker config not found after spawn".into()); + } + } +} + pub async fn start_vpn_worker(vpn_id: &str) -> Result> { + for config in list_vpn_worker_configs() { + if let Some(pid) = config.pid { + if !is_process_running(pid) { + delete_vpn_worker_config(&config.id); + } + } else { + delete_vpn_worker_config(&config.id); + } + } + // Check if a VPN worker for this vpn_id already exists and is running if let Some(existing) = find_vpn_worker_by_vpn_id(vpn_id) { if let Some(pid) = existing.pid { if is_process_running(pid) { - return Ok(existing); + if vpn_worker_accepting_connections(&existing).await { + return Ok(existing); + } + + return wait_for_vpn_worker_ready(&existing.id, &existing.vpn_type).await; } } // Worker config exists but process is dead, clean up @@ -63,7 +176,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result Result= max_attempts { - if let Some(config) = get_vpn_worker_config(&id) { - let process_running = config.pid.map(is_process_running).unwrap_or(false); - // Clean up on failure - delete_vpn_worker_config(&id); - return Err( - format!( - "VPN worker failed to start in time. pid={:?}, process_running={}, local_url={:?}", - config.pid, process_running, config.local_url - ) - .into(), - ); - } - delete_vpn_worker_config(&id); - return Err("VPN worker config not found after spawn".into()); - } - } + wait_for_vpn_worker_ready(&id, vpn_type_str).await } pub async fn stop_vpn_worker(id: &str) -> Result> { diff --git a/src-tauri/tests/test_harness/mod.rs b/src-tauri/tests/test_harness/mod.rs index 8dbb965..54a7a35 100644 --- a/src-tauri/tests/test_harness/mod.rs +++ b/src-tauri/tests/test_harness/mod.rs @@ -16,12 +16,21 @@ const WIREGUARD_IMAGE: &str = "linuxserver/wireguard:latest"; const OPENVPN_IMAGE: &str = "kylemanna/openvpn:latest"; const WG_CONTAINER: &str = "donut-wg-test"; const OVPN_CONTAINER: &str = "donut-ovpn-test"; +const OVPN_VOLUME: &str = "donut-ovpn-test-data"; /// Check if running in CI environment pub fn is_ci() -> bool { std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok() } +fn has_external_wireguard_service() -> bool { + std::env::var("VPN_TEST_WG_HOST").is_ok() +} + +fn has_external_openvpn_service() -> bool { + std::env::var("VPN_TEST_OVPN_HOST").is_ok() +} + /// Check if Docker is available pub fn is_docker_available() -> bool { Command::new("docker") @@ -33,14 +42,10 @@ pub fn is_docker_available() -> bool { /// Start a WireGuard test server and return client config pub async fn start_wireguard_server() -> Result { - if is_ci() { - // In CI, use the service container configured in workflow + if has_external_wireguard_service() { let host = std::env::var("VPN_TEST_WG_HOST").unwrap_or_else(|_| "localhost".into()); let port = std::env::var("VPN_TEST_WG_PORT").unwrap_or_else(|_| "51820".into()); - // Wait for service to be ready - wait_for_service(&host, port.parse().unwrap_or(51820)).await?; - return get_ci_wireguard_config(&host, &port); } @@ -71,6 +76,8 @@ pub async fn start_wireguard_server() -> Result { "SERVERPORT=51820", "-e", "PEERDNS=auto", + "-e", + "INTERNAL_SUBNET=10.64.0.0", WIREGUARD_IMAGE, ]) .output() @@ -105,14 +112,10 @@ pub async fn start_wireguard_server() -> Result { /// Start an OpenVPN test server and return client config pub async fn start_openvpn_server() -> Result { - if is_ci() { - // In CI, use the service container configured in workflow + if has_external_openvpn_service() { let host = std::env::var("VPN_TEST_OVPN_HOST").unwrap_or_else(|_| "localhost".into()); let port = std::env::var("VPN_TEST_OVPN_PORT").unwrap_or_else(|_| "1194".into()); - // Wait for service to be ready - wait_for_service(&host, port.parse().unwrap_or(1194)).await?; - return get_ci_openvpn_config(&host, &port); } @@ -125,9 +128,139 @@ pub async fn start_openvpn_server() -> Result { .args(["rm", "-f", OVPN_CONTAINER]) .output(); - // For OpenVPN, we need to initialize PKI first, which is complex - // For simplicity in tests, we'll use a pre-configured test config - Err("OpenVPN container setup requires pre-configured PKI. Use test fixtures instead.".to_string()) + let _ = Command::new("docker") + .args(["volume", "rm", "-f", OVPN_VOLUME]) + .output(); + + let create_volume = Command::new("docker") + .args(["volume", "create", OVPN_VOLUME]) + .output() + .map_err(|e| format!("Failed to create OpenVPN test volume: {e}"))?; + if !create_volume.status.success() { + return Err(format!( + "Failed to create OpenVPN test volume: {}", + String::from_utf8_lossy(&create_volume.stderr) + )); + } + + let genconfig = Command::new("docker") + .args([ + "run", + "--rm", + "-v", + &format!("{OVPN_VOLUME}:/etc/openvpn"), + "-e", + "EASYRSA_BATCH=1", + OPENVPN_IMAGE, + "ovpn_genconfig", + "-u", + "udp://127.0.0.1", + "-s", + "10.9.0.0/24", + ]) + .output() + .map_err(|e| format!("Failed to generate OpenVPN config: {e}"))?; + if !genconfig.status.success() { + return Err(format!( + "OpenVPN config generation failed: {}", + String::from_utf8_lossy(&genconfig.stderr) + )); + } + + let init_pki = Command::new("docker") + .args([ + "run", + "--rm", + "-v", + &format!("{OVPN_VOLUME}:/etc/openvpn"), + "-e", + "EASYRSA_BATCH=1", + OPENVPN_IMAGE, + "ovpn_initpki", + "nopass", + ]) + .output() + .map_err(|e| format!("Failed to initialize OpenVPN PKI: {e}"))?; + if !init_pki.status.success() { + return Err(format!( + "OpenVPN PKI initialization failed: {}", + String::from_utf8_lossy(&init_pki.stderr) + )); + } + + let build_client = Command::new("docker") + .args([ + "run", + "--rm", + "-v", + &format!("{OVPN_VOLUME}:/etc/openvpn"), + "-e", + "EASYRSA_BATCH=1", + OPENVPN_IMAGE, + "easyrsa", + "build-client-full", + "donut-test-client", + "nopass", + ]) + .output() + .map_err(|e| format!("Failed to build OpenVPN client certificate: {e}"))?; + if !build_client.status.success() { + return Err(format!( + "OpenVPN client certificate build failed: {}", + String::from_utf8_lossy(&build_client.stderr) + )); + } + + let start_server = Command::new("docker") + .args([ + "run", + "-d", + "--name", + OVPN_CONTAINER, + "--cap-add=NET_ADMIN", + "-p", + "1194:1194/udp", + "-v", + &format!("{OVPN_VOLUME}:/etc/openvpn"), + OPENVPN_IMAGE, + ]) + .output() + .map_err(|e| format!("Failed to start OpenVPN container: {e}"))?; + if !start_server.status.success() { + return Err(format!( + "OpenVPN container start failed: {}", + String::from_utf8_lossy(&start_server.stderr) + )); + } + + sleep(Duration::from_secs(10)).await; + + let client_config = Command::new("docker") + .args([ + "run", + "--rm", + "-v", + &format!("{OVPN_VOLUME}:/etc/openvpn"), + OPENVPN_IMAGE, + "ovpn_getclient", + "donut-test-client", + ]) + .output() + .map_err(|e| format!("Failed to fetch OpenVPN client config: {e}"))?; + if !client_config.status.success() { + return Err(format!( + "Failed to read OpenVPN client config: {}", + String::from_utf8_lossy(&client_config.stderr) + )); + } + + let raw_config = String::from_utf8_lossy(&client_config.stdout).to_string(); + Ok(OpenVpnTestConfig { + raw_config, + remote_host: "127.0.0.1".to_string(), + remote_port: 1194, + protocol: "udp".to_string(), + }) } /// Stop all VPN test servers @@ -135,21 +268,9 @@ pub async fn stop_vpn_servers() { let _ = Command::new("docker") .args(["rm", "-f", WG_CONTAINER, OVPN_CONTAINER]) .output(); -} - -/// Wait for a network service to be ready -async fn wait_for_service(host: &str, port: u16) -> Result<(), String> { - let timeout = Duration::from_secs(30); - let start = std::time::Instant::now(); - - while start.elapsed() < timeout { - if std::net::TcpStream::connect(format!("{host}:{port}")).is_ok() { - return Ok(()); - } - sleep(Duration::from_millis(500)).await; - } - - Err(format!("Timeout waiting for service at {host}:{port}")) + let _ = Command::new("docker") + .args(["volume", "rm", "-f", OVPN_VOLUME]) + .output(); } /// WireGuard test configuration @@ -160,6 +281,7 @@ pub struct WireGuardTestConfig { pub peer_public_key: String, pub peer_endpoint: String, pub allowed_ips: Vec, + pub preshared_key: Option, } /// OpenVPN test configuration @@ -178,6 +300,7 @@ fn parse_wireguard_test_config(content: &str) -> Result Result dns = Some(value.to_string()), ("peer", "PublicKey") => peer_public_key = value.to_string(), ("peer", "Endpoint") => peer_endpoint = value.to_string(), + ("peer", "PresharedKey") => preshared_key = Some(value.to_string()), ("peer", "AllowedIPs") => { allowed_ips = value.split(',').map(|s| s.trim().to_string()).collect(); } @@ -230,12 +354,21 @@ fn parse_wireguard_test_config(content: &str) -> Result Result { - // In CI, use environment variables or test fixtures + if std::env::var("VPN_TEST_WG_PRIVATE_KEY").is_err() + || std::env::var("VPN_TEST_WG_PUBLIC_KEY").is_err() + { + return Err( + "External WireGuard test service is configured, but VPN_TEST_WG_PRIVATE_KEY and VPN_TEST_WG_PUBLIC_KEY are missing" + .to_string(), + ); + } + let private_key = std::env::var("VPN_TEST_WG_PRIVATE_KEY").unwrap_or_else(|_| "test-private-key".to_string()); let public_key = @@ -248,11 +381,21 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result Result { + if let Ok(raw_config) = std::env::var("VPN_TEST_OVPN_RAW_CONFIG") { + return Ok(OpenVpnTestConfig { + raw_config, + remote_host: host.to_string(), + remote_port: port.parse().unwrap_or(1194), + protocol: "udp".to_string(), + }); + } + let raw_config = format!( r#" client diff --git a/src-tauri/tests/vpn_integration.rs b/src-tauri/tests/vpn_integration.rs index 91c589a..e85c80a 100644 --- a/src-tauri/tests/vpn_integration.rs +++ b/src-tauri/tests/vpn_integration.rs @@ -3,13 +3,22 @@ //! These tests verify VPN config parsing, storage, and tunnel functionality. //! Connection tests require Docker and are skipped if Docker is not available. +mod common; mod test_harness; +use common::TestUtils; use donutbrowser_lib::vpn::{ detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig, VpnStorage, VpnType, WireGuardConfig, }; +use serde_json::Value; use serial_test::serial; +use std::path::PathBuf; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::time::sleep; // ============================================================================ // Config Parsing Tests @@ -420,6 +429,530 @@ async fn test_tunnel_manager() { assert_eq!(manager.active_count(), 0); } -// NOTE: Actual connection tests require Docker containers running. -// These are meant to be run with the CI workflow that sets up service containers. -// To run locally: docker run -d --cap-add=NET_ADMIN -p 51820:51820/udp -e PEERS=1 linuxserver/wireguard +struct TestEnvGuard { + _root: PathBuf, + previous_data_dir: Option, + previous_cache_dir: Option, +} + +impl TestEnvGuard { + fn new() -> Result> { + static TEST_RUNTIME_ROOT: OnceLock = OnceLock::new(); + + let root = TEST_RUNTIME_ROOT + .get_or_init(|| { + std::env::temp_dir().join(format!("donutbrowser-vpn-e2e-{}", std::process::id())) + }) + .clone(); + let data_dir = root.join("data"); + let cache_dir = root.join("cache"); + let vpn_dir = data_dir.join("vpn"); + + let _ = std::fs::remove_dir_all(&data_dir); + let _ = std::fs::remove_dir_all(&cache_dir); + std::fs::create_dir_all(&vpn_dir)?; + std::fs::create_dir_all(&data_dir)?; + std::fs::create_dir_all(&cache_dir)?; + + let previous_data_dir = std::env::var("DONUTBROWSER_DATA_DIR").ok(); + let previous_cache_dir = std::env::var("DONUTBROWSER_CACHE_DIR").ok(); + + std::env::set_var("DONUTBROWSER_DATA_DIR", &data_dir); + std::env::set_var("DONUTBROWSER_CACHE_DIR", &cache_dir); + + Ok(Self { + _root: root, + previous_data_dir, + previous_cache_dir, + }) + } +} + +impl Drop for TestEnvGuard { + fn drop(&mut self) { + if let Some(value) = &self.previous_data_dir { + std::env::set_var("DONUTBROWSER_DATA_DIR", value); + } else { + std::env::remove_var("DONUTBROWSER_DATA_DIR"); + } + + if let Some(value) = &self.previous_cache_dir { + std::env::set_var("DONUTBROWSER_CACHE_DIR", value); + } else { + std::env::remove_var("DONUTBROWSER_CACHE_DIR"); + } + } +} + +struct ProxyProcess { + id: String, + local_port: u16, + local_url: String, +} + +async fn ensure_donut_proxy_binary() -> Result> { + let cargo_manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?; + let project_root = PathBuf::from(cargo_manifest_dir) + .parent() + .unwrap() + .to_path_buf(); + + let proxy_binary_name = if cfg!(windows) { + "donut-proxy.exe" + } else { + "donut-proxy" + }; + let proxy_binary = project_root + .join("src-tauri") + .join("target") + .join("debug") + .join(proxy_binary_name); + + if !proxy_binary.exists() { + let build_status = tokio::process::Command::new("cargo") + .args(["build", "--bin", "donut-proxy"]) + .current_dir(project_root.join("src-tauri")) + .status() + .await?; + + if !build_status.success() { + return Err("Failed to build donut-proxy binary".into()); + } + } + + if !proxy_binary.exists() { + return Err("donut-proxy binary was not created successfully".into()); + } + + Ok(proxy_binary) +} + +fn new_test_vpn_config(name: &str, vpn_type: VpnType, config_data: String) -> VpnConfig { + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + VpnConfig { + id: uuid::Uuid::new_v4().to_string(), + name: name.to_string(), + vpn_type, + config_data, + created_at, + last_used: None, + sync_enabled: false, + last_sync: None, + } +} + +fn build_wireguard_config(config: &test_harness::WireGuardTestConfig) -> String { + format!( + "[Interface]\nPrivateKey = {}\nAddress = {}\n{}\n[Peer]\nPublicKey = {}\n{}Endpoint = {}\nAllowedIPs = {}\nPersistentKeepalive = 25\n", + config.private_key, + config.address, + config + .dns + .as_ref() + .map(|dns| format!("DNS = {dns}\n")) + .unwrap_or_default(), + config.peer_public_key, + config + .preshared_key + .as_ref() + .map(|key| format!("PresharedKey = {key}\n")) + .unwrap_or_default(), + config.peer_endpoint, + config.allowed_ips.join(", ") + ) +} + +fn openvpn_client_available() -> bool { + if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") { + return PathBuf::from(path).exists(); + } + + std::process::Command::new(if cfg!(windows) { "where" } else { "which" }) + .arg("openvpn") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +#[cfg(windows)] +fn openvpn_adapter_available() -> bool { + let openvpn = std::process::Command::new("openvpn") + .arg("--show-adapters") + .output(); + + openvpn + .ok() + .map(|output| { + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + text + .lines() + .map(str::trim) + .any(|line| !line.is_empty() && !line.starts_with("Available adapters")) + }) + .unwrap_or(false) +} + +#[cfg(not(windows))] +fn openvpn_adapter_available() -> bool { + true +} + +async fn start_proxy_with_upstream( + binary_path: &PathBuf, + upstream_proxy: &str, + bypass_rules: &[String], + blocklist_file: Option<&str>, + profile_id: Option<&str>, +) -> Result> { + let upstream_url = url::Url::parse(upstream_proxy)?; + let host = upstream_url + .host_str() + .ok_or("Upstream proxy host is missing")? + .to_string(); + let port = upstream_url + .port() + .ok_or("Upstream proxy port is missing")?; + + let mut args = vec![ + "proxy".to_string(), + "start".to_string(), + "--host".to_string(), + host, + "--proxy-port".to_string(), + port.to_string(), + "--type".to_string(), + upstream_url.scheme().to_string(), + ]; + + if !bypass_rules.is_empty() { + args.push("--bypass-rules".to_string()); + args.push(serde_json::to_string(bypass_rules)?); + } + + if let Some(blocklist_file) = blocklist_file { + args.push("--blocklist-file".to_string()); + args.push(blocklist_file.to_string()); + } + + if let Some(profile_id) = profile_id { + args.push("--profile-id".to_string()); + args.push(profile_id.to_string()); + } + + let arg_refs = args.iter().map(String::as_str).collect::>(); + let output = TestUtils::execute_command(binary_path, &arg_refs).await?; + if !output.status.success() { + return Err( + format!( + "Failed to start local proxy - stdout: {}, stderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .into(), + ); + } + + let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?; + Ok(ProxyProcess { + id: config["id"].as_str().ok_or("Missing proxy id")?.to_string(), + local_port: config["localPort"].as_u64().ok_or("Missing local port")? as u16, + local_url: config["localUrl"] + .as_str() + .ok_or("Missing local URL")? + .to_string(), + }) +} + +async fn stop_proxy( + binary_path: &PathBuf, + proxy_id: &str, +) -> Result<(), Box> { + let output = + TestUtils::execute_command(binary_path, &["proxy", "stop", "--id", proxy_id]).await?; + if !output.status.success() { + return Err( + format!( + "Failed to stop proxy '{}' - stdout: {}, stderr: {}", + proxy_id, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .into(), + ); + } + Ok(()) +} + +async fn raw_http_request_via_proxy( + local_port: u16, + url: &str, + host_header: &str, +) -> Result> { + let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?; + let request = format!("GET {url} HTTP/1.1\r\nHost: {host_header}\r\nConnection: close\r\n\r\n"); + stream.write_all(request.as_bytes()).await?; + + let mut response = Vec::new(); + stream.read_to_end(&mut response).await?; + Ok(String::from_utf8_lossy(&response).to_string()) +} + +async fn https_get_via_proxy( + local_proxy_url: &str, + url: &str, +) -> Result> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .no_proxy() + .proxy(reqwest::Proxy::all(local_proxy_url)?) + .build()?; + + Ok(client.get(url).send().await?.text().await?) +} + +async fn cleanup_runtime() { + let _ = donutbrowser_lib::proxy_runner::stop_all_proxy_processes().await; + let _ = donutbrowser_lib::vpn_worker_runner::stop_all_vpn_workers().await; + test_harness::stop_vpn_servers().await; +} + +async fn wait_for_file( + path: &std::path::Path, + timeout: Duration, +) -> Result<(), Box> { + let deadline = tokio::time::Instant::now() + timeout; + + while tokio::time::Instant::now() < deadline { + if path.exists() { + return Ok(()); + } + + sleep(Duration::from_millis(250)).await; + } + + Err(format!("Timed out waiting for file: {}", path.display()).into()) +} + +async fn run_proxy_feature_suite( + binary_path: &PathBuf, + vpn_id: &str, +) -> Result<(), Box> { + let vpn_worker = donutbrowser_lib::vpn_worker_runner::start_vpn_worker(vpn_id) + .await + .map_err(|error| error.to_string())?; + let vpn_upstream = vpn_worker + .local_url + .clone() + .ok_or("VPN worker did not expose a local URL")?; + + let profile_id = format!("vpn-e2e-{}", uuid::Uuid::new_v4()); + let proxy = + start_proxy_with_upstream(binary_path, &vpn_upstream, &[], None, Some(&profile_id)).await?; + + sleep(Duration::from_millis(500)).await; + + let http_response = + raw_http_request_via_proxy(proxy.local_port, "http://example.com/", "example.com").await?; + assert!( + http_response.contains("Example Domain"), + "HTTP traffic through donut-proxy+VPN should succeed, got: {}", + &http_response[..http_response.len().min(300)] + ); + + let https_body = https_get_via_proxy(&proxy.local_url, "https://example.com/").await?; + assert!( + https_body.contains("Example Domain"), + "HTTPS traffic through donut-proxy+VPN should succeed" + ); + + let stats_file = donutbrowser_lib::app_dirs::cache_dir() + .join("traffic_stats") + .join(format!("{}.json", profile_id)); + wait_for_file(&stats_file, Duration::from_secs(8)).await?; + + assert!( + stats_file.exists(), + "Traffic stats should exist for VPN-backed local proxy" + ); + let stats: Value = serde_json::from_str(&std::fs::read_to_string(&stats_file)?)?; + let total_requests = stats["total_requests"].as_u64().unwrap_or_default(); + assert!( + total_requests > 0, + "Traffic stats should record requests for VPN-backed local proxy" + ); + let domains = stats["domains"] + .as_object() + .ok_or("Traffic stats are missing per-domain data")?; + assert!( + domains.contains_key("example.com"), + "Traffic stats should include example.com domain activity" + ); + + stop_proxy(binary_path, &proxy.id).await?; + + let blocklist_file = tempfile::NamedTempFile::new()?; + std::fs::write(blocklist_file.path(), "example.com\n")?; + let blocked_proxy = start_proxy_with_upstream( + binary_path, + &vpn_upstream, + &[], + blocklist_file.path().to_str(), + None, + ) + .await?; + let blocked_response = raw_http_request_via_proxy( + blocked_proxy.local_port, + "http://example.com/", + "example.com", + ) + .await?; + assert!( + blocked_response.contains("403") || blocked_response.contains("Blocked by DNS blocklist"), + "DNS blocklist should be enforced before forwarding to the VPN upstream" + ); + stop_proxy(binary_path, &blocked_proxy.id).await?; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let bypass_target_port = listener.local_addr()?.port(); + let bypass_server = tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let io = hyper_util::rt::TokioIo::new(stream); + tokio::spawn(async move { + let service = hyper::service::service_fn(|_req| async move { + Ok::<_, hyper::Error>( + hyper::Response::builder() + .status(hyper::StatusCode::OK) + .body(http_body_util::Full::new(hyper::body::Bytes::from( + "VPN-BYPASS-OK", + ))) + .unwrap(), + ) + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, service) + .await; + }); + } + }); + + let bypass_proxy = start_proxy_with_upstream( + binary_path, + &vpn_upstream, + &["127.0.0.1".to_string(), "localhost".to_string()], + None, + None, + ) + .await?; + let bypass_response = raw_http_request_via_proxy( + bypass_proxy.local_port, + &format!("http://127.0.0.1:{bypass_target_port}/"), + &format!("127.0.0.1:{bypass_target_port}"), + ) + .await?; + assert!( + bypass_response.contains("VPN-BYPASS-OK"), + "Bypass rules should still work when donut-proxy is chained to a VPN worker" + ); + stop_proxy(binary_path, &bypass_proxy.id).await?; + bypass_server.abort(); + + donutbrowser_lib::vpn_worker_runner::stop_vpn_worker(&vpn_worker.id) + .await + .map_err(|error| error.to_string())?; + Ok(()) +} + +#[tokio::test] +#[serial] +async fn test_wireguard_traffic_flows_through_donut_proxy( +) -> Result<(), Box> { + let _env = TestEnvGuard::new()?; + cleanup_runtime().await; + + if !test_harness::is_docker_available() { + eprintln!("skipping WireGuard e2e test because Docker is unavailable"); + return Ok(()); + } + + let binary_path = ensure_donut_proxy_binary().await?; + let wg_config = match test_harness::start_wireguard_server().await { + Ok(config) => config, + Err(error) => { + eprintln!("skipping WireGuard e2e test: {error}"); + return Ok(()); + } + }; + + let vpn_config = new_test_vpn_config( + "WireGuard E2E", + VpnType::WireGuard, + build_wireguard_config(&wg_config), + ); + { + let storage = donutbrowser_lib::vpn::VPN_STORAGE.lock().unwrap(); + storage.save_config(&vpn_config)?; + } + + let result = run_proxy_feature_suite(&binary_path, &vpn_config.id).await; + cleanup_runtime().await; + result +} + +#[tokio::test] +#[serial] +async fn test_openvpn_traffic_flows_through_donut_proxy( +) -> Result<(), Box> { + let _env = TestEnvGuard::new()?; + cleanup_runtime().await; + + if std::env::var("DONUTBROWSER_RUN_OPENVPN_E2E") + .ok() + .as_deref() + != Some("1") + { + eprintln!("skipping OpenVPN e2e test because DONUTBROWSER_RUN_OPENVPN_E2E is not set"); + return Ok(()); + } + + if !test_harness::is_docker_available() { + eprintln!("skipping OpenVPN e2e test because Docker is unavailable"); + return Ok(()); + } + + if !openvpn_client_available() { + eprintln!("skipping OpenVPN e2e test because the OpenVPN client binary is unavailable"); + return Ok(()); + } + + if !openvpn_adapter_available() { + eprintln!("skipping OpenVPN e2e test because no Windows OpenVPN adapter is available"); + return Ok(()); + } + + let binary_path = ensure_donut_proxy_binary().await?; + let ovpn_config = match test_harness::start_openvpn_server().await { + Ok(config) => config, + Err(error) => { + eprintln!("skipping OpenVPN e2e test: {error}"); + return Ok(()); + } + }; + + let vpn_config = new_test_vpn_config("OpenVPN E2E", VpnType::OpenVPN, ovpn_config.raw_config); + { + let storage = donutbrowser_lib::vpn::VPN_STORAGE.lock().unwrap(); + storage.save_config(&vpn_config)?; + } + + let result = run_proxy_feature_suite(&binary_path, &vpn_config.id).await; + cleanup_runtime().await; + result +} diff --git a/src/components/vpn-form-dialog.tsx b/src/components/vpn-form-dialog.tsx index e8693b3..bd31822 100644 --- a/src/components/vpn-form-dialog.tsx +++ b/src/components/vpn-form-dialog.tsx @@ -3,8 +3,10 @@ import { invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { LoadingButton } from "@/components/loading-button"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Dialog, DialogContent, @@ -51,6 +53,14 @@ interface OpenVpnFormData { rawConfig: string; } +interface VpnDependencyStatus { + isAvailable: boolean; + requiresExternalInstall: boolean; + missingBinary: boolean; + missingWindowsAdapter: boolean; + dependencyCheckFailed: boolean; +} + const defaultWireGuardForm: WireGuardFormData = { name: "", privateKey: "", @@ -92,12 +102,15 @@ export function VpnFormDialog({ onClose, editingVpn, }: VpnFormDialogProps) { + const { t } = useTranslation(); const [isSubmitting, setIsSubmitting] = useState(false); const [vpnType, setVpnType] = useState("WireGuard"); const [wireGuardForm, setWireGuardForm] = useState(defaultWireGuardForm); const [openVpnForm, setOpenVpnForm] = useState(defaultOpenVpnForm); + const [vpnDependencyStatus, setVpnDependencyStatus] = + useState(null); const resetForms = useCallback(() => { setVpnType("WireGuard"); @@ -120,6 +133,32 @@ export function VpnFormDialog({ } }, [isOpen, editingVpn, resetForms]); + useEffect(() => { + if (!isOpen) { + setVpnDependencyStatus(null); + return; + } + + let cancelled = false; + + void invoke("get_vpn_dependency_status", { vpnType }) + .then((status) => { + if (!cancelled) { + setVpnDependencyStatus(status); + } + }) + .catch((error) => { + console.error("Failed to load VPN dependency status:", error); + if (!cancelled) { + setVpnDependencyStatus(null); + } + }); + + return () => { + cancelled = true; + }; + }, [isOpen, vpnType]); + const handleClose = useCallback(() => { if (!isSubmitting) { onClose(); @@ -258,6 +297,36 @@ export function VpnFormDialog({ ? "Enter your WireGuard interface and peer details." : "Paste your .ovpn configuration file content."; + let dependencyWarningTitle: string | null = null; + let dependencyWarningDescription: string | null = null; + + if ( + vpnType === "OpenVPN" && + vpnDependencyStatus?.requiresExternalInstall && + !vpnDependencyStatus.isAvailable + ) { + if (vpnDependencyStatus.missingBinary) { + dependencyWarningTitle = t("vpnForm.dependencies.openVpnMissingTitle"); + dependencyWarningDescription = t( + "vpnForm.dependencies.openVpnMissingDescription", + ); + } else if (vpnDependencyStatus.missingWindowsAdapter) { + dependencyWarningTitle = t( + "vpnForm.dependencies.openVpnAdapterMissingTitle", + ); + dependencyWarningDescription = t( + "vpnForm.dependencies.openVpnAdapterMissingDescription", + ); + } else if (vpnDependencyStatus.dependencyCheckFailed) { + dependencyWarningTitle = t( + "vpnForm.dependencies.openVpnCheckFailedTitle", + ); + dependencyWarningDescription = t( + "vpnForm.dependencies.openVpnCheckFailedDescription", + ); + } + } + return ( @@ -268,6 +337,17 @@ export function VpnFormDialog({
+ {dependencyWarningTitle && dependencyWarningDescription && ( + + + {dependencyWarningTitle} + + + {dependencyWarningDescription} + + + )} + {!editingVpn && (
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3073ad8..eb4d468 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -794,6 +794,16 @@ "button": "Clone" } }, + "vpnForm": { + "dependencies": { + "openVpnMissingTitle": "OpenVPN is not installed", + "openVpnMissingDescription": "You can save this configuration, but Donut Browser cannot connect it until OpenVPN is installed on this device.", + "openVpnAdapterMissingTitle": "OpenVPN adapter is missing", + "openVpnAdapterMissingDescription": "OpenVPN is installed, but no TAP/Wintun/ovpn-dco adapter was found. Repair or reinstall OpenVPN before connecting on Windows.", + "openVpnCheckFailedTitle": "OpenVPN install could not be verified", + "openVpnCheckFailedDescription": "Donut Browser could not inspect the local OpenVPN installation. Repair or reinstall OpenVPN before connecting on Windows." + } + }, "extensions": { "title": "Extensions", "description": "Manage browser extensions and extension groups for your profiles.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a90c79b..d9f44db 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -794,6 +794,16 @@ "button": "Clonar" } }, + "vpnForm": { + "dependencies": { + "openVpnMissingTitle": "OpenVPN no está instalado", + "openVpnMissingDescription": "Puedes guardar esta configuración, pero Donut Browser no podrá conectarse hasta que OpenVPN esté instalado en este dispositivo.", + "openVpnAdapterMissingTitle": "Falta el adaptador de OpenVPN", + "openVpnAdapterMissingDescription": "OpenVPN está instalado, pero no se encontró ningún adaptador TAP/Wintun/ovpn-dco. Repara o reinstala OpenVPN antes de conectarte en Windows.", + "openVpnCheckFailedTitle": "No se pudo verificar la instalación de OpenVPN", + "openVpnCheckFailedDescription": "Donut Browser no pudo inspeccionar la instalación local de OpenVPN. Repara o reinstala OpenVPN antes de conectarte en Windows." + } + }, "extensions": { "title": "Extensiones", "description": "Administra extensiones de navegador y grupos de extensiones para tus perfiles.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 8978abf..bb53e01 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -794,6 +794,16 @@ "button": "Cloner" } }, + "vpnForm": { + "dependencies": { + "openVpnMissingTitle": "OpenVPN n'est pas installé", + "openVpnMissingDescription": "Vous pouvez enregistrer cette configuration, mais Donut Browser ne pourra pas s'y connecter tant qu'OpenVPN n'est pas installé sur cet appareil.", + "openVpnAdapterMissingTitle": "L'adaptateur OpenVPN est manquant", + "openVpnAdapterMissingDescription": "OpenVPN est installé, mais aucun adaptateur TAP/Wintun/ovpn-dco n'a été trouvé. Réparez ou réinstallez OpenVPN avant de vous connecter sous Windows.", + "openVpnCheckFailedTitle": "L'installation d'OpenVPN n'a pas pu être vérifiée", + "openVpnCheckFailedDescription": "Donut Browser n'a pas pu inspecter l'installation locale d'OpenVPN. Réparez ou réinstallez OpenVPN avant de vous connecter sous Windows." + } + }, "extensions": { "title": "Extensions", "description": "Gérez les extensions de navigateur et les groupes d'extensions pour vos profils.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2c840d7..c996ee6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -794,6 +794,16 @@ "button": "複製" } }, + "vpnForm": { + "dependencies": { + "openVpnMissingTitle": "OpenVPN がインストールされていません", + "openVpnMissingDescription": "この設定は保存できますが、このデバイスに OpenVPN がインストールされるまで Donut Browser では接続できません。", + "openVpnAdapterMissingTitle": "OpenVPN アダプターが見つかりません", + "openVpnAdapterMissingDescription": "OpenVPN はインストールされていますが、TAP/Wintun/ovpn-dco アダプターが見つかりませんでした。Windows で接続する前に OpenVPN を修復または再インストールしてください。", + "openVpnCheckFailedTitle": "OpenVPN のインストールを確認できませんでした", + "openVpnCheckFailedDescription": "Donut Browser はローカルの OpenVPN インストールを確認できませんでした。Windows で接続する前に OpenVPN を修復または再インストールしてください。" + } + }, "extensions": { "title": "拡張機能", "description": "プロファイル用のブラウザ拡張機能と拡張機能グループを管理します。", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 9d8a4c7..afd2567 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -794,6 +794,16 @@ "button": "Clonar" } }, + "vpnForm": { + "dependencies": { + "openVpnMissingTitle": "OpenVPN não está instalado", + "openVpnMissingDescription": "Você pode salvar esta configuração, mas o Donut Browser não poderá se conectar até que o OpenVPN esteja instalado neste dispositivo.", + "openVpnAdapterMissingTitle": "O adaptador do OpenVPN está ausente", + "openVpnAdapterMissingDescription": "O OpenVPN está instalado, mas nenhum adaptador TAP/Wintun/ovpn-dco foi encontrado. Repare ou reinstale o OpenVPN antes de se conectar no Windows.", + "openVpnCheckFailedTitle": "Não foi possível verificar a instalação do OpenVPN", + "openVpnCheckFailedDescription": "O Donut Browser não conseguiu inspecionar a instalação local do OpenVPN. Repare ou reinstale o OpenVPN antes de se conectar no Windows." + } + }, "extensions": { "title": "Extensões", "description": "Gerencie extensões de navegador e grupos de extensões para seus perfis.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 9f0bc71..4c9df8b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -794,6 +794,16 @@ "button": "Клонировать" } }, + "vpnForm": { + "dependencies": { + "openVpnMissingTitle": "OpenVPN не установлен", + "openVpnMissingDescription": "Вы можете сохранить эту конфигурацию, но Donut Browser не сможет подключиться, пока OpenVPN не будет установлен на этом устройстве.", + "openVpnAdapterMissingTitle": "Отсутствует адаптер OpenVPN", + "openVpnAdapterMissingDescription": "OpenVPN установлен, но адаптер TAP/Wintun/ovpn-dco не найден. Восстановите или переустановите OpenVPN перед подключением в Windows.", + "openVpnCheckFailedTitle": "Не удалось проверить установку OpenVPN", + "openVpnCheckFailedDescription": "Donut Browser не смог проверить локальную установку OpenVPN. Восстановите или переустановите OpenVPN перед подключением в Windows." + } + }, "extensions": { "title": "Расширения", "description": "Управляйте расширениями браузера и группами расширений для ваших профилей.", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c0d3a4b..cfe1e5a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -794,6 +794,16 @@ "button": "克隆" } }, + "vpnForm": { + "dependencies": { + "openVpnMissingTitle": "未安装 OpenVPN", + "openVpnMissingDescription": "你现在可以保存这个配置,但在此设备上安装 OpenVPN 之前,Donut Browser 无法连接它。", + "openVpnAdapterMissingTitle": "缺少 OpenVPN 适配器", + "openVpnAdapterMissingDescription": "已安装 OpenVPN,但未找到 TAP/Wintun/ovpn-dco 适配器。在 Windows 上连接前,请修复或重新安装 OpenVPN。", + "openVpnCheckFailedTitle": "无法验证 OpenVPN 安装", + "openVpnCheckFailedDescription": "Donut Browser 无法检查本机 OpenVPN 安装。在 Windows 上连接前,请修复或重新安装 OpenVPN。" + } + }, "extensions": { "title": "扩展程序", "description": "管理配置文件的浏览器扩展程序和扩展程序组。",