//! Launch-time consistency check: resolve the proxy's exit IP, geolocate it //! with the bundled MaxMind database (the same source the fingerprint generator //! uses), then compare its timezone and country against the profile //! fingerprint's timezone and language. A mismatch (e.g. a US fingerprint //! behind a German exit IP) is a strong anti-bot tell even though the real //! device never leaks — so we warn the user after launch and offer to match the //! fingerprint to the exit. Launches never rewrite the fingerprint silently, so //! a real mismatch always surfaces here. use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Mutex; use crate::profile::types::BrowserProfile; use crate::proxy_manager::PROXY_MANAGER; /// Exit-node lookups are cached per proxy for this long. A stored proxy's exit /// geolocation is stable enough that re-resolving the exit IP through the proxy /// on every launch is wasteful. const EXIT_CACHE_TTL_SECS: u64 = 30 * 60; #[derive(Clone)] struct CachedExit { fetched_at: u64, /// The proxy URL this exit was measured through. Editing a stored proxy keeps /// its id, so without this an entry outlives the endpoint it describes: the /// check would compare a re-generated fingerprint against the *old* exit and /// either warn about a correct profile or — worse — call a genuinely /// mismatched one consistent, which is exactly the tell it exists to catch. proxy_url: String, timezone: Option, country_code: Option, ip: Option, } lazy_static::lazy_static! { static ref EXIT_CACHE: Mutex> = Mutex::new(HashMap::new()); } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ConsistencyResult { /// True when everything we could check lines up (or there was nothing to /// check — no proxy assigned). pub consistent: bool, /// True when we actually reached an exit node and compared something. pub checked: bool, pub exit_ip: Option, pub exit_country_code: Option, pub exit_timezone: Option, pub fingerprint_timezone: Option, pub fingerprint_language: Option, /// One of "timezone", "language" — the dimensions that disagree. pub mismatches: Vec, } impl ConsistencyResult { fn skip() -> Self { Self { consistent: true, checked: false, exit_ip: None, exit_country_code: None, exit_timezone: None, fingerprint_timezone: None, fingerprint_language: None, mismatches: Vec::new(), } } } /// URL for handing this proxy to reqwest. VLESS is reached through the /// authenticated loopback Xray-core worker already serving the profile. fn proxy_url(settings: &crate::browser::ProxySettings, profile_id: Option<&str>) -> Option { match settings.proxy_type.to_lowercase().as_str() { "http" | "https" | "socks4" | "socks5" => Some( crate::proxy_manager::ProxyManager::build_proxy_url(settings), ), "vless" => profile_id .and_then(crate::xray_worker_storage::find_xray_worker_by_profile_id) .map(|worker| { crate::proxy_manager::ProxyManager::build_proxy_url(&worker.local_proxy_settings()) }), _ => None, } } /// Whether the fingerprint's language is plausible for the exit country. /// /// Validated against the same CLDR data the fingerprint generator samples from /// (`geolocation::LocaleSelector`), not a hand-written country->language table. /// The generator picks a language at random weighted by CLDR speaker share, so /// any table naming one "expected" language per country flags fingerprints /// Donut itself produced — roughly 10% of US profiles legitimately get `es-US` /// and ~23% of Canadian ones get `fr-CA`. `None` means the country has no CLDR /// data and the check is skipped. fn language_matches_country(cc: &str, language: &str) -> Option { crate::geolocation::locale_selector()?.region_speaks(cc, language) } /// Extract (timezone, language) from a profile's stored fingerprint JSON. fn fingerprint_locale(profile: &BrowserProfile) -> (Option, Option) { let Some(config) = &profile.wayfern_config else { return (None, None); }; let Some(fp_str) = &config.fingerprint else { return (None, None); }; let Ok(fp) = serde_json::from_str::(fp_str) else { return (None, None); }; let timezone = fp .get("timezone") .and_then(|v| v.as_str()) .map(str::to_string); let language = fp .get("language") .and_then(|v| v.as_str()) .map(str::to_string); (timezone, language) } /// Run the check for a profile. No-ops (consistent, unchecked) when the /// profile has no proxy or the exit node can't be reached. pub async fn check_profile_consistency( profile: &BrowserProfile, ) -> Result { let Some(proxy_id) = &profile.proxy_id else { return Ok(ConsistencyResult::skip()); }; let Some(settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) else { return Ok(ConsistencyResult::skip()); }; let profile_id = profile.id.to_string(); let Some(url) = proxy_url(&settings, Some(&profile_id)) else { return Ok(ConsistencyResult::skip()); }; let cache_identity = if settings.proxy_type.eq_ignore_ascii_case("vless") { settings.vless_uri.clone().unwrap_or_else(|| url.clone()) } else { url.clone() }; let now = crate::proxy_manager::now_secs(); // Serve a fresh cached exit lookup for this proxy if we have one, but only if // it was measured through the proxy's current endpoint and credentials. let cached = { let cache = EXIT_CACHE.lock().unwrap(); cache .get(proxy_id) .filter(|c| { c.proxy_url == cache_identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS }) .cloned() }; let (exit_tz, exit_cc, exit_ip) = if let Some(c) = cached { (c.timezone, c.country_code, c.ip) } else { // Resolve the exit IP through the proxy, then geolocate it with the SAME // bundled MaxMind database the fingerprint generator (and the on-demand // match) use. Using one geo source everywhere means the check can never // disagree with what generation produced — a second source (e.g. ip-api) // routinely reports a different IANA zone for the same IP in multi-zone // countries, which would flag correctly-generated fingerprints and would // leave the "match to proxy" fix unable to satisfy the check. let exit_ip = crate::ip_utils::fetch_public_ip(Some(&url)) .await .map_err(|e| format!("exit-node lookup failed: {e}"))?; match crate::geolocation::get_geolocation(&exit_ip) { Ok(geo) => { let tz = Some(geo.timezone); let cc = geo.locale.region.clone(); let ip = Some(exit_ip); EXIT_CACHE.lock().unwrap().insert( proxy_id.clone(), CachedExit { fetched_at: now, proxy_url: cache_identity, timezone: tz.clone(), country_code: cc.clone(), ip: ip.clone(), }, ); (tz, cc, ip) } // Reached the exit but couldn't place it (database missing, or a private // exit IP). Skip rather than warn on an unknown location — the same // database gates fingerprint geo, so there's nothing to disagree with. Err(e) => { log::debug!("Consistency check: could not geolocate exit IP: {e}"); return Ok(ConsistencyResult::skip()); } } }; let (fp_tz, fp_lang) = fingerprint_locale(profile); let mut mismatches = Vec::new(); if let (Some(exit), Some(fp)) = (&exit_tz, &fp_tz) { if !exit.eq_ignore_ascii_case(fp) { mismatches.push("timezone".to_string()); } } if let (Some(cc), Some(lang)) = (&exit_cc, &fp_lang) { if language_matches_country(cc, lang) == Some(false) { mismatches.push("language".to_string()); } } Ok(ConsistencyResult { consistent: mismatches.is_empty(), checked: true, exit_ip, exit_country_code: exit_cc, exit_timezone: exit_tz, fingerprint_timezone: fp_tz, fingerprint_language: fp_lang, mismatches, }) } #[tauri::command] pub async fn check_profile_fingerprint_consistency( profile_id: String, ) -> Result { let profiles = crate::profile::ProfileManager::instance() .list_profiles() .map_err(|e| e.to_string())?; let profile = profiles .into_iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?; check_profile_consistency(&profile).await } /// Rewrite a profile's stored fingerprint so its geolocation (timezone, /// language, coordinates) matches `exit_ip`, and persist it. This is the /// on-demand resolution for the consistency warning: launches no longer rewrite /// the fingerprint silently, so the user opts into matching it here. /// /// `exit_ip` is the exit the consistency check already resolved, applied via /// MaxMind directly — no second proxy round-trip — and it forces geolocation /// even when the profile has geo spoofing disabled, since the user explicitly /// asked to match. Takes effect on the next launch of the profile. #[tauri::command] pub async fn match_profile_fingerprint_to_exit( profile_id: String, exit_ip: String, ) -> Result<(), String> { let manager = crate::profile::ProfileManager::instance(); let mut profile = manager .list_profiles() .map_err(|e| e.to_string())? .into_iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?; let mut config = profile .wayfern_config .clone() .filter(|c| c.fingerprint.is_some()) .ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?; let fingerprint = config.fingerprint.clone().unwrap(); let geoip_override = serde_json::Value::String(exit_ip); let refreshed = crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation( &fingerprint, None, Some(&geoip_override), ) .await .ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?; config.fingerprint = Some(refreshed); profile.wayfern_config = Some(config); manager.save_profile(&profile).map_err(|e| { serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } }) .to_string() })?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn language_check_accepts_the_main_language_of_the_country() { assert_eq!(language_matches_country("US", "en-US"), Some(true)); assert_eq!(language_matches_country("de", "de-DE"), Some(true)); assert_eq!(language_matches_country("BR", "pt-BR"), Some(true)); } #[test] fn language_check_accepts_what_the_fingerprint_generator_emits() { // These are not the "expected" language for the country, but the generator // samples the CLDR distribution and produces them routinely — CLDR puts es // at 9.6% in the US and fr at 30% in Canada. Flagging them warns the user // about a fingerprint Donut itself created. assert_eq!(language_matches_country("US", "es-US"), Some(true)); assert_eq!(language_matches_country("CA", "fr-CA"), Some(true)); assert_eq!(language_matches_country("CA", "en-CA"), Some(true)); } #[test] fn language_check_flags_us_fingerprint_behind_armenian_exit() { // The reported scenario: a US (en) fingerprint routed through an Armenian // exit. Armenia's CLDR lists hy/ku/az, never en, so this must flag. assert_eq!(language_matches_country("AM", "en-US"), Some(false)); // And a fingerprint actually matched to Armenia must not flag. assert_eq!(language_matches_country("AM", "hy-AM"), Some(true)); } #[test] fn language_check_still_flags_implausible_combinations() { // Nothing in CLDR associates these with the country, so they remain the // anti-bot tell the check exists to surface. (Japan lists ja/ryu/ko only.) assert_eq!(language_matches_country("JP", "pt-BR"), Some(false)); assert_eq!(language_matches_country("JP", "de-DE"), Some(false)); assert_eq!(language_matches_country("BR", "ru-RU"), Some(false)); } #[test] fn language_check_tolerates_minority_languages_the_generator_can_emit() { // CLDR lists ja for Brazil (0.21%, the Japanese-Brazilian community) and de // for the US (0.47%), and the generator samples both. They are weak signals // but flagging them would contradict our own fingerprints, so the check // accepts anything CLDR lists at all. That is the deliberate ceiling on // this dimension — timezone, compared exactly, carries the real signal. assert_eq!(language_matches_country("BR", "ja-JP"), Some(true)); assert_eq!(language_matches_country("US", "de-DE"), Some(true)); } #[test] fn language_check_skips_countries_without_cldr_data() { // The old hand-written table returned None for ~180 countries and silently // skipped the check; only genuinely unknown territories should do that now. assert_eq!(language_matches_country("ZZ", "en-US"), None); // Countries the old table never covered are now checked. assert!(language_matches_country("ID", "id-ID").is_some()); assert!(language_matches_country("CH", "de-CH").is_some()); } #[test] fn proxy_url_percent_encodes_credentials_and_skips_shadowsocks() { let http = crate::browser::ProxySettings { proxy_type: "http".into(), host: "h".into(), port: 8080, username: Some("u".into()), password: Some("p".into()), vless_uri: None, }; assert_eq!(proxy_url(&http, None).as_deref(), Some("http://u:p@h:8080")); // A password with URL-reserved characters must not break the authority — // unencoded, the `/` truncates the host and reqwest targets `u` instead. let reserved = crate::browser::ProxySettings { proxy_type: "http".into(), host: "gw.provider.io".into(), port: 8080, username: Some("user".into()), password: Some("ab/cd@ef".into()), vless_uri: None, }; assert_eq!( proxy_url(&reserved, None).as_deref(), Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080") ); // Username-only proxies keep their auth. let user_only = crate::browser::ProxySettings { proxy_type: "socks5".into(), host: "h".into(), port: 1080, username: Some("justuser".into()), password: None, vless_uri: None, }; assert_eq!( proxy_url(&user_only, None).as_deref(), Some("socks5://justuser@h:1080") ); let ss = crate::browser::ProxySettings { proxy_type: "ss".into(), host: "h".into(), port: 8080, username: None, password: None, vless_uri: None, }; assert_eq!(proxy_url(&ss, None), None); } }