use crate::proxy_storage::{ delete_proxy_config, generate_proxy_id, get_proxy_config, is_process_running, list_proxy_configs, save_proxy_config, ProxyConfig, }; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; static PROXY_PROCESSES: std::sync::LazyLock< std::sync::Mutex>, > = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new())); static SIDECAR_VERSION_VERIFIED: AtomicBool = AtomicBool::new(false); const RETAINED_PROXY_LOGS: usize = 20; fn prune_stale_proxy_logs(temp_dir: &Path, retain: usize) { let active_ids = PROXY_PROCESSES .lock() .map(|processes| processes.keys().cloned().collect::>()) .unwrap_or_default(); let Ok(entries) = std::fs::read_dir(temp_dir) else { return; }; let mut logs = entries .flatten() .filter_map(|entry| { let file_name = entry.file_name(); let file_name = file_name.to_str()?; let id = file_name .strip_prefix("donut-proxy-")? .strip_suffix(".log")?; if active_ids.iter().any(|active_id| active_id == id) { return None; } let modified = entry .metadata() .and_then(|metadata| metadata.modified()) .unwrap_or(std::time::UNIX_EPOCH); Some((modified, entry.path())) }) .collect::>(); logs.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.0)); for (_, path) in logs.into_iter().skip(retain) { if let Err(error) = std::fs::remove_file(&path) { log::debug!( "Stale proxy log prune failed path={} err=\"{error}\"", path.display() ); } } } fn target_binary_name(base_name: &str) -> String { let target = env!("DONUT_BUILD_TARGET"); #[cfg(windows)] { format!("{base_name}-{target}.exe") } #[cfg(not(windows))] { format!("{base_name}-{target}") } } fn unsuffixed_binary_name(base_name: &str) -> String { #[cfg(windows)] { format!("{base_name}.exe") } #[cfg(not(windows))] { base_name.to_string() } } fn is_executable_file(path: &Path) -> bool { let Ok(metadata) = path.metadata() else { return false; }; if !metadata.is_file() { return false; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; metadata.permissions().mode() & 0o111 != 0 } #[cfg(windows)] { path .extension() .and_then(|extension| extension.to_str()) .is_some_and(|extension| extension.eq_ignore_ascii_case("exe")) } } fn push_candidate_dir(dirs: &mut Vec, dir: Option) { if let Some(dir) = dir { if !dirs.iter().any(|existing| existing == &dir) { dirs.push(dir); } } } pub(crate) fn find_sidecar_executable( base_name: &str, ) -> Result> { let current_exe = std::env::current_exe()?; let current_dir = current_exe .parent() .ok_or("Failed to get parent directory of current executable")?; if current_exe .file_stem() .and_then(|stem| stem.to_str()) .is_some_and(|stem| stem == base_name) { return Ok(current_exe); } let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let mut search_dirs = Vec::new(); push_candidate_dir(&mut search_dirs, Some(current_dir.to_path_buf())); push_candidate_dir( &mut search_dirs, current_dir.parent().map(std::path::Path::to_path_buf), ); push_candidate_dir( &mut search_dirs, current_dir .parent() .and_then(|parent| parent.parent()) .map(Path::to_path_buf), ); push_candidate_dir(&mut search_dirs, Some(current_dir.join("binaries"))); push_candidate_dir( &mut search_dirs, current_dir.parent().map(|parent| parent.join("binaries")), ); push_candidate_dir( &mut search_dirs, current_dir .parent() .and_then(|parent| parent.parent()) .map(|parent| parent.join("binaries")), ); push_candidate_dir(&mut search_dirs, Some(manifest_dir.join("binaries"))); push_candidate_dir( &mut search_dirs, Some(manifest_dir.join("target").join("debug")), ); push_candidate_dir( &mut search_dirs, Some(manifest_dir.join("target").join("release")), ); let exact_names = [ unsuffixed_binary_name(base_name), target_binary_name(base_name), ]; for dir in &search_dirs { for name in &exact_names { if name.is_empty() { continue; } let candidate = dir.join(name); if is_executable_file(&candidate) { return Ok(candidate); } } } Err( format!( "Failed to locate '{}' executable. Searched in: {}", base_name, search_dirs .iter() .map(|dir| dir.display().to_string()) .collect::>() .join(", ") ) .into(), ) } fn parse_sidecar_version(stdout: &[u8]) -> Option { let output = std::str::from_utf8(stdout).ok()?.trim(); output .strip_prefix("donut-proxy ") .map(str::trim) .filter(|version| !version.is_empty() && !version.contains(char::is_whitespace)) .map(str::to_string) } fn sidecar_version_mismatch_error() -> Box { serde_json::json!({ "code": "PROXY_SIDECAR_VERSION_MISMATCH" }) .to_string() .into() } /// Verify that the installed sidecar was built for the same release as the /// main app. Windows can otherwise retain an executing, locked sidecar while /// NSIS replaces the app, leaving an incompatible mixed-version installation. pub(crate) async fn ensure_sidecar_version() -> Result<(), Box> { if SIDECAR_VERSION_VERIFIED.load(Ordering::Acquire) { return Ok(()); } let executable = match find_sidecar_executable("donut-proxy") { Ok(executable) => executable, Err(e) => { log::error!("donut-proxy not found for version check err=\"{e}\""); return Err(sidecar_version_mismatch_error()); } }; let checked = std::time::Instant::now(); let mut command = std::process::Command::new(&executable); command.arg("--version"); #[cfg(windows)] { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; command.creation_flags(CREATE_NO_WINDOW); } let output = match command.output() { Ok(output) => output, Err(e) => { log::error!( "donut-proxy version check failed to run path={} err=\"{e}\"", executable.display() ); return Err(sidecar_version_mismatch_error()); } }; let actual_version = parse_sidecar_version(&output.stdout); let expected_version = env!("BUILD_VERSION"); log::debug!( "Sidecar version checked path={} elapsed_ms={}", executable.display(), checked.elapsed().as_millis() ); if output.status.success() && actual_version.as_deref() == Some(expected_version) { SIDECAR_VERSION_VERIFIED.store(true, Ordering::Release); return Ok(()); } log::error!( "donut-proxy version mismatch expected={} actual={:?} status=\"{}\" stdout={:?} stderr={:?}", expected_version, actual_version, output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); Err(sidecar_version_mismatch_error()) } pub async fn start_proxy_process( upstream_url: Option, port: Option, ) -> Result> { start_proxy_process_with_profile( upstream_url, port, None, Vec::new(), None, false, None, None, true, ) .await } #[allow(clippy::too_many_arguments)] pub async fn start_proxy_process_with_profile( upstream_url: Option, port: Option, profile_id: Option, bypass_rules: Vec, blocklist_file: Option, dns_allowlist_mode: bool, dns_server: Option, local_protocol: Option, record_domains: bool, ) -> Result> { let dns_server = crate::profile_dns::normalize_dns_server(dns_server)?; ensure_sidecar_version().await?; let id = generate_proxy_id(); let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string()); // Get available port if not specified let local_port = port.unwrap_or_else(|| { // Find an available port let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.local_addr().unwrap().port() }); let config = ProxyConfig::new(id.clone(), upstream, Some(local_port)) .with_profile_id(profile_id.clone()) .with_bypass_rules(bypass_rules) .with_blocklist_file(blocklist_file) .with_dns_allowlist_mode(dns_allowlist_mode) .with_dns_server(dns_server) .with_local_protocol(local_protocol) .with_record_domains(record_domains); save_proxy_config(&config)?; spawn_proxy_worker(&config, false).await } /// Restart only a dead worker whose browser is still alive. Keep its full /// route and listening port: the running browser cannot use a new port. pub async fn recover_proxy_process( id: &str, ) -> Result, Box> { let Some(config) = get_proxy_config(id) else { return Ok(None); }; if !proxy_needs_recovery(&config) { return Ok(None); } ensure_sidecar_version().await?; spawn_proxy_worker(&config, true).await.map(Some) } pub(crate) fn proxy_needs_recovery(config: &ProxyConfig) -> bool { config.profile_id.is_some() && config.local_port.is_some_and(|port| port != 0) && config.pid.is_some_and(|pid| !is_process_running(pid)) && crate::proxy_storage::browser_owner_is_alive(config) } async fn spawn_proxy_worker( config: &ProxyConfig, recovering: bool, ) -> Result> { let started = std::time::Instant::now(); let id = config.id.clone(); let profile_id = config.profile_id.as_deref().unwrap_or("none"); let upstream_kind = upstream_scheme(&config.upstream_url); // Spawn proxy worker process in the background using std::process::Command // This ensures proper process detachment on Unix systems let exe = find_sidecar_executable("donut-proxy")?; let temp_dir = std::env::temp_dir(); let log_path = temp_dir.join(format!("donut-proxy-{id}.log")); let log_file = if recovering && log_path.exists() { crate::app_dirs::restrict_to_owner(&log_path); std::fs::OpenOptions::new().append(true).open(&log_path) } else { crate::app_dirs::create_owner_only(&log_path) }; prune_stale_proxy_logs(&temp_dir, RETAINED_PROXY_LOGS); let spawned_pid: u32; #[cfg(unix)] { use std::os::unix::process::CommandExt; use std::process::Command as StdCommand; let mut cmd = StdCommand::new(&exe); cmd.arg("proxy-worker"); cmd.arg("start"); cmd.arg("--id"); cmd.arg(&id); cmd.env_remove("DONUT_PROXY_USERNAME"); cmd.env_remove("DONUT_PROXY_PASSWORD"); cmd.stdin(Stdio::null()); cmd.stdout(Stdio::null()); // Always log to file for diagnostics (both debug and release builds) if let Ok(file) = log_file { cmd.stderr(Stdio::from(file)); } else { cmd.stderr(Stdio::null()); } // Properly detach the process on Unix by creating a new session unsafe { cmd.pre_exec(|| { // Create a new process group so the process survives parent exit libc::setsid(); // Set high priority so the proxy is killed last under resource pressure // Negative nice value = higher priority. Try -10, fall back to -5 if it fails. if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 { let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5); } Ok(()) }); } // Spawn detached process let child = cmd.spawn()?; let pid = child.id(); spawned_pid = pid; log::debug!("Proxy worker spawned id={id} pid={pid}"); // Store PID { let mut processes = PROXY_PROCESSES.lock().unwrap(); processes.insert(id.clone(), pid); } watch_proxy_exit(child, id.clone()); } #[cfg(windows)] { use std::os::windows::io::AsRawHandle; use std::os::windows::process::CommandExt; use std::process::Command as StdCommand; use windows::Win32::Foundation::{CloseHandle, SetHandleInformation, HANDLE, HANDLE_FLAGS}; use windows::Win32::System::Threading::{ OpenProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS, PROCESS_SET_INFORMATION, }; // Mark current stdout/stderr as non-inheritable so the spawned worker process // does not inherit pipe handles from our parent (prevents blocking when parent exits). let stdout_handle = std::io::stdout().as_raw_handle(); let stderr_handle = std::io::stderr().as_raw_handle(); const HANDLE_FLAG_INHERIT: u32 = 0x00000001; unsafe { if !stdout_handle.is_null() { let _ = SetHandleInformation(HANDLE(stdout_handle), HANDLE_FLAG_INHERIT, HANDLE_FLAGS(0)); } if !stderr_handle.is_null() { let _ = SetHandleInformation(HANDLE(stderr_handle), HANDLE_FLAG_INHERIT, HANDLE_FLAGS(0)); } } let mut cmd = StdCommand::new(&exe); cmd.arg("proxy-worker"); cmd.arg("start"); cmd.arg("--id"); cmd.arg(&id); cmd.env_remove("DONUT_PROXY_USERNAME"); cmd.env_remove("DONUT_PROXY_PASSWORD"); cmd.stdin(Stdio::null()); cmd.stdout(Stdio::null()); // Log to file for diagnostics (matching Unix behavior) if let Ok(file) = log_file { cmd.stderr(Stdio::from(file)); } else { cmd.stderr(Stdio::null()); } // On Windows, use DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP for proper detachment. 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(); spawned_pid = pid; // Set high priority so the proxy is killed last under resource pressure unsafe { if let Ok(handle) = OpenProcess(PROCESS_SET_INFORMATION, false, pid) { let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS); let _ = CloseHandle(handle); } } // Store PID { let mut processes = PROXY_PROCESSES.lock().unwrap(); processes.insert(id.clone(), pid); } watch_proxy_exit(child, id.clone()); } // Give the process a moment to start up before checking tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; // Wait for the worker to bind to the port and update config // Since we pre-allocated the port, the worker should bind immediately // We check quickly with short intervals to make startup fast let mut attempts = 0; let max_attempts = 40; // 4 seconds max (40 * 100ms) - give it more time to start loop { // Use shorter sleep for faster startup tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; if let Some(updated_config) = get_proxy_config(&id).filter(|c| c.pid == Some(spawned_pid)) { // Check if local_url is set (worker has bound and updated config) if let Some(ref local_url) = updated_config.local_url { if !local_url.is_empty() { if let Some(port) = updated_config.local_port { // Try to connect immediately - port should be ready since we pre-allocated it match tokio::time::timeout( tokio::time::Duration::from_millis(100), tokio::net::TcpStream::connect(("127.0.0.1", port)), ) .await { Ok(Ok(_stream)) => { log::info!( "Proxy worker started id={id} pid={spawned_pid} port={port} profile={} upstream={upstream_kind} elapsed_ms={}", profile_id, started.elapsed().as_millis() ); return Ok(updated_config); } Ok(Err(_)) | Err(_) => { // Port not ready yet, continue waiting } } } } } } attempts += 1; if attempts >= max_attempts { // Try to get the config one more time for better error message let detail = if let Some(config) = get_proxy_config(&id) { // Check if process is still running let process_running = config.pid.map(is_process_running).unwrap_or(false); format!( "Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}", config.id, config.local_url, config.local_port, config.pid, process_running ) } else { format!("Config not found for id: {}", id) }; // The detached worker (if it did spawn) would otherwise outlive this // failed start with nothing tracking it — callers only get an error // string, so this is the last place that can still kill it. let _ = stop_proxy_process_inner(&id, "start_timeout", !recovering).await; return Err(format!("Proxy worker failed to start in time. {detail}").into()); } } } fn watch_proxy_exit(mut child: std::process::Child, id: String) { let pid = child.id(); // A recovered worker is a child of the long-lived GUI. Reap it off the // runtime so repeated failures do not leave zombies until the GUI exits. std::thread::spawn(move || match child.wait() { Ok(status) => log::info!("Proxy worker exited id={id} pid={pid} status=\"{status}\""), Err(error) => log::warn!("Proxy worker wait failed id={id} pid={pid} err=\"{error}\""), }); } pub async fn stop_proxy_process(id: &str) -> Result> { stop_proxy_process_because(id, "requested").await } /// `reason` names why the worker is stopped, for its one stop line. pub(crate) async fn stop_proxy_process_because( id: &str, reason: &str, ) -> Result> { stop_proxy_process_inner(id, reason, true).await } async fn stop_proxy_process_inner( id: &str, reason: &str, remove_config: bool, ) -> Result> { let config = get_proxy_config(id); let pid = PROXY_PROCESSES .lock() .unwrap() .get(id) .copied() .or_else(|| config.as_ref().and_then(|config| config.pid)); if let Some(pid) = pid { let was_running = is_process_running(pid); // Kill the process #[cfg(unix)] { use std::process::Command; let _ = Command::new("kill") .arg("-TERM") .arg(pid.to_string()) .output(); } #[cfg(windows)] { use std::os::windows::process::CommandExt; use std::process::Command; const CREATE_NO_WINDOW: u32 = 0x08000000; let _ = Command::new("taskkill") .args(["/F", "/PID", &pid.to_string()]) .creation_flags(CREATE_NO_WINDOW) .output(); } // Wait a bit for the process to exit tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; // Remove from tracking { let mut processes = PROXY_PROCESSES.lock().unwrap(); processes.remove(id); } // Delete the config file if remove_config { delete_proxy_config(id); } let how = if !was_running { "already_gone" } else if is_process_running(pid) { "still_running" } else { "terminated" }; let profile = config .as_ref() .and_then(|config| config.profile_id.as_deref()) .unwrap_or("none"); let browser_pid = config .as_ref() .and_then(|config| config.browser_pid) .map(|pid| pid.to_string()) .unwrap_or_else(|| "none".to_string()); log::info!( "Proxy worker stopped id={id} pid={pid} profile={profile} browser_pid={browser_pid} reason={reason} how={how}" ); return Ok(true); } Ok(false) } /// The scheme of an upstream URL, never its host or credentials. fn upstream_scheme(upstream: &str) -> &str { match upstream.split_once("://") { Some((scheme, _)) => scheme, None if upstream == "DIRECT" => "direct", None => "unknown", } } pub async fn stop_all_proxy_processes() -> Result<(), Box> { let configs = list_proxy_configs(); for config in configs { let _ = stop_proxy_process(&config.id).await; } Ok(()) } #[cfg(test)] mod tests { use super::{ is_executable_file, parse_sidecar_version, prune_stale_proxy_logs, target_binary_name, }; use std::fs; use std::time::Duration; #[test] fn parses_exact_sidecar_version_output() { assert_eq!( parse_sidecar_version(b"donut-proxy v0.28.2\n").as_deref(), Some("v0.28.2") ); assert_eq!( parse_sidecar_version(b"donut-proxy nightly-2026-07-19-a4ed5c8\r\n").as_deref(), Some("nightly-2026-07-19-a4ed5c8") ); } #[test] fn rejects_missing_or_ambiguous_sidecar_version_output() { assert_eq!(parse_sidecar_version(b""), None); assert_eq!(parse_sidecar_version(b"donut-proxy"), None); assert_eq!(parse_sidecar_version(b"other-proxy v0.28.2"), None); assert_eq!( parse_sidecar_version(b"donut-proxy v0.28.2\nunexpected"), None ); } #[test] fn sidecar_target_name_is_exact_and_metadata_files_are_not_executables() { let target_name = target_binary_name("xray"); assert!(target_name.starts_with("xray-")); assert!(target_name.contains(env!("DONUT_BUILD_TARGET"))); let temp = tempfile::tempdir().unwrap(); let marker = temp.path().join(format!("{target_name}.source.json")); fs::write(&marker, "{}").unwrap(); assert!(!is_executable_file(&marker)); let executable = temp.path().join(target_name); fs::write(&executable, "binary").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); } assert!(is_executable_file(&executable)); } #[test] fn prunes_only_old_proxy_logs() { let temp = tempfile::tempdir().unwrap(); for id in ["oldest", "middle", "newest"] { fs::write(temp.path().join(format!("donut-proxy-{id}.log")), id).unwrap(); std::thread::sleep(Duration::from_millis(10)); } fs::write(temp.path().join("unrelated.log"), "keep").unwrap(); prune_stale_proxy_logs(temp.path(), 2); assert!(!temp.path().join("donut-proxy-oldest.log").exists()); assert!(temp.path().join("donut-proxy-middle.log").exists()); assert!(temp.path().join("donut-proxy-newest.log").exists()); assert!(temp.path().join("unrelated.log").exists()); } }