Files
donutbrowser/src-tauri/src/proxy_runner.rs
T
2026-10-03 02:24:34 +04:00

631 lines
18 KiB
Rust

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::collections::HashMap<String, u32>>,
> = 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::<Vec<_>>())
.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::<Vec<_>>();
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!(
"Failed to prune stale proxy log {}: {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<PathBuf>, dir: Option<PathBuf>) {
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<PathBuf, Box<dyn std::error::Error>> {
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::<Vec<_>>()
.join(", ")
)
.into(),
)
}
fn parse_sidecar_version(stdout: &[u8]) -> Option<String> {
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<dyn std::error::Error> {
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<dyn std::error::Error>> {
if SIDECAR_VERSION_VERIFIED.load(Ordering::Acquire) {
return Ok(());
}
let executable = match find_sidecar_executable("donut-proxy") {
Ok(executable) => executable,
Err(e) => {
log::error!("Failed to locate donut-proxy for version verification: {e}");
return Err(sidecar_version_mismatch_error());
}
};
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!(
"Failed to run {} for version verification: {e}",
executable.display()
);
return Err(sidecar_version_mismatch_error());
}
};
let actual_version = parse_sidecar_version(&output.stdout);
let expected_version = env!("BUILD_VERSION");
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 {}, got {:?}; 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<String>,
port: Option<u16>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
start_proxy_process_with_profile(
upstream_url,
port,
None,
Vec::new(),
None,
false,
None,
true,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn start_proxy_process_with_profile(
upstream_url: Option<String>,
port: Option<u16>,
profile_id: Option<String>,
bypass_rules: Vec<String>,
blocklist_file: Option<String>,
dns_allowlist_mode: bool,
local_protocol: Option<String>,
record_domains: bool,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
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_local_protocol(local_protocol)
.with_record_domains(record_domains);
save_proxy_config(&config)?;
// Log profile_id for debugging
if let Some(ref pid) = profile_id {
log::info!("Saved proxy config {} with profile_id: {}", id, pid);
} else {
log::info!("Saved proxy config {} without profile_id", id);
}
// 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 = crate::app_dirs::create_owner_only(&log_path);
prune_stale_proxy_logs(&temp_dir, RETAINED_PROXY_LOGS);
#[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 {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
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();
// Store PID
{
let mut processes = PROXY_PROCESSES.lock().unwrap();
processes.insert(id.clone(), pid);
}
// Don't wait for the child - it's detached
drop(child);
}
#[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 {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
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();
// 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);
}
drop(child);
}
// 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) {
// 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)) => {
// Port is listening and accepting connections!
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(&id).await;
return Err(format!("Proxy worker failed to start in time. {detail}").into());
}
}
}
pub async fn stop_proxy_process(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
let pid = PROXY_PROCESSES
.lock()
.unwrap()
.get(id)
.copied()
.or_else(|| get_proxy_config(id).and_then(|config| config.pid));
if let Some(pid) = 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
delete_proxy_config(id);
return Ok(true);
}
Ok(false)
}
pub async fn stop_all_proxy_processes() -> Result<(), Box<dyn std::error::Error>> {
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());
}
}