mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-19 14:07:19 +02:00
737 lines
22 KiB
Rust
737 lines
22 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!(
|
|
"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<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!("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<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,
|
|
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,
|
|
dns_server: Option<String>,
|
|
local_protocol: Option<String>,
|
|
record_domains: bool,
|
|
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
|
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<Option<ProxyConfig>, Box<dyn std::error::Error>> {
|
|
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<ProxyConfig, Box<dyn std::error::Error>> {
|
|
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<bool, Box<dyn std::error::Error>> {
|
|
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<bool, Box<dyn std::error::Error>> {
|
|
stop_proxy_process_inner(id, reason, true).await
|
|
}
|
|
|
|
async fn stop_proxy_process_inner(
|
|
id: &str,
|
|
reason: &str,
|
|
remove_config: bool,
|
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
|
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<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());
|
|
}
|
|
}
|