mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-25 13:26:27 +02:00
3116 lines
97 KiB
Rust
3116 lines
97 KiB
Rust
use chrono::Utc;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::Mutex;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use tauri_plugin_shell::ShellExt;
|
|
|
|
use crate::browser::ProxySettings;
|
|
use crate::events;
|
|
use crate::ip_utils;
|
|
|
|
// Export data format for JSON export
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ProxyExportData {
|
|
pub version: String,
|
|
pub proxies: Vec<ExportedProxy>,
|
|
pub exported_at: String,
|
|
pub source: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ExportedProxy {
|
|
pub name: String,
|
|
#[serde(rename = "type")]
|
|
pub proxy_type: String,
|
|
pub host: String,
|
|
pub port: u16,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub username: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub password: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ProxyImportResult {
|
|
pub imported_count: usize,
|
|
pub skipped_count: usize,
|
|
pub errors: Vec<String>,
|
|
pub proxies: Vec<StoredProxy>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ParsedProxyLine {
|
|
pub proxy_type: String,
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub username: Option<String>,
|
|
pub password: Option<String>,
|
|
pub original_line: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "status")]
|
|
pub enum ProxyParseResult {
|
|
#[serde(rename = "parsed")]
|
|
Parsed(ParsedProxyLine),
|
|
#[serde(rename = "ambiguous")]
|
|
Ambiguous {
|
|
line: String,
|
|
possible_formats: Vec<String>,
|
|
},
|
|
#[serde(rename = "invalid")]
|
|
Invalid { line: String, reason: String },
|
|
}
|
|
|
|
// Store active proxy information
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ProxyInfo {
|
|
pub id: String,
|
|
pub local_url: String,
|
|
pub upstream_host: String,
|
|
pub upstream_port: u16,
|
|
pub upstream_type: String,
|
|
pub local_port: u16,
|
|
// Optional profile ID to which this proxy instance is logically tied
|
|
pub profile_id: Option<String>,
|
|
}
|
|
|
|
// Proxy check result cache
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ProxyCheckResult {
|
|
pub ip: String,
|
|
pub city: Option<String>,
|
|
pub country: Option<String>,
|
|
pub country_code: Option<String>,
|
|
pub timestamp: u64,
|
|
pub is_valid: bool,
|
|
}
|
|
|
|
pub const CLOUD_PROXY_ID: &str = "cloud-included-proxy";
|
|
|
|
// Stored proxy configuration with name and ID for reuse
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StoredProxy {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub proxy_settings: ProxySettings,
|
|
#[serde(default)]
|
|
pub sync_enabled: bool,
|
|
#[serde(default)]
|
|
pub last_sync: Option<u64>,
|
|
#[serde(default)]
|
|
pub is_cloud_managed: bool,
|
|
#[serde(default)]
|
|
pub is_cloud_derived: bool,
|
|
#[serde(default)]
|
|
pub geo_country: Option<String>,
|
|
// Legacy field kept for deserialization compat; mapped to geo_region on load
|
|
#[serde(default)]
|
|
pub geo_state: Option<String>,
|
|
#[serde(default)]
|
|
pub geo_region: Option<String>,
|
|
#[serde(default)]
|
|
pub geo_city: Option<String>,
|
|
#[serde(default)]
|
|
pub geo_isp: Option<String>,
|
|
}
|
|
|
|
impl StoredProxy {
|
|
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
|
|
let sync_enabled = crate::sync::is_sync_configured();
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
name,
|
|
proxy_settings,
|
|
sync_enabled,
|
|
last_sync: None,
|
|
is_cloud_managed: false,
|
|
is_cloud_derived: false,
|
|
geo_country: None,
|
|
geo_state: None,
|
|
geo_region: None,
|
|
geo_city: None,
|
|
geo_isp: None,
|
|
}
|
|
}
|
|
|
|
/// Migrate legacy geo_state to geo_region
|
|
pub fn migrate_geo_fields(&mut self) {
|
|
if self.geo_region.is_none() && self.geo_state.is_some() {
|
|
self.geo_region = self.geo_state.take();
|
|
}
|
|
}
|
|
|
|
/// Get the effective region (prefers geo_region, falls back to geo_state for compat)
|
|
pub fn effective_region(&self) -> Option<&String> {
|
|
self.geo_region.as_ref().or(self.geo_state.as_ref())
|
|
}
|
|
|
|
pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
|
|
self.proxy_settings = proxy_settings;
|
|
}
|
|
|
|
pub fn update_name(&mut self, name: String) {
|
|
self.name = name;
|
|
}
|
|
}
|
|
|
|
// Global proxy manager to track active proxies and stored proxy configurations
|
|
pub struct ProxyManager {
|
|
active_proxies: Mutex<HashMap<u32, ProxyInfo>>, // Maps browser process ID to proxy info
|
|
// Store proxy info by profile name for persistence across browser restarts
|
|
profile_proxies: Mutex<HashMap<String, ProxySettings>>, // Maps profile name to proxy settings
|
|
// Track active proxy IDs by profile name for targeted cleanup
|
|
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
|
|
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
|
|
}
|
|
|
|
impl ProxyManager {
|
|
pub fn new() -> Self {
|
|
let manager = Self {
|
|
active_proxies: Mutex::new(HashMap::new()),
|
|
profile_proxies: Mutex::new(HashMap::new()),
|
|
profile_active_proxy_ids: Mutex::new(HashMap::new()),
|
|
stored_proxies: Mutex::new(HashMap::new()),
|
|
};
|
|
|
|
// Load stored proxies on initialization
|
|
if let Err(e) = manager.load_stored_proxies() {
|
|
log::warn!("Failed to load stored proxies: {e}");
|
|
}
|
|
|
|
manager
|
|
}
|
|
|
|
fn get_proxies_dir(&self) -> PathBuf {
|
|
crate::app_dirs::proxies_dir()
|
|
}
|
|
|
|
fn get_proxy_check_cache_dir(&self) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|
let path = crate::app_dirs::cache_dir().join("proxy_checks");
|
|
fs::create_dir_all(&path)?;
|
|
Ok(path)
|
|
}
|
|
|
|
// Get the path to a specific proxy check cache file
|
|
fn get_proxy_check_cache_file(
|
|
&self,
|
|
proxy_id: &str,
|
|
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|
let cache_dir = self.get_proxy_check_cache_dir()?;
|
|
Ok(cache_dir.join(format!("{proxy_id}.json")))
|
|
}
|
|
|
|
// Load cached proxy check result
|
|
fn load_proxy_check_cache(&self, proxy_id: &str) -> Option<ProxyCheckResult> {
|
|
let cache_file = match self.get_proxy_check_cache_file(proxy_id) {
|
|
Ok(file) => file,
|
|
Err(_) => return None,
|
|
};
|
|
|
|
if !cache_file.exists() {
|
|
return None;
|
|
}
|
|
|
|
let content = match fs::read_to_string(&cache_file) {
|
|
Ok(content) => content,
|
|
Err(_) => return None,
|
|
};
|
|
|
|
serde_json::from_str::<ProxyCheckResult>(&content).ok()
|
|
}
|
|
|
|
// Save proxy check result to cache
|
|
fn save_proxy_check_cache(
|
|
&self,
|
|
proxy_id: &str,
|
|
result: &ProxyCheckResult,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let cache_file = self.get_proxy_check_cache_file(proxy_id)?;
|
|
let content = serde_json::to_string_pretty(result)?;
|
|
fs::write(&cache_file, content)?;
|
|
Ok(())
|
|
}
|
|
|
|
// Get current timestamp
|
|
fn get_current_timestamp() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
}
|
|
|
|
pub async fn get_ip_geolocation(
|
|
ip: &str,
|
|
) -> Result<(Option<String>, Option<String>, Option<String>), String> {
|
|
// Use ip-api.com (free, no API key required)
|
|
let url = format!(
|
|
"http://ip-api.com/json/{}?fields=status,message,country,countryCode,city",
|
|
ip
|
|
);
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(5))
|
|
.build()
|
|
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
|
|
|
match client.get(&url).send().await {
|
|
Ok(response) => {
|
|
if response.status().is_success() {
|
|
match response.json::<serde_json::Value>().await {
|
|
Ok(json) => {
|
|
if json.get("status").and_then(|s| s.as_str()) == Some("success") {
|
|
let country = json
|
|
.get("country")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
let country_code = json
|
|
.get("countryCode")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
let city = json
|
|
.get("city")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
Ok((city, country, country_code))
|
|
} else {
|
|
Ok((None, None, None))
|
|
}
|
|
}
|
|
Err(e) => Err(format!("Failed to parse geolocation response: {e}")),
|
|
}
|
|
} else {
|
|
Ok((None, None, None))
|
|
}
|
|
}
|
|
Err(e) => Err(format!("Failed to fetch geolocation: {e}")),
|
|
}
|
|
}
|
|
|
|
pub fn get_proxy_file_path(&self, proxy_id: &str) -> PathBuf {
|
|
self.get_proxies_dir().join(format!("{proxy_id}.json"))
|
|
}
|
|
|
|
// Load stored proxies from disk
|
|
fn load_stored_proxies(&self) -> Result<(), Box<dyn std::error::Error>> {
|
|
let proxies_dir = self.get_proxies_dir();
|
|
|
|
if !proxies_dir.exists() {
|
|
log::debug!("Proxies directory does not exist: {:?}", proxies_dir);
|
|
return Ok(()); // No proxies directory yet
|
|
}
|
|
|
|
log::debug!("Loading stored proxies from: {:?}", proxies_dir);
|
|
|
|
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
|
let mut loaded_count = 0;
|
|
let mut error_count = 0;
|
|
|
|
// Read all JSON files from the proxies directory
|
|
for entry in fs::read_dir(&proxies_dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
|
|
if path.extension().is_some_and(|ext| ext == "json") {
|
|
match fs::read_to_string(&path) {
|
|
Ok(content) => match serde_json::from_str::<StoredProxy>(&content) {
|
|
Ok(proxy) => {
|
|
log::debug!("Loaded stored proxy: {} ({})", proxy.name, proxy.id);
|
|
stored_proxies.insert(proxy.id.clone(), proxy);
|
|
loaded_count += 1;
|
|
}
|
|
Err(e) => {
|
|
log::warn!(
|
|
"Failed to parse proxy file {:?} as StoredProxy: {}",
|
|
path,
|
|
e
|
|
);
|
|
error_count += 1;
|
|
}
|
|
},
|
|
Err(e) => {
|
|
log::warn!("Failed to read proxy file {:?}: {}", path, e);
|
|
error_count += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
log::info!(
|
|
"Loaded {} stored proxies ({} errors)",
|
|
loaded_count,
|
|
error_count
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
// Save a single proxy to disk
|
|
fn save_proxy(&self, proxy: &StoredProxy) -> Result<(), Box<dyn std::error::Error>> {
|
|
let proxies_dir = self.get_proxies_dir();
|
|
|
|
// Ensure directory exists
|
|
fs::create_dir_all(&proxies_dir)?;
|
|
|
|
let proxy_file = self.get_proxy_file_path(&proxy.id);
|
|
let content = serde_json::to_string_pretty(proxy)?;
|
|
fs::write(&proxy_file, content)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Delete a proxy file from disk
|
|
fn delete_proxy_file(&self, proxy_id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
let proxy_file = self.get_proxy_file_path(proxy_id);
|
|
if proxy_file.exists() {
|
|
fs::remove_file(proxy_file)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// Create a new stored proxy
|
|
pub fn create_stored_proxy(
|
|
&self,
|
|
_app_handle: &tauri::AppHandle,
|
|
name: String,
|
|
proxy_settings: ProxySettings,
|
|
) -> Result<StoredProxy, String> {
|
|
// Check if name already exists
|
|
{
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
if stored_proxies.values().any(|p| p.name == name) {
|
|
return Err(format!("Proxy with name '{name}' already exists"));
|
|
}
|
|
}
|
|
|
|
let stored_proxy = StoredProxy::new(name, proxy_settings);
|
|
|
|
{
|
|
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
|
stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone());
|
|
}
|
|
|
|
if let Err(e) = self.save_proxy(&stored_proxy) {
|
|
log::warn!("Failed to save proxy: {e}");
|
|
}
|
|
|
|
// Emit event for reactive UI updates
|
|
if let Err(e) = events::emit_empty("proxies-changed") {
|
|
log::error!("Failed to emit proxies-changed event: {e}");
|
|
}
|
|
|
|
if stored_proxy.sync_enabled {
|
|
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
|
let id = stored_proxy.id.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
scheduler.queue_proxy_sync(id).await;
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(stored_proxy)
|
|
}
|
|
|
|
// Check if a cloud-managed proxy exists
|
|
pub fn has_cloud_proxy(&self) -> bool {
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
stored_proxies.contains_key(CLOUD_PROXY_ID)
|
|
}
|
|
|
|
// Upsert the cloud-managed proxy (create or update)
|
|
pub fn upsert_cloud_proxy(&self, proxy_settings: ProxySettings) -> Result<StoredProxy, String> {
|
|
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
|
|
|
if let Some(existing) = stored_proxies.get_mut(CLOUD_PROXY_ID) {
|
|
existing.proxy_settings = proxy_settings;
|
|
let updated = existing.clone();
|
|
drop(stored_proxies);
|
|
|
|
if let Err(e) = self.save_proxy(&updated) {
|
|
log::warn!("Failed to save cloud proxy: {e}");
|
|
}
|
|
if let Err(e) = events::emit_empty("proxies-changed") {
|
|
log::error!("Failed to emit proxies-changed event: {e}");
|
|
}
|
|
Ok(updated)
|
|
} else {
|
|
let cloud_proxy = StoredProxy {
|
|
id: CLOUD_PROXY_ID.to_string(),
|
|
name: "Included Proxy".to_string(),
|
|
proxy_settings,
|
|
sync_enabled: false,
|
|
last_sync: None,
|
|
is_cloud_managed: true,
|
|
is_cloud_derived: false,
|
|
geo_country: None,
|
|
geo_state: None,
|
|
geo_region: None,
|
|
geo_city: None,
|
|
geo_isp: None,
|
|
};
|
|
stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone());
|
|
drop(stored_proxies);
|
|
|
|
if let Err(e) = self.save_proxy(&cloud_proxy) {
|
|
log::warn!("Failed to save cloud proxy: {e}");
|
|
}
|
|
if let Err(e) = events::emit_empty("proxies-changed") {
|
|
log::error!("Failed to emit proxies-changed event: {e}");
|
|
}
|
|
Ok(cloud_proxy)
|
|
}
|
|
}
|
|
|
|
// Remove the cloud-managed proxy
|
|
pub fn remove_cloud_proxy(&self) {
|
|
let removed = {
|
|
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
|
stored_proxies.remove(CLOUD_PROXY_ID).is_some()
|
|
};
|
|
|
|
if removed {
|
|
if let Err(e) = self.delete_proxy_file(CLOUD_PROXY_ID) {
|
|
log::warn!("Failed to delete cloud proxy file: {e}");
|
|
}
|
|
if let Err(e) = events::emit_empty("proxies-changed") {
|
|
log::error!("Failed to emit proxies-changed event: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn remove_cloud_proxies(&self) {
|
|
let removed_ids: Vec<String> = {
|
|
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
|
let ids_to_remove: Vec<String> = stored_proxies
|
|
.values()
|
|
.filter(|p| p.is_cloud_managed || p.is_cloud_derived)
|
|
.map(|p| p.id.clone())
|
|
.collect();
|
|
for id in &ids_to_remove {
|
|
stored_proxies.remove(id);
|
|
}
|
|
ids_to_remove
|
|
};
|
|
|
|
if !removed_ids.is_empty() {
|
|
for id in &removed_ids {
|
|
if let Err(e) = self.delete_proxy_file(id) {
|
|
log::warn!("Failed to delete cloud proxy file {id}: {e}");
|
|
}
|
|
}
|
|
if let Err(e) = events::emit_empty("proxies-changed") {
|
|
log::error!("Failed to emit proxies-changed event: {e}");
|
|
}
|
|
if let Err(e) = events::emit_empty("stored-proxies-changed") {
|
|
log::error!("Failed to emit stored-proxies-changed event: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build a geo-targeted username from base username and location parts
|
|
// LP v2 format: username-country-{cc}[-region-{region}][-city-{city}][-isp-{isp}]
|
|
// Note: sid and ttl are NOT included here — they are injected at browser launch time
|
|
// per-profile via resolve_proxy_for_profile()
|
|
fn build_geo_username(
|
|
base_username: &str,
|
|
country: &str,
|
|
region: &Option<String>,
|
|
city: &Option<String>,
|
|
isp: &Option<String>,
|
|
) -> String {
|
|
let mut username = format!("{}-country-{}", base_username, country);
|
|
if let Some(region) = region {
|
|
username = format!("{}-region-{}", username, region);
|
|
}
|
|
if let Some(city) = city {
|
|
username = format!("{}-city-{}", username, city);
|
|
}
|
|
if let Some(isp) = isp {
|
|
username = format!("{}-isp-{}", username, isp);
|
|
}
|
|
username
|
|
}
|
|
|
|
/// Generate a deterministic 11-char alphanumeric session ID from a profile UUID.
|
|
/// This ensures the same profile always gets the same sticky IP session,
|
|
/// even across credential refreshes.
|
|
pub fn generate_sid_for_profile(profile_id: &str) -> String {
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
let mut hasher = DefaultHasher::new();
|
|
profile_id.hash(&mut hasher);
|
|
let hash = hasher.finish();
|
|
|
|
// Convert to base36 (a-z0-9) and take 11 chars
|
|
let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz0123456789".chars().collect();
|
|
let mut sid = String::with_capacity(11);
|
|
let mut val = hash;
|
|
for _ in 0..11 {
|
|
sid.push(chars[(val % 36) as usize]);
|
|
val /= 36;
|
|
}
|
|
sid
|
|
}
|
|
|
|
/// Build the full proxy username with sid and ttl for a specific profile launch.
|
|
/// This is called at browser launch time, not at proxy creation time.
|
|
pub fn build_username_with_sid(base_geo_username: &str, profile_id: &str) -> String {
|
|
let sid = Self::generate_sid_for_profile(profile_id);
|
|
format!("{}-sid-{}-ttl-1440m", base_geo_username, sid)
|
|
}
|
|
|
|
/// Resolve proxy settings for a specific profile, injecting profile-specific sid
|
|
/// for cloud-derived proxies with geo targeting.
|
|
pub fn resolve_proxy_for_profile(
|
|
&self,
|
|
proxy_id: &str,
|
|
profile_id: &str,
|
|
) -> Option<ProxySettings> {
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
let proxy = stored_proxies.get(proxy_id)?;
|
|
let mut settings = proxy.proxy_settings.clone();
|
|
|
|
// For cloud-derived proxies with geo targeting, inject profile-specific sid
|
|
if proxy.is_cloud_derived && proxy.geo_country.is_some() {
|
|
if let Some(ref username) = settings.username {
|
|
settings.username = Some(Self::build_username_with_sid(username, profile_id));
|
|
}
|
|
}
|
|
|
|
Some(settings)
|
|
}
|
|
|
|
// Create a cloud-derived location proxy from the base cloud proxy credentials
|
|
pub fn create_cloud_location_proxy(
|
|
&self,
|
|
name: String,
|
|
country: String,
|
|
region: Option<String>,
|
|
city: Option<String>,
|
|
isp: Option<String>,
|
|
) -> Result<StoredProxy, String> {
|
|
// Get base cloud proxy credentials
|
|
let base_proxy = {
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
stored_proxies
|
|
.get(CLOUD_PROXY_ID)
|
|
.cloned()
|
|
.ok_or_else(|| "No cloud proxy available. Please log in first.".to_string())?
|
|
};
|
|
|
|
let base_username = base_proxy
|
|
.proxy_settings
|
|
.username
|
|
.as_ref()
|
|
.ok_or_else(|| "Cloud proxy has no username".to_string())?;
|
|
|
|
let geo_username = Self::build_geo_username(base_username, &country, ®ion, &city, &isp);
|
|
|
|
let proxy_settings = ProxySettings {
|
|
proxy_type: base_proxy.proxy_settings.proxy_type.clone(),
|
|
host: base_proxy.proxy_settings.host.clone(),
|
|
port: base_proxy.proxy_settings.port,
|
|
username: Some(geo_username),
|
|
password: base_proxy.proxy_settings.password.clone(),
|
|
};
|
|
|
|
// Check if name already exists
|
|
{
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
if stored_proxies.values().any(|p| p.name == name) {
|
|
return Err(format!("Proxy with name '{}' already exists", name));
|
|
}
|
|
}
|
|
|
|
let stored_proxy = StoredProxy {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
name,
|
|
proxy_settings,
|
|
sync_enabled: false,
|
|
last_sync: None,
|
|
is_cloud_managed: false,
|
|
is_cloud_derived: true,
|
|
geo_country: Some(country),
|
|
geo_state: None,
|
|
geo_region: region,
|
|
geo_city: city,
|
|
geo_isp: isp,
|
|
};
|
|
|
|
{
|
|
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
|
stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone());
|
|
}
|
|
|
|
if let Err(e) = self.save_proxy(&stored_proxy) {
|
|
log::warn!("Failed to save location proxy: {e}");
|
|
}
|
|
|
|
if let Err(e) = events::emit_empty("proxies-changed") {
|
|
log::error!("Failed to emit proxies-changed event: {e}");
|
|
}
|
|
|
|
Ok(stored_proxy)
|
|
}
|
|
|
|
// Update all cloud-derived proxies when base cloud proxy credentials change
|
|
pub fn update_cloud_derived_proxies(&self) {
|
|
let base_proxy = {
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
match stored_proxies.get(CLOUD_PROXY_ID) {
|
|
Some(p) => p.clone(),
|
|
None => return, // No cloud proxy, nothing to update
|
|
}
|
|
};
|
|
|
|
let base_username = match &base_proxy.proxy_settings.username {
|
|
Some(u) => u.clone(),
|
|
None => return,
|
|
};
|
|
|
|
let mut updated = false;
|
|
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
|
|
|
for proxy in stored_proxies.values_mut() {
|
|
if !proxy.is_cloud_derived {
|
|
continue;
|
|
}
|
|
|
|
let country = match &proxy.geo_country {
|
|
Some(c) => c.clone(),
|
|
None => continue,
|
|
};
|
|
|
|
let region = proxy.effective_region().cloned();
|
|
let geo_username = Self::build_geo_username(
|
|
&base_username,
|
|
&country,
|
|
®ion,
|
|
&proxy.geo_city,
|
|
&proxy.geo_isp,
|
|
);
|
|
|
|
proxy.proxy_settings.username = Some(geo_username);
|
|
proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone();
|
|
proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone();
|
|
proxy.proxy_settings.port = base_proxy.proxy_settings.port;
|
|
|
|
updated = true;
|
|
}
|
|
|
|
if updated {
|
|
// Save all updated proxies
|
|
let proxies_to_save: Vec<StoredProxy> = stored_proxies
|
|
.values()
|
|
.filter(|p| p.is_cloud_derived)
|
|
.cloned()
|
|
.collect();
|
|
drop(stored_proxies);
|
|
|
|
for proxy in &proxies_to_save {
|
|
if let Err(e) = self.save_proxy(proxy) {
|
|
log::warn!("Failed to save updated derived proxy {}: {e}", proxy.id);
|
|
}
|
|
}
|
|
|
|
if let Err(e) = events::emit_empty("proxies-changed") {
|
|
log::error!("Failed to emit proxies-changed event: {e}");
|
|
}
|
|
|
|
log::debug!("Updated {} cloud-derived proxies", proxies_to_save.len());
|
|
}
|
|
}
|
|
|
|
pub fn remove_from_memory(&self, proxy_id: &str) {
|
|
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
|
stored_proxies.remove(proxy_id);
|
|
}
|
|
|
|
// Get all stored proxies
|
|
pub fn get_stored_proxies(&self) -> Vec<StoredProxy> {
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
let mut list: Vec<StoredProxy> = stored_proxies.values().cloned().collect();
|
|
// Sort case-insensitively by name for consistent ordering across UI/API consumers
|
|
list.sort_by_key(|p| p.name.to_lowercase());
|
|
list
|
|
}
|
|
|
|
// Get a stored proxy by ID
|
|
|
|
// Update a stored proxy
|
|
pub fn update_stored_proxy(
|
|
&self,
|
|
_app_handle: &tauri::AppHandle,
|
|
proxy_id: &str,
|
|
name: Option<String>,
|
|
proxy_settings: Option<ProxySettings>,
|
|
) -> Result<StoredProxy, String> {
|
|
// First, check for conflicts without holding a mutable reference
|
|
{
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
|
|
// Check if proxy exists
|
|
if !stored_proxies.contains_key(proxy_id) {
|
|
return Err(format!("Proxy with ID '{proxy_id}' not found"));
|
|
}
|
|
|
|
// Block editing cloud-managed proxies
|
|
if stored_proxies
|
|
.get(proxy_id)
|
|
.is_some_and(|p| p.is_cloud_managed)
|
|
{
|
|
return Err("Cannot edit a cloud-managed proxy".to_string());
|
|
}
|
|
|
|
// Check if new name conflicts with existing proxies
|
|
if let Some(ref new_name) = name {
|
|
if stored_proxies
|
|
.values()
|
|
.any(|p| p.id != proxy_id && p.name == *new_name)
|
|
{
|
|
return Err(format!("Proxy with name '{new_name}' already exists"));
|
|
}
|
|
}
|
|
} // Release the lock here
|
|
|
|
// Now get mutable access for updates
|
|
let updated_proxy = {
|
|
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
|
let stored_proxy = stored_proxies.get_mut(proxy_id).unwrap(); // Safe because we checked above
|
|
|
|
if let Some(new_name) = name {
|
|
stored_proxy.update_name(new_name);
|
|
}
|
|
|
|
if let Some(new_settings) = proxy_settings {
|
|
stored_proxy.update_settings(new_settings);
|
|
}
|
|
|
|
stored_proxy.clone()
|
|
};
|
|
|
|
if let Err(e) = self.save_proxy(&updated_proxy) {
|
|
log::warn!("Failed to save proxy: {e}");
|
|
}
|
|
|
|
// Emit event for reactive UI updates
|
|
if let Err(e) = events::emit_empty("proxies-changed") {
|
|
log::error!("Failed to emit proxies-changed event: {e}");
|
|
}
|
|
|
|
if updated_proxy.sync_enabled {
|
|
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
|
let id = updated_proxy.id.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
scheduler.queue_proxy_sync(id).await;
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(updated_proxy)
|
|
}
|
|
|
|
// Delete a stored proxy
|
|
pub fn delete_stored_proxy(
|
|
&self,
|
|
app_handle: &tauri::AppHandle,
|
|
proxy_id: &str,
|
|
) -> Result<(), String> {
|
|
// Remember if sync was enabled before deleting
|
|
let was_sync_enabled = {
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
|
|
// Block deleting cloud-managed proxies
|
|
if stored_proxies
|
|
.get(proxy_id)
|
|
.is_some_and(|p| p.is_cloud_managed)
|
|
{
|
|
return Err("Cannot delete a cloud-managed proxy".to_string());
|
|
}
|
|
|
|
stored_proxies
|
|
.get(proxy_id)
|
|
.map(|p| p.sync_enabled)
|
|
.unwrap_or(false)
|
|
};
|
|
|
|
{
|
|
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
|
if stored_proxies.remove(proxy_id).is_none() {
|
|
return Err(format!("Proxy with ID '{proxy_id}' not found"));
|
|
}
|
|
}
|
|
|
|
if let Err(e) = self.delete_proxy_file(proxy_id) {
|
|
log::warn!("Failed to delete proxy file: {e}");
|
|
}
|
|
|
|
// If sync was enabled, also delete from S3
|
|
if was_sync_enabled {
|
|
let proxy_id_owned = proxy_id.to_string();
|
|
let app_handle_clone = app_handle.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
match crate::sync::SyncEngine::create_from_settings(&app_handle_clone).await {
|
|
Ok(engine) => {
|
|
if let Err(e) = engine.delete_proxy(&proxy_id_owned).await {
|
|
log::warn!("Failed to delete proxy {} from sync: {}", proxy_id_owned, e);
|
|
} else {
|
|
log::info!("Proxy {} deleted from S3 sync storage", proxy_id_owned);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::debug!("Sync not configured, skipping remote deletion: {}", e);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Emit event for reactive UI updates
|
|
if let Err(e) = events::emit_empty("proxies-changed") {
|
|
log::error!("Failed to emit proxies-changed event: {e}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Check if a proxy is cloud-managed or cloud-derived (needs fresh credentials)
|
|
pub fn is_cloud_or_derived(&self, proxy_id: &str) -> bool {
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
stored_proxies
|
|
.get(proxy_id)
|
|
.is_some_and(|p| p.is_cloud_managed || p.is_cloud_derived)
|
|
}
|
|
|
|
// Get proxy settings for a stored proxy ID
|
|
pub fn get_proxy_settings_by_id(&self, proxy_id: &str) -> Option<ProxySettings> {
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
stored_proxies
|
|
.get(proxy_id)
|
|
.map(|p| p.proxy_settings.clone())
|
|
}
|
|
|
|
// Build proxy URL string from ProxySettings
|
|
fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
|
|
let mut url = format!("{}://", proxy_settings.proxy_type);
|
|
|
|
if let (Some(username), Some(password)) = (&proxy_settings.username, &proxy_settings.password) {
|
|
url.push_str(&urlencoding::encode(username));
|
|
url.push(':');
|
|
url.push_str(&urlencoding::encode(password));
|
|
url.push('@');
|
|
} else if let Some(username) = &proxy_settings.username {
|
|
url.push_str(&urlencoding::encode(username));
|
|
url.push('@');
|
|
}
|
|
|
|
url.push_str(&proxy_settings.host);
|
|
url.push(':');
|
|
url.push_str(&proxy_settings.port.to_string());
|
|
|
|
url
|
|
}
|
|
|
|
// Check if a proxy is valid by making HTTP requests through it
|
|
pub async fn check_proxy_validity(
|
|
&self,
|
|
proxy_id: &str,
|
|
proxy_settings: &ProxySettings,
|
|
) -> Result<ProxyCheckResult, String> {
|
|
let proxy_url = Self::build_proxy_url(proxy_settings);
|
|
|
|
// Fetch public IP through the proxy using shared IP utilities
|
|
let ip = match ip_utils::fetch_public_ip(Some(&proxy_url)).await {
|
|
Ok(ip) => ip,
|
|
Err(e) => {
|
|
// Save failed check result
|
|
let failed_result = ProxyCheckResult {
|
|
ip: String::new(),
|
|
city: None,
|
|
country: None,
|
|
country_code: None,
|
|
timestamp: Self::get_current_timestamp(),
|
|
is_valid: false,
|
|
};
|
|
let _ = self.save_proxy_check_cache(proxy_id, &failed_result);
|
|
return Err(format!("Failed to fetch public IP: {e}"));
|
|
}
|
|
};
|
|
|
|
// Get geolocation
|
|
let (city, country, country_code): (Option<String>, Option<String>, Option<String>) =
|
|
Self::get_ip_geolocation(&ip).await.unwrap_or_default();
|
|
|
|
// Create successful result
|
|
let result = ProxyCheckResult {
|
|
ip: ip.clone(),
|
|
city,
|
|
country,
|
|
country_code,
|
|
timestamp: Self::get_current_timestamp(),
|
|
is_valid: true,
|
|
};
|
|
|
|
// Save to cache
|
|
let _ = self.save_proxy_check_cache(proxy_id, &result);
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
// Get cached proxy check result
|
|
pub fn get_cached_proxy_check(&self, proxy_id: &str) -> Option<ProxyCheckResult> {
|
|
self.load_proxy_check_cache(proxy_id)
|
|
}
|
|
|
|
// Export all proxies as JSON
|
|
pub fn export_proxies_json(&self) -> Result<String, String> {
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
let proxies: Vec<ExportedProxy> = stored_proxies
|
|
.values()
|
|
.filter(|p| !p.is_cloud_managed && !p.is_cloud_derived)
|
|
.map(|p| ExportedProxy {
|
|
name: p.name.clone(),
|
|
proxy_type: p.proxy_settings.proxy_type.clone(),
|
|
host: p.proxy_settings.host.clone(),
|
|
port: p.proxy_settings.port,
|
|
username: p.proxy_settings.username.clone(),
|
|
password: p.proxy_settings.password.clone(),
|
|
})
|
|
.collect();
|
|
|
|
let export_data = ProxyExportData {
|
|
version: "1.0".to_string(),
|
|
proxies,
|
|
exported_at: Utc::now().to_rfc3339(),
|
|
source: "DonutBrowser".to_string(),
|
|
};
|
|
|
|
serde_json::to_string_pretty(&export_data).map_err(|e| format!("Failed to serialize: {e}"))
|
|
}
|
|
|
|
// Export all proxies as TXT (one per line: protocol://user:pass@host:port)
|
|
pub fn export_proxies_txt(&self) -> String {
|
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
|
stored_proxies
|
|
.values()
|
|
.filter(|p| !p.is_cloud_managed && !p.is_cloud_derived)
|
|
.map(|p| Self::build_proxy_url(&p.proxy_settings))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
// Parse TXT content with auto-detection of formats
|
|
pub fn parse_txt_proxies(content: &str) -> Vec<ProxyParseResult> {
|
|
content
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#'))
|
|
.map(|line| Self::parse_single_proxy_line(line.trim()))
|
|
.collect()
|
|
}
|
|
|
|
// Parse a single proxy line with format auto-detection
|
|
fn parse_single_proxy_line(line: &str) -> ProxyParseResult {
|
|
// Format 1: protocol://username:password@host:port (full URL)
|
|
if let Some(result) = Self::try_parse_url_format(line) {
|
|
return result;
|
|
}
|
|
|
|
// Try colon-separated formats
|
|
let parts: Vec<&str> = line.split(':').collect();
|
|
|
|
match parts.len() {
|
|
// host:port (no auth)
|
|
2 => {
|
|
if let Ok(port) = parts[1].parse::<u16>() {
|
|
return ProxyParseResult::Parsed(ParsedProxyLine {
|
|
proxy_type: "http".to_string(),
|
|
host: parts[0].to_string(),
|
|
port,
|
|
username: None,
|
|
password: None,
|
|
original_line: line.to_string(),
|
|
});
|
|
}
|
|
ProxyParseResult::Invalid {
|
|
line: line.to_string(),
|
|
reason: "Invalid port number".to_string(),
|
|
}
|
|
}
|
|
// Could be: host:port:user or user:pass@host (with @ in the middle)
|
|
3 => {
|
|
// Try username:password@host:port first
|
|
if let Some(result) = Self::try_parse_user_pass_at_host_port(line) {
|
|
return result;
|
|
}
|
|
ProxyParseResult::Invalid {
|
|
line: line.to_string(),
|
|
reason: "Could not determine format with 3 parts".to_string(),
|
|
}
|
|
}
|
|
// 4 parts: could be host:port:user:pass OR user:pass:host:port
|
|
4 => {
|
|
// Try to detect which format
|
|
let port_at_1 = parts[1].parse::<u16>().is_ok();
|
|
let port_at_3 = parts[3].parse::<u16>().is_ok();
|
|
|
|
match (port_at_1, port_at_3) {
|
|
// host:port:user:pass
|
|
(true, false) => {
|
|
let port = parts[1].parse::<u16>().unwrap();
|
|
ProxyParseResult::Parsed(ParsedProxyLine {
|
|
proxy_type: "http".to_string(),
|
|
host: parts[0].to_string(),
|
|
port,
|
|
username: Some(parts[2].to_string()),
|
|
password: Some(parts[3].to_string()),
|
|
original_line: line.to_string(),
|
|
})
|
|
}
|
|
// user:pass:host:port
|
|
(false, true) => {
|
|
let port = parts[3].parse::<u16>().unwrap();
|
|
ProxyParseResult::Parsed(ParsedProxyLine {
|
|
proxy_type: "http".to_string(),
|
|
host: parts[2].to_string(),
|
|
port,
|
|
username: Some(parts[0].to_string()),
|
|
password: Some(parts[1].to_string()),
|
|
original_line: line.to_string(),
|
|
})
|
|
}
|
|
// Both could be ports - ambiguous
|
|
(true, true) => ProxyParseResult::Ambiguous {
|
|
line: line.to_string(),
|
|
possible_formats: vec![
|
|
"host:port:username:password".to_string(),
|
|
"username:password:host:port".to_string(),
|
|
],
|
|
},
|
|
// Neither is a valid port
|
|
(false, false) => ProxyParseResult::Invalid {
|
|
line: line.to_string(),
|
|
reason: "No valid port number found".to_string(),
|
|
},
|
|
}
|
|
}
|
|
_ => ProxyParseResult::Invalid {
|
|
line: line.to_string(),
|
|
reason: format!("Unexpected format with {} parts", parts.len()),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Try to parse URL format: protocol://username:password@host:port
|
|
fn try_parse_url_format(line: &str) -> Option<ProxyParseResult> {
|
|
// Check for protocol prefix using strip_prefix
|
|
let (protocol, rest) = if let Some(rest) = line.strip_prefix("http://") {
|
|
("http", rest)
|
|
} else if let Some(rest) = line.strip_prefix("https://") {
|
|
("https", rest)
|
|
} else if let Some(rest) = line.strip_prefix("socks4://") {
|
|
("socks4", rest)
|
|
} else if let Some(rest) = line.strip_prefix("socks5://") {
|
|
("socks5", rest)
|
|
} else if let Some(rest) = line.strip_prefix("socks://") {
|
|
("socks5", rest) // Default socks to socks5
|
|
} else {
|
|
return None;
|
|
};
|
|
|
|
// Check if there's auth (contains @)
|
|
if let Some(at_pos) = rest.rfind('@') {
|
|
let auth = &rest[..at_pos];
|
|
let host_port = &rest[at_pos + 1..];
|
|
|
|
// Parse auth (user:pass)
|
|
let (username, password) = if let Some(colon_pos) = auth.find(':') {
|
|
let user = urlencoding::decode(&auth[..colon_pos]).unwrap_or_default();
|
|
let pass = urlencoding::decode(&auth[colon_pos + 1..]).unwrap_or_default();
|
|
(Some(user.to_string()), Some(pass.to_string()))
|
|
} else {
|
|
(
|
|
Some(urlencoding::decode(auth).unwrap_or_default().to_string()),
|
|
None,
|
|
)
|
|
};
|
|
|
|
// Parse host:port
|
|
if let Some(colon_pos) = host_port.rfind(':') {
|
|
let host = &host_port[..colon_pos];
|
|
if let Ok(port) = host_port[colon_pos + 1..].parse::<u16>() {
|
|
return Some(ProxyParseResult::Parsed(ParsedProxyLine {
|
|
proxy_type: protocol.to_string(),
|
|
host: host.to_string(),
|
|
port,
|
|
username,
|
|
password,
|
|
original_line: line.to_string(),
|
|
}));
|
|
}
|
|
}
|
|
} else {
|
|
// No auth, just host:port
|
|
if let Some(colon_pos) = rest.rfind(':') {
|
|
let host = &rest[..colon_pos];
|
|
if let Ok(port) = rest[colon_pos + 1..].parse::<u16>() {
|
|
return Some(ProxyParseResult::Parsed(ParsedProxyLine {
|
|
proxy_type: protocol.to_string(),
|
|
host: host.to_string(),
|
|
port,
|
|
username: None,
|
|
password: None,
|
|
original_line: line.to_string(),
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
Some(ProxyParseResult::Invalid {
|
|
line: line.to_string(),
|
|
reason: "Invalid URL format".to_string(),
|
|
})
|
|
}
|
|
|
|
// Try to parse: username:password@host:port format (no protocol)
|
|
fn try_parse_user_pass_at_host_port(line: &str) -> Option<ProxyParseResult> {
|
|
if let Some(at_pos) = line.rfind('@') {
|
|
let auth = &line[..at_pos];
|
|
let host_port = &line[at_pos + 1..];
|
|
|
|
// Parse auth
|
|
let (username, password) = if let Some(colon_pos) = auth.find(':') {
|
|
(
|
|
Some(auth[..colon_pos].to_string()),
|
|
Some(auth[colon_pos + 1..].to_string()),
|
|
)
|
|
} else {
|
|
return None;
|
|
};
|
|
|
|
// Parse host:port
|
|
if let Some(colon_pos) = host_port.rfind(':') {
|
|
let host = &host_port[..colon_pos];
|
|
if let Ok(port) = host_port[colon_pos + 1..].parse::<u16>() {
|
|
return Some(ProxyParseResult::Parsed(ParsedProxyLine {
|
|
proxy_type: "http".to_string(),
|
|
host: host.to_string(),
|
|
port,
|
|
username,
|
|
password,
|
|
original_line: line.to_string(),
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// Import proxies from JSON content
|
|
pub fn import_proxies_json(
|
|
&self,
|
|
app_handle: &tauri::AppHandle,
|
|
content: &str,
|
|
) -> Result<ProxyImportResult, String> {
|
|
let export_data: ProxyExportData =
|
|
serde_json::from_str(content).map_err(|e| format!("Invalid JSON format: {e}"))?;
|
|
|
|
let mut imported = Vec::new();
|
|
let mut skipped = 0;
|
|
let mut errors = Vec::new();
|
|
|
|
for exported in export_data.proxies {
|
|
let proxy_settings = ProxySettings {
|
|
proxy_type: exported.proxy_type,
|
|
host: exported.host,
|
|
port: exported.port,
|
|
username: exported.username,
|
|
password: exported.password,
|
|
};
|
|
|
|
match self.create_stored_proxy(app_handle, exported.name.clone(), proxy_settings) {
|
|
Ok(proxy) => imported.push(proxy),
|
|
Err(e) => {
|
|
if e.contains("already exists") {
|
|
skipped += 1;
|
|
} else {
|
|
errors.push(format!("Failed to import '{}': {}", exported.name, e));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(ProxyImportResult {
|
|
imported_count: imported.len(),
|
|
skipped_count: skipped,
|
|
errors,
|
|
proxies: imported,
|
|
})
|
|
}
|
|
|
|
// Import proxies from already parsed proxy lines
|
|
pub fn import_proxies_from_parsed(
|
|
&self,
|
|
app_handle: &tauri::AppHandle,
|
|
parsed_proxies: Vec<ParsedProxyLine>,
|
|
name_prefix: Option<String>,
|
|
) -> Result<ProxyImportResult, String> {
|
|
let mut imported = Vec::new();
|
|
let mut skipped = 0;
|
|
let mut errors = Vec::new();
|
|
let prefix = name_prefix.unwrap_or_else(|| "Imported".to_string());
|
|
|
|
for (i, parsed) in parsed_proxies.into_iter().enumerate() {
|
|
let proxy_name = format!("{} Proxy {}", prefix, i + 1);
|
|
let proxy_settings = ProxySettings {
|
|
proxy_type: parsed.proxy_type,
|
|
host: parsed.host,
|
|
port: parsed.port,
|
|
username: parsed.username,
|
|
password: parsed.password,
|
|
};
|
|
|
|
match self.create_stored_proxy(app_handle, proxy_name.clone(), proxy_settings) {
|
|
Ok(proxy) => imported.push(proxy),
|
|
Err(e) => {
|
|
if e.contains("already exists") {
|
|
skipped += 1;
|
|
} else {
|
|
errors.push(format!("Failed to import '{}': {}", proxy_name, e));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(ProxyImportResult {
|
|
imported_count: imported.len(),
|
|
skipped_count: skipped,
|
|
errors,
|
|
proxies: imported,
|
|
})
|
|
}
|
|
|
|
// Start a proxy for given proxy settings and associate it with a browser process ID
|
|
// If proxy_settings is None, starts a direct proxy for traffic monitoring
|
|
pub async fn start_proxy(
|
|
&self,
|
|
app_handle: tauri::AppHandle,
|
|
proxy_settings: Option<&ProxySettings>,
|
|
browser_pid: u32,
|
|
profile_id: Option<&str>,
|
|
bypass_rules: Vec<String>,
|
|
) -> Result<ProxySettings, String> {
|
|
if let Some(name) = profile_id {
|
|
// Check if we have an active proxy recorded for this profile
|
|
let maybe_existing_id = {
|
|
let map = self.profile_active_proxy_ids.lock().unwrap();
|
|
map.get(name).cloned()
|
|
};
|
|
|
|
if let Some(existing_id) = maybe_existing_id {
|
|
// Find the existing proxy info
|
|
let existing_info = {
|
|
let proxies = self.active_proxies.lock().unwrap();
|
|
proxies.values().find(|p| p.id == existing_id).cloned()
|
|
};
|
|
|
|
if let Some(existing) = existing_info {
|
|
let desired_type = proxy_settings
|
|
.map(|p| p.proxy_type.as_str())
|
|
.unwrap_or("DIRECT");
|
|
let desired_host = proxy_settings.map(|p| p.host.as_str()).unwrap_or("DIRECT");
|
|
let desired_port = proxy_settings.map(|p| p.port).unwrap_or(0);
|
|
|
|
let is_same_upstream = existing.upstream_type == desired_type
|
|
&& existing.upstream_host == desired_host
|
|
&& existing.upstream_port == desired_port;
|
|
|
|
if is_same_upstream {
|
|
// Settings match - can reuse existing proxy
|
|
// Just update the PID mapping if needed
|
|
let proxies = self.active_proxies.lock().unwrap();
|
|
if proxies.contains_key(&browser_pid) {
|
|
// Already mapped, reuse it
|
|
return Ok(ProxySettings {
|
|
proxy_type: "http".to_string(),
|
|
host: "127.0.0.1".to_string(),
|
|
port: existing.local_port,
|
|
username: None,
|
|
password: None,
|
|
});
|
|
}
|
|
// Need to add this PID to the mapping - we'll do that after starting
|
|
}
|
|
// Settings differ - we'll create a new proxy, but don't stop the old one
|
|
// It will be cleaned up by periodic cleanup if it becomes dead
|
|
}
|
|
}
|
|
}
|
|
// Check if we already have a proxy for this browser PID
|
|
// If settings match, reuse it; otherwise create a new one (don't stop the old one)
|
|
{
|
|
let proxies = self.active_proxies.lock().unwrap();
|
|
if let Some(existing) = proxies.get(&browser_pid) {
|
|
let desired_type = proxy_settings
|
|
.map(|p| p.proxy_type.as_str())
|
|
.unwrap_or("DIRECT");
|
|
let desired_host = proxy_settings.map(|p| p.host.as_str()).unwrap_or("DIRECT");
|
|
let desired_port = proxy_settings.map(|p| p.port).unwrap_or(0);
|
|
|
|
let is_same_upstream = existing.upstream_type == desired_type
|
|
&& existing.upstream_host == desired_host
|
|
&& existing.upstream_port == desired_port;
|
|
|
|
if is_same_upstream {
|
|
// Check if profile_id matches
|
|
let profile_id_matches = match (profile_id, &existing.profile_id) {
|
|
(Some(ref new_id), Some(ref old_id)) => new_id == old_id,
|
|
(None, None) => true,
|
|
_ => false,
|
|
};
|
|
|
|
if profile_id_matches {
|
|
// Reuse existing local proxy (settings and profile_id match)
|
|
return Ok(ProxySettings {
|
|
proxy_type: "http".to_string(),
|
|
host: "127.0.0.1".to_string(),
|
|
port: existing.local_port,
|
|
username: None,
|
|
password: None,
|
|
});
|
|
}
|
|
// Profile ID changed - we'll create a new proxy but don't stop the old one
|
|
// It will be cleaned up by periodic cleanup if it becomes dead
|
|
}
|
|
// Upstream changed - we'll create a new proxy but don't stop the old one
|
|
// It will be cleaned up by periodic cleanup if it becomes dead
|
|
}
|
|
}
|
|
|
|
// Start a new proxy using the donut-proxy binary with the correct CLI interface
|
|
let mut proxy_cmd = app_handle
|
|
.shell()
|
|
.sidecar("donut-proxy")
|
|
.map_err(|e| format!("Failed to create sidecar: {e}"))?
|
|
.arg("proxy")
|
|
.arg("start");
|
|
|
|
// Add upstream proxy settings if provided, otherwise create direct proxy
|
|
if let Some(proxy_settings) = proxy_settings {
|
|
proxy_cmd = proxy_cmd
|
|
.arg("--host")
|
|
.arg(&proxy_settings.host)
|
|
.arg("--proxy-port")
|
|
.arg(proxy_settings.port.to_string())
|
|
.arg("--type")
|
|
.arg(&proxy_settings.proxy_type);
|
|
|
|
// Add credentials if provided
|
|
if let Some(username) = &proxy_settings.username {
|
|
proxy_cmd = proxy_cmd.arg("--username").arg(username);
|
|
}
|
|
if let Some(password) = &proxy_settings.password {
|
|
proxy_cmd = proxy_cmd.arg("--password").arg(password);
|
|
}
|
|
}
|
|
|
|
// Add profile ID if provided for traffic tracking
|
|
if let Some(id) = profile_id {
|
|
proxy_cmd = proxy_cmd.arg("--profile-id").arg(id);
|
|
}
|
|
|
|
// Add bypass rules if any
|
|
if !bypass_rules.is_empty() {
|
|
let rules_json = serde_json::to_string(&bypass_rules)
|
|
.map_err(|e| format!("Failed to serialize bypass rules: {e}"))?;
|
|
proxy_cmd = proxy_cmd.arg("--bypass-rules").arg(rules_json);
|
|
}
|
|
|
|
// Execute the command and wait for it to complete
|
|
// The donut-proxy binary should start the worker and then exit
|
|
let output = proxy_cmd
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Failed to execute donut-proxy: {e}"))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
return Err(format!(
|
|
"Proxy start failed - stdout: {stdout}, stderr: {stderr}"
|
|
));
|
|
}
|
|
|
|
let json_string =
|
|
String::from_utf8(output.stdout).map_err(|e| format!("Failed to parse proxy output: {e}"))?;
|
|
|
|
// Parse the JSON output
|
|
let json: Value = serde_json::from_str(json_string.trim())
|
|
.map_err(|e| format!("Failed to parse JSON: {e}. Output was: {}", json_string))?;
|
|
|
|
// Extract proxy information
|
|
let id = json["id"].as_str().ok_or("Missing proxy ID")?;
|
|
let local_port = json["localPort"]
|
|
.as_u64()
|
|
.ok_or_else(|| format!("Missing local port in JSON: {}", json_string))?
|
|
as u16;
|
|
let local_url = json["localUrl"]
|
|
.as_str()
|
|
.ok_or_else(|| format!("Missing local URL in JSON: {}", json_string))?
|
|
.to_string();
|
|
|
|
let proxy_info = ProxyInfo {
|
|
id: id.to_string(),
|
|
local_url,
|
|
upstream_host: proxy_settings
|
|
.map(|p| p.host.clone())
|
|
.unwrap_or_else(|| "DIRECT".to_string()),
|
|
upstream_port: proxy_settings.map(|p| p.port).unwrap_or(0),
|
|
upstream_type: proxy_settings
|
|
.map(|p| p.proxy_type.clone())
|
|
.unwrap_or_else(|| "DIRECT".to_string()),
|
|
local_port,
|
|
profile_id: profile_id.map(|s| s.to_string()),
|
|
};
|
|
|
|
// Wait for the local proxy port to be ready to accept connections
|
|
{
|
|
use tokio::net::TcpStream;
|
|
use tokio::time::{sleep, Duration};
|
|
let mut ready = false;
|
|
for _ in 0..50 {
|
|
match TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, proxy_info.local_port)).await {
|
|
Ok(_stream) => {
|
|
ready = true;
|
|
break;
|
|
}
|
|
Err(_) => {
|
|
sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
}
|
|
if !ready {
|
|
return Err(format!(
|
|
"Local proxy on 127.0.0.1:{} did not become ready in time",
|
|
proxy_info.local_port
|
|
));
|
|
}
|
|
}
|
|
|
|
// Store the proxy info
|
|
{
|
|
let mut proxies = self.active_proxies.lock().unwrap();
|
|
proxies.insert(browser_pid, proxy_info.clone());
|
|
}
|
|
|
|
// Store the profile proxy info for persistence
|
|
if let Some(id) = profile_id {
|
|
if let Some(proxy_settings) = proxy_settings {
|
|
let mut profile_proxies = self.profile_proxies.lock().unwrap();
|
|
profile_proxies.insert(id.to_string(), proxy_settings.clone());
|
|
}
|
|
// Also record the active proxy id for this profile for quick cleanup on changes
|
|
let mut map = self.profile_active_proxy_ids.lock().unwrap();
|
|
map.insert(id.to_string(), proxy_info.id.clone());
|
|
}
|
|
|
|
// Return proxy settings for the browser
|
|
Ok(ProxySettings {
|
|
proxy_type: "http".to_string(),
|
|
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
|
port: proxy_info.local_port,
|
|
username: None,
|
|
password: None,
|
|
})
|
|
}
|
|
|
|
// Stop the proxy associated with a browser process ID
|
|
pub async fn stop_proxy(
|
|
&self,
|
|
app_handle: tauri::AppHandle,
|
|
browser_pid: u32,
|
|
) -> Result<(), String> {
|
|
let (proxy_id, profile_id): (String, Option<String>) = {
|
|
let mut proxies = self.active_proxies.lock().unwrap();
|
|
match proxies.remove(&browser_pid) {
|
|
Some(proxy) => (proxy.id, proxy.profile_id.clone()),
|
|
None => return Ok(()), // No proxy to stop
|
|
}
|
|
};
|
|
|
|
// Stop the proxy using the donut-proxy binary
|
|
let proxy_cmd = app_handle
|
|
.shell()
|
|
.sidecar("donut-proxy")
|
|
.map_err(|e| format!("Failed to create sidecar: {e}"))?
|
|
.arg("proxy")
|
|
.arg("stop")
|
|
.arg("--id")
|
|
.arg(&proxy_id);
|
|
|
|
let output = proxy_cmd.output().await.unwrap();
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
log::warn!("Proxy stop error: {stderr}");
|
|
// We still return Ok since we've already removed the proxy from our tracking
|
|
}
|
|
|
|
// Clear profile-to-proxy mapping if it references this proxy
|
|
if let Some(id) = profile_id {
|
|
let mut map = self.profile_active_proxy_ids.lock().unwrap();
|
|
if let Some(current_id) = map.get(&id) {
|
|
if current_id == &proxy_id {
|
|
map.remove(&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}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Stop the proxy associated with a profile ID
|
|
pub async fn stop_proxy_by_profile_id(
|
|
&self,
|
|
app_handle: tauri::AppHandle,
|
|
profile_id: &str,
|
|
) -> Result<(), String> {
|
|
// Find the proxy ID for this profile
|
|
let proxy_id = {
|
|
let map = self.profile_active_proxy_ids.lock().unwrap();
|
|
map.get(profile_id).cloned()
|
|
};
|
|
|
|
if let Some(proxy_id) = proxy_id {
|
|
// Find the PID for this proxy
|
|
let pid = {
|
|
let proxies = self.active_proxies.lock().unwrap();
|
|
proxies.iter().find_map(|(pid, proxy)| {
|
|
if proxy.id == proxy_id {
|
|
Some(*pid)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
};
|
|
|
|
if let Some(pid) = pid {
|
|
// Use the existing stop_proxy method
|
|
self.stop_proxy(app_handle, pid).await
|
|
} else {
|
|
// Proxy not found in active_proxies, try to stop it directly by ID
|
|
let proxy_cmd = app_handle
|
|
.shell()
|
|
.sidecar("donut-proxy")
|
|
.map_err(|e| format!("Failed to create sidecar: {e}"))?
|
|
.arg("proxy")
|
|
.arg("stop")
|
|
.arg("--id")
|
|
.arg(&proxy_id);
|
|
|
|
let output = proxy_cmd.output().await.unwrap();
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
log::warn!("Proxy stop error: {stderr}");
|
|
}
|
|
|
|
// Clear profile-to-proxy mapping
|
|
let mut map = self.profile_active_proxy_ids.lock().unwrap();
|
|
map.remove(profile_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}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
} else {
|
|
// No proxy found for this profile
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// Update the PID mapping for an existing proxy
|
|
pub fn update_proxy_pid(&self, old_pid: u32, new_pid: u32) -> Result<(), String> {
|
|
let mut proxies = self.active_proxies.lock().unwrap();
|
|
if let Some(proxy_info) = proxies.remove(&old_pid) {
|
|
proxies.insert(new_pid, proxy_info);
|
|
Ok(())
|
|
} else {
|
|
Err(format!("No proxy found for PID {old_pid}"))
|
|
}
|
|
}
|
|
|
|
// Clean up proxies for dead browser processes
|
|
// Only clean up orphaned config files where the proxy process itself is dead
|
|
pub async fn cleanup_dead_proxies(
|
|
&self,
|
|
_app_handle: tauri::AppHandle,
|
|
) -> Result<Vec<u32>, String> {
|
|
// Don't stop proxies for dead browser processes - let them run indefinitely
|
|
// The proxy processes are idle and don't consume CPU when not in use
|
|
// Only clean up config files where the proxy process itself is dead (see below)
|
|
let dead_pids: Vec<u32> = Vec::new();
|
|
|
|
// Clean up orphaned proxy configs (only where proxy process is definitely dead)
|
|
// IMPORTANT: Only clean up configs where the proxy process itself is dead
|
|
// If the proxy process is running (even if idle), leave it alone
|
|
// The user doesn't care if proxy processes run indefinitely as long as they're not consuming CPU
|
|
let orphaned_configs = {
|
|
use crate::proxy_storage::{is_process_running, list_proxy_configs};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
let all_configs = list_proxy_configs();
|
|
let tracked_proxy_ids: std::collections::HashSet<String> = {
|
|
let proxies = self.active_proxies.lock().unwrap();
|
|
proxies.values().map(|p| p.id.clone()).collect()
|
|
};
|
|
|
|
// Get current time for grace period check
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
|
|
all_configs
|
|
.into_iter()
|
|
.filter(|config| {
|
|
// If proxy is tracked in active_proxies, it's definitely not orphaned
|
|
if tracked_proxy_ids.contains(&config.id) {
|
|
return false;
|
|
}
|
|
|
|
// Extract creation time from proxy ID (format: proxy_{timestamp}_{random})
|
|
// This gives us a grace period for newly created proxies
|
|
let proxy_age = config
|
|
.id
|
|
.strip_prefix("proxy_")
|
|
.and_then(|s| s.split('_').next())
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.map(|created_at| now.saturating_sub(created_at))
|
|
.unwrap_or(0);
|
|
|
|
// Grace period: don't clean up proxies created in the last 120 seconds
|
|
// This prevents race conditions during startup (increased from 60 to 120 for safety)
|
|
if proxy_age < 120 {
|
|
log::debug!(
|
|
"Skipping cleanup of proxy {} - too new (age: {}s)",
|
|
config.id,
|
|
proxy_age
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// ONLY clean up if we can verify the proxy process is dead
|
|
// If proxy process is running, leave it alone (even if idle)
|
|
if let Some(proxy_pid) = config.pid {
|
|
// Check if proxy process is actually dead
|
|
if !is_process_running(proxy_pid) {
|
|
// Proxy process is dead, clean up the config file
|
|
log::info!(
|
|
"Proxy {} process (PID {}) is dead, will clean up config",
|
|
config.id,
|
|
proxy_pid
|
|
);
|
|
return true;
|
|
}
|
|
// Proxy process is running - leave it alone
|
|
log::debug!(
|
|
"Skipping cleanup of proxy {} - process (PID {}) is still running",
|
|
config.id,
|
|
proxy_pid
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// No PID in config - can't verify if process is dead
|
|
// Be conservative: don't clean up (might be starting up or PID not set yet)
|
|
log::debug!(
|
|
"Skipping cleanup of proxy {} - no PID in config (might be starting up)",
|
|
config.id
|
|
);
|
|
false
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
|
|
// Clean up orphaned config files (proxy process is dead)
|
|
for config in orphaned_configs {
|
|
log::info!(
|
|
"Cleaning up orphaned proxy config: {} (proxy process is dead)",
|
|
config.id
|
|
);
|
|
// Just delete the config file - the process is already dead
|
|
use crate::proxy_storage::delete_proxy_config;
|
|
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}");
|
|
}
|
|
|
|
Ok(dead_pids)
|
|
}
|
|
|
|
/// Snapshot the set of tracked proxy IDs (for asserting in tests).
|
|
#[cfg(test)]
|
|
fn tracked_proxy_ids(&self) -> std::collections::HashSet<String> {
|
|
let proxies = self.active_proxies.lock().unwrap();
|
|
proxies.values().map(|p| p.id.clone()).collect()
|
|
}
|
|
|
|
/// Snapshot active proxy count.
|
|
#[cfg(test)]
|
|
fn active_proxy_count(&self) -> usize {
|
|
self.active_proxies.lock().unwrap().len()
|
|
}
|
|
|
|
/// Snapshot profile-to-proxy-id mapping count.
|
|
#[cfg(test)]
|
|
fn profile_proxy_mapping_count(&self) -> usize {
|
|
self.profile_active_proxy_ids.lock().unwrap().len()
|
|
}
|
|
|
|
/// Insert a proxy info entry directly (for testing).
|
|
#[cfg(test)]
|
|
fn insert_active_proxy(&self, browser_pid: u32, info: ProxyInfo) {
|
|
self
|
|
.active_proxies
|
|
.lock()
|
|
.unwrap()
|
|
.insert(browser_pid, info);
|
|
}
|
|
|
|
/// Insert a profile-to-proxy mapping directly (for testing).
|
|
#[cfg(test)]
|
|
fn insert_profile_proxy_mapping(&self, profile_id: String, proxy_id: String) {
|
|
self
|
|
.profile_active_proxy_ids
|
|
.lock()
|
|
.unwrap()
|
|
.insert(profile_id, proxy_id);
|
|
}
|
|
|
|
/// Get active proxy info by browser PID (for testing).
|
|
#[cfg(test)]
|
|
fn get_active_proxy(&self, browser_pid: u32) -> Option<ProxyInfo> {
|
|
self
|
|
.active_proxies
|
|
.lock()
|
|
.unwrap()
|
|
.get(&browser_pid)
|
|
.cloned()
|
|
}
|
|
}
|
|
|
|
// Create a singleton instance of the proxy manager
|
|
lazy_static::lazy_static! {
|
|
pub static ref PROXY_MANAGER: ProxyManager = ProxyManager::new();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::env;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
use tokio::process::Command;
|
|
use tokio::time::sleep;
|
|
|
|
// Mock HTTP server for testing
|
|
|
|
use http_body_util::Full;
|
|
use hyper::body::Bytes;
|
|
use hyper::server::conn::http1;
|
|
use hyper::service::service_fn;
|
|
use hyper::Response;
|
|
use hyper_util::rt::TokioIo;
|
|
use tokio::net::TcpListener;
|
|
|
|
// Helper function to build donut-proxy binary for testing
|
|
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|
let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR")?;
|
|
let project_root = PathBuf::from(cargo_manifest_dir)
|
|
.parent()
|
|
.unwrap()
|
|
.to_path_buf();
|
|
let proxy_binary_name = if cfg!(windows) {
|
|
"donut-proxy.exe"
|
|
} else {
|
|
"donut-proxy"
|
|
};
|
|
let proxy_binary = project_root
|
|
.join("src-tauri")
|
|
.join("target")
|
|
.join("debug")
|
|
.join(proxy_binary_name);
|
|
|
|
// Check if binary already exists
|
|
if proxy_binary.exists() {
|
|
return Ok(proxy_binary);
|
|
}
|
|
|
|
// Build the donut-proxy binary
|
|
println!("Building donut-proxy binary for tests...");
|
|
|
|
let build_status = Command::new("cargo")
|
|
.args(["build", "--bin", "donut-proxy"])
|
|
.current_dir(project_root.join("src-tauri"))
|
|
.status()
|
|
.await?;
|
|
|
|
if !build_status.success() {
|
|
return Err("Failed to build donut-proxy binary".into());
|
|
}
|
|
|
|
if !proxy_binary.exists() {
|
|
return Err("donut-proxy binary was not created successfully".into());
|
|
}
|
|
|
|
Ok(proxy_binary)
|
|
}
|
|
|
|
#[test]
|
|
fn test_proxy_settings_validation() {
|
|
// Test valid proxy settings
|
|
let valid_settings = ProxySettings {
|
|
proxy_type: "http".to_string(),
|
|
host: "127.0.0.1".to_string(),
|
|
port: 8080,
|
|
username: Some("user".to_string()),
|
|
password: Some("pass".to_string()),
|
|
};
|
|
|
|
assert!(
|
|
!valid_settings.host.is_empty(),
|
|
"Valid settings should have non-empty host"
|
|
);
|
|
assert!(
|
|
valid_settings.port > 0,
|
|
"Valid settings should have positive port"
|
|
);
|
|
assert_eq!(valid_settings.proxy_type, "http", "Proxy type should match");
|
|
assert!(
|
|
valid_settings.username.is_some(),
|
|
"Username should be present"
|
|
);
|
|
assert!(
|
|
valid_settings.password.is_some(),
|
|
"Password should be present"
|
|
);
|
|
|
|
// Test proxy settings with empty values
|
|
let empty_settings = ProxySettings {
|
|
proxy_type: "http".to_string(),
|
|
host: "".to_string(),
|
|
port: 0,
|
|
username: None,
|
|
password: None,
|
|
};
|
|
|
|
assert!(
|
|
empty_settings.host.is_empty(),
|
|
"Empty settings should have empty host"
|
|
);
|
|
assert_eq!(
|
|
empty_settings.port, 0,
|
|
"Empty settings should have zero port"
|
|
);
|
|
assert!(empty_settings.username.is_none(), "Username should be None");
|
|
assert!(empty_settings.password.is_none(), "Password should be None");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_proxy_manager_concurrent_access() {
|
|
use std::sync::Arc;
|
|
|
|
let proxy_manager = Arc::new(ProxyManager::new());
|
|
let mut handles = vec![];
|
|
|
|
// Spawn multiple tasks that access the proxy manager concurrently
|
|
for i in 0..10 {
|
|
let pm = proxy_manager.clone();
|
|
let handle = tokio::spawn(async move {
|
|
let browser_pid = (1000 + i) as u32;
|
|
let proxy_info = ProxyInfo {
|
|
id: format!("proxy_{i}"),
|
|
local_url: format!("http://127.0.0.1:{}", 8000 + i),
|
|
upstream_host: "127.0.0.1".to_string(),
|
|
upstream_port: 3128,
|
|
upstream_type: "http".to_string(),
|
|
local_port: (8000 + i) as u16,
|
|
profile_id: None,
|
|
};
|
|
|
|
// Add proxy
|
|
{
|
|
let mut active_proxies = pm.active_proxies.lock().unwrap();
|
|
active_proxies.insert(browser_pid, proxy_info);
|
|
}
|
|
|
|
browser_pid
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
// Wait for all tasks to complete
|
|
let results: Vec<u32> = futures_util::future::join_all(handles)
|
|
.await
|
|
.into_iter()
|
|
.map(|r| r.unwrap())
|
|
.collect();
|
|
|
|
// Verify all browser PIDs were processed
|
|
assert_eq!(results.len(), 10);
|
|
for (i, &browser_pid) in results.iter().enumerate() {
|
|
assert_eq!(browser_pid, (1000 + i) as u32);
|
|
}
|
|
}
|
|
|
|
// Integration test that actually builds and uses donut-proxy binary
|
|
#[tokio::test]
|
|
async fn test_proxy_integration_with_real_proxy() -> Result<(), Box<dyn std::error::Error>> {
|
|
// This test requires donut-proxy binary to be available
|
|
// Skip if we can't find the binary or if proxy startup fails
|
|
use crate::proxy_runner::{start_proxy_process, stop_proxy_process};
|
|
use tokio::net::TcpStream;
|
|
|
|
// Start a mock upstream HTTP server
|
|
let upstream_listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let upstream_addr = upstream_listener.local_addr()?;
|
|
|
|
// Spawn upstream server
|
|
let server_handle = tokio::spawn(async move {
|
|
while let Ok((stream, _)) = upstream_listener.accept().await {
|
|
let io = TokioIo::new(stream);
|
|
tokio::task::spawn(async move {
|
|
let _ = http1::Builder::new()
|
|
.serve_connection(
|
|
io,
|
|
service_fn(|_req| async {
|
|
Ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from("Upstream OK"))))
|
|
}),
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
});
|
|
|
|
// Wait for server to start
|
|
sleep(Duration::from_millis(100)).await;
|
|
|
|
let upstream_url = format!("http://{}:{}", upstream_addr.ip(), upstream_addr.port());
|
|
|
|
// Try to start proxy - if it fails, skip the test
|
|
let config = match start_proxy_process(Some(upstream_url), None).await {
|
|
Ok(config) => config,
|
|
Err(e) => {
|
|
println!("Skipping proxy integration test - proxy startup failed: {e}");
|
|
server_handle.abort();
|
|
return Ok(()); // Skip test instead of failing
|
|
}
|
|
};
|
|
|
|
// Verify proxy configuration
|
|
assert!(!config.id.is_empty());
|
|
assert!(config.local_port.is_some());
|
|
|
|
let proxy_id = config.id.clone();
|
|
let local_port = config.local_port.unwrap();
|
|
|
|
// Verify the local port is listening (should be fast now)
|
|
match tokio::time::timeout(
|
|
Duration::from_millis(500),
|
|
TcpStream::connect(("127.0.0.1", local_port)),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Ok(_)) => {
|
|
println!("Proxy is listening on port {local_port}");
|
|
}
|
|
Ok(Err(e)) => {
|
|
println!("Warning: Proxy port {local_port} is not listening: {e:?}");
|
|
// Don't fail the test, just log a warning
|
|
}
|
|
Err(_) => {
|
|
println!("Warning: Proxy port {local_port} connection check timed out");
|
|
// Don't fail the test, just log a warning
|
|
}
|
|
}
|
|
|
|
// Test stopping the proxy
|
|
let stopped = stop_proxy_process(&proxy_id).await?;
|
|
assert!(stopped);
|
|
|
|
println!("Integration test passed: proxy start/stop works correctly");
|
|
|
|
// Clean up server
|
|
server_handle.abort();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Test that validates the command line arguments are constructed correctly
|
|
#[test]
|
|
fn test_proxy_command_construction() {
|
|
let proxy_settings = ProxySettings {
|
|
proxy_type: "http".to_string(),
|
|
host: "proxy.example.com".to_string(),
|
|
port: 8080,
|
|
username: Some("user".to_string()),
|
|
password: Some("pass".to_string()),
|
|
};
|
|
|
|
// Test command arguments match expected format
|
|
let expected_args = [
|
|
"proxy",
|
|
"start",
|
|
"--host",
|
|
"proxy.example.com",
|
|
"--proxy-port",
|
|
"8080",
|
|
"--type",
|
|
"http",
|
|
"--username",
|
|
"user",
|
|
"--password",
|
|
"pass",
|
|
];
|
|
|
|
// This test verifies the argument structure without actually running the command
|
|
assert_eq!(
|
|
proxy_settings.host, "proxy.example.com",
|
|
"Host should match expected value"
|
|
);
|
|
assert_eq!(
|
|
proxy_settings.port, 8080,
|
|
"Port should match expected value"
|
|
);
|
|
assert_eq!(
|
|
proxy_settings.proxy_type, "http",
|
|
"Proxy type should match expected value"
|
|
);
|
|
assert_eq!(
|
|
proxy_settings.username.as_ref().unwrap(),
|
|
"user",
|
|
"Username should match expected value"
|
|
);
|
|
assert_eq!(
|
|
proxy_settings.password.as_ref().unwrap(),
|
|
"pass",
|
|
"Password should match expected value"
|
|
);
|
|
|
|
// Verify expected args structure
|
|
assert_eq!(expected_args[0], "proxy", "First arg should be 'proxy'");
|
|
assert_eq!(expected_args[1], "start", "Second arg should be 'start'");
|
|
assert_eq!(expected_args[2], "--host", "Third arg should be '--host'");
|
|
assert_eq!(
|
|
expected_args[3], "proxy.example.com",
|
|
"Fourth arg should be host value"
|
|
);
|
|
}
|
|
|
|
// Test the CLI detachment specifically - ensure the CLI exits properly
|
|
#[tokio::test]
|
|
async fn test_cli_exits_after_proxy_start() -> Result<(), Box<dyn std::error::Error>> {
|
|
let proxy_path = ensure_donut_proxy_binary().await?;
|
|
|
|
// Test that the CLI exits quickly with a mock upstream
|
|
let mut cmd = Command::new(&proxy_path);
|
|
cmd
|
|
.arg("proxy")
|
|
.arg("start")
|
|
.arg("--host")
|
|
.arg("httpbin.org")
|
|
.arg("--proxy-port")
|
|
.arg("80")
|
|
.arg("--type")
|
|
.arg("http");
|
|
|
|
let start_time = std::time::Instant::now();
|
|
let output = tokio::time::timeout(Duration::from_secs(10), cmd.output()).await;
|
|
|
|
match output {
|
|
Ok(Ok(cmd_output)) => {
|
|
let execution_time = start_time.elapsed();
|
|
|
|
if cmd_output.status.success() {
|
|
let stdout = String::from_utf8(cmd_output.stdout)?;
|
|
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
|
|
|
// Clean up - try to stop the proxy
|
|
if let Some(proxy_id) = config["id"].as_str() {
|
|
let mut stop_cmd = Command::new(&proxy_path);
|
|
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
|
let _ = stop_cmd.output().await;
|
|
}
|
|
}
|
|
|
|
println!("CLI detachment test passed - CLI exited in {execution_time:?}");
|
|
}
|
|
Ok(Err(e)) => {
|
|
return Err(format!("Command execution failed: {e}").into());
|
|
}
|
|
Err(_) => {
|
|
return Err("CLI command timed out - this indicates improper detachment".into());
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Test that validates proper CLI detachment behavior
|
|
#[tokio::test]
|
|
async fn test_cli_detachment_behavior() -> Result<(), Box<dyn std::error::Error>> {
|
|
let proxy_path = ensure_donut_proxy_binary().await?;
|
|
|
|
// Test that the CLI command exits quickly even with a real upstream
|
|
let mut cmd = Command::new(&proxy_path);
|
|
cmd
|
|
.arg("proxy")
|
|
.arg("start")
|
|
.arg("--host")
|
|
.arg("httpbin.org")
|
|
.arg("--proxy-port")
|
|
.arg("80")
|
|
.arg("--type")
|
|
.arg("http");
|
|
|
|
let output = tokio::time::timeout(Duration::from_secs(10), cmd.output()).await??;
|
|
|
|
if output.status.success() {
|
|
let stdout = String::from_utf8(output.stdout)?;
|
|
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
|
let proxy_id = config["id"].as_str().unwrap();
|
|
|
|
// Clean up
|
|
let mut stop_cmd = Command::new(&proxy_path);
|
|
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
|
let _ = stop_cmd.output().await;
|
|
|
|
println!("CLI detachment test passed");
|
|
} else {
|
|
// Even if the upstream fails, the CLI should still exit quickly
|
|
println!("CLI command failed but exited quickly as expected");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Test that validates URL encoding for special characters in credentials
|
|
#[tokio::test]
|
|
async fn test_proxy_credentials_encoding() -> Result<(), Box<dyn std::error::Error>> {
|
|
let proxy_path = ensure_donut_proxy_binary().await?;
|
|
|
|
// Test with credentials that include special characters
|
|
let mut cmd = Command::new(&proxy_path);
|
|
cmd
|
|
.arg("proxy")
|
|
.arg("start")
|
|
.arg("--host")
|
|
.arg("test.example.com")
|
|
.arg("--proxy-port")
|
|
.arg("8080")
|
|
.arg("--type")
|
|
.arg("http")
|
|
.arg("--username")
|
|
.arg("user@domain.com")
|
|
.arg("--password")
|
|
.arg("pass word!");
|
|
|
|
let output = tokio::time::timeout(Duration::from_secs(10), cmd.output()).await??;
|
|
|
|
if output.status.success() {
|
|
let stdout = String::from_utf8(output.stdout)?;
|
|
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
|
|
|
let upstream_url = config["upstreamUrl"].as_str().unwrap();
|
|
|
|
println!("Generated upstream URL: {upstream_url}");
|
|
|
|
// Verify that special characters are properly encoded
|
|
assert!(upstream_url.contains("user%40domain.com"));
|
|
assert!(upstream_url.contains("pass%20word"));
|
|
|
|
println!("URL encoding test passed - special characters handled correctly");
|
|
|
|
// Clean up
|
|
let proxy_id = config["id"].as_str().unwrap();
|
|
let mut stop_cmd = Command::new(&proxy_path);
|
|
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
|
let _ = stop_cmd.output().await;
|
|
} else {
|
|
let stdout = String::from_utf8(output.stdout)?;
|
|
let stderr = String::from_utf8(output.stderr)?;
|
|
println!("Command failed (expected for non-existent upstream):");
|
|
println!("Stdout: {stdout}");
|
|
println!("Stderr: {stderr}");
|
|
|
|
println!("URL encoding test completed - credentials should be properly encoded");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Complex proxy process monitoring tests
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
fn make_proxy_info(id: &str, port: u16, profile_id: Option<&str>) -> ProxyInfo {
|
|
ProxyInfo {
|
|
id: id.to_string(),
|
|
local_url: format!("http://127.0.0.1:{port}"),
|
|
upstream_host: "10.0.0.1".to_string(),
|
|
upstream_port: 3128,
|
|
upstream_type: "http".to_string(),
|
|
local_port: port,
|
|
profile_id: profile_id.map(|s| s.to_string()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_pid_mapping_lifecycle() {
|
|
let pm = ProxyManager::new();
|
|
|
|
// Initially empty
|
|
assert_eq!(pm.active_proxy_count(), 0);
|
|
|
|
// Register proxies for 3 browser PIDs
|
|
pm.insert_active_proxy(1001, make_proxy_info("px_a", 9001, Some("profile_1")));
|
|
pm.insert_active_proxy(1002, make_proxy_info("px_b", 9002, Some("profile_2")));
|
|
pm.insert_active_proxy(1003, make_proxy_info("px_c", 9003, None));
|
|
|
|
assert_eq!(pm.active_proxy_count(), 3);
|
|
|
|
// Verify each PID resolves correctly
|
|
let a = pm.get_active_proxy(1001).unwrap();
|
|
assert_eq!(a.id, "px_a");
|
|
assert_eq!(a.local_port, 9001);
|
|
assert_eq!(a.profile_id.as_deref(), Some("profile_1"));
|
|
|
|
let c = pm.get_active_proxy(1003).unwrap();
|
|
assert!(c.profile_id.is_none());
|
|
|
|
// Unknown PID returns None
|
|
assert!(pm.get_active_proxy(9999).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_proxy_pid_remaps_correctly() {
|
|
let pm = ProxyManager::new();
|
|
pm.insert_active_proxy(100, make_proxy_info("px_remap", 9010, Some("prof_a")));
|
|
|
|
// Old PID 100 → new PID 200
|
|
pm.update_proxy_pid(100, 200).unwrap();
|
|
|
|
// Old PID should be gone
|
|
assert!(pm.get_active_proxy(100).is_none());
|
|
|
|
// New PID should have the same proxy info
|
|
let info = pm.get_active_proxy(200).unwrap();
|
|
assert_eq!(info.id, "px_remap");
|
|
assert_eq!(info.local_port, 9010);
|
|
assert_eq!(info.profile_id.as_deref(), Some("prof_a"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_proxy_pid_error_for_unknown_pid() {
|
|
let pm = ProxyManager::new();
|
|
let result = pm.update_proxy_pid(777, 888);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("No proxy found for PID 777"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_profile_proxy_id_mapping_tracks_active_proxy() {
|
|
let pm = ProxyManager::new();
|
|
|
|
pm.insert_active_proxy(500, make_proxy_info("px_1", 9100, Some("profile_x")));
|
|
pm.insert_profile_proxy_mapping("profile_x".to_string(), "px_1".to_string());
|
|
|
|
// Verify mapping exists
|
|
{
|
|
let map = pm.profile_active_proxy_ids.lock().unwrap();
|
|
assert_eq!(map.get("profile_x").unwrap(), "px_1");
|
|
}
|
|
|
|
// Simulate profile-specific cleanup: remove the profile mapping
|
|
{
|
|
let mut map = pm.profile_active_proxy_ids.lock().unwrap();
|
|
map.remove("profile_x");
|
|
}
|
|
|
|
assert_eq!(pm.profile_proxy_mapping_count(), 0);
|
|
// Active proxy itself should still be there
|
|
assert_eq!(pm.active_proxy_count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tracked_proxy_ids_returns_all_unique_ids() {
|
|
let pm = ProxyManager::new();
|
|
pm.insert_active_proxy(1, make_proxy_info("alpha", 8001, None));
|
|
pm.insert_active_proxy(2, make_proxy_info("beta", 8002, None));
|
|
pm.insert_active_proxy(3, make_proxy_info("gamma", 8003, None));
|
|
|
|
let ids = pm.tracked_proxy_ids();
|
|
assert_eq!(ids.len(), 3);
|
|
assert!(ids.contains("alpha"));
|
|
assert!(ids.contains("beta"));
|
|
assert!(ids.contains("gamma"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_concurrent_pid_registration_and_removal() {
|
|
use std::sync::Arc;
|
|
|
|
let pm = Arc::new(ProxyManager::new());
|
|
let mut handles = vec![];
|
|
|
|
// Phase 1: concurrent insertion of 50 proxies
|
|
for i in 0..50 {
|
|
let pm = pm.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
let pid = 2000 + i as u32;
|
|
let info = make_proxy_info(&format!("px_{i}"), 7000 + i as u16, None);
|
|
pm.insert_active_proxy(pid, info);
|
|
}));
|
|
}
|
|
for h in handles.drain(..) {
|
|
h.await.unwrap();
|
|
}
|
|
assert_eq!(pm.active_proxy_count(), 50);
|
|
|
|
// Phase 2: concurrent removal of half the proxies
|
|
for i in (0..50).step_by(2) {
|
|
let pm = pm.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
let pid = 2000 + i as u32;
|
|
let mut proxies = pm.active_proxies.lock().unwrap();
|
|
proxies.remove(&pid);
|
|
}));
|
|
}
|
|
for h in handles.drain(..) {
|
|
h.await.unwrap();
|
|
}
|
|
assert_eq!(pm.active_proxy_count(), 25);
|
|
|
|
// Phase 3: remaining proxies should all have odd indices
|
|
let proxies = pm.active_proxies.lock().unwrap();
|
|
for (&pid, info) in proxies.iter() {
|
|
let idx = (pid - 2000) as usize;
|
|
assert!(idx % 2 == 1, "Only odd-index proxies should remain");
|
|
assert_eq!(info.id, format!("px_{idx}"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_process_running_detection_with_child_lifecycle() {
|
|
use crate::proxy_storage::is_process_running;
|
|
|
|
// Spawn a long-lived child so we can check while it runs
|
|
let mut child = std::process::Command::new(if cfg!(windows) { "timeout" } else { "sleep" })
|
|
.args(if cfg!(windows) {
|
|
vec!["/T", "10"]
|
|
} else {
|
|
vec!["10"]
|
|
})
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn()
|
|
.expect("spawn sleep");
|
|
|
|
let pid = child.id();
|
|
|
|
// Process should be alive
|
|
assert!(
|
|
is_process_running(pid),
|
|
"Child process must be detected as running (PID {pid})"
|
|
);
|
|
|
|
// Kill it
|
|
child.kill().expect("kill child");
|
|
child.wait().expect("wait child");
|
|
|
|
// Process should now be dead
|
|
assert!(
|
|
!is_process_running(pid),
|
|
"Killed child must be detected as dead (PID {pid})"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cleanup_distinguishes_live_and_dead_proxy_configs() {
|
|
use crate::proxy_storage::{save_proxy_config, ProxyConfig};
|
|
|
|
// Spawn a live child process to use its PID.
|
|
// On Windows, `timeout` requires console input and exits immediately in CI,
|
|
// so use `ping` which works reliably in non-interactive contexts.
|
|
let mut live_child = std::process::Command::new(if cfg!(windows) { "ping" } else { "sleep" })
|
|
.args(if cfg!(windows) {
|
|
vec!["-n", "30", "127.0.0.1"]
|
|
} else {
|
|
vec!["30"]
|
|
})
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn()
|
|
.expect("spawn live child");
|
|
let live_pid = live_child.id();
|
|
|
|
// Spawn and kill a short-lived process to get a dead PID
|
|
let dead_child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
|
|
.args(if cfg!(windows) {
|
|
vec!["/C", "exit"]
|
|
} else {
|
|
vec![]
|
|
})
|
|
.spawn()
|
|
.expect("spawn dead child");
|
|
let dead_pid = dead_child.id();
|
|
let mut dead_child = dead_child;
|
|
dead_child.wait().expect("wait for dead child");
|
|
|
|
// Use an old timestamp so the configs aren't in the grace period
|
|
let old_ts = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()
|
|
- 300; // 5 minutes ago
|
|
|
|
// Save both proxy configs to disk
|
|
let live_id = format!("proxy_{old_ts}_11111");
|
|
let dead_id = format!("proxy_{old_ts}_22222");
|
|
|
|
let live_config = ProxyConfig {
|
|
id: live_id.clone(),
|
|
upstream_url: "DIRECT".to_string(),
|
|
local_port: Some(19001),
|
|
ignore_proxy_certificate: None,
|
|
local_url: Some("http://127.0.0.1:19001".to_string()),
|
|
pid: Some(live_pid),
|
|
profile_id: None,
|
|
bypass_rules: Vec::new(),
|
|
};
|
|
let dead_config = ProxyConfig {
|
|
id: dead_id.clone(),
|
|
upstream_url: "DIRECT".to_string(),
|
|
local_port: Some(19002),
|
|
ignore_proxy_certificate: None,
|
|
local_url: Some("http://127.0.0.1:19002".to_string()),
|
|
pid: Some(dead_pid),
|
|
profile_id: None,
|
|
bypass_rules: Vec::new(),
|
|
};
|
|
|
|
save_proxy_config(&live_config).unwrap();
|
|
save_proxy_config(&dead_config).unwrap();
|
|
|
|
// Verify is_process_running differentiates them
|
|
assert!(
|
|
crate::proxy_storage::is_process_running(live_pid),
|
|
"Live PID should be detected"
|
|
);
|
|
assert!(
|
|
!crate::proxy_storage::is_process_running(dead_pid),
|
|
"Dead PID should not be detected"
|
|
);
|
|
|
|
// Clean up
|
|
live_child.kill().expect("kill live child");
|
|
live_child.wait().expect("wait live child");
|
|
crate::proxy_storage::delete_proxy_config(&live_id);
|
|
crate::proxy_storage::delete_proxy_config(&dead_id);
|
|
}
|
|
|
|
#[test]
|
|
fn test_proxy_config_persistence_roundtrip() {
|
|
use crate::proxy_storage::{
|
|
delete_proxy_config, generate_proxy_id, get_proxy_config, save_proxy_config, ProxyConfig,
|
|
};
|
|
|
|
let id = generate_proxy_id();
|
|
let config = ProxyConfig {
|
|
id: id.clone(),
|
|
upstream_url: "socks5://user:pass@10.0.0.1:1080".to_string(),
|
|
local_port: Some(18080),
|
|
ignore_proxy_certificate: Some(true),
|
|
local_url: Some("http://127.0.0.1:18080".to_string()),
|
|
pid: Some(12345),
|
|
profile_id: Some("prof_abc".to_string()),
|
|
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
|
};
|
|
|
|
// Save
|
|
save_proxy_config(&config).unwrap();
|
|
|
|
// Load and compare
|
|
let loaded = get_proxy_config(&id).expect("Config should be loadable");
|
|
assert_eq!(loaded.id, config.id);
|
|
assert_eq!(loaded.upstream_url, config.upstream_url);
|
|
assert_eq!(loaded.local_port, config.local_port);
|
|
assert_eq!(
|
|
loaded.ignore_proxy_certificate,
|
|
config.ignore_proxy_certificate
|
|
);
|
|
assert_eq!(loaded.local_url, config.local_url);
|
|
assert_eq!(loaded.pid, config.pid);
|
|
assert_eq!(loaded.profile_id, config.profile_id);
|
|
assert_eq!(loaded.bypass_rules, config.bypass_rules);
|
|
|
|
// Clean up
|
|
assert!(delete_proxy_config(&id));
|
|
assert!(get_proxy_config(&id).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_proxy_config_update_preserves_fields() {
|
|
use crate::proxy_storage::{
|
|
delete_proxy_config, get_proxy_config, save_proxy_config, update_proxy_config, ProxyConfig,
|
|
};
|
|
|
|
let id = format!("proxy_test_update_{}", rand::random::<u32>());
|
|
let mut config = ProxyConfig::new(id.clone(), "DIRECT".to_string(), Some(17777));
|
|
config.pid = Some(99999);
|
|
config.profile_id = Some("prof_up".to_string());
|
|
config.bypass_rules = vec!["google.com".to_string()];
|
|
|
|
save_proxy_config(&config).unwrap();
|
|
|
|
// Update: change the local_url (simulates worker binding)
|
|
config.local_url = Some("http://127.0.0.1:17777".to_string());
|
|
assert!(update_proxy_config(&config));
|
|
|
|
let reloaded = get_proxy_config(&id).unwrap();
|
|
assert_eq!(
|
|
reloaded.local_url.as_deref(),
|
|
Some("http://127.0.0.1:17777")
|
|
);
|
|
// Other fields should be preserved
|
|
assert_eq!(reloaded.pid, Some(99999));
|
|
assert_eq!(reloaded.bypass_rules, vec!["google.com".to_string()]);
|
|
|
|
delete_proxy_config(&id);
|
|
}
|
|
|
|
#[test]
|
|
fn test_proxy_config_list_filters_json_only() {
|
|
use crate::proxy_storage::{
|
|
delete_proxy_config, list_proxy_configs, save_proxy_config, ProxyConfig,
|
|
};
|
|
|
|
let id1 = format!("proxy_list_test_{}", rand::random::<u32>());
|
|
let id2 = format!("proxy_list_test_{}", rand::random::<u32>());
|
|
|
|
let c1 = ProxyConfig::new(id1.clone(), "DIRECT".to_string(), Some(16001));
|
|
let c2 = ProxyConfig::new(id2.clone(), "DIRECT".to_string(), Some(16002));
|
|
|
|
save_proxy_config(&c1).unwrap();
|
|
save_proxy_config(&c2).unwrap();
|
|
|
|
let all = list_proxy_configs();
|
|
let our_ids: Vec<_> = all.iter().filter(|c| c.id == id1 || c.id == id2).collect();
|
|
assert_eq!(our_ids.len(), 2, "Both test configs should be listed");
|
|
|
|
delete_proxy_config(&id1);
|
|
delete_proxy_config(&id2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_proxy_id_uniqueness_and_format() {
|
|
use crate::proxy_storage::generate_proxy_id;
|
|
|
|
let mut ids = std::collections::HashSet::new();
|
|
for _ in 0..100 {
|
|
let id = generate_proxy_id();
|
|
assert!(id.starts_with("proxy_"), "ID must start with proxy_");
|
|
// Format: proxy_{timestamp}_{random}
|
|
let parts: Vec<&str> = id.split('_').collect();
|
|
assert_eq!(
|
|
parts.len(),
|
|
3,
|
|
"ID should have exactly 3 underscore-separated parts"
|
|
);
|
|
assert!(
|
|
parts[1].parse::<u64>().is_ok(),
|
|
"Second part must be a unix timestamp"
|
|
);
|
|
assert!(
|
|
parts[2].parse::<u32>().is_ok(),
|
|
"Third part must be a u32 random"
|
|
);
|
|
ids.insert(id);
|
|
}
|
|
assert_eq!(ids.len(), 100, "All 100 generated IDs must be unique");
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiple_profiles_share_proxy_independently() {
|
|
let pm = ProxyManager::new();
|
|
|
|
// Two profiles sharing the same upstream but with distinct proxy instances
|
|
let info_a = ProxyInfo {
|
|
id: "px_shared_a".to_string(),
|
|
local_url: "http://127.0.0.1:9201".to_string(),
|
|
upstream_host: "proxy.shared.com".to_string(),
|
|
upstream_port: 8080,
|
|
upstream_type: "http".to_string(),
|
|
local_port: 9201,
|
|
profile_id: Some("profile_alpha".to_string()),
|
|
};
|
|
let info_b = ProxyInfo {
|
|
id: "px_shared_b".to_string(),
|
|
local_url: "http://127.0.0.1:9202".to_string(),
|
|
upstream_host: "proxy.shared.com".to_string(),
|
|
upstream_port: 8080,
|
|
upstream_type: "http".to_string(),
|
|
local_port: 9202,
|
|
profile_id: Some("profile_beta".to_string()),
|
|
};
|
|
|
|
pm.insert_active_proxy(3001, info_a);
|
|
pm.insert_active_proxy(3002, info_b);
|
|
pm.insert_profile_proxy_mapping("profile_alpha".to_string(), "px_shared_a".to_string());
|
|
pm.insert_profile_proxy_mapping("profile_beta".to_string(), "px_shared_b".to_string());
|
|
|
|
// Remove alpha's browser → should NOT affect beta
|
|
{
|
|
let mut proxies = pm.active_proxies.lock().unwrap();
|
|
proxies.remove(&3001);
|
|
}
|
|
{
|
|
let mut map = pm.profile_active_proxy_ids.lock().unwrap();
|
|
map.remove("profile_alpha");
|
|
}
|
|
|
|
assert_eq!(pm.active_proxy_count(), 1);
|
|
assert_eq!(pm.profile_proxy_mapping_count(), 1);
|
|
let remaining = pm.get_active_proxy(3002).unwrap();
|
|
assert_eq!(remaining.id, "px_shared_b");
|
|
assert_eq!(remaining.profile_id.as_deref(), Some("profile_beta"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_proxy_url_construction() {
|
|
// Basic HTTP
|
|
let url = ProxyManager::build_proxy_url(&ProxySettings {
|
|
proxy_type: "http".to_string(),
|
|
host: "1.2.3.4".to_string(),
|
|
port: 8080,
|
|
username: None,
|
|
password: None,
|
|
});
|
|
assert_eq!(url, "http://1.2.3.4:8080");
|
|
|
|
// With credentials
|
|
let url = ProxyManager::build_proxy_url(&ProxySettings {
|
|
proxy_type: "socks5".to_string(),
|
|
host: "proxy.example.com".to_string(),
|
|
port: 1080,
|
|
username: Some("user".to_string()),
|
|
password: Some("p@ss".to_string()),
|
|
});
|
|
assert_eq!(url, "socks5://user:p%40ss@proxy.example.com:1080");
|
|
|
|
// Username-only (no password)
|
|
let url = ProxyManager::build_proxy_url(&ProxySettings {
|
|
proxy_type: "http".to_string(),
|
|
host: "host.io".to_string(),
|
|
port: 3128,
|
|
username: Some("justuser".to_string()),
|
|
password: None,
|
|
});
|
|
assert_eq!(url, "http://justuser@host.io:3128");
|
|
}
|
|
|
|
#[test]
|
|
fn test_geo_username_construction() {
|
|
// Country only
|
|
let u = ProxyManager::build_geo_username("base_user", "US", &None, &None, &None);
|
|
assert_eq!(u, "base_user-country-US");
|
|
|
|
// Country + region
|
|
let u = ProxyManager::build_geo_username(
|
|
"base_user",
|
|
"US",
|
|
&Some("california".to_string()),
|
|
&None,
|
|
&None,
|
|
);
|
|
assert_eq!(u, "base_user-country-US-region-california");
|
|
|
|
// All fields
|
|
let u = ProxyManager::build_geo_username(
|
|
"user",
|
|
"DE",
|
|
&Some("bavaria".to_string()),
|
|
&Some("munich".to_string()),
|
|
&Some("Telekom".to_string()),
|
|
);
|
|
assert_eq!(u, "user-country-DE-region-bavaria-city-munich-isp-Telekom");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sid_generation_determinism_and_format() {
|
|
let sid1 = ProxyManager::generate_sid_for_profile("my-profile-uuid");
|
|
let sid2 = ProxyManager::generate_sid_for_profile("my-profile-uuid");
|
|
assert_eq!(sid1, sid2, "Same input must produce same SID");
|
|
assert_eq!(sid1.len(), 11, "SID must be exactly 11 characters");
|
|
|
|
// All chars should be alphanumeric lowercase
|
|
assert!(
|
|
sid1
|
|
.chars()
|
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()),
|
|
"SID chars must be [a-z0-9]"
|
|
);
|
|
|
|
// Different profiles produce different SIDs
|
|
let sid3 = ProxyManager::generate_sid_for_profile("another-profile");
|
|
assert_ne!(sid1, sid3, "Different profiles must produce different SIDs");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_username_with_sid() {
|
|
let full = ProxyManager::build_username_with_sid("user-country-US", "profile-123");
|
|
// Should contain the geo base, then -sid-{11chars}-ttl-1440m
|
|
assert!(full.starts_with("user-country-US-sid-"));
|
|
assert!(full.ends_with("-ttl-1440m"));
|
|
// SID portion
|
|
let after_sid = full.strip_prefix("user-country-US-sid-").unwrap();
|
|
let sid = after_sid.strip_suffix("-ttl-1440m").unwrap();
|
|
assert_eq!(sid.len(), 11);
|
|
}
|
|
|
|
#[test]
|
|
fn test_stored_proxy_geo_field_migration() {
|
|
// Simulate legacy data with geo_state but no geo_region
|
|
let mut proxy = StoredProxy {
|
|
id: "test_migrate".to_string(),
|
|
name: "Test".to_string(),
|
|
proxy_settings: ProxySettings {
|
|
proxy_type: "http".to_string(),
|
|
host: "h.com".to_string(),
|
|
port: 80,
|
|
username: None,
|
|
password: None,
|
|
},
|
|
sync_enabled: false,
|
|
last_sync: None,
|
|
is_cloud_managed: false,
|
|
is_cloud_derived: false,
|
|
geo_country: Some("US".to_string()),
|
|
geo_state: Some("california".to_string()),
|
|
geo_region: None,
|
|
geo_city: None,
|
|
geo_isp: None,
|
|
};
|
|
|
|
// Before migration
|
|
assert_eq!(proxy.effective_region().unwrap(), "california");
|
|
assert!(proxy.geo_region.is_none());
|
|
|
|
// After migration
|
|
proxy.migrate_geo_fields();
|
|
assert_eq!(proxy.geo_region.as_deref(), Some("california"));
|
|
assert!(proxy.geo_state.is_none(), "geo_state should be taken");
|
|
assert_eq!(proxy.effective_region().unwrap(), "california");
|
|
}
|
|
|
|
#[test]
|
|
fn test_cleanup_skips_recently_created_configs() {
|
|
use crate::proxy_storage::{delete_proxy_config, save_proxy_config, ProxyConfig};
|
|
|
|
// Use current timestamp so it falls within the 120s grace period
|
|
let now_ts = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
|
|
let recent_id = format!("proxy_{now_ts}_99999");
|
|
|
|
// Spawn and kill a child so the PID is dead
|
|
let dead_child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
|
|
.args(if cfg!(windows) {
|
|
vec!["/C", "exit"]
|
|
} else {
|
|
vec![]
|
|
})
|
|
.spawn()
|
|
.unwrap();
|
|
let dead_pid = dead_child.id();
|
|
let mut dead_child = dead_child;
|
|
dead_child.wait().unwrap();
|
|
|
|
let config = ProxyConfig {
|
|
id: recent_id.clone(),
|
|
upstream_url: "DIRECT".to_string(),
|
|
local_port: Some(19999),
|
|
ignore_proxy_certificate: None,
|
|
local_url: None,
|
|
pid: Some(dead_pid),
|
|
profile_id: None,
|
|
bypass_rules: Vec::new(),
|
|
};
|
|
save_proxy_config(&config).unwrap();
|
|
|
|
// The cleanup logic inspects the timestamp in the proxy ID.
|
|
// Since we used the current timestamp, the proxy_age will be < 120 seconds,
|
|
// so it should be skipped despite the dead PID.
|
|
|
|
// Verify the grace period logic directly:
|
|
let proxy_age = recent_id
|
|
.strip_prefix("proxy_")
|
|
.and_then(|s| s.split('_').next())
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.map(|created_at| now_ts.saturating_sub(created_at))
|
|
.unwrap_or(0);
|
|
|
|
assert!(
|
|
proxy_age < 120,
|
|
"Recently created config should be in grace period"
|
|
);
|
|
|
|
// Clean up test config
|
|
delete_proxy_config(&recent_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_concurrent_config_operations() {
|
|
use crate::proxy_storage::{
|
|
delete_proxy_config, get_proxy_config, save_proxy_config, ProxyConfig,
|
|
};
|
|
use std::sync::Arc;
|
|
|
|
let ids: Vec<String> = (0..20)
|
|
.map(|i| format!("proxy_conc_test_{}_{}", i, rand::random::<u32>()))
|
|
.collect();
|
|
let ids = Arc::new(ids);
|
|
|
|
// Concurrent writes
|
|
let mut handles = vec![];
|
|
for id in ids.iter() {
|
|
let id = id.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
let config = ProxyConfig::new(id.clone(), "DIRECT".to_string(), Some(15000));
|
|
save_proxy_config(&config).unwrap();
|
|
}));
|
|
}
|
|
for h in handles {
|
|
h.await.unwrap();
|
|
}
|
|
|
|
// Verify all were written
|
|
for id in ids.iter() {
|
|
assert!(
|
|
get_proxy_config(id).is_some(),
|
|
"Config {id} should be readable after concurrent write"
|
|
);
|
|
}
|
|
|
|
// Concurrent deletes
|
|
let mut handles = vec![];
|
|
for id in ids.iter() {
|
|
let id = id.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
delete_proxy_config(&id);
|
|
}));
|
|
}
|
|
for h in handles {
|
|
h.await.unwrap();
|
|
}
|
|
|
|
// Verify all deleted
|
|
for id in ids.iter() {
|
|
assert!(
|
|
get_proxy_config(id).is_none(),
|
|
"Config {id} should be gone after concurrent delete"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_proxy_txt_parsing_various_formats() {
|
|
// URL format
|
|
let results = ProxyManager::parse_txt_proxies("http://user:pass@proxy.com:8080\n");
|
|
assert_eq!(results.len(), 1);
|
|
match &results[0] {
|
|
ProxyParseResult::Parsed(p) => {
|
|
assert_eq!(p.proxy_type, "http");
|
|
assert_eq!(p.host, "proxy.com");
|
|
assert_eq!(p.port, 8080);
|
|
assert_eq!(p.username.as_deref(), Some("user"));
|
|
assert_eq!(p.password.as_deref(), Some("pass"));
|
|
}
|
|
_ => panic!("Expected Parsed result"),
|
|
}
|
|
|
|
// host:port format
|
|
let results = ProxyManager::parse_txt_proxies("10.0.0.1:3128\n");
|
|
match &results[0] {
|
|
ProxyParseResult::Parsed(p) => {
|
|
assert_eq!(p.host, "10.0.0.1");
|
|
assert_eq!(p.port, 3128);
|
|
assert!(p.username.is_none());
|
|
}
|
|
_ => panic!("Expected Parsed"),
|
|
}
|
|
|
|
// host:port:user:pass format
|
|
let results = ProxyManager::parse_txt_proxies("myhost:9090:admin:secret\n");
|
|
match &results[0] {
|
|
ProxyParseResult::Parsed(p) => {
|
|
assert_eq!(p.host, "myhost");
|
|
assert_eq!(p.port, 9090);
|
|
assert_eq!(p.username.as_deref(), Some("admin"));
|
|
assert_eq!(p.password.as_deref(), Some("secret"));
|
|
}
|
|
_ => panic!("Expected Parsed"),
|
|
}
|
|
|
|
// Comments and empty lines should be skipped
|
|
let results = ProxyManager::parse_txt_proxies("# comment\n\n \n1.2.3.4:80\n");
|
|
assert_eq!(results.len(), 1);
|
|
|
|
// SOCKS5 URL
|
|
let results = ProxyManager::parse_txt_proxies("socks5://u:p@1.2.3.4:1080\n");
|
|
match &results[0] {
|
|
ProxyParseResult::Parsed(p) => {
|
|
assert_eq!(p.proxy_type, "socks5");
|
|
assert_eq!(p.host, "1.2.3.4");
|
|
assert_eq!(p.port, 1080);
|
|
}
|
|
_ => panic!("Expected Parsed"),
|
|
}
|
|
|
|
// Ambiguous: both positions could be ports
|
|
let results = ProxyManager::parse_txt_proxies("1234:5678:9012:3456\n");
|
|
match &results[0] {
|
|
ProxyParseResult::Ambiguous {
|
|
possible_formats, ..
|
|
} => {
|
|
assert_eq!(possible_formats.len(), 2);
|
|
}
|
|
_ => panic!("Expected Ambiguous"),
|
|
}
|
|
|
|
// Invalid
|
|
let results = ProxyManager::parse_txt_proxies("notaproxy\n");
|
|
match &results[0] {
|
|
ProxyParseResult::Invalid { .. } => {}
|
|
_ => panic!("Expected Invalid"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiple_proxy_types_coexist() {
|
|
let pm = ProxyManager::new();
|
|
|
|
// Different proxy types for different profiles
|
|
let types = [
|
|
("http", 3128),
|
|
("https", 3129),
|
|
("socks4", 1080),
|
|
("socks5", 1081),
|
|
];
|
|
|
|
for (i, (ptype, port)) in types.iter().enumerate() {
|
|
let info = ProxyInfo {
|
|
id: format!("px_type_{ptype}"),
|
|
local_url: format!("http://127.0.0.1:{}", 9300 + i as u16),
|
|
upstream_host: "upstream.test".to_string(),
|
|
upstream_port: *port,
|
|
upstream_type: ptype.to_string(),
|
|
local_port: 9300 + i as u16,
|
|
profile_id: Some(format!("profile_{ptype}")),
|
|
};
|
|
pm.insert_active_proxy(4000 + i as u32, info);
|
|
}
|
|
|
|
assert_eq!(pm.active_proxy_count(), 4);
|
|
|
|
// Verify each type is stored correctly
|
|
let info = pm.get_active_proxy(4000).unwrap();
|
|
assert_eq!(info.upstream_type, "http");
|
|
let info = pm.get_active_proxy(4003).unwrap();
|
|
assert_eq!(info.upstream_type, "socks5");
|
|
assert_eq!(info.upstream_port, 1081);
|
|
}
|
|
|
|
#[test]
|
|
fn test_overwrite_pid_mapping() {
|
|
let pm = ProxyManager::new();
|
|
|
|
// Register proxy for PID 5000
|
|
pm.insert_active_proxy(5000, make_proxy_info("px_old", 9400, Some("prof_ow")));
|
|
|
|
// Overwrite the same PID with a new proxy (simulates browser reconnect with different proxy)
|
|
pm.insert_active_proxy(5000, make_proxy_info("px_new", 9401, Some("prof_ow")));
|
|
|
|
// Should only have 1 entry, with the new proxy
|
|
assert_eq!(pm.active_proxy_count(), 1);
|
|
let info = pm.get_active_proxy(5000).unwrap();
|
|
assert_eq!(info.id, "px_new");
|
|
assert_eq!(info.local_port, 9401);
|
|
}
|
|
|
|
#[test]
|
|
fn test_proxy_config_with_bypass_rules_roundtrip() {
|
|
use crate::proxy_storage::{
|
|
delete_proxy_config, get_proxy_config, save_proxy_config, ProxyConfig,
|
|
};
|
|
|
|
let id = format!("proxy_bypass_test_{}", rand::random::<u32>());
|
|
let rules = vec![
|
|
"*.google.com".to_string(),
|
|
"localhost".to_string(),
|
|
"192.168.0.*".to_string(),
|
|
"^.*\\.internal\\.corp$".to_string(),
|
|
];
|
|
|
|
let config = ProxyConfig::new(id.clone(), "http://upstream:3128".to_string(), Some(18888))
|
|
.with_profile_id(Some("prof_bypass".to_string()))
|
|
.with_bypass_rules(rules.clone());
|
|
|
|
save_proxy_config(&config).unwrap();
|
|
|
|
let loaded = get_proxy_config(&id).unwrap();
|
|
assert_eq!(loaded.bypass_rules.len(), 4);
|
|
assert_eq!(loaded.bypass_rules, rules);
|
|
assert_eq!(loaded.profile_id.as_deref(), Some("prof_bypass"));
|
|
|
|
delete_proxy_config(&id);
|
|
}
|
|
}
|