diff --git a/.vscode/settings.json b/.vscode/settings.json index 38c589f..9069853 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -131,6 +131,7 @@ "osascript", "oscpu", "outpath", + "OVPN", "patchelf", "pathex", "pathlib", @@ -183,6 +184,7 @@ "stefanzweifel", "subdirs", "subkey", + "subsec", "SUPPRESSMSGBOXES", "swatinem", "sysinfo", @@ -212,6 +214,7 @@ "venv", "vercel", "VERYSILENT", + "vpns", "wayfern", "webgl", "webrtc", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bbb0415..9b95007 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1409,6 +1409,47 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deranged" version = "0.5.5" @@ -1481,7 +1522,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1596,6 +1637,7 @@ dependencies = [ "serde_yaml", "serial_test", "single-instance", + "smoltcp", "sys-locale", "sysinfo", "tao", @@ -1794,7 +1836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2516,6 +2558,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2549,6 +2600,16 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2739,7 +2800,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -3387,6 +3448,12 @@ dependencies = [ "core-foundation-sys", ] +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "markup5ever" version = "0.14.1" @@ -4119,7 +4186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -4587,6 +4654,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" @@ -5248,7 +5337,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5827,6 +5916,20 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smoltcp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a1a996951e50b5971a2c8c0fa05a381480d70a933064245c4a223ddc87ccc97" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 0.3.100", + "heapless", + "managed", +] + [[package]] name = "socket2" version = "0.6.2" @@ -6551,7 +6654,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7606,7 +7709,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 01b167b..5cd81b5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -100,6 +100,7 @@ quick-xml = { version = "0.39", features = ["serialize"] } # VPN support boringtun = "0.7" +smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] } # Daemon dependencies (tray icon) tray-icon = "0.21" diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index be0f04a..852aa0d 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -601,6 +601,7 @@ async fn create_profile( &request.version, request.release_type.as_deref().unwrap_or("stable"), request.proxy_id.clone(), + None, // vpn_id camoufox_config, wayfern_config, request.group_id.clone(), diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 37f10d1..ca8ac23 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -511,6 +511,7 @@ mod tests { version: version.to_string(), process_id: None, proxy_id: None, + vpn_id: None, last_launch: None, release_type: "stable".to_string(), camoufox_config: None, diff --git a/src-tauri/src/bin/donut_daemon.rs b/src-tauri/src/bin/donut_daemon.rs index dc925b4..fdca470 100644 --- a/src-tauri/src/bin/donut_daemon.rs +++ b/src-tauri/src/bin/donut_daemon.rs @@ -243,7 +243,8 @@ fn run_daemon() { // Use swap to only run cleanup once if SHOULD_QUIT.swap(false, Ordering::SeqCst) { - // Cleanup + tray::quit_gui(); + let mut state = read_state(); state.daemon_pid = None; let _ = write_state(&state); @@ -357,10 +358,6 @@ fn main() { match args[1].as_str() { "start" => { - // "start" is now an alias for "run" - // On macOS, the daemon should be started via launchctl (see daemon_spawn.rs) - // This command is kept for backward compatibility - eprintln!("Starting daemon..."); run_daemon(); } "stop" => { diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index ee0399e..5c6aaf5 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -172,6 +172,24 @@ async fn main() { ) .arg(Arg::new("action").required(true).help("Action (start)")), ) + .subcommand( + Command::new("vpn-worker") + .about("Run a VPN worker process (internal use)") + .arg( + Arg::new("id") + .long("id") + .required(true) + .help("VPN worker configuration ID"), + ) + .arg( + Arg::new("port") + .long("port") + .value_parser(clap::value_parser!(u16)) + .required(true) + .help("Local SOCKS5 port"), + ) + .arg(Arg::new("action").required(true).help("Action (start)")), + ) .get_matches(); if let Some(proxy_matches) = matches.subcommand_matches("proxy") { @@ -333,6 +351,107 @@ async fn main() { log::error!("Invalid action for proxy-worker. Use 'start'"); process::exit(1); } + } else if let Some(vpn_matches) = matches.subcommand_matches("vpn-worker") { + let id = vpn_matches.get_one::("id").expect("id is required"); + let action = vpn_matches + .get_one::("action") + .expect("action is required"); + let port = *vpn_matches + .get_one::("port") + .expect("port is required"); + + if action == "start" { + set_high_priority(); + + log::info!("VPN worker starting, config id: {}", id); + log::info!("Process PID: {}", std::process::id()); + + // Retry config loading to handle file system race condition + let config = { + let mut attempts = 0; + loop { + if let Some(config) = donutbrowser_lib::vpn_worker_storage::get_vpn_worker_config(id) { + log::info!( + "Found VPN worker config: id={}, vpn_type={}, vpn_id={}", + config.id, + config.vpn_type, + config.vpn_id + ); + break config; + } + attempts += 1; + if attempts >= 10 { + log::error!( + "VPN worker configuration {} not found after {} attempts", + id, + attempts + ); + process::exit(1); + } + log::info!( + "VPN worker config {} not found yet, retrying ({}/10)...", + id, + attempts + ); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + }; + + // Read the decrypted VPN config from the temp file + let vpn_config_data = match std::fs::read_to_string(&config.config_file_path) { + Ok(data) => data, + Err(e) => { + log::error!( + "Failed to read VPN config file {}: {}", + config.config_file_path, + e + ); + process::exit(1); + } + }; + + match config.vpn_type.as_str() { + "wireguard" => { + let wg_config = match donutbrowser_lib::vpn::parse_wireguard_config(&vpn_config_data) { + Ok(c) => c, + Err(e) => { + log::error!("Failed to parse WireGuard config: {}", e); + process::exit(1); + } + }; + + let server = + donutbrowser_lib::vpn::socks5_server::WireGuardSocks5Server::new(wg_config, port); + if let Err(e) = server.run(id.clone()).await { + log::error!("VPN worker failed: {}", e); + process::exit(1); + } + } + "openvpn" => { + let ovpn_config = match donutbrowser_lib::vpn::parse_openvpn_config(&vpn_config_data) { + Ok(c) => c, + Err(e) => { + log::error!("Failed to parse OpenVPN config: {}", e); + process::exit(1); + } + }; + + let server = + donutbrowser_lib::vpn::openvpn_socks5::OpenVpnSocks5Server::new(ovpn_config, port); + if let Err(e) = server.run(id.clone()).await { + log::error!("VPN worker failed: {}", e); + process::exit(1); + } + } + other => { + log::error!("Unknown VPN type: {}", other); + process::exit(1); + } + } + } else { + log::error!("Invalid action for vpn-worker. Use 'start'"); + process::exit(1); + } } else { log::error!("No command specified"); process::exit(1); diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 660ce1a..d53c730 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -113,11 +113,34 @@ impl BrowserRunner { }); // Always start a local proxy for Camoufox (for traffic monitoring and geoip support) - let upstream_proxy = profile + let mut upstream_proxy = profile .proxy_id .as_ref() .and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id)); + // If profile has a VPN instead of proxy, start VPN worker and use it as upstream + if upstream_proxy.is_none() { + if let Some(ref vpn_id) = profile.vpn_id { + match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await { + Ok(vpn_worker) => { + if let Some(port) = vpn_worker.local_port { + upstream_proxy = Some(ProxySettings { + proxy_type: "socks5".to_string(), + host: "127.0.0.1".to_string(), + port, + username: None, + password: None, + }); + log::info!("VPN worker started for Camoufox profile on port {}", port); + } + } + Err(e) => { + return Err(format!("Failed to start VPN worker: {e}").into()); + } + } + } + } + log::info!( "Starting local proxy for Camoufox profile: {} (upstream: {})", profile.name, @@ -312,11 +335,34 @@ impl BrowserRunner { }); // Always start a local proxy for Wayfern (for traffic monitoring and geoip support) - let upstream_proxy = profile + let mut upstream_proxy = profile .proxy_id .as_ref() .and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id)); + // If profile has a VPN instead of proxy, start VPN worker and use it as upstream + if upstream_proxy.is_none() { + if let Some(ref vpn_id) = profile.vpn_id { + match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await { + Ok(vpn_worker) => { + if let Some(port) = vpn_worker.local_port { + upstream_proxy = Some(ProxySettings { + proxy_type: "socks5".to_string(), + host: "127.0.0.1".to_string(), + port, + username: None, + password: None, + }); + log::info!("VPN worker started for Wayfern profile on port {}", port); + } + } + Err(e) => { + return Err(format!("Failed to start VPN worker: {e}").into()); + } + } + } + } + log::info!( "Starting local proxy for Wayfern profile: {} (upstream: {})", profile.name, @@ -2413,11 +2459,34 @@ pub async fn launch_browser_profile( // This ensures all traffic goes through the local proxy for monitoring and future features if profile.browser != "camoufox" && profile.browser != "wayfern" { // Determine upstream proxy if configured; otherwise use DIRECT (no upstream) - let upstream_proxy = profile_for_launch + let mut upstream_proxy = profile_for_launch .proxy_id .as_ref() .and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id)); + // If profile has a VPN instead of proxy, start VPN worker and use it as upstream + if upstream_proxy.is_none() { + if let Some(ref vpn_id) = profile_for_launch.vpn_id { + match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await { + Ok(vpn_worker) => { + if let Some(port) = vpn_worker.local_port { + upstream_proxy = Some(ProxySettings { + proxy_type: "socks5".to_string(), + host: "127.0.0.1".to_string(), + port, + username: None, + password: None, + }); + log::info!("VPN worker started for profile on port {}", port); + } + } + Err(e) => { + return Err(format!("Failed to start VPN worker: {e}")); + } + } + } + } + // Use a temporary PID (1) to start the proxy, we'll update it after browser launch let temp_pid = 1u32; let profile_id_str = profile.id.to_string(); diff --git a/src-tauri/src/daemon/autostart.rs b/src-tauri/src/daemon/autostart.rs index 1e78aa4..c59d477 100644 --- a/src-tauri/src/daemon/autostart.rs +++ b/src-tauri/src/daemon/autostart.rs @@ -103,11 +103,6 @@ pub fn enable_autostart() -> io::Result<()> { LimitLoadToSessionType Aqua - KeepAlive - - SuccessfulExit - - ProcessType Interactive StandardOutPath @@ -188,6 +183,26 @@ pub fn load_launch_agent() -> io::Result<()> { Ok(()) } +#[cfg(target_os = "macos")] +pub fn start_launch_agent() -> io::Result<()> { + use std::process::Command; + + let output = Command::new("launchctl") + .args(["start", "com.donutbrowser.daemon"]) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(io::Error::other(format!( + "launchctl start failed: {}", + stderr + ))); + } + + log::info!("Started launch agent via launchctl"); + Ok(()) +} + #[cfg(target_os = "macos")] pub fn unload_launch_agent() -> io::Result<()> { use std::process::Command; @@ -233,7 +248,7 @@ pub fn enable_autostart() -> io::Result<()> { r#"[Desktop Entry] Type=Application Name=Donut Browser Daemon -Exec={} start +Exec={} run Hidden=false NoDisplay=true X-GNOME-Autostart-enabled=true @@ -281,7 +296,7 @@ pub fn enable_autostart() -> io::Result<()> { key.set_value( "DonutBrowserDaemon", - &format!("\"{}\" start", daemon_path.display()), + &format!("\"{}\" run", daemon_path.display()), )?; log::info!("Added registry autostart entry"); diff --git a/src-tauri/src/daemon/tray.rs b/src-tauri/src/daemon/tray.rs index 2d3d5a8..701dcc4 100644 --- a/src-tauri/src/daemon/tray.rs +++ b/src-tauri/src/daemon/tray.rs @@ -127,6 +127,32 @@ pub fn activate_gui() { } } +pub fn quit_gui() { + log::info!("[daemon] Quitting GUI..."); + + #[cfg(target_os = "macos")] + { + let _ = Command::new("osascript") + .args(["-e", "tell application \"Donut Browser\" to quit"]) + .output(); + } + + #[cfg(target_os = "windows")] + { + let _ = Command::new("taskkill") + .args(["/IM", "Donut.exe", "/F"]) + .output(); + let _ = Command::new("taskkill") + .args(["/IM", "donutbrowser.exe", "/F"]) + .output(); + } + + #[cfg(target_os = "linux")] + { + let _ = Command::new("pkill").args(["-x", "donutbrowser"]).output(); + } +} + pub fn set_gui_running(running: bool) { GUI_RUNNING.store(running, Ordering::SeqCst); } diff --git a/src-tauri/src/daemon_spawn.rs b/src-tauri/src/daemon_spawn.rs index 0a1e8fd..f795aa8 100644 --- a/src-tauri/src/daemon_spawn.rs +++ b/src-tauri/src/daemon_spawn.rs @@ -32,7 +32,7 @@ fn read_state() -> DaemonState { DaemonState::default() } -fn is_daemon_running() -> bool { +pub fn is_daemon_running() -> bool { let state = read_state(); if let Some(pid) = state.daemon_pid { @@ -243,6 +243,11 @@ fn spawn_daemon_macos() -> Result<(), String> { autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?; log::info!("launchctl load completed"); + // Also explicitly start the agent in case it was already loaded but stopped + if let Err(e) = autostart::start_launch_agent() { + log::debug!("launchctl start note (non-fatal): {}", e); + } + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a17b15..b53a89f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,6 +49,8 @@ mod mcp_server; mod tag_manager; mod version_updater; pub mod vpn; +pub mod vpn_worker_runner; +pub mod vpn_worker_storage; use browser_runner::{ check_browser_exists, kill_browser_profile, launch_browser_profile, open_url_with_profile, @@ -57,7 +59,7 @@ use browser_runner::{ use profile::manager::{ check_browser_status, clone_profile, create_browser_profile_new, delete_profile, list_browser_profiles, rename_profile, update_camoufox_config, update_profile_note, - update_profile_proxy, update_profile_tags, update_wayfern_config, + update_profile_proxy, update_profile_tags, update_profile_vpn, update_wayfern_config, }; use browser_version_manager::{ @@ -80,8 +82,9 @@ use settings_manager::{ }; use sync::{ - is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile, request_profile_sync, - set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled, + is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile, + is_vpn_in_use_by_synced_profile, request_profile_sync, set_group_sync_enabled, + set_profile_sync_enabled, set_proxy_sync_enabled, set_vpn_sync_enabled, }; use tag_manager::get_all_tags; @@ -469,69 +472,142 @@ async fn get_vpn_config(vpn_id: String) -> Result { } #[tauri::command] -async fn delete_vpn_config(vpn_id: String) -> Result<(), String> { - // First disconnect if connected +async fn delete_vpn_config(app_handle: tauri::AppHandle, vpn_id: String) -> Result<(), String> { + // First disconnect if connected (stop VPN worker) + let _ = vpn_worker_runner::stop_vpn_worker_by_vpn_id(&vpn_id).await; + + // Check if sync was enabled before deleting + let was_sync_enabled = { + let storage = vpn::VPN_STORAGE + .lock() + .map_err(|e| format!("Failed to lock VPN storage: {e}"))?; + storage + .load_config(&vpn_id) + .map(|c| c.sync_enabled) + .unwrap_or(false) + }; + + // Delete from storage { - let mut manager = vpn::TUNNEL_MANAGER.lock().await; - if manager.is_tunnel_active(&vpn_id) { - if let Some(tunnel) = manager.get_tunnel_mut(&vpn_id) { - let _ = tunnel.disconnect().await; - } - manager.remove_tunnel(&vpn_id); - } - } - - // Then delete from storage - let storage = vpn::VPN_STORAGE - .lock() - .map_err(|e| format!("Failed to lock VPN storage: {e}"))?; - - storage - .delete_config(&vpn_id) - .map_err(|e| format!("Failed to delete VPN config: {e}")) -} - -#[tauri::command] -async fn connect_vpn(vpn_id: String) -> Result<(), String> { - // Load config from storage - let config = { let storage = vpn::VPN_STORAGE .lock() .map_err(|e| format!("Failed to lock VPN storage: {e}"))?; storage - .load_config(&vpn_id) - .map_err(|e| format!("Failed to load VPN config: {e}"))? - }; - - // Create and connect the appropriate tunnel - let mut manager = vpn::TUNNEL_MANAGER.lock().await; - - // Check if already connected - if manager.is_tunnel_active(&vpn_id) { - return Ok(()); + .delete_config(&vpn_id) + .map_err(|e| format!("Failed to delete VPN config: {e}"))?; } - let mut tunnel: Box = match config.vpn_type { - vpn::VpnType::WireGuard => { - let wg_config = vpn::parse_wireguard_config(&config.config_data) - .map_err(|e| format!("Invalid WireGuard config: {e}"))?; - Box::new(vpn::WireGuardTunnel::new(vpn_id.clone(), wg_config)) + // If sync was enabled, also delete from remote + if was_sync_enabled { + let vpn_id_clone = vpn_id.clone(); + let app_handle_clone = app_handle.clone(); + tauri::async_runtime::spawn(async move { + match sync::SyncEngine::create_from_settings(&app_handle_clone).await { + Ok(engine) => { + if let Err(e) = engine.delete_vpn(&vpn_id_clone).await { + log::warn!("Failed to delete VPN {} from sync: {}", vpn_id_clone, e); + } else { + log::info!("VPN {} deleted from sync storage", vpn_id_clone); + } + } + Err(e) => { + log::debug!("Sync not configured, skipping remote VPN deletion: {}", e); + } + } + }); + } + + let _ = events::emit("vpn-configs-changed", ()); + + Ok(()) +} + +#[tauri::command] +async fn create_vpn_config_manual( + name: String, + vpn_type: vpn::VpnType, + config_data: String, +) -> Result { + let storage = vpn::VPN_STORAGE + .lock() + .map_err(|e| format!("Failed to lock VPN storage: {e}"))?; + + storage + .create_config_manual(&name, vpn_type, &config_data) + .map_err(|e| format!("Failed to create VPN config: {e}")) +} + +#[tauri::command] +async fn update_vpn_config(vpn_id: String, name: String) -> Result { + let storage = vpn::VPN_STORAGE + .lock() + .map_err(|e| format!("Failed to lock VPN storage: {e}"))?; + + storage + .update_config_name(&vpn_id, &name) + .map_err(|e| format!("Failed to update VPN config: {e}")) +} + +#[tauri::command] +async fn check_vpn_validity( + vpn_id: String, +) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Start a temporary VPN worker to send real traffic + 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)); + + // 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, + } } - vpn::VpnType::OpenVPN => { - let ovpn_config = vpn::parse_openvpn_config(&config.config_data) - .map_err(|e| format!("Invalid OpenVPN config: {e}"))?; - Box::new(vpn::OpenVpnTunnel::new(vpn_id.clone(), ovpn_config)) + 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, + } } }; - tunnel - .connect() + // Stop the temporary VPN worker + let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await; + + Ok(result) +} + +#[tauri::command] +async fn connect_vpn(vpn_id: String) -> Result<(), String> { + // Start VPN worker process (detached, survives GUI shutdown) + vpn_worker_runner::start_vpn_worker(&vpn_id) .await .map_err(|e| format!("Failed to connect VPN: {e}"))?; - manager.register_tunnel(vpn_id.clone(), tunnel); - // Update last_used timestamp { let storage = vpn::VPN_STORAGE @@ -545,27 +621,27 @@ async fn connect_vpn(vpn_id: String) -> Result<(), String> { #[tauri::command] async fn disconnect_vpn(vpn_id: String) -> Result<(), String> { - let mut manager = vpn::TUNNEL_MANAGER.lock().await; - - if let Some(tunnel) = manager.get_tunnel_mut(&vpn_id) { - tunnel - .disconnect() - .await - .map_err(|e| format!("Failed to disconnect VPN: {e}"))?; - } - - manager.remove_tunnel(&vpn_id); + vpn_worker_runner::stop_vpn_worker_by_vpn_id(&vpn_id) + .await + .map_err(|e| format!("Failed to disconnect VPN: {e}"))?; Ok(()) } #[tauri::command] async fn get_vpn_status(vpn_id: String) -> Result { - let manager = vpn::TUNNEL_MANAGER.lock().await; + use crate::proxy_storage::is_process_running; - if let Some(tunnel) = manager.get_tunnel(&vpn_id) { - Ok(tunnel.get_status()) + if let Some(worker) = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id) { + let connected = worker.pid.map(is_process_running).unwrap_or(false); + Ok(vpn::VpnStatus { + connected, + vpn_id, + connected_at: None, + bytes_sent: None, + bytes_received: None, + last_handshake: None, + }) } else { - // Not connected Ok(vpn::VpnStatus { connected: false, vpn_id, @@ -579,8 +655,23 @@ async fn get_vpn_status(vpn_id: String) -> Result { #[tauri::command] async fn list_active_vpn_connections() -> Result, String> { - let manager = vpn::TUNNEL_MANAGER.lock().await; - Ok(manager.get_all_statuses()) + use crate::proxy_storage::is_process_running; + + let workers = vpn_worker_storage::list_vpn_worker_configs(); + Ok( + workers + .into_iter() + .filter(|w| w.pid.map(is_process_running).unwrap_or(false)) + .map(|w| vpn::VpnStatus { + connected: true, + vpn_id: w.vpn_id, + connected_at: None, + bytes_sent: None, + bytes_received: None, + last_handshake: None, + }) + .collect(), + ) } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -645,6 +736,30 @@ pub fn run() { log::warn!("Failed to start daemon: {e}"); } + // Monitor daemon health - quit GUI if daemon dies + let app_handle_daemon = app.handle().clone(); + tauri::async_runtime::spawn(async move { + // Give the daemon time to fully start + tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + interval.tick().await; + + let is_running = tokio::task::spawn_blocking(daemon_spawn::is_daemon_running) + .await + .unwrap_or(false); + + if !is_running { + log::warn!("Daemon is no longer running, quitting GUI"); + app_handle_daemon.exit(0); + break; + } + } + }); + // Create the main window programmatically #[allow(unused_variables)] let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) @@ -1124,6 +1239,7 @@ pub fn run() { get_all_tags, get_browser_release_types, update_profile_proxy, + update_profile_vpn, update_profile_tags, update_profile_note, check_browser_status, @@ -1191,6 +1307,8 @@ pub fn run() { set_group_sync_enabled, is_proxy_in_use_by_synced_profile, is_group_in_use_by_synced_profile, + set_vpn_sync_enabled, + is_vpn_in_use_by_synced_profile, read_profile_cookies, copy_profile_cookies, check_wayfern_terms_accepted, @@ -1208,6 +1326,9 @@ pub fn run() { list_vpn_configs, get_vpn_config, delete_vpn_config, + create_vpn_config_manual, + update_vpn_config, + check_vpn_validity, connect_vpn, disconnect_vpn, get_vpn_status, @@ -1247,12 +1368,10 @@ mod tests { // Commands that are intentionally not used in the frontend // but are used via MCP server or other programmatic APIs let mcp_only_commands = [ - "list_vpn_configs", - "get_vpn_config", - "delete_vpn_config", "connect_vpn", "disconnect_vpn", "get_vpn_status", + "get_vpn_config", "list_active_vpn_connections", ]; diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index a1e1aaf..92a9315 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1702,16 +1702,8 @@ impl McpServer { message: "Missing vpn_id".to_string(), })?; - // First disconnect if connected - { - let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await; - if manager.is_tunnel_active(vpn_id) { - if let Some(tunnel) = manager.get_tunnel_mut(vpn_id) { - let _ = tunnel.disconnect().await; - } - manager.remove_tunnel(vpn_id); - } - } + // First disconnect if connected (stop VPN worker) + let _ = crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(vpn_id).await; let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError { code: -32000, @@ -1743,63 +1735,14 @@ impl McpServer { message: "Missing vpn_id".to_string(), })?; - // Load config from storage - let config = { - let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError { + // Start VPN worker process + crate::vpn_worker_runner::start_vpn_worker(vpn_id) + .await + .map_err(|e| McpError { code: -32000, - message: format!("Failed to lock VPN storage: {e}"), + message: format!("Failed to connect VPN: {e}"), })?; - storage.load_config(vpn_id).map_err(|e| McpError { - code: -32000, - message: format!("Failed to load VPN config: {e}"), - })? - }; - - let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await; - - // Check if already connected - if manager.is_tunnel_active(vpn_id) { - return Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": format!("VPN '{}' is already connected", config.name) - }] - })); - } - - let mut tunnel: Box = match config.vpn_type { - crate::vpn::VpnType::WireGuard => { - let wg_config = - crate::vpn::parse_wireguard_config(&config.config_data).map_err(|e| McpError { - code: -32000, - message: format!("Invalid WireGuard config: {e}"), - })?; - Box::new(crate::vpn::WireGuardTunnel::new( - vpn_id.to_string(), - wg_config, - )) - } - crate::vpn::VpnType::OpenVPN => { - let ovpn_config = - crate::vpn::parse_openvpn_config(&config.config_data).map_err(|e| McpError { - code: -32000, - message: format!("Invalid OpenVPN config: {e}"), - })?; - Box::new(crate::vpn::OpenVpnTunnel::new( - vpn_id.to_string(), - ovpn_config, - )) - } - }; - - tunnel.connect().await.map_err(|e| McpError { - code: -32000, - message: format!("Failed to connect VPN: {e}"), - })?; - - manager.register_tunnel(vpn_id.to_string(), tunnel); - // Update last_used timestamp { let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError { @@ -1812,7 +1755,7 @@ impl McpServer { Ok(serde_json::json!({ "content": [{ "type": "text", - "text": format!("VPN '{}' connected successfully", config.name) + "text": format!("VPN '{}' connected successfully", vpn_id) }] })) } @@ -1829,16 +1772,12 @@ impl McpServer { message: "Missing vpn_id".to_string(), })?; - let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await; - - if let Some(tunnel) = manager.get_tunnel_mut(vpn_id) { - tunnel.disconnect().await.map_err(|e| McpError { + crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(vpn_id) + .await + .map_err(|e| McpError { code: -32000, message: format!("Failed to disconnect VPN: {e}"), })?; - } - - manager.remove_tunnel(vpn_id); Ok(serde_json::json!({ "content": [{ @@ -1860,19 +1799,23 @@ impl McpServer { message: "Missing vpn_id".to_string(), })?; - let manager = crate::vpn::TUNNEL_MANAGER.lock().await; + let connected = + if let Some(worker) = crate::vpn_worker_storage::find_vpn_worker_by_vpn_id(vpn_id) { + worker + .pid + .map(crate::proxy_storage::is_process_running) + .unwrap_or(false) + } else { + false + }; - let status = if let Some(tunnel) = manager.get_tunnel(vpn_id) { - tunnel.get_status() - } else { - crate::vpn::VpnStatus { - connected: false, - vpn_id: vpn_id.to_string(), - connected_at: None, - bytes_sent: None, - bytes_received: None, - last_handshake: None, - } + let status = crate::vpn::VpnStatus { + connected, + vpn_id: vpn_id.to_string(), + connected_at: None, + bytes_sent: None, + bytes_received: None, + last_handshake: None, }; Ok(serde_json::json!({ diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 3051e33..c0e4dee 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -61,10 +61,14 @@ impl ProfileManager { version: &str, release_type: &str, proxy_id: Option, + vpn_id: Option, camoufox_config: Option, wayfern_config: Option, group_id: Option, ) -> Result> { + if proxy_id.is_some() && vpn_id.is_some() { + return Err("Cannot set both proxy_id and vpn_id".into()); + } log::info!("Attempting to create profile: {name}"); // Check if a profile with this name already exists (case insensitive) @@ -163,6 +167,7 @@ impl ProfileManager { browser: browser.to_string(), version: version.to_string(), proxy_id: proxy_id.clone(), + vpn_id: None, process_id: None, last_launch: None, release_type: release_type.to_string(), @@ -277,6 +282,7 @@ impl ProfileManager { browser: browser.to_string(), version: version.to_string(), proxy_id: proxy_id.clone(), + vpn_id: None, process_id: None, last_launch: None, release_type: release_type.to_string(), @@ -323,6 +329,7 @@ impl ProfileManager { browser: browser.to_string(), version: version.to_string(), proxy_id: proxy_id.clone(), + vpn_id: vpn_id.clone(), process_id: None, last_launch: None, release_type: release_type.to_string(), @@ -840,6 +847,7 @@ impl ProfileManager { browser: source.browser, version: source.version, proxy_id: source.proxy_id, + vpn_id: source.vpn_id, process_id: None, last_launch: None, release_type: source.release_type, @@ -1011,8 +1019,9 @@ impl ProfileManager { // Remember old proxy_id for cleanup (not used yet, but may be needed for cleanup) let _old_proxy_id = profile.proxy_id.clone(); - // Update proxy settings + // Update proxy settings and clear VPN (mutual exclusion) profile.proxy_id = proxy_id.clone(); + profile.vpn_id = None; // Save the updated profile self @@ -1075,6 +1084,52 @@ impl ProfileManager { Ok(profile) } + pub async fn update_profile_vpn( + &self, + _app_handle: tauri::AppHandle, + profile_id: &str, + vpn_id: Option, + ) -> Result> { + let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err( + |_| -> Box { + format!("Invalid profile ID: {profile_id}").into() + }, + )?; + let profiles = + self + .list_profiles() + .map_err(|e| -> Box { + format!("Failed to list profiles: {e}").into() + })?; + + let mut profile = profiles + .into_iter() + .find(|p| p.id == profile_uuid) + .ok_or_else(|| -> Box { + format!("Profile with ID '{profile_id}' not found").into() + })?; + + // Update VPN and clear proxy (mutual exclusion) + profile.vpn_id = vpn_id; + profile.proxy_id = None; + + self + .save_profile(&profile) + .map_err(|e| -> Box { + format!("Failed to save profile: {e}").into() + })?; + + if let Err(e) = events::emit("profile-updated", &profile) { + log::warn!("Warning: Failed to emit profile update event: {e}"); + } + + if let Err(e) = events::emit_empty("profiles-changed") { + log::warn!("Warning: Failed to emit profiles-changed event: {e}"); + } + + Ok(profile) + } + pub async fn check_browser_status( &self, app_handle: tauri::AppHandle, @@ -1799,6 +1854,7 @@ pub async fn create_browser_profile_with_group( version: String, release_type: String, proxy_id: Option, + vpn_id: Option, camoufox_config: Option, wayfern_config: Option, group_id: Option, @@ -1812,6 +1868,7 @@ pub async fn create_browser_profile_with_group( &version, &release_type, proxy_id, + vpn_id, camoufox_config, wayfern_config, group_id, @@ -1841,6 +1898,19 @@ pub async fn update_profile_proxy( .map_err(|e| format!("Failed to update profile: {e}")) } +#[tauri::command] +pub async fn update_profile_vpn( + app_handle: tauri::AppHandle, + profile_id: String, + vpn_id: Option, +) -> Result { + let profile_manager = ProfileManager::instance(); + profile_manager + .update_profile_vpn(app_handle, &profile_id, vpn_id) + .await + .map_err(|e| format!("Failed to update profile VPN: {e}")) +} + #[tauri::command] pub fn update_profile_tags( app_handle: tauri::AppHandle, @@ -1898,6 +1968,7 @@ pub async fn create_browser_profile_new( version: String, release_type: String, proxy_id: Option, + vpn_id: Option, camoufox_config: Option, wayfern_config: Option, group_id: Option, @@ -1923,6 +1994,7 @@ pub async fn create_browser_profile_new( version, release_type, proxy_id, + vpn_id, camoufox_config, wayfern_config, group_id, diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 9f89f02..d3eebd2 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -22,6 +22,8 @@ pub struct BrowserProfile { #[serde(default)] pub proxy_id: Option, // Reference to stored proxy #[serde(default)] + pub vpn_id: Option, // Reference to stored VPN config + #[serde(default)] pub process_id: Option, #[serde(default)] pub last_launch: Option, diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 4c3d06b..19c2c88 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -545,6 +545,7 @@ impl ProfileImporter { browser: browser_type.to_string(), version: available_versions, proxy_id: None, + vpn_id: None, process_id: None, last_launch: None, release_type: "stable".to_string(), diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index cccbffd..0f9db28 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -243,8 +243,7 @@ impl ProxyManager { .as_secs() } - // Get geolocation for an IP address - async fn get_ip_geolocation( + pub async fn get_ip_geolocation( ip: &str, ) -> Result<(Option, Option, Option), String> { // Use ip-api.com (free, no API key required) @@ -1626,6 +1625,27 @@ impl ProxyManager { delete_proxy_config(&config.id); } + // Clean up orphaned VPN worker configs where the worker process is dead + { + use crate::proxy_storage::is_process_running; + use crate::vpn_worker_storage::{delete_vpn_worker_config, list_vpn_worker_configs}; + + let vpn_workers = list_vpn_worker_configs(); + for worker in vpn_workers { + if let Some(pid) = worker.pid { + if !is_process_running(pid) { + log::info!( + "Cleaning up orphaned VPN worker config: {} (process PID {} is dead)", + worker.id, + pid + ); + let _ = std::fs::remove_file(&worker.config_file_path); + delete_vpn_worker_config(&worker.id); + } + } + } + } + // Emit event for reactive UI updates if let Err(e) = events::emit_empty("proxies-changed") { log::error!("Failed to emit proxies-changed event: {e}"); diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 08fbebd..c216b61 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -102,6 +102,24 @@ impl SyncEngine { // Generate local manifest let local_manifest = generate_manifest(&profile_id, &profile_dir, &mut hash_cache)?; + let total_size: u64 = local_manifest.files.iter().map(|f| f.size).sum(); + let has_cookies = local_manifest + .files + .iter() + .any(|f| f.path.contains("Cookies") || f.path.contains("cookies")); + let has_local_state = local_manifest + .files + .iter() + .any(|f| f.path.contains("Local State")); + log::info!( + "Profile {} manifest: {} files, {} bytes total, cookies={}, local_state={}", + profile_id, + local_manifest.files.len(), + total_size, + has_cookies, + has_local_state + ); + // Save the hash cache for future runs hash_cache.save(&cache_path)?; @@ -174,13 +192,16 @@ impl SyncEngine { // Upload manifest.json last for atomicity self.upload_manifest(&profile_id, &local_manifest).await?; - // Sync associated proxy and group + // Sync associated proxy, group, and VPN if let Some(proxy_id) = &profile.proxy_id { let _ = self.sync_proxy(proxy_id, Some(app_handle)).await; } if let Some(group_id) = &profile.group_id { let _ = self.sync_group(group_id, Some(app_handle)).await; } + if let Some(vpn_id) = &profile.vpn_id { + let _ = self.sync_vpn(vpn_id, Some(app_handle)).await; + } // Update profile last_sync let mut updated_profile = profile.clone(); @@ -785,6 +806,145 @@ impl SyncEngine { Ok(()) } + async fn sync_vpn(&self, vpn_id: &str, app_handle: Option<&tauri::AppHandle>) -> SyncResult<()> { + let local_vpn = { + let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); + storage.load_config(vpn_id).ok() + }; + + let remote_key = format!("vpns/{}.json", vpn_id); + let stat = self.client.stat(&remote_key).await?; + + match (local_vpn, stat.exists) { + (Some(vpn), true) => { + let local_updated = vpn.last_sync.unwrap_or(0); + let remote_updated: DateTime = stat + .last_modified + .as_ref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now); + let remote_ts = remote_updated.timestamp() as u64; + + if remote_ts > local_updated { + self.download_vpn(vpn_id, app_handle).await?; + } else if local_updated > remote_ts { + self.upload_vpn(&vpn).await?; + } + } + (Some(vpn), false) => { + self.upload_vpn(&vpn).await?; + } + (None, true) => { + self.download_vpn(vpn_id, app_handle).await?; + } + (None, false) => { + log::debug!("VPN {} not found locally or remotely", vpn_id); + } + } + + Ok(()) + } + + async fn upload_vpn(&self, vpn: &crate::vpn::VpnConfig) -> SyncResult<()> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut updated_vpn = vpn.clone(); + updated_vpn.last_sync = Some(now); + + let json = serde_json::to_string_pretty(&updated_vpn) + .map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?; + + let remote_key = format!("vpns/{}.json", vpn.id); + let presign = self + .client + .presign_upload(&remote_key, Some("application/json")) + .await?; + self + .client + .upload_bytes(&presign.url, json.as_bytes(), Some("application/json")) + .await?; + + // Update local VPN with new last_sync + { + let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); + if let Err(e) = storage.update_sync_fields(&vpn.id, vpn.sync_enabled, Some(now)) { + log::warn!("Failed to update VPN last_sync: {}", e); + } + } + + log::info!("VPN {} uploaded", vpn.id); + Ok(()) + } + + async fn download_vpn( + &self, + vpn_id: &str, + app_handle: Option<&tauri::AppHandle>, + ) -> SyncResult<()> { + let remote_key = format!("vpns/{}.json", vpn_id); + let presign = self.client.presign_download(&remote_key).await?; + let data = self.client.download_bytes(&presign.url).await?; + + let mut vpn: crate::vpn::VpnConfig = serde_json::from_slice(&data) + .map_err(|e| SyncError::SerializationError(format!("Failed to parse VPN JSON: {e}")))?; + + vpn.last_sync = Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + vpn.sync_enabled = true; + + // Save via VPN storage (handles encryption) + { + let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); + if let Err(e) = storage.save_config(&vpn) { + log::warn!("Failed to save downloaded VPN: {}", e); + } + } + + // Emit event for UI update + if let Some(_handle) = app_handle { + let _ = events::emit("vpn-configs-changed", ()); + let _ = events::emit( + "vpn-sync-status", + serde_json::json!({ + "id": vpn_id, + "status": "synced" + }), + ); + } + + log::info!("VPN {} downloaded", vpn_id); + Ok(()) + } + + pub async fn sync_vpn_by_id_with_handle( + &self, + vpn_id: &str, + app_handle: &tauri::AppHandle, + ) -> SyncResult<()> { + self.sync_vpn(vpn_id, Some(app_handle)).await + } + + pub async fn delete_vpn(&self, vpn_id: &str) -> SyncResult<()> { + let remote_key = format!("vpns/{}.json", vpn_id); + let tombstone_key = format!("tombstones/vpns/{}.json", vpn_id); + + self + .client + .delete(&remote_key, Some(&tombstone_key)) + .await?; + + log::info!("VPN {} deleted from sync", vpn_id); + Ok(()) + } + /// Download a profile from S3 if it exists remotely but not locally pub async fn download_profile_if_missing( &self, @@ -901,6 +1061,21 @@ impl SyncEngine { })?; // Download all files from manifest + let total_size: u64 = manifest.files.iter().map(|f| f.size).sum(); + log::info!( + "Profile {} recovery: downloading {} files ({} bytes total)", + profile_id, + manifest.files.len(), + total_size + ); + for file in &manifest.files { + log::info!( + " -> {} ({} bytes, hash: {})", + file.path, + file.size, + file.hash + ); + } if !manifest.files.is_empty() { self .download_profile_files(app_handle, profile_id, &profile_dir, &manifest.files) @@ -1100,6 +1275,43 @@ pub async fn enable_proxy_sync_if_needed( Ok(()) } +/// Check if VPN is used by any synced profile +pub fn is_vpn_used_by_synced_profile(vpn_id: &str) -> bool { + let profile_manager = ProfileManager::instance(); + if let Ok(profiles) = profile_manager.list_profiles() { + profiles + .iter() + .any(|p| p.sync_enabled && p.vpn_id.as_deref() == Some(vpn_id)) + } else { + false + } +} + +/// Enable sync for VPN if not already enabled +pub async fn enable_vpn_sync_if_needed( + vpn_id: &str, + _app_handle: &tauri::AppHandle, +) -> Result<(), String> { + let vpn = { + let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); + storage + .load_config(vpn_id) + .map_err(|e| format!("VPN with ID '{vpn_id}' not found: {e}"))? + }; + + if !vpn.sync_enabled { + let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); + storage + .update_sync_fields(vpn_id, true, None) + .map_err(|e| format!("Failed to enable VPN sync: {e}"))?; + + let _ = events::emit("vpn-configs-changed", ()); + log::info!("Auto-enabled sync for VPN {}", vpn_id); + } + + Ok(()) +} + /// Enable sync for group if not already enabled pub async fn enable_group_sync_if_needed( group_id: &str, @@ -1197,10 +1409,6 @@ pub async fn set_profile_sync_enabled( profile.sync_enabled = enabled; - if !enabled { - profile.last_sync = None; - } - profile_manager .save_profile(&profile) .map_err(|e| format!("Failed to save profile: {e}"))?; @@ -1240,6 +1448,13 @@ pub async fn set_profile_sync_enabled( scheduler.queue_group_sync(group_id.clone()).await; } } + if let Some(ref vpn_id) = profile.vpn_id { + if let Err(e) = enable_vpn_sync_if_needed(vpn_id, &app_handle).await { + log::warn!("Failed to enable sync for VPN {}: {}", vpn_id, e); + } else { + scheduler.queue_vpn_sync(vpn_id.clone()).await; + } + } } else { log::warn!("Scheduler not initialized, sync will not start"); } @@ -1526,3 +1741,85 @@ pub fn is_proxy_in_use_by_synced_profile(proxy_id: String) -> bool { pub fn is_group_in_use_by_synced_profile(group_id: String) -> bool { is_group_used_by_synced_profile(&group_id) } + +#[tauri::command] +pub async fn set_vpn_sync_enabled( + app_handle: tauri::AppHandle, + vpn_id: String, + enabled: bool, +) -> Result<(), String> { + let vpn = { + let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); + storage + .load_config(&vpn_id) + .map_err(|e| format!("VPN with ID '{vpn_id}' not found: {e}"))? + }; + + // If disabling, check if VPN is used by any synced profile + if !enabled && is_vpn_used_by_synced_profile(&vpn_id) { + return Err("Sync cannot be disabled while this VPN is used by synced profiles".to_string()); + } + + // If enabling, check that sync settings are configured + if enabled { + let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; + + if !cloud_logged_in { + let manager = SettingsManager::instance(); + let settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; + + if settings.sync_server_url.is_none() { + return Err( + "Sync server not configured. Please configure sync settings first.".to_string(), + ); + } + + let token = manager.get_sync_token(&app_handle).await.ok().flatten(); + if token.is_none() { + return Err("Sync token not configured. Please configure sync settings first.".to_string()); + } + } + } + + let last_sync = if enabled { vpn.last_sync } else { None }; + + { + let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); + storage + .update_sync_fields(&vpn_id, enabled, last_sync) + .map_err(|e| format!("Failed to update VPN sync: {e}"))?; + } + + let _ = events::emit("vpn-configs-changed", ()); + + if enabled { + let _ = events::emit( + "vpn-sync-status", + serde_json::json!({ + "id": vpn_id, + "status": "syncing" + }), + ); + + if let Some(scheduler) = super::get_global_scheduler() { + scheduler.queue_vpn_sync(vpn_id).await; + } + } else { + let _ = events::emit( + "vpn-sync-status", + serde_json::json!({ + "id": vpn_id, + "status": "disabled" + }), + ); + } + + Ok(()) +} + +#[tauri::command] +pub fn is_vpn_in_use_by_synced_profile(vpn_id: String) -> bool { + is_vpn_used_by_synced_profile(&vpn_id) +} diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 26a9d2a..658c596 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -7,11 +7,12 @@ pub mod types; pub use client::SyncClient; pub use engine::{ - enable_group_sync_if_needed, enable_proxy_sync_if_needed, is_group_in_use_by_synced_profile, - is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile, - is_proxy_used_by_synced_profile, request_profile_sync, set_group_sync_enabled, - set_profile_sync_enabled, set_proxy_sync_enabled, sync_profile, trigger_sync_for_profile, - SyncEngine, + enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_vpn_sync_if_needed, + is_group_in_use_by_synced_profile, is_group_used_by_synced_profile, + is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, + is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync, + set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled, set_vpn_sync_enabled, + sync_profile, trigger_sync_for_profile, SyncEngine, }; pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest}; pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler}; diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs index 33af8cf..ce994b3 100644 --- a/src-tauri/src/sync/scheduler.rs +++ b/src-tauri/src/sync/scheduler.rs @@ -34,6 +34,7 @@ pub struct SyncScheduler { pending_profiles: Arc>>, pending_proxies: Arc>>, pending_groups: Arc>>, + pending_vpns: Arc>>, pending_tombstones: Arc>>, running_profiles: Arc>>, in_flight_profiles: Arc>>, @@ -52,6 +53,7 @@ impl SyncScheduler { pending_profiles: Arc::new(Mutex::new(HashMap::new())), pending_proxies: Arc::new(Mutex::new(HashSet::new())), pending_groups: Arc::new(Mutex::new(HashSet::new())), + pending_vpns: Arc::new(Mutex::new(HashSet::new())), pending_tombstones: Arc::new(Mutex::new(Vec::new())), running_profiles: Arc::new(Mutex::new(HashSet::new())), in_flight_profiles: Arc::new(Mutex::new(HashSet::new())), @@ -92,6 +94,12 @@ impl SyncScheduler { } drop(pending_groups); + let pending_vpns = self.pending_vpns.lock().await; + if !pending_vpns.is_empty() { + return true; + } + drop(pending_vpns); + let pending_tombstones = self.pending_tombstones.lock().await; if !pending_tombstones.is_empty() { return true; @@ -190,6 +198,11 @@ impl SyncScheduler { pending.insert(proxy_id); } + pub async fn queue_vpn_sync(&self, vpn_id: String) { + let mut pending = self.pending_vpns.lock().await; + pending.insert(vpn_id); + } + pub async fn queue_group_sync(&self, group_id: String) { let mut pending = self.pending_groups.lock().await; pending.insert(group_id); @@ -269,6 +282,7 @@ impl SyncScheduler { SyncWorkItem::Profile(id) => scheduler.queue_profile_sync(id).await, SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await, SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await, + SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await, SyncWorkItem::Tombstone(entity_type, entity_id) => { scheduler.queue_tombstone(entity_type, entity_id).await } @@ -288,6 +302,7 @@ impl SyncScheduler { self.process_pending_profiles(app_handle).await; self.process_pending_proxies(app_handle).await; self.process_pending_groups(app_handle).await; + self.process_pending_vpns(app_handle).await; self.process_pending_tombstones(app_handle).await; } @@ -366,6 +381,7 @@ impl SyncScheduler { && self.pending_profiles.lock().await.is_empty() && self.pending_proxies.lock().await.is_empty() && self.pending_groups.lock().await.is_empty() + && self.pending_vpns.lock().await.is_empty() }; match result { @@ -537,6 +553,68 @@ impl SyncScheduler { } } + async fn process_pending_vpns(&self, app_handle: &tauri::AppHandle) { + let vpns_to_sync: Vec = { + let mut pending = self.pending_vpns.lock().await; + let list: Vec = pending.drain().collect(); + list + }; + + if vpns_to_sync.is_empty() { + return; + } + + match SyncEngine::create_from_settings(app_handle).await { + Ok(engine) => { + for vpn_id in vpns_to_sync { + log::info!("Syncing VPN {}", vpn_id); + let _ = events::emit( + "vpn-sync-status", + serde_json::json!({ + "id": vpn_id, + "status": "syncing" + }), + ); + match engine.sync_vpn_by_id_with_handle(&vpn_id, app_handle).await { + Ok(()) => { + let _ = events::emit( + "vpn-sync-status", + serde_json::json!({ + "id": vpn_id, + "status": "synced" + }), + ); + } + Err(e) => { + log::error!("Failed to sync VPN {}: {}", vpn_id, e); + let _ = events::emit( + "vpn-sync-status", + serde_json::json!({ + "id": vpn_id, + "status": "error" + }), + ); + } + } + } + + if !self.is_sync_in_progress().await { + log::debug!("All syncs completed after VPN sync, triggering cleanup"); + let registry = + crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); + if let Err(e) = registry.cleanup_unused_binaries() { + log::warn!("Cleanup after sync failed: {e}"); + } else { + log::debug!("Cleanup after sync completed successfully"); + } + } + } + Err(e) => { + log::error!("Failed to create sync engine: {}", e); + } + } + } + async fn process_pending_tombstones(&self, app_handle: &tauri::AppHandle) { let tombstones: Vec<(String, String)> = { let mut pending = self.pending_tombstones.lock().await; @@ -607,6 +685,12 @@ impl SyncScheduler { entity_id ); } + "vpn" => { + log::debug!( + "VPN tombstone for {} - local deletion not implemented", + entity_id + ); + } _ => {} } } diff --git a/src-tauri/src/sync/subscription.rs b/src-tauri/src/sync/subscription.rs index 088c118..cc09e71 100644 --- a/src-tauri/src/sync/subscription.rs +++ b/src-tauri/src/sync/subscription.rs @@ -23,6 +23,7 @@ pub enum SyncWorkItem { Profile(String), Proxy(String), Group(String), + Vpn(String), Tombstone(String, String), } @@ -229,6 +230,11 @@ impl SyncSubscription { .strip_prefix("groups/") .and_then(|s| s.strip_suffix(".json")) .map(|s| SyncWorkItem::Group(s.to_string())) + } else if key.starts_with("vpns/") { + key + .strip_prefix("vpns/") + .and_then(|s| s.strip_suffix(".json")) + .map(|s| SyncWorkItem::Vpn(s.to_string())) } else if key.starts_with("tombstones/") { key.strip_prefix("tombstones/").and_then(|rest| { if rest.starts_with("profiles/") { @@ -246,6 +252,11 @@ impl SyncSubscription { .strip_prefix("groups/") .and_then(|s| s.strip_suffix(".json")) .map(|id| SyncWorkItem::Tombstone("group".to_string(), id.to_string())) + } else if rest.starts_with("vpns/") { + rest + .strip_prefix("vpns/") + .and_then(|s| s.strip_suffix(".json")) + .map(|id| SyncWorkItem::Tombstone("vpn".to_string(), id.to_string())) } else { None } diff --git a/src-tauri/src/vpn/config.rs b/src-tauri/src/vpn/config.rs index e4a4a8c..d7827ac 100644 --- a/src-tauri/src/vpn/config.rs +++ b/src-tauri/src/vpn/config.rs @@ -52,6 +52,10 @@ pub struct VpnConfig { pub config_data: String, // Raw config content (encrypted at rest) pub created_at: i64, pub last_used: Option, + #[serde(default)] + pub sync_enabled: bool, + #[serde(default)] + pub last_sync: Option, } /// Parsed WireGuard configuration diff --git a/src-tauri/src/vpn/mod.rs b/src-tauri/src/vpn/mod.rs index 7acab0e..571b286 100644 --- a/src-tauri/src/vpn/mod.rs +++ b/src-tauri/src/vpn/mod.rs @@ -7,6 +7,8 @@ mod config; mod openvpn; +pub mod openvpn_socks5; +pub mod socks5_server; mod storage; mod tunnel; mod wireguard; @@ -25,7 +27,3 @@ use std::sync::Mutex; /// Global VPN storage instance pub static VPN_STORAGE: Lazy> = Lazy::new(|| Mutex::new(VpnStorage::new())); - -/// Global tunnel manager instance -pub static TUNNEL_MANAGER: Lazy> = - Lazy::new(|| tokio::sync::Mutex::new(TunnelManager::new())); diff --git a/src-tauri/src/vpn/openvpn_socks5.rs b/src-tauri/src/vpn/openvpn_socks5.rs new file mode 100644 index 0000000..81dfed1 --- /dev/null +++ b/src-tauri/src/vpn/openvpn_socks5.rs @@ -0,0 +1,233 @@ +use super::config::{OpenVpnConfig, VpnError}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +pub struct OpenVpnSocks5Server { + config: OpenVpnConfig, + port: u16, +} + +impl OpenVpnSocks5Server { + pub fn new(config: OpenVpnConfig, port: u16) -> Self { + Self { config, port } + } + + fn find_openvpn_binary() -> Result { + let locations = [ + "/usr/sbin/openvpn", + "/usr/local/sbin/openvpn", + "/opt/homebrew/bin/openvpn", + "/usr/bin/openvpn", + "C:\\Program Files\\OpenVPN\\bin\\openvpn.exe", + "C:\\Program Files (x86)\\OpenVPN\\bin\\openvpn.exe", + ]; + + for loc in &locations { + let path = PathBuf::from(loc); + if path.exists() { + return Ok(path); + } + } + + #[cfg(unix)] + { + if let Ok(output) = Command::new("which").arg("openvpn").output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + } + } + + #[cfg(windows)] + { + if let Ok(output) = Command::new("where").arg("openvpn").output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + } + } + + Err(VpnError::Connection( + "OpenVPN binary not found. Please install OpenVPN.".to_string(), + )) + } + + pub async fn run(self, config_id: String) -> Result<(), VpnError> { + let openvpn_bin = Self::find_openvpn_binary()?; + + // 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)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + 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); + + // Start OpenVPN with SOCKS proxy mode + let mut cmd = Command::new(&openvpn_bin); + 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()); + + 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; + + 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)." + ))); + } + Ok(None) => {} + Err(e) => { + let _ = std::fs::remove_file(&config_path); + return Err(VpnError::Connection(format!( + "Failed to check OpenVPN status: {e}" + ))); + } + } + + // Start a basic SOCKS5 proxy that tunnels through the OpenVPN TUN interface + let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port)) + .await + .map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5: {e}")))?; + + let actual_port = listener + .local_addr() + .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); + } + + log::info!( + "[vpn-worker] OpenVPN SOCKS5 server listening on 127.0.0.1:{}", + actual_port + ); + + loop { + match listener.accept().await { + Ok((client, _)) => { + tokio::spawn(Self::handle_socks5_client(client)); + } + Err(e) => { + log::warn!("[vpn-worker] Accept error: {e}"); + } + } + } + } + + async fn handle_socks5_client( + mut client: TcpStream, + ) -> Result<(), Box> { + // SOCKS5 greeting + let mut buf = [0u8; 256]; + let n = client.read(&mut buf).await?; + if n < 3 || buf[0] != 0x05 { + 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 { + return Ok(()); + } + + let dest_addr = match buf[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) + } + 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) + } + _ => return Ok(()), + }; + + // Connect to destination through OpenVPN tunnel (OS routing handles it) + match TcpStream::connect(&dest_addr).await { + Ok(upstream) => { + 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 c2u = tokio::io::copy(&mut cr, &mut uw); + let u2c = tokio::io::copy(&mut ur, &mut cw); + + let _ = tokio::try_join!(c2u, u2c); + } + Err(_) => { + client + .write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + .await?; + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_openvpn_binary_format() { + let result = OpenVpnSocks5Server::find_openvpn_binary(); + match result { + Ok(path) => assert!(!path.as_os_str().is_empty()), + Err(e) => assert!(e.to_string().contains("not found")), + } + } +} diff --git a/src-tauri/src/vpn/socks5_server.rs b/src-tauri/src/vpn/socks5_server.rs new file mode 100644 index 0000000..f53f711 --- /dev/null +++ b/src-tauri/src/vpn/socks5_server.rs @@ -0,0 +1,656 @@ +use super::config::{VpnError, WireGuardConfig}; +use boringtun::noise::{Tunn, TunnResult}; +use boringtun::x25519::{PublicKey, StaticSecret}; +use smoltcp::iface::{Config as IfaceConfig, Interface, SocketHandle, SocketSet}; +use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken}; +use smoltcp::socket::tcp::{Socket as TcpSocket, SocketBuffer}; +use smoltcp::time::Instant as SmolInstant; +use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address}; +use std::collections::VecDeque; +use std::net::{SocketAddr, ToSocketAddrs, UdpSocket}; +use std::sync::{Arc, Mutex}; +use tokio::net::{TcpListener, TcpStream}; + +const SMOLTCP_TCP_RX_BUF: usize = 65536; +const SMOLTCP_TCP_TX_BUF: usize = 65536; + +struct WgDevice { + tunn: Arc>>, + udp_socket: Arc, + peer_addr: SocketAddr, + rx_queue: VecDeque>, + tx_queue: VecDeque>, +} + +impl WgDevice { + fn pump_wg_to_rx(&mut self) { + let mut recv_buf = vec![0u8; 2048]; + loop { + match self.udp_socket.recv_from(&mut recv_buf) { + Ok((len, _)) => { + let mut dst = vec![0u8; 2048]; + let mut tunn = self.tunn.lock().unwrap(); + let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst); + match result { + TunnResult::WriteToTunnelV4(data, _) | TunnResult::WriteToTunnelV6(data, _) => { + self.rx_queue.push_back(data.to_vec()); + } + TunnResult::WriteToNetwork(response) => { + let _ = self.udp_socket.send_to(response, self.peer_addr); + } + _ => {} + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(_) => break, + } + } + } + + fn flush_tx_queue(&mut self) { + while let Some(ip_packet) = self.tx_queue.pop_front() { + let mut dst = vec![0u8; ip_packet.len() + 256]; + let mut tunn = self.tunn.lock().unwrap(); + let result = tunn.encapsulate(&ip_packet, &mut dst); + if let TunnResult::WriteToNetwork(packet) = result { + let _ = self.udp_socket.send_to(packet, self.peer_addr); + } + } + } + + fn tick_timers(&mut self) { + let mut dst = vec![0u8; 2048]; + let mut tunn = self.tunn.lock().unwrap(); + let result = tunn.update_timers(&mut dst); + if let TunnResult::WriteToNetwork(packet) = result { + let _ = self.udp_socket.send_to(packet, self.peer_addr); + } + } +} + +struct WgRxToken { + data: Vec, +} + +impl RxToken for WgRxToken { + fn consume(mut self, f: F) -> R + where + F: FnOnce(&mut [u8]) -> R, + { + f(&mut self.data) + } +} + +struct WgTxToken<'a> { + tx_queue: &'a mut VecDeque>, +} + +impl<'a> TxToken for WgTxToken<'a> { + fn consume(self, len: usize, f: F) -> R + where + F: FnOnce(&mut [u8]) -> R, + { + let mut buf = vec![0u8; len]; + let result = f(&mut buf); + self.tx_queue.push_back(buf); + result + } +} + +impl Device for WgDevice { + type RxToken<'a> = WgRxToken; + type TxToken<'a> = WgTxToken<'a>; + + fn receive(&mut self, _timestamp: SmolInstant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> { + if let Some(data) = self.rx_queue.pop_front() { + Some(( + WgRxToken { data }, + WgTxToken { + tx_queue: &mut self.tx_queue, + }, + )) + } else { + None + } + } + + fn transmit(&mut self, _timestamp: SmolInstant) -> Option> { + Some(WgTxToken { + tx_queue: &mut self.tx_queue, + }) + } + + fn capabilities(&self) -> DeviceCapabilities { + let mut caps = DeviceCapabilities::default(); + caps.medium = Medium::Ip; + caps.max_transmission_unit = 1420; + caps + } +} + +fn parse_key(key: &str) -> Result<[u8; 32], VpnError> { + let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, key) + .map_err(|e| VpnError::InvalidWireGuard(format!("Invalid key encoding: {e}")))?; + if decoded.len() != 32 { + return Err(VpnError::InvalidWireGuard(format!( + "Invalid key length: {} (expected 32)", + decoded.len() + ))); + } + let mut key_bytes = [0u8; 32]; + key_bytes.copy_from_slice(&decoded); + Ok(key_bytes) +} + +fn parse_cidr_address(addr: &str) -> Result<(IpCidr, IpAddress), VpnError> { + let first_addr = addr.split(',').next().unwrap_or(addr).trim(); + + let parts: Vec<&str> = first_addr.split('/').collect(); + let ip_str = parts[0]; + let prefix = if parts.len() > 1 { + parts[1] + .parse::() + .map_err(|_| VpnError::InvalidWireGuard(format!("Invalid prefix length: {}", parts[1])))? + } else { + 32 + }; + + let ip: std::net::IpAddr = ip_str + .parse() + .map_err(|_| VpnError::InvalidWireGuard(format!("Invalid IP address: {ip_str}")))?; + + match ip { + std::net::IpAddr::V4(v4) => { + let smol_ip = Ipv4Address::new( + v4.octets()[0], + v4.octets()[1], + v4.octets()[2], + v4.octets()[3], + ); + Ok(( + IpCidr::new(IpAddress::Ipv4(smol_ip), prefix), + IpAddress::Ipv4(smol_ip), + )) + } + std::net::IpAddr::V6(v6) => { + let smol_ip = smoltcp::wire::Ipv6Address::from_bytes(&v6.octets()); + Ok(( + IpCidr::new(IpAddress::Ipv6(smol_ip), prefix), + IpAddress::Ipv6(smol_ip), + )) + } + } +} + +pub struct WireGuardSocks5Server { + config: WireGuardConfig, + port: u16, +} + +impl WireGuardSocks5Server { + pub fn new(config: WireGuardConfig, port: u16) -> Self { + Self { config, port } + } + + fn create_tunnel(&self) -> Result, VpnError> { + let private_key_bytes = parse_key(&self.config.private_key)?; + let static_private = StaticSecret::from(private_key_bytes); + + let peer_public_bytes = parse_key(&self.config.peer_public_key)?; + let peer_public = PublicKey::from(peer_public_bytes); + + let preshared_key = if let Some(ref psk) = self.config.preshared_key { + Some(parse_key(psk)?) + } else { + None + }; + + Ok(Box::new(Tunn::new( + static_private, + peer_public, + preshared_key, + self.config.persistent_keepalive, + 0, + None, + ))) + } + + fn resolve_endpoint(&self) -> Result { + self + .config + .peer_endpoint + .to_socket_addrs() + .map_err(|e| { + VpnError::Connection(format!( + "Failed to resolve endpoint '{}': {e}", + self.config.peer_endpoint + )) + })? + .next() + .ok_or_else(|| { + VpnError::Connection(format!( + "No addresses found for endpoint: {}", + self.config.peer_endpoint + )) + }) + } + + fn do_handshake( + tunn: &mut Tunn, + socket: &UdpSocket, + peer_addr: SocketAddr, + ) -> Result<(), VpnError> { + let mut dst = vec![0u8; 2048]; + let result = tunn.format_handshake_initiation(&mut dst, false); + + match result { + TunnResult::WriteToNetwork(packet) => { + socket + .send_to(packet, peer_addr) + .map_err(|e| VpnError::Connection(format!("Failed to send handshake: {e}")))?; + } + TunnResult::Err(e) => { + return Err(VpnError::Tunnel(format!( + "Handshake initiation failed: {e:?}" + ))); + } + _ => {} + } + + socket + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .map_err(|e| VpnError::Connection(format!("Failed to set timeout: {e}")))?; + + let mut recv_buf = vec![0u8; 2048]; + match socket.recv_from(&mut recv_buf) { + Ok((len, _)) => { + let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst); + match result { + TunnResult::WriteToNetwork(response) => { + socket + .send_to(response, peer_addr) + .map_err(|e| VpnError::Connection(format!("Failed to send response: {e}")))?; + } + TunnResult::Done => {} + TunnResult::Err(e) => { + return Err(VpnError::Tunnel(format!( + "Handshake response failed: {e:?}" + ))); + } + _ => {} + } + } + Err(e) => { + return Err(VpnError::Connection(format!( + "Handshake timeout or error: {e}" + ))); + } + } + + socket + .set_read_timeout(None) + .map_err(|e| VpnError::Connection(format!("Failed to clear timeout: {e}")))?; + + Ok(()) + } + + pub async fn run(self, config_id: String) -> Result<(), VpnError> { + let peer_addr = self.resolve_endpoint()?; + let mut tunn = self.create_tunnel()?; + + let udp_socket = UdpSocket::bind("0.0.0.0:0") + .map_err(|e| VpnError::Connection(format!("Failed to create UDP socket: {e}")))?; + + Self::do_handshake(&mut tunn, &udp_socket, peer_addr)?; + + udp_socket + .set_nonblocking(true) + .map_err(|e| VpnError::Connection(format!("Failed to set non-blocking: {e}")))?; + + log::info!("[vpn-worker] WireGuard handshake completed"); + + let (cidr, local_ip) = parse_cidr_address(&self.config.address)?; + + let tunn_arc = Arc::new(Mutex::new(tunn)); + let udp_arc = Arc::new(udp_socket); + + let mut device = WgDevice { + tunn: tunn_arc.clone(), + udp_socket: udp_arc.clone(), + peer_addr, + rx_queue: VecDeque::new(), + tx_queue: VecDeque::new(), + }; + + let iface_config = IfaceConfig::new(HardwareAddress::Ip); + let mut iface = Interface::new(iface_config, &mut device, SmolInstant::now()); + iface.update_ip_addrs(|addrs| { + let _ = addrs.push(cidr); + }); + + // Set default gateway + match local_ip { + IpAddress::Ipv4(v4) => { + let octets = v4.as_bytes(); + let gw = Ipv4Address::new(octets[0], octets[1], octets[2], 1); + iface + .routes_mut() + .add_default_ipv4_route(gw) + .map_err(|e| VpnError::Tunnel(format!("Failed to add default route: {e}")))?; + } + IpAddress::Ipv6(_) => { + // IPv6 routing not yet implemented + } + } + + let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port)) + .await + .map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5 listener: {e}")))?; + + let actual_port = listener + .local_addr() + .map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))? + .port(); + + // Update config with actual port and local_url + 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); + } + + log::info!( + "[vpn-worker] SOCKS5 server listening on 127.0.0.1:{}", + actual_port + ); + + let mut sockets = SocketSet::new(vec![]); + + struct Connection { + smol_handle: SocketHandle, + tcp_stream: TcpStream, + socks_done: bool, + read_buf: Vec, + dest_addr: Option, + } + + let mut connections: Vec = Vec::new(); + let mut timer_counter: u64 = 0; + + loop { + // Accept new SOCKS5 connections (non-blocking via short timeout) + if let Ok(Ok((stream, _addr))) = + tokio::time::timeout(tokio::time::Duration::from_millis(1), listener.accept()).await + { + let tcp_rx = SocketBuffer::new(vec![0u8; SMOLTCP_TCP_RX_BUF]); + let tcp_tx = SocketBuffer::new(vec![0u8; SMOLTCP_TCP_TX_BUF]); + let tcp_socket = TcpSocket::new(tcp_rx, tcp_tx); + let handle = sockets.add(tcp_socket); + + connections.push(Connection { + smol_handle: handle, + tcp_stream: stream, + socks_done: false, + read_buf: Vec::new(), + dest_addr: None, + }); + } + + // Pump WireGuard packets into smoltcp rx queue + device.pump_wg_to_rx(); + + // Poll the smoltcp interface + let timestamp = SmolInstant::now(); + let _changed = iface.poll(timestamp, &mut device, &mut sockets); + + // Flush encrypted packets out through WireGuard + device.flush_tx_queue(); + + // Process each connection + let mut completed = Vec::new(); + for (idx, conn) in connections.iter_mut().enumerate() { + if !conn.socks_done { + // Handle SOCKS5 handshake + let mut buf = [0u8; 512]; + match conn.tcp_stream.try_read(&mut buf) { + Ok(0) => { + completed.push(idx); + continue; + } + Ok(n) => { + conn.read_buf.extend_from_slice(&buf[..n]); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(_) => { + completed.push(idx); + continue; + } + } + + if conn.dest_addr.is_none() && 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; + conn.read_buf.drain(..2 + nmethods); + } + + if 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); + continue; + } + + let (addr, addr_len) = match conn.read_buf[3] { + 0x01 => { + // IPv4 + if conn.read_buf.len() < 10 { + continue; + } + let ip = std::net::Ipv4Addr::new( + conn.read_buf[4], + conn.read_buf[5], + conn.read_buf[6], + conn.read_buf[7], + ); + let port = u16::from_be_bytes([conn.read_buf[8], conn.read_buf[9]]); + (SocketAddr::new(std::net::IpAddr::V4(ip), port), 10) + } + 0x03 => { + // Domain name + let domain_len = conn.read_buf[4] as usize; + let needed = 4 + 1 + domain_len + 2; + if conn.read_buf.len() < needed { + continue; + } + let domain = String::from_utf8_lossy(&conn.read_buf[5..5 + domain_len]).to_string(); + let port_start = 5 + domain_len; + let port = + u16::from_be_bytes([conn.read_buf[port_start], conn.read_buf[port_start + 1]]); + // Resolve domain + match format!("{}:{}", domain, port).to_socket_addrs() { + Ok(mut addrs) => { + if let Some(addr) = addrs.next() { + (addr, needed) + } else { + // Send SOCKS5 error: host unreachable + let _ = conn + .tcp_stream + .try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + completed.push(idx); + continue; + } + } + Err(_) => { + let _ = conn + .tcp_stream + .try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + completed.push(idx); + continue; + } + } + } + 0x04 => { + // IPv6 + if conn.read_buf.len() < 22 { + continue; + } + let mut octets = [0u8; 16]; + octets.copy_from_slice(&conn.read_buf[4..20]); + let ip = std::net::Ipv6Addr::from(octets); + let port = u16::from_be_bytes([conn.read_buf[20], conn.read_buf[21]]); + (SocketAddr::new(std::net::IpAddr::V6(ip), port), 22) + } + _ => { + completed.push(idx); + continue; + } + }; + + conn.read_buf.drain(..addr_len); + conn.dest_addr = Some(addr); + + // Open smoltcp TCP socket to the destination + let socket = sockets.get_mut::(conn.smol_handle); + let smol_addr = match addr.ip() { + std::net::IpAddr::V4(v4) => { + let o = v4.octets(); + IpAddress::Ipv4(Ipv4Address::new(o[0], o[1], o[2], o[3])) + } + std::net::IpAddr::V6(v6) => { + IpAddress::Ipv6(smoltcp::wire::Ipv6Address::from_bytes(&v6.octets())) + } + }; + + let local_port = 10000 + (rand::random::() % 50000); + if socket + .connect(iface.context(), (smol_addr, addr.port()), local_port) + .is_err() + { + let _ = conn + .tcp_stream + .try_write(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + completed.push(idx); + 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; + } + } else { + // Data relay between SOCKS5 client and smoltcp socket + let socket = sockets.get_mut::(conn.smol_handle); + + // Client → smoltcp + let mut buf = [0u8; 4096]; + match conn.tcp_stream.try_read(&mut buf) { + Ok(0) => { + socket.close(); + completed.push(idx); + continue; + } + Ok(n) => { + if socket.can_send() { + let _ = socket.send_slice(&buf[..n]); + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(_) => { + socket.close(); + completed.push(idx); + continue; + } + } + + // smoltcp → Client + if socket.can_recv() { + match socket.recv(|data| (data.len(), data.to_vec())) { + Ok(data) if !data.is_empty() => { + if conn.tcp_stream.try_write(&data).is_err() { + socket.close(); + completed.push(idx); + continue; + } + } + _ => {} + } + } + + // Check if smoltcp socket closed + if !socket.is_open() && !socket.is_active() { + completed.push(idx); + } + } + } + + // Remove completed connections (in reverse order) + completed.sort_unstable(); + completed.dedup(); + for idx in completed.into_iter().rev() { + let conn = connections.remove(idx); + sockets.remove(conn.smol_handle); + } + + // Timer ticks for WireGuard keepalives + timer_counter += 1; + if timer_counter.is_multiple_of(500) { + device.tick_timers(); + } + + // Small sleep to avoid busy-spinning + tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_cidr_ipv4() { + let (cidr, ip) = parse_cidr_address("10.0.0.2/24").unwrap(); + assert_eq!(cidr.prefix_len(), 24); + assert_eq!(ip, IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 2))); + } + + #[test] + fn test_parse_cidr_no_prefix() { + let (cidr, _) = parse_cidr_address("10.0.0.2").unwrap(); + assert_eq!(cidr.prefix_len(), 32); + } + + #[test] + fn test_parse_cidr_multi_address() { + let (_, ip) = parse_cidr_address("10.0.0.2/24, fd00::2/128").unwrap(); + assert_eq!(ip, IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 2))); + } + + #[test] + fn test_parse_key_valid() { + let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA="; + assert!(parse_key(key).is_ok()); + } + + #[test] + fn test_parse_key_invalid() { + assert!(parse_key("not-valid").is_err()); + } +} diff --git a/src-tauri/src/vpn/storage.rs b/src-tauri/src/vpn/storage.rs index ad66bba..a3d5ed2 100644 --- a/src-tauri/src/vpn/storage.rs +++ b/src-tauri/src/vpn/storage.rs @@ -32,6 +32,10 @@ struct StoredVpnConfig { nonce: String, // Base64 encoded nonce created_at: i64, last_used: Option, + #[serde(default)] + sync_enabled: bool, + #[serde(default)] + last_sync: Option, } /// VPN storage manager with encryption @@ -220,6 +224,8 @@ impl VpnStorage { nonce, created_at: config.created_at, last_used: config.last_used, + sync_enabled: config.sync_enabled, + last_sync: config.last_sync, }; // Update existing or add new @@ -251,6 +257,8 @@ impl VpnStorage { config_data, created_at: stored.created_at, last_used: stored.last_used, + sync_enabled: stored.sync_enabled, + last_sync: stored.last_sync, }) } @@ -269,6 +277,8 @@ impl VpnStorage { config_data: String::new(), // Don't include config data in list created_at: stored.created_at, last_used: stored.last_used, + sync_enabled: stored.sync_enabled, + last_sync: stored.last_sync, }) .collect(), ) @@ -300,6 +310,67 @@ impl VpnStorage { } } + /// Create a VPN config manually from validated data + pub fn create_config_manual( + &self, + name: &str, + vpn_type: VpnType, + config_data: &str, + ) -> Result { + // Validate the config by parsing it + match vpn_type { + VpnType::WireGuard => { + super::parse_wireguard_config(config_data)?; + } + VpnType::OpenVPN => { + super::parse_openvpn_config(config_data)?; + } + } + + let id = Uuid::new_v4().to_string(); + + let config = VpnConfig { + id, + name: name.to_string(), + vpn_type, + config_data: config_data.to_string(), + created_at: Utc::now().timestamp(), + last_used: None, + sync_enabled: false, + last_sync: None, + }; + + self.save_config(&config)?; + + Ok(config) + } + + /// Update the name of an existing VPN config + pub fn update_config_name(&self, id: &str, new_name: &str) -> Result { + let mut config = self.load_config(id)?; + config.name = new_name.to_string(); + self.save_config(&config)?; + Ok(config) + } + + /// Update sync fields on a VPN config + pub fn update_sync_fields( + &self, + id: &str, + sync_enabled: bool, + last_sync: Option, + ) -> Result<(), VpnError> { + let mut storage = self.load_storage()?; + + if let Some(config) = storage.configs.iter_mut().find(|c| c.id == id) { + config.sync_enabled = sync_enabled; + config.last_sync = last_sync; + self.save_storage(&storage) + } else { + Err(VpnError::NotFound(id.to_string())) + } + } + /// Import a VPN config from raw content pub fn import_config( &self, @@ -333,6 +404,8 @@ impl VpnStorage { config_data: content.to_string(), created_at: Utc::now().timestamp(), last_used: None, + sync_enabled: false, + last_sync: None, }; self.save_config(&config)?; @@ -375,6 +448,8 @@ mod tests { config_data: "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = peer".to_string(), created_at: 1234567890, last_used: None, + sync_enabled: false, + last_sync: None, }; storage.save_config(&config).unwrap(); @@ -397,6 +472,8 @@ mod tests { config_data: "secret1".to_string(), created_at: 1000, last_used: None, + sync_enabled: false, + last_sync: None, }; let config2 = VpnConfig { @@ -406,6 +483,8 @@ mod tests { config_data: "secret2".to_string(), created_at: 2000, last_used: Some(3000), + sync_enabled: false, + last_sync: None, }; storage.save_config(&config1).unwrap(); @@ -430,6 +509,8 @@ mod tests { config_data: "data".to_string(), created_at: 1000, last_used: None, + sync_enabled: false, + last_sync: None, }; storage.save_config(&config).unwrap(); diff --git a/src-tauri/src/vpn_worker_runner.rs b/src-tauri/src/vpn_worker_runner.rs new file mode 100644 index 0000000..71b8905 --- /dev/null +++ b/src-tauri/src/vpn_worker_runner.rs @@ -0,0 +1,245 @@ +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, + get_vpn_worker_config, list_vpn_worker_configs, save_vpn_worker_config, VpnWorkerConfig, +}; +use std::process::Stdio; + +pub async fn start_vpn_worker(vpn_id: &str) -> Result> { + // 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); + } + } + // Worker config exists but process is dead, clean up + delete_vpn_worker_config(&existing.id); + } + + // Load VPN config from storage to determine type + let vpn_config = { + let storage = crate::vpn::VPN_STORAGE + .lock() + .map_err(|e| format!("Failed to lock VPN storage: {e}"))?; + storage + .load_config(vpn_id) + .map_err(|e| format!("Failed to load VPN config: {e}"))? + }; + + let vpn_type_str = match vpn_config.vpn_type { + crate::vpn::VpnType::WireGuard => "wireguard", + crate::vpn::VpnType::OpenVPN => "openvpn", + }; + + // Write decrypted config to a temp file + let config_file_path = std::env::temp_dir() + .join(format!("donut_vpn_{}.conf", vpn_id)) + .to_string_lossy() + .to_string(); + + std::fs::write(&config_file_path, &vpn_config.config_data)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&config_file_path, std::fs::Permissions::from_mode(0o600)); + } + + let id = generate_vpn_worker_id(); + + // Find an available port + let local_port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + listener.local_addr()?.port() + }; + + let config = VpnWorkerConfig::new( + id.clone(), + vpn_id.to_string(), + vpn_type_str.to_string(), + config_file_path, + ); + save_vpn_worker_config(&config)?; + + // Spawn detached VPN worker process + let exe = std::env::current_exe()?; + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + use std::process::Command as StdCommand; + + let mut cmd = StdCommand::new(&exe); + cmd.arg("vpn-worker"); + cmd.arg("start"); + cmd.arg("--id"); + cmd.arg(&id); + cmd.arg("--port"); + cmd.arg(local_port.to_string()); + + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + + let log_path = std::env::temp_dir().join(format!("donut-vpn-{}.log", id)); + if let Ok(file) = std::fs::File::create(&log_path) { + log::info!("VPN worker stderr will be logged to: {:?}", log_path); + cmd.stderr(Stdio::from(file)); + } else { + cmd.stderr(Stdio::null()); + } + + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 { + let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5); + } + Ok(()) + }); + } + + let child = cmd.spawn()?; + let pid = child.id(); + + let mut config_with_pid = config.clone(); + config_with_pid.pid = Some(pid); + config_with_pid.local_port = Some(local_port); + save_vpn_worker_config(&config_with_pid)?; + + drop(child); + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + use std::process::Command as StdCommand; + + let mut cmd = StdCommand::new(&exe); + cmd.arg("vpn-worker"); + cmd.arg("start"); + cmd.arg("--id"); + cmd.arg(&id); + cmd.arg("--port"); + cmd.arg(local_port.to_string()); + + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + + let log_path = std::env::temp_dir().join(format!("donut-vpn-{}.log", id)); + if let Ok(file) = std::fs::File::create(&log_path) { + log::info!("VPN worker stderr will be logged to: {:?}", log_path); + cmd.stderr(Stdio::from(file)); + } else { + cmd.stderr(Stdio::null()); + } + + const DETACHED_PROCESS: u32 = 0x00000008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + + let child = cmd.spawn()?; + let pid = child.id(); + + let mut config_with_pid = config.clone(); + config_with_pid.pid = Some(pid); + config_with_pid.local_port = Some(local_port); + save_vpn_worker_config(&config_with_pid)?; + + drop(child); + } + + // Wait for the worker to update config with local_url + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + let mut attempts = 0; + let max_attempts = 100; // 10 seconds max + + loop { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + if let Some(updated_config) = get_vpn_worker_config(&id) { + if let Some(ref local_url) = updated_config.local_url { + if !local_url.is_empty() { + if let Some(port) = updated_config.local_port { + if let Ok(Ok(_)) = tokio::time::timeout( + tokio::time::Duration::from_millis(100), + tokio::net::TcpStream::connect(("127.0.0.1", port)), + ) + .await + { + return Ok(updated_config); + } + } + } + } + } + + attempts += 1; + if attempts >= 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()); + } + } +} + +pub async fn stop_vpn_worker(id: &str) -> Result> { + let config = get_vpn_worker_config(id); + + if let Some(config) = config { + if let Some(pid) = config.pid { + #[cfg(unix)] + { + use std::process::Command; + let _ = Command::new("kill") + .arg("-TERM") + .arg(pid.to_string()) + .output(); + } + #[cfg(windows)] + { + use std::process::Command; + let _ = Command::new("taskkill") + .args(["/F", "/PID", &pid.to_string()]) + .output(); + } + + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + + // Clean up temp config file + let _ = std::fs::remove_file(&config.config_file_path); + + delete_vpn_worker_config(id); + return Ok(true); + } + + Ok(false) +} + +pub async fn stop_vpn_worker_by_vpn_id(vpn_id: &str) -> Result> { + if let Some(config) = find_vpn_worker_by_vpn_id(vpn_id) { + return stop_vpn_worker(&config.id).await; + } + Ok(false) +} + +pub async fn stop_all_vpn_workers() -> Result<(), Box> { + let configs = list_vpn_worker_configs(); + for config in configs { + let _ = stop_vpn_worker(&config.id).await; + } + Ok(()) +} diff --git a/src-tauri/src/vpn_worker_storage.rs b/src-tauri/src/vpn_worker_storage.rs new file mode 100644 index 0000000..f38383f --- /dev/null +++ b/src-tauri/src/vpn_worker_storage.rs @@ -0,0 +1,107 @@ +use crate::proxy_storage::get_storage_dir; +use serde::{Deserialize, Serialize}; +use std::fs; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VpnWorkerConfig { + pub id: String, + pub vpn_id: String, + pub vpn_type: String, + pub config_file_path: String, + pub local_port: Option, + pub local_url: Option, + pub pid: Option, +} + +impl VpnWorkerConfig { + pub fn new(id: String, vpn_id: String, vpn_type: String, config_file_path: String) -> Self { + Self { + id, + vpn_id, + vpn_type, + config_file_path, + local_port: None, + local_url: None, + pid: None, + } + } +} + +pub fn save_vpn_worker_config(config: &VpnWorkerConfig) -> Result<(), Box> { + let storage_dir = get_storage_dir(); + fs::create_dir_all(&storage_dir)?; + + let file_path = storage_dir.join(format!("vpn_worker_{}.json", config.id)); + let content = serde_json::to_string_pretty(config)?; + fs::write(&file_path, content)?; + + Ok(()) +} + +pub fn get_vpn_worker_config(id: &str) -> Option { + let storage_dir = get_storage_dir(); + let file_path = storage_dir.join(format!("vpn_worker_{}.json", id)); + + if !file_path.exists() { + return None; + } + + match fs::read_to_string(&file_path) { + Ok(content) => serde_json::from_str(&content).ok(), + Err(_) => None, + } +} + +pub fn delete_vpn_worker_config(id: &str) -> bool { + let storage_dir = get_storage_dir(); + let file_path = storage_dir.join(format!("vpn_worker_{}.json", id)); + + if !file_path.exists() { + return false; + } + + fs::remove_file(&file_path).is_ok() +} + +pub fn list_vpn_worker_configs() -> Vec { + let storage_dir = get_storage_dir(); + + if !storage_dir.exists() { + return Vec::new(); + } + + let mut configs = Vec::new(); + if let Ok(entries) = fs::read_dir(&storage_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with("vpn_worker_") && name.ends_with(".json") { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(config) = serde_json::from_str::(&content) { + configs.push(config); + } + } + } + } + } + } + + configs +} + +pub fn find_vpn_worker_by_vpn_id(vpn_id: &str) -> Option { + list_vpn_worker_configs() + .into_iter() + .find(|c| c.vpn_id == vpn_id) +} + +pub fn generate_vpn_worker_id() -> String { + format!( + "vpnw_{}_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + rand::random::() + ) +} diff --git a/src-tauri/tests/vpn_integration.rs b/src-tauri/tests/vpn_integration.rs index b396e4c..91c589a 100644 --- a/src-tauri/tests/vpn_integration.rs +++ b/src-tauri/tests/vpn_integration.rs @@ -188,6 +188,8 @@ fn test_vpn_storage_save_and_load() { config_data: "[Interface]\nPrivateKey=key\n[Peer]\nPublicKey=peer".to_string(), created_at: 1234567890, last_used: None, + sync_enabled: false, + last_sync: None, }; let save_result = storage.save_config(&config); @@ -230,6 +232,8 @@ fn test_vpn_storage_list() { config_data: "secret data".to_string(), created_at: 1000 * i as i64, last_used: None, + sync_enabled: false, + last_sync: None, }; storage.save_config(&config).unwrap(); } @@ -256,6 +260,8 @@ fn test_vpn_storage_delete() { config_data: "data".to_string(), created_at: 1000, last_used: None, + sync_enabled: false, + last_sync: None, }; storage.save_config(&config).unwrap(); diff --git a/src/app/page.tsx b/src/app/page.tsx index 5e34cd9..e76fe5e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrent } from "@tauri-apps/plugin-deep-link"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog"; import { CommercialTrialModal } from "@/components/commercial-trial-modal"; import { CookieCopyDialog } from "@/components/cookie-copy-dialog"; @@ -35,8 +35,14 @@ import { useProfileEvents } from "@/hooks/use-profile-events"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useUpdateNotifications } from "@/hooks/use-update-notifications"; import { useVersionUpdater } from "@/hooks/use-version-updater"; +import { useVpnEvents } from "@/hooks/use-vpn-events"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; -import { showErrorToast, showSuccessToast, showToast } from "@/lib/toast-utils"; +import { + dismissToast, + showErrorToast, + showSuccessToast, + showToast, +} from "@/lib/toast-utils"; import type { BrowserProfile, CamoufoxConfig, WayfernConfig } from "@/types"; type BrowserTypeString = @@ -77,6 +83,8 @@ export default function Home() { error: proxiesError, } = useProxyEvents(); + const { vpnConfigs } = useVpnEvents(); + // Wayfern terms and commercial trial hooks const { termsAccepted, @@ -141,6 +149,8 @@ export default function Home() { const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } = usePermissions(); + const userInitiatedSyncIds = useRef>(new Set()); + const handleSelectGroup = useCallback((groupId: string) => { setSelectedGroupId(groupId); setSelectedProfiles([]); @@ -448,6 +458,7 @@ export default function Home() { version: string; releaseType: string; proxyId?: string; + vpnId?: string; camoufoxConfig?: CamoufoxConfig; wayfernConfig?: WayfernConfig; groupId?: string; @@ -459,6 +470,7 @@ export default function Home() { version: profileData.version, releaseType: profileData.releaseType, proxyId: profileData.proxyId, + vpnId: profileData.vpnId, camoufoxConfig: profileData.camoufoxConfig, wayfernConfig: profileData.wayfernConfig, groupId: @@ -676,18 +688,19 @@ export default function Home() { const handleToggleProfileSync = useCallback( async (profile: BrowserProfile) => { try { + const enabling = !profile.sync_enabled; await invoke("set_profile_sync_enabled", { profileId: profile.id, - enabled: !profile.sync_enabled, + enabled: enabling, + }); + if (enabling) { + userInitiatedSyncIds.current.add(profile.id); + } + showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", { + description: enabling + ? "Profile sync has been enabled" + : "Profile sync has been disabled", }); - showSuccessToast( - profile.sync_enabled ? "Sync disabled" : "Sync enabled", - { - description: profile.sync_enabled - ? "Profile sync has been disabled" - : "Profile sync has been enabled", - }, - ); } catch (error) { console.error("Failed to toggle sync:", error); showErrorToast("Failed to update sync settings"); @@ -696,6 +709,47 @@ export default function Home() { [], ); + useEffect(() => { + let unlisten: (() => void) | undefined; + (async () => { + try { + unlisten = await listen<{ profile_id: string; status: string }>( + "profile-sync-status", + (event) => { + const { profile_id, status } = event.payload; + if (!userInitiatedSyncIds.current.has(profile_id)) return; + + const toastId = `sync-${profile_id}`; + const profile = profiles.find((p) => p.id === profile_id); + const name = profile?.name ?? "Unknown"; + + if (status === "syncing") { + showToast({ + type: "loading", + title: `Syncing profile '${name}'...`, + id: toastId, + duration: 30000, + }); + } else if (status === "synced") { + dismissToast(toastId); + showSuccessToast(`Profile '${name}' synced successfully`); + userInitiatedSyncIds.current.delete(profile_id); + } else if (status === "error") { + dismissToast(toastId); + showErrorToast(`Failed to sync profile '${name}'`); + userInitiatedSyncIds.current.delete(profile_id); + } + }, + ); + } catch (error) { + console.error("Failed to listen for sync status events:", error); + } + })(); + return () => { + if (unlisten) unlisten(); + }; + }, [profiles]); + useEffect(() => { // Check for startup default browser prompt void checkStartupPrompt(); @@ -1035,6 +1089,7 @@ export default function Home() { onAssignmentComplete={handleProxyAssignmentComplete} profiles={profiles} storedProxies={storedProxies} + vpnConfigs={vpnConfigs} /> ([]); const { storedProxies } = useProxyEvents(); + const { vpnConfigs } = useVpnEvents(); const [showProxyForm, setShowProxyForm] = useState(false); const [isCreating, setIsCreating] = useState(false); const [releaseTypes, setReleaseTypes] = useState(); @@ -347,6 +352,11 @@ export function CreateProfileDialog({ if (!profileName.trim()) return; setIsCreating(true); + + const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false; + const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId; + const resolvedVpnId = + isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined; try { if (activeTab === "anti-detect") { // Anti-detect browser - check if Wayfern or Camoufox is selected @@ -365,7 +375,8 @@ export function CreateProfileDialog({ browserStr: "wayfern" as BrowserTypeString, version: bestWayfernVersion.version, releaseType: bestWayfernVersion.releaseType, - proxyId: selectedProxyId, + proxyId: resolvedProxyId, + vpnId: resolvedVpnId, wayfernConfig: finalWayfernConfig, groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, @@ -387,7 +398,8 @@ export function CreateProfileDialog({ browserStr: "camoufox" as BrowserTypeString, version: bestCamoufoxVersion.version, releaseType: bestCamoufoxVersion.releaseType, - proxyId: selectedProxyId, + proxyId: resolvedProxyId, + vpnId: resolvedVpnId, camoufoxConfig: finalCamoufoxConfig, groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, @@ -946,10 +958,10 @@ export function CreateProfileDialog({ )} - {/* Proxy Selection - Always visible */} + {/* Proxy / VPN Selection - Always visible */}
- + Add Proxy
- {storedProxies.length > 0 ? ( + {storedProxies.length > 0 || vpnConfigs.length > 0 ? ( ) : (
- No proxies available. Add one to route this - profile's traffic. + No proxies or VPNs available. Add one to route + this profile's traffic.
)}
@@ -1107,10 +1145,10 @@ export function CreateProfileDialog({ )} - {/* Proxy Selection - Always visible */} + {/* Proxy / VPN Selection - Always visible */}
- + Add Proxy
- {storedProxies.length > 0 ? ( + {storedProxies.length > 0 || vpnConfigs.length > 0 ? ( ) : (
- No proxies available. Add one to route this - profile's traffic. + No proxies or VPNs available. Add one to route + this profile's traffic.
)}
diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 79b9604..a107105 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -65,6 +65,7 @@ import { import { useBrowserState } from "@/hooks/use-browser-state"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useTableSorting } from "@/hooks/use-table-sorting"; +import { useVpnEvents } from "@/hooks/use-vpn-events"; import { getBrowserDisplayName, getBrowserIcon, @@ -81,6 +82,7 @@ import type { ProxyCheckResult, StoredProxy, TrafficSnapshot, + VpnConfig, } from "@/types"; import { BandwidthMiniChart } from "./bandwidth-mini-chart"; import { @@ -137,6 +139,14 @@ type TableMeta = { checkingProfileId: string | null; proxyCheckResults: Record; + // VPN selector state + vpnConfigs: VpnConfig[]; + vpnOverrides: Record; + handleVpnSelection: ( + profileId: string, + vpnId: string | null, + ) => void | Promise; + // Selection helpers isProfileSelected: (id: string) => boolean; handleToggleAll: (checked: boolean) => void; @@ -187,6 +197,53 @@ type TableMeta = { ) => Promise; }; +type SyncStatusDot = { color: string; tooltip: string; animate: boolean }; + +function getProfileSyncStatusDot( + profile: BrowserProfile, + liveStatus: + | "syncing" + | "waiting" + | "synced" + | "error" + | "disabled" + | undefined, +): SyncStatusDot | null { + const status = liveStatus ?? (profile.sync_enabled ? "synced" : "disabled"); + + switch (status) { + case "syncing": + return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true }; + case "waiting": + return { + color: "bg-yellow-500", + tooltip: "Waiting to sync", + animate: false, + }; + case "synced": + return { + color: "bg-green-500", + tooltip: profile.last_sync + ? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}` + : "Synced", + animate: false, + }; + case "error": + return { color: "bg-red-500", tooltip: "Sync error", animate: false }; + case "disabled": + if (profile.last_sync) { + return { + color: "bg-gray-400", + tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`, + animate: false, + }; + } + return null; + default: + return null; + } +} + const TagsCell = React.memo<{ profile: BrowserProfile; isDisabled: boolean; @@ -801,10 +858,14 @@ export function ProfilesDataTable({ ); const { storedProxies } = useProxyEvents(); + const { vpnConfigs } = useVpnEvents(); const [proxyOverrides, setProxyOverrides] = React.useState< Record >({}); + const [vpnOverrides, setVpnOverrides] = React.useState< + Record + >({}); const [showCheckboxes, setShowCheckboxes] = React.useState(false); const [tagsOverrides, setTagsOverrides] = React.useState< Record @@ -903,7 +964,7 @@ export function ProfilesDataTable({ proxyId, }); setProxyOverrides((prev) => ({ ...prev, [profileId]: proxyId })); - // Notify other parts of the app so usage counts and lists refresh + setVpnOverrides((prev) => ({ ...prev, [profileId]: null })); await emit("profile-updated"); } catch (error) { console.error("Failed to update proxy settings:", error); @@ -914,6 +975,25 @@ export function ProfilesDataTable({ [], ); + const handleVpnSelection = React.useCallback( + async (profileId: string, vpnId: string | null) => { + try { + await invoke("update_profile_vpn", { + profileId, + vpnId, + }); + setVpnOverrides((prev) => ({ ...prev, [profileId]: vpnId })); + setProxyOverrides((prev) => ({ ...prev, [profileId]: null })); + await emit("profile-updated"); + } catch (error) { + console.error("Failed to update VPN settings:", error); + } finally { + setOpenProxySelectorFor(null); + } + }, + [], + ); + const handleCreateCountryProxy = React.useCallback( async (profileId: string, country: LocationItem) => { try { @@ -1347,6 +1427,11 @@ export function ProfilesDataTable({ checkingProfileId, proxyCheckResults, + // VPN selector state + vpnConfigs, + vpnOverrides, + handleVpnSelection, + // Selection helpers isProfileSelected: (id: string) => selectedProfiles.includes(id), handleToggleAll, @@ -1415,6 +1500,9 @@ export function ProfilesDataTable({ handleProxySelection, checkingProfileId, proxyCheckResults, + vpnConfigs, + vpnOverrides, + handleVpnSelection, handleToggleAll, handleCheckboxChange, handleIconClick, @@ -1891,7 +1979,7 @@ export function ProfilesDataTable({ }, { id: "proxy", - header: "Proxy", + header: "Proxy / VPN", cell: ({ row, table }) => { const meta = table.options.meta as TableMeta; const profile = row.original; @@ -1908,28 +1996,44 @@ export function ProfilesDataTable({ isBrowserUpdating || isCrossOs; - const hasOverride = Object.hasOwn(meta.proxyOverrides, profile.id); - const effectiveProxyId = hasOverride + const hasProxyOverride = Object.hasOwn( + meta.proxyOverrides, + profile.id, + ); + const effectiveProxyId = hasProxyOverride ? meta.proxyOverrides[profile.id] : (profile.proxy_id ?? null); const effectiveProxy = effectiveProxyId ? (meta.storedProxies.find((p) => p.id === effectiveProxyId) ?? null) : null; - const displayName = effectiveProxy - ? effectiveProxy.name - : "Not Selected"; - const profileHasProxy = Boolean(effectiveProxy); - const tooltipText = - profileHasProxy && effectiveProxy ? effectiveProxy.name : null; + + const hasVpnOverride = Object.hasOwn(meta.vpnOverrides, profile.id); + const effectiveVpnId = hasVpnOverride + ? meta.vpnOverrides[profile.id] + : (profile.vpn_id ?? null); + const effectiveVpn = effectiveVpnId + ? (meta.vpnConfigs.find((v) => v.id === effectiveVpnId) ?? null) + : null; + + const hasAssignment = Boolean(effectiveProxy || effectiveVpn); + const displayName = effectiveVpn + ? effectiveVpn.name + : effectiveProxy + ? effectiveProxy.name + : "Not Selected"; + const vpnBadge = effectiveVpn + ? effectiveVpn.vpn_type === "WireGuard" + ? "WG" + : "OVPN" + : null; + const tooltipText = hasAssignment ? displayName : null; const isSelectorOpen = meta.openProxySelectorFor === profile.id; + const selectedId = effectiveVpnId ?? effectiveProxyId ?? null; // When profile is running, show bandwidth chart instead of proxy selector if (isRunning && meta.trafficSnapshots) { - // Find the traffic snapshot for this profile by matching profile_id const snapshot = meta.trafficSnapshots[profile.id]; - // Only use recent_bandwidth (last 60 seconds) - minimal data needed for mini chart - // Create a new array reference to ensure React detects changes const bandwidthData = snapshot?.recent_bandwidth ? [...snapshot.recent_bandwidth] : []; @@ -1966,13 +2070,21 @@ export function ProfilesDataTable({ : "cursor-pointer hover:bg-accent/50", )} > + {vpnBadge && ( + + {vpnBadge} + + )} - {profileHasProxy + {hasAssignment ? trimName(displayName, 10) : displayName} @@ -1994,8 +2106,8 @@ export function ProfilesDataTable({ { if (meta.canCreateLocationProxy) @@ -2003,7 +2115,7 @@ export function ProfilesDataTable({ }} /> - No proxies found. + No proxies or VPNs found. - No Proxy + None {meta.storedProxies.map((proxy) => ( ))} + {meta.vpnConfigs.length > 0 && ( + + {meta.vpnConfigs.map((vpn) => ( + + void meta.handleVpnSelection( + profile.id, + vpn.id, + ) + } + > + + + {vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} + + {vpn.name} + + ))} + + )} {meta.canCreateLocationProxy && meta.countries.length > 0 && ( @@ -2078,7 +2222,7 @@ export function ProfilesDataTable({ )} - {profileHasProxy && effectiveProxy && !isDisabled && ( + {effectiveProxy && !effectiveVpn && !isDisabled && ( { + cell: ({ row, table }) => { const profile = row.original; + const meta = table.options.meta as TableMeta; + const liveStatus = meta.syncStatuses[profile.id] as + | "syncing" + | "waiting" + | "synced" + | "error" + | "disabled" + | undefined; - if (!profile.sync_enabled && profile.last_sync) { - return ( - - - - - - - - Sync is disabled, last sync{" "} - {formatRelativeTime(profile.last_sync)} - - - ); - } + const dot = getProfileSyncStatusDot(profile, liveStatus); + if (!dot) return null; - return null; + return ( + + + + + + + {dot.tooltip} + + ); }, }, { diff --git a/src/components/proxy-assignment-dialog.tsx b/src/components/proxy-assignment-dialog.tsx index dfd616f..b4e27df 100644 --- a/src/components/proxy-assignment-dialog.tsx +++ b/src/components/proxy-assignment-dialog.tsx @@ -5,6 +5,7 @@ import { emit } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { LoadingButton } from "@/components/loading-button"; +import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, @@ -17,11 +18,13 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { BrowserProfile, StoredProxy } from "@/types"; +import type { BrowserProfile, StoredProxy, VpnConfig } from "@/types"; import { RippleButton } from "./ui/ripple"; interface ProxyAssignmentDialogProps { @@ -31,6 +34,7 @@ interface ProxyAssignmentDialogProps { onAssignmentComplete: () => void; profiles?: BrowserProfile[]; storedProxies?: StoredProxy[]; + vpnConfigs?: VpnConfig[]; } export function ProxyAssignmentDialog({ @@ -40,11 +44,28 @@ export function ProxyAssignmentDialog({ onAssignmentComplete, profiles = [], storedProxies = [], + vpnConfigs = [], }: ProxyAssignmentDialogProps) { - const [selectedProxyId, setSelectedProxyId] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">( + "none", + ); const [isAssigning, setIsAssigning] = useState(false); const [error, setError] = useState(null); + const handleValueChange = useCallback((value: string) => { + if (value === "none") { + setSelectedId(null); + setSelectionType("none"); + } else if (value.startsWith("vpn-")) { + setSelectedId(value.slice(4)); + setSelectionType("vpn"); + } else { + setSelectedId(value); + setSelectionType("proxy"); + } + }, []); + const handleAssign = useCallback(async () => { setIsAssigning(true); setError(null); @@ -60,24 +81,29 @@ export function ProxyAssignmentDialog({ return; } - // Update each profile's proxy sequentially to avoid file locking issues for (const profileId of validProfiles) { - await invoke("update_profile_proxy", { - profileId, - proxyId: selectedProxyId, - }); + if (selectionType === "vpn") { + await invoke("update_profile_vpn", { + profileId, + vpnId: selectedId, + }); + } else { + await invoke("update_profile_proxy", { + profileId, + proxyId: selectionType === "proxy" ? selectedId : null, + }); + } } - // Notify other parts of the app so usage counts and lists refresh await emit("profile-updated"); onAssignmentComplete(); onClose(); } catch (err) { - console.error("Failed to assign proxies to profiles:", err); + console.error("Failed to assign proxy/VPN to profiles:", err); const errorMessage = err instanceof Error ? err.message - : "Failed to assign proxies to profiles"; + : "Failed to assign proxy/VPN to profiles"; setError(errorMessage); toast.error(errorMessage); } finally { @@ -85,7 +111,8 @@ export function ProxyAssignmentDialog({ } }, [ selectedProfiles, - selectedProxyId, + selectedId, + selectionType, profiles, onAssignmentComplete, onClose, @@ -93,18 +120,27 @@ export function ProxyAssignmentDialog({ useEffect(() => { if (isOpen) { - setSelectedProxyId(null); + setSelectedId(null); + setSelectionType("none"); setError(null); } }, [isOpen]); + const selectValue = + selectionType === "none" + ? "none" + : selectionType === "vpn" + ? `vpn-${selectedId}` + : (selectedId ?? "none"); + return ( - Assign Proxy + Assign Proxy / VPN - Assign a proxy to {selectedProfiles.length} selected profile(s). + Assign a proxy or VPN to {selectedProfiles.length} selected + profile(s). @@ -120,7 +156,7 @@ export function ProxyAssignmentDialog({ const displayName = profile ? profile.name : profileId; return (
  • - • {displayName} + • {displayName}
  • ); })} @@ -129,24 +165,42 @@ export function ProxyAssignmentDialog({
    - - - + - No Proxy - {storedProxies.map((proxy) => ( - - {proxy.name} - {proxy.is_cloud_managed ? " (Included)" : ""} - - ))} + None + {storedProxies.length > 0 && ( + + Proxies + {storedProxies.map((proxy) => ( + + {proxy.name} + {proxy.is_cloud_managed ? " (Included)" : ""} + + ))} + + )} + {vpnConfigs.length > 0 && ( + + VPNs + {vpnConfigs.map((vpn) => ( + + + + {vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} + + {vpn.name} + + + ))} + + )}
    diff --git a/src/components/proxy-import-dialog.tsx b/src/components/proxy-import-dialog.tsx index c7292ad..0c4a66a 100644 --- a/src/components/proxy-import-dialog.tsx +++ b/src/components/proxy-import-dialog.tsx @@ -3,7 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; -import { LuShield, LuUpload } from "react-icons/lu"; +import { LuUpload } from "react-icons/lu"; import { toast } from "sonner"; import { LoadingButton } from "@/components/loading-button"; import { @@ -22,8 +22,6 @@ import type { ParsedProxyLine, ProxyImportResult, ProxyParseResult, - VpnImportResult, - VpnType, } from "@/types"; import { RippleButton } from "./ui/ripple"; @@ -32,13 +30,7 @@ interface ProxyImportDialogProps { onClose: () => void; } -type ImportStep = - | "dropzone" - | "preview" - | "ambiguous" - | "result" - | "vpn-preview" - | "vpn-result"; +type ImportStep = "dropzone" | "preview" | "ambiguous" | "result"; interface AmbiguousProxy { line: string; @@ -46,13 +38,6 @@ interface AmbiguousProxy { selectedFormat?: string; } -interface VpnPreviewData { - content: string; - filename: string; - detectedType: VpnType | null; - endpoint: string | null; -} - export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { const [step, setStep] = useState("dropzone"); const [isDragOver, setIsDragOver] = useState(false); @@ -68,11 +53,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { ); const [isImporting, setIsImporting] = useState(false); const [namePrefix, setNamePrefix] = useState("Imported"); - // VPN import state - const [vpnPreview, setVpnPreview] = useState(null); - const [vpnName, setVpnName] = useState(""); - const [vpnImportResult, setVpnImportResult] = - useState(null); const os = getCurrentOS(); const modKey = os === "macos" ? "⌘" : "Ctrl"; @@ -86,76 +66,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { setImportResult(null); setIsImporting(false); setNamePrefix("Imported"); - // Reset VPN state - setVpnPreview(null); - setVpnName(""); - setVpnImportResult(null); }, []); - // Detect VPN type from content - const detectVpnType = useCallback( - ( - content: string, - filename: string, - ): { isVpn: boolean; type: VpnType | null; endpoint: string | null } => { - const lowerFilename = filename.toLowerCase(); - - // Check for WireGuard config - if ( - lowerFilename.endsWith(".conf") && - content.includes("[Interface]") && - content.includes("[Peer]") - ) { - // Extract endpoint from WireGuard config - const endpointMatch = content.match(/Endpoint\s*=\s*([^\s\n]+)/i); - return { - isVpn: true, - type: "WireGuard", - endpoint: endpointMatch ? endpointMatch[1] : null, - }; - } - - // Check for OpenVPN config - if ( - lowerFilename.endsWith(".ovpn") || - (content.includes("remote ") && - (content.includes("client") || content.includes("dev tun"))) - ) { - // Extract remote from OpenVPN config - const remoteMatch = content.match(/remote\s+(\S+)(?:\s+(\d+))?/i); - const endpoint = remoteMatch - ? `${remoteMatch[1]}${remoteMatch[2] ? `:${remoteMatch[2]}` : ""}` - : null; - return { isVpn: true, type: "OpenVPN", endpoint }; - } - - return { isVpn: false, type: null, endpoint: null }; - }, - [], - ); - const processContent = useCallback( - async (content: string, isJson: boolean, filename: string = "") => { + async (content: string, isJson: boolean, _filename: string = "") => { try { - // Check if it's a VPN config - const vpnDetection = detectVpnType(content, filename); - if (vpnDetection.isVpn) { - setVpnPreview({ - content, - filename, - detectedType: vpnDetection.type, - endpoint: vpnDetection.endpoint, - }); - // Generate default name from filename - const baseName = filename - .replace(/\.(conf|ovpn)$/i, "") - .replace(/_/g, " ") - .replace(/-/g, " "); - setVpnName(baseName || `${vpnDetection.type} VPN`); - setStep("vpn-preview"); - return; - } - if (isJson) { setIsImporting(true); const result = await invoke( @@ -213,7 +128,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { setIsImporting(false); } }, - [detectVpnType], + [], ); const handleFileRead = useCallback( @@ -239,17 +154,13 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { const files = Array.from(e.dataTransfer.files); const validFile = files.find( - (f) => - f.name.endsWith(".json") || - f.name.endsWith(".txt") || - f.name.endsWith(".conf") || // WireGuard - f.name.endsWith(".ovpn"), // OpenVPN + (f) => f.name.endsWith(".json") || f.name.endsWith(".txt"), ); if (validFile) { handleFileRead(validFile); } else { - toast.error("Please drop a .json, .txt, .conf, or .ovpn file"); + toast.error("Please drop a .json or .txt file"); } }, [handleFileRead], @@ -311,33 +222,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { } }, [parsedProxies, namePrefix]); - const handleVpnImport = useCallback(async () => { - if (!vpnPreview) return; - - setIsImporting(true); - try { - const result = await invoke("import_vpn_config", { - content: vpnPreview.content, - filename: vpnPreview.filename, - name: vpnName.trim() || null, - }); - - setVpnImportResult(result); - setStep("vpn-result"); - - if (result.success) { - await emit("vpn-configs-changed"); - } - } catch (error) { - console.error("Failed to import VPN config:", error); - toast.error( - error instanceof Error ? error.message : "Failed to import VPN config", - ); - } finally { - setIsImporting(false); - } - }, [vpnPreview, vpnName]); - const handleAmbiguousFormatSelect = useCallback( (index: number, format: string) => { setAmbiguousProxies((prev) => @@ -389,20 +273,13 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { - - {step === "vpn-preview" || step === "vpn-result" - ? "Import VPN Config" - : "Import Proxies"} - + Import Proxies - {step === "dropzone" && - "Import proxies from a JSON or TXT file, or VPN configs (.conf/.ovpn)"} + {step === "dropzone" && "Import proxies from a JSON or TXT file"} {step === "preview" && "Review the proxies to import"} {step === "ambiguous" && "Some proxies have ambiguous formats. Please select the correct format."} {step === "result" && "Import completed"} - {step === "vpn-preview" && "Review the VPN configuration to import"} - {step === "vpn-result" && "VPN import completed"} @@ -432,14 +309,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { >

    - Drop a proxy or VPN config file + Drop a proxy config file
    - (.json, .txt, .conf, .ovpn) + (.json, .txt)

    { const file = e.target.files?.[0]; @@ -594,75 +471,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { )} - {step === "vpn-preview" && vpnPreview && ( -
    -
    - -
    -
    - {vpnPreview.detectedType} Configuration -
    - {vpnPreview.endpoint && ( -
    - Endpoint: {vpnPreview.endpoint} -
    - )} -
    -
    - -
    - - setVpnName(e.target.value)} - /> -
    - -
    - - -
    -                  {vpnPreview.content.slice(0, 1000)}
    -                  {vpnPreview.content.length > 1000 && "..."}
    -                
    -
    -
    -
    - )} - - {step === "vpn-result" && vpnImportResult && ( -
    -
    - {vpnImportResult.success ? ( -
    - -
    -
    - VPN Imported Successfully -
    -
    - {vpnImportResult.name} ({vpnImportResult.vpn_type}) -
    -
    -
    - ) : ( -
    -
    - Import Failed -
    -
    - {vpnImportResult.error} -
    -
    - )} -
    -
    - )} - {step === "dropzone" && ( @@ -702,24 +510,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { {step === "result" && ( Done )} - - {step === "vpn-preview" && ( - <> - - Back - - void handleVpnImport()} - > - Import VPN - - - )} - - {step === "vpn-result" && ( - Done - )}
    diff --git a/src/components/proxy-management-dialog.tsx b/src/components/proxy-management-dialog.tsx index d1fd61c..8f0b09c 100644 --- a/src/components/proxy-management-dialog.tsx +++ b/src/components/proxy-management-dialog.tsx @@ -30,26 +30,31 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useProxyEvents } from "@/hooks/use-proxy-events"; +import { useVpnEvents } from "@/hooks/use-vpn-events"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; -import type { ProxyCheckResult, StoredProxy } from "@/types"; +import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types"; import { FlagIcon } from "./flag-icon"; import { LocationProxyDialog } from "./location-proxy-dialog"; import { ProxyCheckButton } from "./proxy-check-button"; import { RippleButton } from "./ui/ripple"; +import { VpnCheckButton } from "./vpn-check-button"; +import { VpnFormDialog } from "./vpn-form-dialog"; +import { VpnImportDialog } from "./vpn-import-dialog"; type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting"; function getSyncStatusDot( - proxy: StoredProxy, + item: { sync_enabled?: boolean; last_sync?: number }, liveStatus: SyncStatus | undefined, ): { color: string; tooltip: string; animate: boolean } { - const status = liveStatus ?? (proxy.sync_enabled ? "synced" : "disabled"); + const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled"); switch (status) { case "syncing": @@ -57,8 +62,8 @@ function getSyncStatusDot( case "synced": return { color: "bg-green-500", - tooltip: proxy.last_sync - ? `Synced ${new Date(proxy.last_sync * 1000).toLocaleString()}` + tooltip: item.last_sync + ? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}` : "Synced", animate: false, }; @@ -84,6 +89,7 @@ export function ProxyManagementDialog({ isOpen, onClose, }: ProxyManagementDialogProps) { + // Proxy state const [showProxyForm, setShowProxyForm] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false); @@ -103,7 +109,23 @@ export function ProxyManagementDialog({ {}, ); + // VPN state + const [showVpnForm, setShowVpnForm] = useState(false); + const [showVpnImportDialog, setShowVpnImportDialog] = useState(false); + const [editingVpn, setEditingVpn] = useState(null); + const [vpnToDelete, setVpnToDelete] = useState(null); + const [isDeletingVpn, setIsDeletingVpn] = useState(false); + const [checkingVpnId, setCheckingVpnId] = useState(null); + const [vpnSyncStatus, setVpnSyncStatus] = useState< + Record + >({}); + const [vpnInUse, setVpnInUse] = useState>({}); + const [isTogglingVpnSync, setIsTogglingVpnSync] = useState< + Record + >({}); + const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents(); + const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents(); const [cloudProxyUsage, setCloudProxyUsage] = useState<{ used_mb: number; limit_mb: number; @@ -158,6 +180,29 @@ export function ProxyManagementDialog({ }; }, []); + // Listen for VPN sync status events + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setupListener = async () => { + unlisten = await listen<{ id: string; status: string }>( + "vpn-sync-status", + (event) => { + const { id, status } = event.payload; + setVpnSyncStatus((prev) => ({ + ...prev, + [id]: status as SyncStatus, + })); + }, + ); + }; + + void setupListener(); + return () => { + unlisten?.(); + }; + }, []); + // Load cached check results on mount and when proxies change useEffect(() => { const loadCachedResults = async () => { @@ -190,8 +235,30 @@ export function ProxyManagementDialog({ } }, [storedProxies]); + // Load VPN in-use status + useEffect(() => { + const loadVpnInUse = async () => { + const inUse: Record = {}; + for (const vpn of vpnConfigs) { + try { + const inUseBySynced = await invoke( + "is_vpn_in_use_by_synced_profile", + { vpnId: vpn.id }, + ); + inUse[vpn.id] = inUseBySynced; + } catch (_error) { + // Ignore errors + } + } + setVpnInUse(inUse); + }; + if (vpnConfigs.length > 0) { + void loadVpnInUse(); + } + }, [vpnConfigs]); + + // Proxy handlers const handleDeleteProxy = useCallback((proxy: StoredProxy) => { - // Open in-app confirmation dialog setProxyToDelete(proxy); }, []); @@ -245,106 +312,377 @@ export function ProxyManagementDialog({ } }, []); + // VPN handlers + const handleDeleteVpn = useCallback((vpn: VpnConfig) => { + setVpnToDelete(vpn); + }, []); + + const handleConfirmDeleteVpn = useCallback(async () => { + if (!vpnToDelete) return; + setIsDeletingVpn(true); + try { + await invoke("delete_vpn_config", { vpnId: vpnToDelete.id }); + toast.success("VPN deleted successfully"); + await emit("vpn-configs-changed"); + } catch (error) { + console.error("Failed to delete VPN:", error); + toast.error("Failed to delete VPN"); + } finally { + setIsDeletingVpn(false); + setVpnToDelete(null); + } + }, [vpnToDelete]); + + const handleCreateVpn = useCallback(() => { + setEditingVpn(null); + setShowVpnForm(true); + }, []); + + const handleEditVpn = useCallback((vpn: VpnConfig) => { + setEditingVpn(vpn); + setShowVpnForm(true); + }, []); + + const handleVpnFormClose = useCallback(() => { + setShowVpnForm(false); + setEditingVpn(null); + }, []); + + const handleToggleVpnSync = useCallback(async (vpn: VpnConfig) => { + setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true })); + try { + await invoke("set_vpn_sync_enabled", { + vpnId: vpn.id, + enabled: !vpn.sync_enabled, + }); + showSuccessToast(vpn.sync_enabled ? "Sync disabled" : "Sync enabled"); + await emit("vpn-configs-changed"); + } catch (error) { + console.error("Failed to toggle VPN sync:", error); + showErrorToast( + error instanceof Error ? error.message : "Failed to update sync", + ); + } finally { + setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false })); + } + }, []); + return ( <> - Proxy Management + Proxies & VPNs - Manage your saved proxy configurations for reuse across profiles + Manage your proxy and VPN configurations for reuse across profiles -
    - {/* Proxy actions */} -
    -
    - setShowImportDialog(true)} - className="flex gap-2 items-center" - > - - Import - - setShowExportDialog(true)} - className="flex gap-2 items-center" - disabled={storedProxies.length === 0} - > - - Export - + + + + Proxies + + + VPNs + + + + +
    +
    +
    + setShowImportDialog(true)} + className="flex gap-2 items-center" + > + + Import + + setShowExportDialog(true)} + className="flex gap-2 items-center" + disabled={storedProxies.length === 0} + > + + Export + +
    +
    + {storedProxies.some((p) => p.is_cloud_managed) && ( + setShowLocationDialog(true)} + className="flex gap-2 items-center" + > + + Location + + )} + + + Create + +
    +
    + + {isLoading ? ( +
    + Loading proxies... +
    + ) : storedProxies.length === 0 ? ( +
    + No proxies created yet. Create your first proxy using the + button above. +
    + ) : ( +
    + + + + + Name + Usage + Sync + Actions + + + + {storedProxies.map((proxy) => { + const isCloud = proxy.is_cloud_managed === true; + const syncDot = getSyncStatusDot( + proxy, + proxySyncStatus[proxy.id], + ); + const isDerived = proxy.is_cloud_derived === true; + return ( + + +
    +
    + {isDerived && proxy.geo_country && ( + + )} + {!isCloud && !isDerived && ( + + +
    + + +

    {syncDot.tooltip}

    +
    + + )} + {proxy.name} +
    + {isCloud && cloudProxyUsage && ( + + {cloudProxyUsage.used_mb} /{" "} + {cloudProxyUsage.limit_mb} MB used + + )} +
    + + + + {proxyUsage[proxy.id] ?? 0} + + + + {isCloud ? ( + Cloud + ) : ( + + +
    + + handleToggleSync(proxy) + } + disabled={ + isTogglingSync[proxy.id] || + proxyInUse[proxy.id] + } + /> +
    +
    + + {proxyInUse[proxy.id] ? ( +

    + Sync cannot be disabled while this + proxy is used by synced profiles +

    + ) : ( +

    + {proxy.sync_enabled + ? "Disable sync" + : "Enable sync"} +

    + )} +
    +
    + )} +
    + +
    + { + setProxyCheckResults((prev) => ({ + ...prev, + [proxy.id]: result, + })); + }} + onCheckFailed={(result) => { + setProxyCheckResults((prev) => ({ + ...prev, + [proxy.id]: result, + })); + }} + /> + {!isCloud && !isDerived && ( + + + + + +

    Edit proxy

    +
    +
    + )} + {!isCloud && ( + + + + + + + + {(proxyUsage[proxy.id] ?? 0) > 0 ? ( +

    + Cannot delete: in use by{" "} + {proxyUsage[proxy.id]} profile + {proxyUsage[proxy.id] > 1 + ? "s" + : ""} +

    + ) : ( +

    Delete proxy

    + )} +
    +
    + )} +
    +
    + + ); + })} + +
    +
    +
    + )}
    -
    - {storedProxies.some((p) => p.is_cloud_managed) && ( + + + +
    +
    +
    + setShowVpnImportDialog(true)} + className="flex gap-2 items-center" + > + + Import + +
    setShowLocationDialog(true)} + onClick={handleCreateVpn} className="flex gap-2 items-center" > - - Location + + Create - )} - - - Create - -
    -
    +
    - {/* Proxies list */} - {isLoading ? ( -
    - Loading proxies... -
    - ) : storedProxies.length === 0 ? ( -
    - No proxies created yet. Create your first proxy using the button - above. -
    - ) : ( -
    - - - - - Name - Usage - Sync - Actions - - - - {storedProxies.map((proxy) => { - const isCloud = proxy.is_cloud_managed === true; - const syncDot = getSyncStatusDot( - proxy, - proxySyncStatus[proxy.id], - ); - const isDerived = proxy.is_cloud_derived === true; - return ( - - -
    -
    - {isDerived && proxy.geo_country && ( - - )} - {!isCloud && !isDerived && ( + {isLoadingVpns ? ( +
    + Loading VPNs... +
    + ) : vpnConfigs.length === 0 ? ( +
    + No VPN configs created yet. Import or create one using the + buttons above. +
    + ) : ( +
    + +
    + + + Name + Type + Usage + Sync + Actions + + + + {vpnConfigs.map((vpn) => { + const syncDot = getSyncStatusDot( + vpn, + vpnSyncStatus[vpn.id], + ); + return ( + + +
    {syncDot.tooltip}

    - )} - {proxy.name} -
    - {isCloud && cloudProxyUsage && ( - - {cloudProxyUsage.used_mb} /{" "} - {cloudProxyUsage.limit_mb} MB used - - )} -
    -
    - - - {proxyUsage[proxy.id] ?? 0} - - - - {isCloud ? ( - Cloud - ) : ( - - -
    - - handleToggleSync(proxy) - } - disabled={ - isTogglingSync[proxy.id] || - proxyInUse[proxy.id] - } - /> -
    -
    - - {proxyInUse[proxy.id] ? ( -

    - Sync cannot be disabled while this proxy - is used by synced profiles -

    - ) : ( -

    - {proxy.sync_enabled - ? "Disable sync" - : "Enable sync"} -

    - )} -
    -
    - )} -
    - -
    - { - setProxyCheckResults((prev) => ({ - ...prev, - [proxy.id]: result, - })); - }} - onCheckFailed={(result) => { - setProxyCheckResults((prev) => ({ - ...prev, - [proxy.id]: result, - })); - }} - /> - {!isCloud && !isDerived && ( + {vpn.name} +
    +
    + + + {vpn.vpn_type === "WireGuard" + ? "WG" + : "OVPN"} + + + + + {vpnUsage[vpn.id] ?? 0} + + + - - - -

    Edit proxy

    -
    -
    - )} - {!isCloud && ( - - - - - + /> + - {(proxyUsage[proxy.id] ?? 0) > 0 ? ( + {vpnInUse[vpn.id] ? (

    - Cannot delete: in use by{" "} - {proxyUsage[proxy.id]} profile - {proxyUsage[proxy.id] > 1 ? "s" : ""} + Sync cannot be disabled while this VPN + is used by synced profiles

    ) : ( -

    Delete proxy

    +

    + {vpn.sync_enabled + ? "Disable sync" + : "Enable sync"} +

    )}
    - )} - -
    -
    - ); - })} -
    -
    -
    + + +
    + + + + + + +

    Edit VPN

    +
    +
    + + + + + + + + {(vpnUsage[vpn.id] ?? 0) > 0 ? ( +

    + Cannot delete: in use by{" "} + {vpnUsage[vpn.id]} profile + {vpnUsage[vpn.id] > 1 ? "s" : ""} +

    + ) : ( +

    Delete VPN

    + )} +
    +
    +
    +
    + + ); + })} + + + +
    + )}
    - )} -
    + + @@ -525,6 +841,25 @@ export function ProxyManagementDialog({ isOpen={showLocationDialog} onClose={() => setShowLocationDialog(false)} /> + + + setVpnToDelete(null)} + onConfirm={handleConfirmDeleteVpn} + title="Delete VPN" + description={`This action cannot be undone. This will permanently delete the VPN "${vpnToDelete?.name ?? ""}".`} + confirmButtonText="Delete" + isLoading={isDeletingVpn} + /> + setShowVpnImportDialog(false)} + /> ); } diff --git a/src/components/vpn-check-button.tsx b/src/components/vpn-check-button.tsx new file mode 100644 index 0000000..8691d2a --- /dev/null +++ b/src/components/vpn-check-button.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import * as React from "react"; +import { FiCheck } from "react-icons/fi"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { formatRelativeTime } from "@/lib/flag-utils"; +import type { ProxyCheckResult } from "@/types"; + +interface VpnCheckButtonProps { + vpnId: string; + vpnName: string; + checkingVpnId: string | null; + setCheckingVpnId: (id: string | null) => void; + disabled?: boolean; +} + +export function VpnCheckButton({ + vpnId, + vpnName, + checkingVpnId, + setCheckingVpnId, + disabled = false, +}: VpnCheckButtonProps) { + const [result, setResult] = React.useState(); + + const handleCheck = React.useCallback(async () => { + if (checkingVpnId === vpnId) return; + + setCheckingVpnId(vpnId); + try { + const checkResult = await invoke("check_vpn_validity", { + vpnId, + }); + setResult(checkResult); + + if (checkResult.is_valid) { + toast.success(`VPN "${vpnName}" configuration is valid`); + } else { + toast.error(`VPN "${vpnName}" configuration is invalid`); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(`VPN check failed: ${errorMessage}`); + + setResult({ + ip: "", + timestamp: Math.floor(Date.now() / 1000), + is_valid: false, + }); + } finally { + setCheckingVpnId(null); + } + }, [vpnId, vpnName, checkingVpnId, setCheckingVpnId]); + + const isCurrentlyChecking = checkingVpnId === vpnId; + + return ( + + + + + + {isCurrentlyChecking ? ( +

    Checking VPN config...

    + ) : result?.is_valid ? ( +
    +

    Configuration valid

    +

    + Checked {formatRelativeTime(result.timestamp)} +

    +
    + ) : result && !result.is_valid ? ( +
    +

    Configuration invalid

    +

    + Checked {formatRelativeTime(result.timestamp)} +

    +
    + ) : ( +

    Check VPN config validity

    + )} +
    +
    + ); +} diff --git a/src/components/vpn-form-dialog.tsx b/src/components/vpn-form-dialog.tsx new file mode 100644 index 0000000..9d6042f --- /dev/null +++ b/src/components/vpn-form-dialog.tsx @@ -0,0 +1,489 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { emit } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { LoadingButton } from "@/components/loading-button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RippleButton } from "@/components/ui/ripple"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import type { VpnConfig, VpnType } from "@/types"; + +interface VpnFormDialogProps { + isOpen: boolean; + onClose: () => void; + editingVpn?: VpnConfig | null; +} + +interface WireGuardFormData { + name: string; + privateKey: string; + address: string; + dns: string; + mtu: string; + peerPublicKey: string; + peerEndpoint: string; + allowedIps: string; + persistentKeepalive: string; + presharedKey: string; +} + +interface OpenVpnFormData { + name: string; + rawConfig: string; +} + +const defaultWireGuardForm: WireGuardFormData = { + name: "", + privateKey: "", + address: "", + dns: "", + mtu: "", + peerPublicKey: "", + peerEndpoint: "", + allowedIps: "0.0.0.0/0, ::/0", + persistentKeepalive: "", + presharedKey: "", +}; + +const defaultOpenVpnForm: OpenVpnFormData = { + name: "", + rawConfig: "", +}; + +function buildWireGuardConfig(form: WireGuardFormData): string { + const lines: string[] = ["[Interface]"]; + lines.push(`PrivateKey = ${form.privateKey.trim()}`); + lines.push(`Address = ${form.address.trim()}`); + if (form.dns.trim()) lines.push(`DNS = ${form.dns.trim()}`); + if (form.mtu.trim()) lines.push(`MTU = ${form.mtu.trim()}`); + lines.push(""); + lines.push("[Peer]"); + lines.push(`PublicKey = ${form.peerPublicKey.trim()}`); + lines.push(`Endpoint = ${form.peerEndpoint.trim()}`); + lines.push(`AllowedIPs = ${form.allowedIps.trim()}`); + if (form.persistentKeepalive.trim()) + lines.push(`PersistentKeepalive = ${form.persistentKeepalive.trim()}`); + if (form.presharedKey.trim()) + lines.push(`PresharedKey = ${form.presharedKey.trim()}`); + return lines.join("\n"); +} + +export function VpnFormDialog({ + isOpen, + onClose, + editingVpn, +}: VpnFormDialogProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [vpnType, setVpnType] = useState("WireGuard"); + const [wireGuardForm, setWireGuardForm] = + useState(defaultWireGuardForm); + const [openVpnForm, setOpenVpnForm] = + useState(defaultOpenVpnForm); + + const resetForms = useCallback(() => { + setVpnType("WireGuard"); + setWireGuardForm(defaultWireGuardForm); + setOpenVpnForm(defaultOpenVpnForm); + }, []); + + useEffect(() => { + if (isOpen) { + if (editingVpn) { + setVpnType(editingVpn.vpn_type); + if (editingVpn.vpn_type === "WireGuard") { + setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name }); + } else { + setOpenVpnForm({ name: editingVpn.name, rawConfig: "" }); + } + } else { + resetForms(); + } + } + }, [isOpen, editingVpn, resetForms]); + + const handleClose = useCallback(() => { + if (!isSubmitting) { + onClose(); + } + }, [isSubmitting, onClose]); + + const handleSubmit = useCallback(async () => { + if (editingVpn) { + const name = + vpnType === "WireGuard" + ? wireGuardForm.name.trim() + : openVpnForm.name.trim(); + + if (!name) { + toast.error("VPN name is required"); + return; + } + + setIsSubmitting(true); + try { + await invoke("update_vpn_config", { + vpnId: editingVpn.id, + name, + }); + await emit("vpn-configs-changed"); + toast.success("VPN updated successfully"); + onClose(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(`Failed to update VPN: ${errorMessage}`); + } finally { + setIsSubmitting(false); + } + return; + } + + if (vpnType === "WireGuard") { + const { name, privateKey, address, peerPublicKey, peerEndpoint } = + wireGuardForm; + + if (!name.trim()) { + toast.error("VPN name is required"); + return; + } + if (!privateKey.trim()) { + toast.error("Private key is required"); + return; + } + if (!address.trim()) { + toast.error("Address is required"); + return; + } + if (!peerPublicKey.trim()) { + toast.error("Peer public key is required"); + return; + } + if (!peerEndpoint.trim()) { + toast.error("Peer endpoint is required"); + return; + } + + setIsSubmitting(true); + try { + const configData = buildWireGuardConfig(wireGuardForm); + await invoke("create_vpn_config_manual", { + name: name.trim(), + vpnType: "WireGuard", + configData, + }); + await emit("vpn-configs-changed"); + toast.success("WireGuard VPN created successfully"); + onClose(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(`Failed to create VPN: ${errorMessage}`); + } finally { + setIsSubmitting(false); + } + } else { + const { name, rawConfig } = openVpnForm; + + if (!name.trim()) { + toast.error("VPN name is required"); + return; + } + if (!rawConfig.trim()) { + toast.error("OpenVPN config content is required"); + return; + } + + setIsSubmitting(true); + try { + await invoke("create_vpn_config_manual", { + name: name.trim(), + vpnType: "OpenVPN", + configData: rawConfig, + }); + await emit("vpn-configs-changed"); + toast.success("OpenVPN configuration created successfully"); + onClose(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(`Failed to create VPN: ${errorMessage}`); + } finally { + setIsSubmitting(false); + } + } + }, [editingVpn, vpnType, wireGuardForm, openVpnForm, onClose]); + + const updateWireGuard = useCallback( + (field: keyof WireGuardFormData, value: string) => { + setWireGuardForm((prev) => ({ ...prev, [field]: value })); + }, + [], + ); + + const updateOpenVpn = useCallback( + (field: keyof OpenVpnFormData, value: string) => { + setOpenVpnForm((prev) => ({ ...prev, [field]: value })); + }, + [], + ); + + const dialogTitle = editingVpn + ? "Edit VPN" + : vpnType === "WireGuard" + ? "Create WireGuard VPN" + : "Create OpenVPN Configuration"; + + const dialogDescription = editingVpn + ? "Update the name of your VPN configuration." + : vpnType === "WireGuard" + ? "Enter your WireGuard interface and peer details." + : "Paste your .ovpn configuration file content."; + + return ( + + + + {dialogTitle} + {dialogDescription} + + + +
    + {!editingVpn && ( +
    + + +
    + )} + + {vpnType === "WireGuard" && ( + <> +
    + + updateWireGuard("name", e.target.value)} + placeholder="e.g. Home WireGuard" + disabled={isSubmitting} + /> +
    + + {!editingVpn && ( + <> +
    + + + updateWireGuard("privateKey", e.target.value) + } + placeholder="Base64-encoded private key" + disabled={isSubmitting} + /> +
    + +
    + + + updateWireGuard("address", e.target.value) + } + placeholder="e.g. 10.0.0.2/24" + disabled={isSubmitting} + /> +
    + +
    +
    + + + updateWireGuard("dns", e.target.value) + } + placeholder="e.g. 1.1.1.1" + disabled={isSubmitting} + /> +
    + +
    + + + updateWireGuard("mtu", e.target.value) + } + placeholder="e.g. 1420" + disabled={isSubmitting} + /> +
    +
    + +
    + + + updateWireGuard("peerPublicKey", e.target.value) + } + placeholder="Base64-encoded peer public key" + disabled={isSubmitting} + /> +
    + +
    + + + updateWireGuard("peerEndpoint", e.target.value) + } + placeholder="e.g. vpn.example.com:51820" + disabled={isSubmitting} + /> +
    + +
    + + + updateWireGuard("allowedIps", e.target.value) + } + placeholder="e.g. 0.0.0.0/0, ::/0" + disabled={isSubmitting} + /> +
    + +
    +
    + + + updateWireGuard( + "persistentKeepalive", + e.target.value, + ) + } + placeholder="e.g. 25" + disabled={isSubmitting} + /> +
    + +
    + + + updateWireGuard("presharedKey", e.target.value) + } + placeholder="Base64-encoded preshared key" + disabled={isSubmitting} + /> +
    +
    + + )} + + )} + + {vpnType === "OpenVPN" && ( + <> +
    + + updateOpenVpn("name", e.target.value)} + placeholder="e.g. Work OpenVPN" + disabled={isSubmitting} + /> +
    + + {!editingVpn && ( +
    + +