diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index dfa512c..41e84f6 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -294,7 +294,7 @@ impl BrowserRunner { config_for_generation.fingerprint = None; // Generate a new fingerprint - let new_fingerprint = self + let (new_fingerprint, geolocation_applied) = self .wayfern_manager .generate_fingerprint_config(&app_handle, profile, &config_for_generation) .await @@ -318,13 +318,19 @@ impl BrowserRunner { updated_wayfern_config.os = wayfern_config.os.clone(); } // The fresh fingerprint's location matches the current routing; record - // its signature so launches keep it in sync with the non-randomize path. - updated_wayfern_config.geo_proxy_signature = + // its signature so launches keep it in sync with the non-randomize + // path. Only when geolocation actually applied — otherwise leave it + // unset so the refresh path can repair the location if the user later + // turns randomize off. + updated_wayfern_config.geo_proxy_signature = if geolocation_applied { Some(crate::wayfern_manager::WayfernManager::geo_signature( upstream_proxy.as_ref(), profile.vpn_id.as_deref(), wayfern_config.geoip.as_ref(), - )); + )) + } else { + None + }; updated_profile.wayfern_config = Some(updated_wayfern_config.clone()); log::info!( diff --git a/src-tauri/src/ip_utils.rs b/src-tauri/src/ip_utils.rs index 0689f09..144f3df 100644 --- a/src-tauri/src/ip_utils.rs +++ b/src-tauri/src/ip_utils.rs @@ -28,7 +28,9 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result { "https://ipecho.net/plain", ]; - let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(5)); + // 10s rather than 5s: residential proxies that allocate an exit on first + // connect routinely need more than 5s for the initial request. + let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10)); let client = if let Some(proxy_url) = proxy { let proxy = reqwest::Proxy::all(proxy_url) @@ -46,25 +48,40 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result { let mut errors = Vec::new(); + // Overall deadline across all endpoints. Without it, a proxy that accepts + // connections but stalls holds callers for the full 6 x 10s; slow-but-live + // proxies still get the whole 10s on the endpoints that fit the budget. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + for url in &urls { - match client.get(*url).send().await { - Ok(response) if response.status().is_success() => match response.text().await { - Ok(text) => { - let ip = text.trim().to_string(); - if validate_ip(&ip) { - return Ok(ip); + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + errors.push(format!("{}: skipped (30s overall deadline reached)", url)); + continue; + } + + let attempt = async { + match client.get(*url).send().await { + Ok(response) if response.status().is_success() => match response.text().await { + Ok(text) => { + let ip = text.trim().to_string(); + if validate_ip(&ip) { + Ok(ip) + } else { + Err(format!("{}: response is not an IP address", url)) + } } - } - Err(e) => { - errors.push(format!("{}: {}", url, e)); - } - }, - Ok(response) => { - errors.push(format!("{}: HTTP {}", url, response.status())); - } - Err(e) => { - errors.push(format!("{}: {}", url, e)); + Err(e) => Err(format!("{}: {}", url, e)), + }, + Ok(response) => Err(format!("{}: HTTP {}", url, response.status())), + Err(e) => Err(format!("{}: {}", url, e)), } + }; + + match tokio::time::timeout(remaining, attempt).await { + Ok(Ok(ip)) => return Ok(ip), + Ok(Err(e)) => errors.push(e), + Err(_) => errors.push(format!("{}: timed out (30s overall deadline reached)", url)), } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8f36f0c..9e1e4d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1213,6 +1213,7 @@ async fn generate_sample_fingerprint( manager .generate_fingerprint_config(&app_handle, &temp_profile, &config) .await + .map(|(fingerprint, _geolocation_applied)| fingerprint) .map_err(|e| format!("Failed to generate fingerprint: {e}")) } else { Err(format!( diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 1570f60..6287def 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -168,6 +168,11 @@ impl ProfileManager { } } + // Whether the fingerprint's location fields are known to match the + // profile's routing. Provided fingerprints keep the old stamping + // behavior; for generated ones this comes from the geolocation lookup. + let mut geolocation_applied = true; + // Generate fingerprint if not already provided if config.fingerprint.is_none() { log::info!("Generating fingerprint for Wayfern profile: {name}"); @@ -209,8 +214,9 @@ impl ProfileManager { .generate_fingerprint_config(app_handle, &temp_profile, &config) .await { - Ok(generated_fingerprint) => { + Ok((generated_fingerprint, geo_applied)) => { config.fingerprint = Some(generated_fingerprint); + geolocation_applied = geo_applied; log::info!("Successfully generated fingerprint for Wayfern profile: {name}"); } Err(e) => { @@ -226,15 +232,27 @@ impl ProfileManager { // Record which proxy/geoip the fingerprint's location data was computed // for. On launch this is compared against the profile's current routing // so a proxy that was changed after creation triggers a location refresh - // instead of showing a stale timezone. - config.geo_proxy_signature = Some(crate::wayfern_manager::WayfernManager::geo_signature( - proxy_id - .as_ref() - .and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id)) - .as_ref(), - None, - config.geoip.as_ref(), - )); + // instead of showing a stale timezone. Only stamped when geolocation + // actually succeeded: on failure the fingerprint carries the HOST + // timezone/locale, and a stamped signature would match at launch and + // suppress the refresh that repairs it — latching the leak permanently. + config.geo_proxy_signature = if geolocation_applied { + Some(crate::wayfern_manager::WayfernManager::geo_signature( + proxy_id + .as_ref() + .and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id)) + .as_ref(), + None, + config.geoip.as_ref(), + )) + } else { + if !matches!(config.geoip.as_ref(), Some(serde_json::Value::Bool(false))) { + log::warn!( + "Geolocation could not be applied for Wayfern profile {name}; leaving geo signature unset so the next launch refreshes location through the profile's proxy" + ); + } + None + }; // Clear the proxy from config after fingerprint generation config.proxy = None; diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index e68795c..c75f3ac 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -329,7 +329,9 @@ impl ProfileImporter { .generate_fingerprint_config(app_handle, &temp_profile, &config) .await { - Ok(fp) => config.fingerprint = Some(fp), + // geo_proxy_signature is intentionally left unset here: the first + // launch's signature-mismatch refresh verifies the location either way. + Ok((fp, _geolocation_applied)) => config.fingerprint = Some(fp), Err(e) => { return Err( format!( diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index a9573cc..205c8be 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -284,7 +284,11 @@ impl WayfernManager { vpn_id: Option<&str>, geoip: Option<&serde_json::Value>, ) -> String { - match geoip { + // The "v2:" prefix invalidates every signature stamped before geolocation + // failures stopped being stamped: those may describe fingerprints that + // silently carry the host's location, so each pre-v2 profile gets one + // launch-time refresh and is re-stamped in the current format. + let base = match geoip { Some(serde_json::Value::Bool(false)) => "off".to_string(), Some(serde_json::Value::String(ip)) if !ip.is_empty() => format!("ip:{ip}"), _ => { @@ -302,7 +306,8 @@ impl WayfernManager { "direct".to_string() } } - } + }; + format!("v2:{base}") } /// Apply timezone/geolocation fields to a fingerprint object from the proxy's @@ -397,12 +402,44 @@ impl WayfernManager { } } + /// True when `url` is a socks proxy on a remote (non-loopback) host — the + /// case where reqwest's SOCKS connector can't be trusted with the + /// geolocation fetch. Loopback socks URLs are the app's own donut-proxy + /// workers, whose single-segment replies don't trigger the connector bug. + fn is_remote_socks_url(url: &str) -> bool { + url.starts_with("socks") + && url::Url::parse(url) + .ok() + .and_then(|u| match u.host() { + Some(url::Host::Ipv4(ip)) => Some(!ip.is_loopback()), + Some(url::Host::Ipv6(ip)) => Some(!ip.is_loopback()), + // socks is a non-special scheme, so the url crate keeps even + // IP-literal hosts as Domain — parse them before comparing. + Some(url::Host::Domain(domain)) => Some( + domain != "localhost" + && domain + .parse::() + .map(|ip| !ip.is_loopback()) + .unwrap_or(true), + ), + None => None, + }) + .unwrap_or(false) + } + + /// Generate a fingerprint for `config`, returning the fingerprint JSON and + /// whether fresh geolocation was applied to it. Callers must only stamp + /// `geo_proxy_signature` when geolocation succeeded: the base fingerprint + /// comes from a headless Wayfern launched without a proxy, so on failure it + /// silently carries the HOST timezone/locale — stamping the signature then + /// would tell the launch-time refresh the location is already correct for + /// this proxy and permanently disable the one path that can repair it. pub async fn generate_fingerprint_config( &self, _app_handle: &AppHandle, profile: &BrowserProfile, config: &WayfernConfig, - ) -> Result> { + ) -> Result<(String, bool), Box> { let executable_path = BrowserRunner::instance() .get_browser_executable_path(profile) .map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?; @@ -549,7 +586,7 @@ impl WayfernManager { .send_cdp_command(&ws_url, "Wayfern.getFingerprint", json!({})) .await; - let fingerprint = match get_result { + let (fingerprint, geolocation_applied) = match get_result { Ok(result) => { // Wayfern.getFingerprint returns { fingerprint: {...} } // We need to extract just the fingerprint object @@ -557,16 +594,57 @@ impl WayfernManager { // Normalize the fingerprint: convert JSON string fields to proper types let mut normalized = Self::normalize_fingerprint(fp); + // reqwest's SOCKS connector (hyper-util) corrupts its parse buffer + // when a proxy splits a handshake reply across TCP segments, so a + // socks upstream here can fail even though the proxy is healthy. + // Route the geolocation lookup through a temporary local donut-proxy + // worker — the same path the browser itself uses — and fall back to + // the upstream URL only if the worker can't start. Two exclusions: + // no worker when geolocation won't fetch through the proxy at all + // (disabled, or a fixed geoip IP), and none for loopback socks URLs — + // launch-time callers pass the already-running local worker's + // socks5://127.0.0.1 URL, whose single-segment replies don't trigger + // the bug, so chaining a second worker would only add latency. + let needs_proxied_geo_fetch = !matches!( + config.geoip.as_ref(), + Some(serde_json::Value::Bool(false)) | Some(serde_json::Value::String(_)) + ); + let remote_socks_upstream = config + .proxy + .as_deref() + .filter(|url| Self::is_remote_socks_url(url)); + let (geo_proxy, temp_worker_id) = match remote_socks_upstream { + Some(url) if needs_proxied_geo_fetch => { + match crate::proxy_runner::start_proxy_process(Some(url.to_string()), None) + .await + .map_err(|e| e.to_string()) + { + Ok(worker) => { + let local_url = format!("http://127.0.0.1:{}", worker.local_port.unwrap_or(0)); + (Some(local_url), Some(worker.id)) + } + Err(e) => { + log::warn!( + "Could not start local proxy worker for geolocation ({e}); using the socks upstream directly" + ); + (config.proxy.clone(), None) + } + } + } + _ => (config.proxy.clone(), None), + }; + // Apply timezone/geolocation for the proxy this fingerprint is being // generated against. Shared with the launch-time location refresh. - Self::apply_geolocation( - &mut normalized, - config.proxy.as_deref(), - config.geoip.as_ref(), - ) - .await; + let geolocation_applied = + Self::apply_geolocation(&mut normalized, geo_proxy.as_deref(), config.geoip.as_ref()) + .await; - normalized + if let Some(worker_id) = temp_worker_id { + let _ = crate::proxy_runner::stop_proxy_process(&worker_id).await; + } + + (normalized, geolocation_applied) } Err(e) => { cleanup().await; @@ -599,7 +677,7 @@ impl WayfernManager { ); } - Ok(fingerprint_json) + Ok((fingerprint_json, geolocation_applied)) } #[allow(clippy::too_many_arguments)] @@ -1418,6 +1496,37 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) { mod tests { use super::*; + #[test] + fn remote_socks_url_detection() { + // Remote socks upstreams (the hyper-util-affected case) are detected... + assert!(WayfernManager::is_remote_socks_url( + "socks5://user:pass@gw.dataimpulse.com:10000" + )); + assert!(WayfernManager::is_remote_socks_url("socks5://1.2.3.4:1080")); + assert!(WayfernManager::is_remote_socks_url("socks4://1.2.3.4:1080")); + + // ...but the app's own loopback workers are not. socks is a non-special + // URL scheme, so the IP literal parses as Host::Domain — the launch-time + // randomize path depends on this returning false. + assert!(!WayfernManager::is_remote_socks_url( + "socks5://127.0.0.1:24001" + )); + assert!(!WayfernManager::is_remote_socks_url("socks5://[::1]:24001")); + assert!(!WayfernManager::is_remote_socks_url( + "socks5://localhost:24001" + )); + + // Non-socks schemes and unparsable URLs never need the workaround. + assert!(!WayfernManager::is_remote_socks_url( + "http://gw.dataimpulse.com:10000" + )); + assert!(!WayfernManager::is_remote_socks_url( + "https://gw.dataimpulse.com:10000" + )); + assert!(!WayfernManager::is_remote_socks_url("socks5://")); + assert!(!WayfernManager::is_remote_socks_url("not a url")); + } + #[test] fn window_size_prefers_outer_window_dimensions() { // Field names + values mirror a real Wayfern fingerprint (camelCase).