diff --git a/AGENTS.md b/AGENTS.md index daf00b5..bfeaf85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ donutbrowser/ │ ├── app/ # App router (page.tsx, layout.tsx) │ ├── components/ # 50+ React components (dialogs, tables, UI) │ ├── hooks/ # Event-driven React hooks -│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, vi, zh) +│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, tr, vi, zh) │ ├── lib/ # Utilities (themes, toast, browser-utils) │ └── types.ts # Shared TypeScript interfaces ├── src-tauri/ # Rust backend (Tauri) @@ -38,6 +38,10 @@ donutbrowser/ │ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi) │ │ ├── settings_manager.rs # App settings persistence │ │ ├── cookie_manager.rs # Cookie import/export +│ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch) +│ │ ├── fingerprint_consistency.rs # Launch-time proxy exit vs fingerprint timezone/language check +│ │ ├── dns_blocklist.rs # Hagezi DNS blocklists + user custom lists/allowlist +│ │ ├── traffic_stats.rs # Per-profile traffic stats + secure history erase │ │ ├── extension_manager.rs # Browser extension management │ │ ├── group_manager.rs # Profile group management │ │ ├── synchronizer.rs # Real-time profile synchronizer @@ -79,12 +83,12 @@ Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropria - Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`. - This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it. -- Adding a new string means adding the key to ALL nine locale files in `src/i18n/locales/` (en, es, fr, ja, ko, pt, ru, vi, zh) — not just `en.json`. The English version alone is incomplete work. +- Adding a new string means adding the key to EVERY locale file in `src/i18n/locales/` — currently en, es, fr, ja, ko, pt, ru, tr, vi, zh — not just `en.json`. The English version alone is incomplete work. Don't trust this list: enumerate `src/i18n/locales/*.json` and update every file you find, because a newly added locale is exactly what a hardcoded list silently skips. - Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first. - Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it. - **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site. - Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix. -- When adding or removing keys across all nine locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Nine sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away. +- When adding or removing keys across the locales, use a one-shot Python script in the scratchpad dir that globs `src/i18n/locales/*.json`, mutates each, and writes it back. Sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away. Finish by diffing every locale's flattened key set against `en.json` — zero missing and zero extra, for all of them. ## Backend error codes (mandatory) @@ -98,7 +102,7 @@ User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BA ``` 2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`. 3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`. -4. Add `backendErrors.fooBar` to all nine locale files. +4. Add `backendErrors.fooBar` to every locale file in `src/i18n/locales/`. Raw error strings reach the user untranslated; that's the bug pattern this rule blocks. @@ -175,7 +179,7 @@ Reference implementations: `proxy-management-dialog.tsx`, `extension-management- All app-wide shortcuts live in `src/lib/shortcuts.ts`: -- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all nine locales. +- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in every locale. - `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette. - `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut. - `matchesGroupDigit(event)` returns 1–9 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table. @@ -185,7 +189,7 @@ Dispatch: the global `keydown` listener and the `runShortcut` callback both live 1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant. 2. Add a `case "yourId":` in `runShortcut` in `page.tsx`. 3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`. -4. Add `shortcuts.yourId` (label) to all nine locale files. +4. Add `shortcuts.yourId` (label) to every locale file in `src/i18n/locales/`. The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching. diff --git a/package.json b/package.json index b1c52b8..80f32da 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "cmdk": "^1.1.1", "color": "^5.0.3", "flag-icons": "^7.5.0", - "framer-motion": "^12.42.2", "i18next": "^26.3.4", "lucide-react": "^1.23.0", "motion": "^12.42.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d1eefb..9a8c1a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,9 +109,6 @@ importers: flag-icons: specifier: ^7.5.0 version: 7.5.0 - framer-motion: - specifier: ^12.42.2 - version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) i18next: specifier: ^26.3.4 version: 26.3.4(typescript@6.0.3) diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 82ec879..99e743f 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -39,6 +39,7 @@ pub struct ApiProfile { pub is_running: bool, pub proxy_bypass_rules: Vec, pub vpn_id: Option, + pub clear_on_close: bool, } #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -94,6 +95,9 @@ pub struct UpdateProfileRequest { pub proxy_bypass_rules: Option>, /// One of "Disabled", "Regular", "Encrypted". pub sync_mode: Option, + /// Wipe browsing data (keeping extensions and bookmarks) when the browser + /// exits. Rejected (400) for ephemeral or password-protected profiles. + pub clear_on_close: Option, } #[derive(Clone)] @@ -756,6 +760,11 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) { StatusCode::NOT_FOUND } else if code == "INTERNAL_ERROR" { StatusCode::INTERNAL_SERVER_ERROR + } else if code.ends_with("_REQUIRES_PRO") || code.ends_with("_PAYMENT_REQUIRED") { + // Paid-feature gates (FINGERPRINT_REQUIRES_PRO, PROXY_PAYMENT_REQUIRED). + // Mapping them here lets the gate live in the shared manager instead of + // being re-implemented in each handler to get the status right. + StatusCode::PAYMENT_REQUIRED } else { // Validation-style codes (NAME_CANNOT_BE_EMPTY, GROUP_ALREADY_EXISTS, // WAYFERN_VERSION_NOT_AVAILABLE, ...). @@ -838,6 +847,7 @@ async fn get_profiles() -> Result, StatusCode> { is_running: profile.process_id.is_some(), // Simple check based on process_id proxy_bypass_rules: profile.proxy_bypass_rules.clone(), vpn_id: profile.vpn_id.clone(), + clear_on_close: profile.clear_on_close, }) .collect(); @@ -891,6 +901,7 @@ async fn get_profile( is_running: profile.process_id.is_some(), // Simple check based on process_id proxy_bypass_rules: profile.proxy_bypass_rules.clone(), vpn_id: profile.vpn_id.clone(), + clear_on_close: profile.clear_on_close, }, })) } else { @@ -1055,6 +1066,7 @@ async fn create_profile( is_running: false, proxy_bypass_rules: profile.proxy_bypass_rules, vpn_id: profile.vpn_id, + clear_on_close: profile.clear_on_close, }, })) } @@ -1196,6 +1208,14 @@ async fn update_profile( } } + if let Some(clear_on_close) = request.clear_on_close { + if let Err(e) = + profile_manager.update_profile_clear_on_close(&state.app_handle, &id, clear_on_close) + { + return Err(manager_error_response(e)); + } + } + // Return updated profile get_profile(Path(id), State(state)) .await @@ -2412,17 +2432,8 @@ async fn import_profiles_api( .as_ref() .and_then(|config| serde_json::from_value(config.clone()).ok()); - let fingerprint_os = wayfern_config.as_ref().and_then(|c| c.os.as_deref()); - if !crate::cloud_auth::CLOUD_AUTH - .is_fingerprint_os_allowed(fingerprint_os) - .await - { - return Err(( - StatusCode::PAYMENT_REQUIRED, - "Fingerprint OS spoofing requires an active Pro subscription.".to_string(), - )); - } - + // The Pro gate for fingerprint OS spoofing lives inside import_profiles, so + // every surface inherits it; manager_error_response maps the code to 402. let importer = crate::profile_importer::ProfileImporter::instance(); importer .import_profiles( @@ -2706,7 +2717,7 @@ mod tests { } let import_item = schema_required(&spec, "ImportProfileItem"); - for field in ["proxy_id", "browser_type"] { + for field in ["proxy_id", "vpn_id", "browser_type"] { assert!( !import_item.iter().any(|f| f == field), "{field} must be optional on import items, required list: {import_item:?}" diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index f659bd2..baca780 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -668,6 +668,7 @@ mod tests { created_by_email: None, dns_blocklist: None, password_protected: false, + clear_on_close: false, created_at: None, updated_at: None, } diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index b3532eb..28e00fd 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -163,6 +163,12 @@ async fn main() { .long("blocklist-file") .help("Path to DNS blocklist file (one domain per line)"), ) + .arg( + Arg::new("dns-allowlist-mode") + .long("dns-allowlist-mode") + .num_args(0) + .help("Treat --blocklist-file as an allowlist (block all domains not listed)"), + ) .arg( Arg::new("local-protocol") .long("local-protocol") @@ -256,6 +262,7 @@ async fn main() { .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); let blocklist_file = start_matches.get_one::("blocklist-file").cloned(); + let dns_allowlist_mode = start_matches.get_flag("dns-allowlist-mode"); let local_protocol = start_matches.get_one::("local-protocol").cloned(); match start_proxy_process_with_profile( @@ -264,6 +271,7 @@ async fn main() { profile_id, bypass_rules, blocklist_file, + dns_allowlist_mode, local_protocol, ) .await diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index d9ee5c4..cde8b0c 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -642,6 +642,7 @@ mod tests { created_by_email: None, dns_blocklist: None, password_protected: false, + clear_on_close: false, created_at: None, updated_at: None, }; diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 41e84f6..1a5e6f0 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -34,24 +34,29 @@ impl BrowserRunner { crate::app_dirs::binaries_dir() } - /// Resolve the DNS blocklist level to a cached file path. - /// If a level is set but the cache is missing, fetches on demand (blocks until done). + /// Resolve the DNS blocklist level to a cached file path plus whether that + /// file should be treated as an allowlist. If a level is set but the cache + /// is missing, fetches/compiles on demand (blocks until done). async fn resolve_blocklist_file( profile: &crate::profile::BrowserProfile, - ) -> Result, String> { + ) -> Result<(Option, bool), String> { let Some(ref level_str) = profile.dns_blocklist else { - return Ok(None); + return Ok((None, false)); }; let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else { - return Ok(None); + return Ok((None, false)); }; if level == crate::dns_blocklist::BlocklistLevel::None { - return Ok(None); + return Ok((None, false)); } + // Only the user's custom list can be an allowlist; the Hagezi tiers are + // always block lists. + let allowlist_mode = level == crate::dns_blocklist::BlocklistLevel::Custom + && crate::dns_blocklist::CustomDnsConfig::load().allowlist_mode; let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level) .await .map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?; - Ok(Some(path.to_string_lossy().to_string())) + Ok((Some(path.to_string_lossy().to_string()), allowlist_mode)) } /// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy, @@ -247,15 +252,20 @@ impl BrowserRunner { // Start the proxy and get local proxy settings // If proxy startup fails, DO NOT launch Wayfern - it requires local proxy let profile_id_str = profile.id.to_string(); - let blocklist_file = Self::resolve_blocklist_file(profile).await?; + let (blocklist_file, dns_allowlist_mode) = Self::resolve_blocklist_file(profile).await?; + // Unique per-launch key: a shared constant here would let concurrent + // launches overwrite each other's active_proxies entry, ending with one + // browser's worker tracked under another browser's PID. + let launch_placeholder_pid = crate::proxy_manager::next_launch_placeholder_pid(); let local_proxy = PROXY_MANAGER .start_proxy( app_handle.clone(), upstream_proxy.as_ref(), - 0, // Use 0 as temporary PID, will be updated later + launch_placeholder_pid, Some(&profile_id_str), profile.proxy_bypass_rules.clone(), blocklist_file, + dns_allowlist_mode, // Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC // UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without // leaking the real IP, rather than being forced direct as they @@ -269,6 +279,40 @@ impl BrowserRunner { error_msg })?; + // If any step below fails before the browser is up, the detached worker + // must be stopped here: its config never gets a browser_pid, so neither + // the GUI sweeps nor the worker's own watchdog would ever reap it — it + // would survive until machine reboot. + struct ProxyLaunchGuard { + app_handle: tauri::AppHandle, + placeholder_pid: u32, + profile_name: String, + armed: bool, + } + impl Drop for ProxyLaunchGuard { + fn drop(&mut self) { + if self.armed { + log::warn!( + "Launch failed after local proxy start for profile {}; stopping proxy worker", + self.profile_name + ); + let app_handle = self.app_handle.clone(); + let pid = self.placeholder_pid; + tauri::async_runtime::spawn(async move { + if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle, pid).await { + log::warn!("Failed to stop proxy worker after failed launch: {e}"); + } + }); + } + } + } + let mut proxy_launch_guard = ProxyLaunchGuard { + app_handle: app_handle.clone(), + placeholder_pid: launch_placeholder_pid, + profile_name: profile.name.clone(), + armed: true, + }; + // Format proxy URL for wayfern - use SOCKS5 for the local proxy so // Chromium proxies UDP (QUIC/WebRTC), not just TCP. let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port); @@ -317,11 +361,10 @@ impl BrowserRunner { if wayfern_config.os.is_some() { 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. Only when geolocation actually applied — otherwise leave it - // unset so the refresh path can repair the location if the user later - // turns randomize off. + // Record which routing this fresh fingerprint's geolocation was built + // for (provenance only — nothing rewrites the fingerprint from it). + // Only when geolocation actually applied; otherwise leave it unset so a + // later on-demand match can tell the location was never resolved. updated_wayfern_config.geo_proxy_signature = if geolocation_applied { Some(crate::wayfern_manager::WayfernManager::geo_signature( upstream_proxy.as_ref(), @@ -338,63 +381,15 @@ impl BrowserRunner { profile.name, updated_wayfern_config.fingerprint.as_ref().map(|f| f.len()).unwrap_or(0) ); - } else { - // Safety net: the stored fingerprint's timezone and geolocation were - // computed for whatever proxy was set when the fingerprint was - // generated. If the profile's proxy or VPN has changed since (the - // common case being a user who forgot to set a proxy at creation and - // added one afterwards), that location data is stale and the user would - // see the wrong timezone on first launch. When the routing signature no - // longer matches, refresh just the location fields of the stored - // fingerprint through the current proxy. Wayfern only; the randomize - // path above already regenerates the whole fingerprint each launch. - let current_geo_sig = crate::wayfern_manager::WayfernManager::geo_signature( - upstream_proxy.as_ref(), - profile.vpn_id.as_deref(), - wayfern_config.geoip.as_ref(), - ); - let geo_enabled = !matches!( - wayfern_config.geoip.as_ref(), - Some(serde_json::Value::Bool(false)) - ); - if geo_enabled - && wayfern_config.geo_proxy_signature.as_deref() != Some(current_geo_sig.as_str()) - { - if let Some(stored_fp) = wayfern_config.fingerprint.clone() { - log::info!( - "Routing changed for Wayfern profile {} since its fingerprint was generated (was {:?}, now {}); refreshing timezone and geolocation", - profile.name, - wayfern_config.geo_proxy_signature, - current_geo_sig - ); - match crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation( - &stored_fp, - wayfern_config.proxy.as_deref(), - wayfern_config.geoip.as_ref(), - ) - .await - { - Some(refreshed) => { - // Use the refreshed fingerprint for this launch... - wayfern_config.fingerprint = Some(refreshed.clone()); - wayfern_config.geo_proxy_signature = Some(current_geo_sig.clone()); - // ...and persist it so the corrected location sticks and we do - // not refresh again on the next launch with the same proxy. - let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default(); - cfg.fingerprint = Some(refreshed); - cfg.geo_proxy_signature = Some(current_geo_sig); - updated_profile.wayfern_config = Some(cfg); - } - None => { - log::warn!( - "Could not refresh geolocation for Wayfern profile {} (proxy unreachable?); launching with existing location and will retry next launch", - profile.name - ); - } - } - } - } } + // A non-randomize profile keeps its configured fingerprint verbatim, even + // when its proxy/VPN routing has changed since the fingerprint was built. + // We deliberately do NOT silently rewrite its timezone/language to match + // the new exit: that hid every real fingerprint-vs-exit mismatch (a US + // fingerprint behind a German exit would be quietly relabelled German + // before the launch-time consistency check could see it). The check now + // surfaces the mismatch, and the user re-matches on demand via + // `match_profile_fingerprint_to_exit`. // Create ephemeral dir for ephemeral or password-protected profiles if profile.password_protected { @@ -457,6 +452,10 @@ impl BrowserRunner { format!("Failed to launch Wayfern: {e}").into() })?; + // Browser is up and using the worker — failures past this point must + // not stop it. + proxy_launch_guard.armed = false; + // Get the process ID from launch result let process_id = wayfern_result.processId.unwrap_or(0); log::info!("Wayfern launched successfully with PID: {process_id}"); @@ -483,11 +482,18 @@ impl BrowserRunner { updated_profile.process_id = Some(process_id); updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()); - // Update the proxy manager with the correct PID - if let Err(e) = PROXY_MANAGER.update_proxy_pid(0, process_id) { - log::warn!("Warning: Failed to update proxy PID mapping: {e}"); - } else { - log::info!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}"); + // Update the proxy manager with the correct PID. When the browser + // reported no PID, keep the entry keyed by its unique placeholder (which + // the cleanup sweep skips) rather than remapping to a shared 0 key that + // concurrent launches could collide on. + if process_id != 0 { + if let Err(e) = PROXY_MANAGER.update_proxy_pid(launch_placeholder_pid, process_id) { + log::warn!("Warning: Failed to update proxy PID mapping: {e}"); + } else { + log::info!( + "Updated proxy PID mapping from launch placeholder {launch_placeholder_pid} to actual PID: {process_id}" + ); + } } // Persist the real browser PID so the detached proxy worker self-reaps @@ -1096,6 +1102,10 @@ impl BrowserRunner { crate::profile::password::complete_after_quit_and_wait(profile).await; } else if profile.ephemeral { crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string()); + } else if profile.clear_on_close { + // Awaited for the same reason as re-encryption above: a queued sync + // must see the cleared dir, not the pre-clear snapshot. + crate::profile::clear_on_close::clear_profile_browsing_data(profile).await; } log::info!( @@ -1296,11 +1306,8 @@ pub async fn launch_browser_profile_impl( updated_profile.id ); - // Now update the proxy with the correct PID if we have one - if let Some(actual_pid) = updated_profile.process_id { - // Update the proxy manager with the correct PID (we always started with temp pid 1) - let _ = PROXY_MANAGER.update_proxy_pid(1u32, actual_pid); - } + // The proxy PID mapping was already reconciled inside launch_browser_internal + // (placeholder → real browser PID); nothing is ever keyed by a constant here. Ok(updated_profile) } diff --git a/src-tauri/src/dns_blocklist.rs b/src-tauri/src/dns_blocklist.rs index 852435f..28660d4 100644 --- a/src-tauri/src/dns_blocklist.rs +++ b/src-tauri/src/dns_blocklist.rs @@ -16,6 +16,9 @@ pub enum BlocklistLevel { Pro, ProPlus, Ultimate, + /// User-defined list: compiled from custom source URLs + custom block + /// domains, with custom allow domains removed (allowlist overrides). + Custom, } impl BlocklistLevel { @@ -26,6 +29,7 @@ impl BlocklistLevel { "pro" => Some(Self::Pro), "pro_plus" => Some(Self::ProPlus), "ultimate" => Some(Self::Ultimate), + "custom" => Some(Self::Custom), "none" => Some(Self::None), _ => None, } @@ -39,6 +43,7 @@ impl BlocklistLevel { Self::Pro => "pro", Self::ProPlus => "pro_plus", Self::Ultimate => "ultimate", + Self::Custom => "custom", } } @@ -50,12 +55,13 @@ impl BlocklistLevel { Self::Pro => "Pro", Self::ProPlus => "Pro++", Self::Ultimate => "Ultimate", + Self::Custom => "Custom", } } pub fn url(&self) -> Option<&'static str> { match self { - Self::None => None, + Self::None | Self::Custom => None, Self::Light => { Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt") } @@ -80,6 +86,7 @@ impl BlocklistLevel { Self::Pro => Some("pro.txt"), Self::ProPlus => Some("pro.plus.txt"), Self::Ultimate => Some("ultimate.txt"), + Self::Custom => Some("custom.txt"), } } @@ -94,6 +101,150 @@ impl BlocklistLevel { } } +/// User-defined DNS filtering: extra blocklist source URLs plus manual block / +/// allow domain rules. Allow rules override blocks (the standard exceptions +/// model). Stored as one JSON blob; the compiled result lands in `custom.txt`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CustomDnsConfig { + #[serde(default)] + pub sources: Vec, + #[serde(default)] + pub block_domains: Vec, + #[serde(default)] + pub allow_domains: Vec, + /// When true the custom list is a strict allowlist: the browser may only + /// reach `allow_domains` (and their subdomains); everything else is blocked. + /// Sources/block_domains are ignored in this mode. + #[serde(default)] + pub allowlist_mode: bool, + #[serde(default)] + pub updated_at: Option, +} + +fn normalize_domain(raw: &str) -> Option { + let mut line = raw.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with('!') { + return None; + } + // Hosts-file format ("0.0.0.0 ads.example.com", "127.0.0.1 tracker.net") is + // common in public blocklists — take the domain after the sink IP rather + // than rejecting the whole line on the embedded space. + if let Some((first, rest)) = line.split_once(char::is_whitespace) { + if matches!(first, "0.0.0.0" | "127.0.0.1" | "::" | "::1") { + line = rest.trim(); + } + } + // Strip a trailing comment ("0.0.0.0 ads.example.com # AdGuard"). Public + // hosts lists annotate entries this way; without this the whitespace guard + // below rejects every annotated line, silently dropping most of the source. + for marker in ['#', '!'] { + if let Some((before, _)) = line.split_once(marker) { + line = before.trim(); + } + } + let d = line + .trim_start_matches("*.") + .trim_start_matches("||") + .trim_end_matches('^') + .trim_end_matches('.') + .to_lowercase(); + if d.is_empty() { + return None; + } + // Reject anything still containing whitespace or a scheme — not a bare domain. + if d.contains(char::is_whitespace) || d.contains("://") { + return None; + } + Some(d) +} + +impl CustomDnsConfig { + fn path() -> PathBuf { + app_dirs::data_subdir().join("custom_dns.json") + } + + pub fn load() -> Self { + match std::fs::read_to_string(Self::path()) { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + pub fn save(&self) -> Result<(), String> { + let path = Self::path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; + std::fs::write(&path, json).map_err(|e| e.to_string()) + } + + /// Serialize to a plain-text rule list (uBlock-ish): `! source:` comments for + /// source URLs, `@@domain` for allow rules, bare domains for blocks. + pub fn to_txt(&self) -> String { + let mut out = String::new(); + for s in &self.sources { + out.push_str("! source: "); + out.push_str(s); + out.push('\n'); + } + for d in &self.allow_domains { + out.push_str("@@"); + out.push_str(d); + out.push('\n'); + } + for d in &self.block_domains { + out.push_str(d); + out.push('\n'); + } + out + } + + /// Parse a plain-text rule list back into a config (sources from + /// `! source:` lines, `@@`-prefixed as allow, bare domains as block). + pub fn from_txt(content: &str) -> Self { + let mut cfg = CustomDnsConfig::default(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(src) = line.strip_prefix("! source:") { + let src = src.trim(); + if !src.is_empty() { + cfg.sources.push(src.to_string()); + } + continue; + } + if line.starts_with('#') || line.starts_with('!') { + continue; + } + if let Some(allow) = line.strip_prefix("@@") { + if let Some(d) = normalize_domain(allow) { + cfg.allow_domains.push(d); + } + } else if let Some(d) = normalize_domain(line) { + cfg.block_domains.push(d); + } + } + cfg.dedup(); + cfg + } + + /// Drop duplicates, preserving first-seen order so an exported rule list + /// still reads the way the user wrote it. + fn dedup(&mut self) { + for v in [ + &mut self.sources, + &mut self.block_domains, + &mut self.allow_domains, + ] { + let mut seen = std::collections::HashSet::new(); + v.retain(|item| seen.insert(item.clone())); + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlocklistCacheStatus { pub level: String, @@ -193,6 +344,19 @@ impl BlocklistManager { } pub async fn ensure_cached(level: BlocklistLevel) -> Result { + if level == BlocklistLevel::Custom { + // Recompile only when the compiled file is missing or stale. Edits call + // compile_custom_blocklist directly and refresh_all_stale keeps sources + // current, so recompiling unconditionally would re-download every source + // on every profile launch — and this is awaited on the blocking launch + // path, so the browser cannot start until it finishes. + if let Some(path) = Self::cached_file_path(level) { + if Self::is_cache_fresh(level) { + return Ok(path); + } + } + return Self::compile_custom_blocklist().await; + } if let Some(path) = Self::cached_file_path(level) { if path.exists() { return Ok(path); @@ -201,6 +365,120 @@ impl BlocklistManager { Self::fetch_blocklist(level).await } + /// Compile the user's custom DNS file. In blocklist mode: fetch every source, + /// union with the manual block domains, then remove the allow domains + /// (allowlist overrides). In allowlist mode: the file is just the allow + /// domains — the worker then blocks everything NOT listed. Always rewrites + /// `custom.txt`. + pub async fn compile_custom_blocklist() -> Result { + use std::collections::HashSet; + + let config = CustomDnsConfig::load(); + let mut domains: HashSet = HashSet::new(); + let path = + Self::cached_file_path(BlocklistLevel::Custom).ok_or("No filename for custom level")?; + + if config.allowlist_mode { + // Strict allowlist: the compiled file is the set of permitted domains. + for d in &config.allow_domains { + if let Some(n) = normalize_domain(d) { + domains.insert(n); + } + } + } else { + // Fetch every source concurrently. Sequential awaits make this the sum of + // all source latencies, and it runs on the blocking profile-launch path. + let fetches = config.sources.iter().map(|source| async move { + match HTTP_CLIENT.get(source).send().await { + Ok(resp) if resp.status().is_success() => resp + .text() + .await + .map_err(|e| format!("custom source {source} body read failed: {e}")), + Ok(resp) => Err(format!( + "custom source {source} returned HTTP {}", + resp.status() + )), + Err(e) => Err(format!("custom source {source} failed: {e}")), + } + }); + + let mut source_failures = 0usize; + for result in futures_util::future::join_all(fetches).await { + match result { + Ok(body) => { + for line in body.lines() { + if let Some(d) = normalize_domain(line) { + domains.insert(d); + } + } + } + Err(e) => { + log::warn!("[dns-blocklist] {e}"); + source_failures += 1; + } + } + } + + // A failed source must never shrink the compiled list. Overwriting with a + // short (or empty) result would silently stop blocking whatever that + // source contributed — and `is_blocked` fails open on an empty set, so a + // single offline launch would disable custom filtering entirely and + // destroy the good cached list. Re-seed from the cached file so a network + // failure degrades to "stale" instead of "off"; manual rule edits below + // still apply, and the next successful compile rewrites cleanly. + if source_failures > 0 { + match std::fs::read_to_string(&path) { + Ok(cached) => { + let before = domains.len(); + for line in cached.lines() { + if let Some(d) = normalize_domain(line) { + domains.insert(d); + } + } + log::warn!( + "[dns-blocklist] {source_failures} custom source(s) failed; retained {} domain(s) from the cached list", + domains.len().saturating_sub(before) + ); + } + Err(_) => log::warn!( + "[dns-blocklist] {source_failures} custom source(s) failed and no cached list exists; custom filtering is incomplete" + ), + } + } + + for d in &config.block_domains { + if let Some(n) = normalize_domain(d) { + domains.insert(n); + } + } + // Allow rules override blocks (exceptions). + for d in &config.allow_domains { + if let Some(n) = normalize_domain(d) { + domains.remove(&n); + } + } + } + + let cache_dir = Self::cache_dir(); + std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?; + + let mut sorted: Vec<&str> = domains.iter().map(String::as_str).collect(); + sorted.sort_unstable(); + let body = sorted.join("\n"); + + let tmp_path = path.with_extension("tmp"); + std::fs::write(&tmp_path, &body) + .map_err(|e| format!("Failed to write custom blocklist: {e}"))?; + std::fs::rename(&tmp_path, &path) + .map_err(|e| format!("Failed to rename custom blocklist: {e}"))?; + + log::info!( + "[dns-blocklist] Compiled custom blocklist ({} domains)", + domains.len() + ); + Ok(path) + } + pub async fn refresh_all_stale(&self) { for &level in BlocklistLevel::all_downloadable() { if !Self::is_cache_fresh(level) { @@ -219,6 +497,13 @@ impl BlocklistManager { } } } + // Recompile the custom list too so its sources track upstream changes. + let config = CustomDnsConfig::load(); + if !config.sources.is_empty() || !config.block_domains.is_empty() { + if let Err(e) = Self::compile_custom_blocklist().await { + log::error!("[dns-blocklist] Failed to recompile custom list: {e}"); + } + } } pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option { @@ -289,6 +574,102 @@ pub async fn refresh_dns_blocklists() -> Result<(), String> { Ok(()) } +#[tauri::command] +pub async fn get_custom_dns_config() -> Result { + Ok(CustomDnsConfig::load()) +} + +/// Normalize, persist, recompile and announce a custom DNS config. Shared by +/// the set and import commands so both apply the same normalization and the +/// same side effects — a step added here reaches both. +async fn persist_custom_config(mut config: CustomDnsConfig) -> Result { + config.sources = std::mem::take(&mut config.sources) + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + config.block_domains = config + .block_domains + .iter() + .filter_map(|d| normalize_domain(d)) + .collect(); + config.allow_domains = config + .allow_domains + .iter() + .filter_map(|d| normalize_domain(d)) + .collect(); + config.updated_at = Some(crate::proxy_manager::now_secs()); + config.dedup(); + config + .save() + .map_err(|_| serde_json::json!({ "code": "DNS_RULES_SAVE_FAILED" }).to_string())?; + // Recompile so a profile relaunch picks up the new rules immediately. + let _ = BlocklistManager::compile_custom_blocklist().await; + let _ = crate::events::emit_empty("custom-dns-changed"); + Ok(config) +} + +/// Persist the custom DNS config and recompile the `custom.txt` list. Domains +/// are normalized; blank/comment entries are dropped. +#[tauri::command] +pub async fn set_custom_dns_config( + sources: Vec, + block_domains: Vec, + allow_domains: Vec, + allowlist_mode: bool, +) -> Result { + persist_custom_config(CustomDnsConfig { + sources, + block_domains, + allow_domains, + allowlist_mode, + updated_at: None, + }) + .await +} + +/// Import custom DNS rules. `format` is "json" (a full CustomDnsConfig) or +/// "txt" (uBlock-ish: `! source:`, `@@allow`, bare block domains). +#[tauri::command] +pub async fn import_custom_dns_rules( + content: String, + format: String, +) -> Result { + let config = match format.as_str() { + "json" => serde_json::from_str::(&content) + .map_err(|_| serde_json::json!({ "code": "INVALID_DNS_RULES_JSON" }).to_string())?, + "txt" => CustomDnsConfig::from_txt(&content), + other => { + return Err( + serde_json::json!({ + "code": "UNSUPPORTED_DNS_RULES_FORMAT", + "params": { "format": other }, + }) + .to_string(), + ) + } + }; + persist_custom_config(config).await +} + +/// Export the custom DNS rules as "json" or "txt". +#[tauri::command] +pub async fn export_custom_dns_rules(format: String) -> Result { + let config = CustomDnsConfig::load(); + match format.as_str() { + "json" => serde_json::to_string_pretty(&config) + .map_err(|_| serde_json::json!({ "code": "DNS_RULES_EXPORT_FAILED" }).to_string()), + "txt" => Ok(config.to_txt()), + other => Err( + serde_json::json!({ + "code": "UNSUPPORTED_DNS_RULES_FORMAT", + "params": { "format": other }, + }) + .to_string(), + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -306,6 +687,83 @@ mod tests { ); } + #[test] + fn test_custom_config_txt_roundtrip() { + let txt = "! source: https://example.com/list.txt\n@@allowed.com\nblocked.com\n*.tracker.net\n"; + let cfg = CustomDnsConfig::from_txt(txt); + assert_eq!(cfg.sources, vec!["https://example.com/list.txt"]); + assert_eq!(cfg.allow_domains, vec!["allowed.com"]); + assert_eq!(cfg.block_domains, vec!["blocked.com", "tracker.net"]); + // Re-exporting produces parseable text. + let reparsed = CustomDnsConfig::from_txt(&cfg.to_txt()); + assert_eq!(reparsed.block_domains, cfg.block_domains); + assert_eq!(reparsed.allow_domains, cfg.allow_domains); + assert_eq!(reparsed.sources, cfg.sources); + } + + #[test] + fn test_normalize_domain() { + assert_eq!( + normalize_domain("*.Example.COM"), + Some("example.com".into()) + ); + assert_eq!(normalize_domain("||ads.net^"), Some("ads.net".into())); + assert_eq!(normalize_domain(" "), None); + assert_eq!(normalize_domain("# comment"), None); + assert_eq!(normalize_domain("http://x.com"), None); + // Hosts-file format lines yield the domain, not None. + assert_eq!( + normalize_domain("0.0.0.0 ads.example.com"), + Some("ads.example.com".into()) + ); + assert_eq!( + normalize_domain("127.0.0.1\ttracker.net"), + Some("tracker.net".into()) + ); + // A bare two-token line that isn't hosts-format is still rejected. + assert_eq!(normalize_domain("foo bar"), None); + } + + #[test] + fn test_normalize_domain_strips_trailing_comments() { + // Public hosts lists (StevenBlack, AdAway) annotate entries inline. Without + // comment stripping the whitespace guard drops every annotated line, so a + // source compiles down to a fraction of its real domain count. + assert_eq!( + normalize_domain("0.0.0.0 ads.example.com # AdGuard"), + Some("ads.example.com".into()) + ); + assert_eq!( + normalize_domain("127.0.0.1 tracker.net #comment"), + Some("tracker.net".into()) + ); + assert_eq!( + normalize_domain("plain.example.com # trailing note"), + Some("plain.example.com".into()) + ); + assert_eq!( + normalize_domain("ads.example.com ! adblock note"), + Some("ads.example.com".into()) + ); + // A sink IP followed only by a comment has no domain left. + assert_eq!(normalize_domain("0.0.0.0 # just a comment"), None); + // Full-line comments stay rejected. + assert_eq!(normalize_domain("! adblock header"), None); + } + + #[test] + fn test_custom_level_roundtrip() { + assert_eq!( + BlocklistLevel::parse_level("custom"), + Some(BlocklistLevel::Custom) + ); + assert_eq!(BlocklistLevel::Custom.as_str(), "custom"); + assert_eq!(BlocklistLevel::Custom.filename(), Some("custom.txt")); + assert!(BlocklistLevel::Custom.url().is_none()); + // Custom must NOT be in the auto-refresh set (no upstream URL). + assert!(!BlocklistLevel::all_downloadable().contains(&BlocklistLevel::Custom)); + } + #[test] fn test_level_urls_all_present() { for &level in BlocklistLevel::all_downloadable() { diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index 19f2705..8b4e601 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -280,6 +280,7 @@ mod tests { created_by_email: None, dns_blocklist: None, password_protected: false, + clear_on_close: false, created_at: None, updated_at: None, } diff --git a/src-tauri/src/fingerprint_consistency.rs b/src-tauri/src/fingerprint_consistency.rs new file mode 100644 index 0000000..3e17f25 --- /dev/null +++ b/src-tauri/src/fingerprint_consistency.rs @@ -0,0 +1,381 @@ +//! 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, or None for upstreams reqwest can't +/// drive (Shadowsocks). +fn proxy_url(settings: &crate::browser::ProxySettings) -> Option { + match settings.proxy_type.to_lowercase().as_str() { + "http" | "https" | "socks4" | "socks5" => Some( + crate::proxy_manager::ProxyManager::build_proxy_url(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 Some(url) = proxy_url(&settings) else { + return Ok(ConsistencyResult::skip()); + }; + + 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 == url && 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: url.clone(), + 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()), + }; + assert_eq!(proxy_url(&http).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()), + }; + assert_eq!( + proxy_url(&reserved).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, + }; + assert_eq!( + proxy_url(&user_only).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, + }; + assert_eq!(proxy_url(&ss), None); + } +} diff --git a/src-tauri/src/geolocation.rs b/src-tauri/src/geolocation.rs index ce0646d..64871cb 100644 --- a/src-tauri/src/geolocation.rs +++ b/src-tauri/src/geolocation.rs @@ -9,6 +9,7 @@ use rand::RngExt; use std::collections::HashMap; use std::net::IpAddr; use std::str::FromStr; +use std::sync::OnceLock; const TERRITORY_INFO_XML: &str = include_str!("territory_info.xml"); @@ -41,6 +42,27 @@ pub enum GeolocationError { Ip(#[from] IpError), } +/// The language part of a locale or language tag: `"en"` from `"en-US"`, +/// `"zh"` from `"zh_Hant"`. +pub fn primary_subtag(tag: &str) -> &str { + tag.split(['-', '_']).next().unwrap_or(tag) +} + +/// Shared selector over the bundled CLDR territory data. Parsing the XML costs +/// real time, and the data is immutable, so build it once. +pub fn locale_selector() -> Option<&'static LocaleSelector> { + static SELECTOR: OnceLock> = OnceLock::new(); + SELECTOR + .get_or_init(|| match LocaleSelector::new() { + Ok(s) => Some(s), + Err(e) => { + log::warn!("Failed to build the CLDR locale selector: {e}"); + None + } + }) + .as_ref() +} + #[derive(Debug, Clone)] pub struct Locale { pub language: String, @@ -152,6 +174,28 @@ impl LocaleSelector { Ok(Self { territories }) } + /// Whether CLDR associates `language` with `region` at all. + /// + /// Returns `None` for a territory with no language data, so callers can skip + /// rather than guess. The test is deliberately "is this language listed for + /// this country", not "is this the country's main language": `from_region` + /// samples the whole listed distribution weighted by speaker share, so every + /// listed language is one this selector itself can emit. Anything stricter + /// would flag fingerprints Donut generated on purpose — CLDR puts `es` at + /// 9.6% in the US and `fr` at 30% in Canada, and those get picked. + pub fn region_speaks(&self, region: &str, language: &str) -> Option { + let languages = self.territories.get(®ion.to_uppercase())?; + if languages.is_empty() { + return None; + } + let primary = primary_subtag(language); + Some( + languages + .iter() + .any(|l| primary_subtag(&l.language).eq_ignore_ascii_case(primary)), + ) + } + #[allow(clippy::wrong_self_convention)] pub fn from_region(&self, region: &str) -> Result { let region_upper = region.to_uppercase(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c1954be..93609d3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,6 +29,7 @@ mod downloader; mod ephemeral_dirs; mod extension_manager; mod extraction; +mod fingerprint_consistency; mod geoip_downloader; mod geolocation; mod group_manager; @@ -68,9 +69,10 @@ use browser_runner::{ use profile::manager::{ check_browser_status, clone_profile, create_browser_profile_new, delete_profile, - list_browser_profiles, rename_profile, update_profile_dns_blocklist, update_profile_launch_hook, - update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules, - update_profile_tags, update_profile_vpn, update_profile_window_color, update_wayfern_config, + list_browser_profiles, rename_profile, update_profile_clear_on_close, + update_profile_dns_blocklist, update_profile_launch_hook, update_profile_note, + update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn, + update_profile_window_color, update_wayfern_config, }; use profile::password::{ @@ -784,6 +786,13 @@ async fn clear_all_traffic_stats() -> Result<(), String> { .map_err(|e| format!("Failed to clear traffic stats: {e}")) } +#[tauri::command] +async fn clear_profile_traffic_stats(profile_id: String) -> Result<(), String> { + crate::traffic_stats::delete_traffic_stats(&profile_id); + let _ = events::emit_empty("traffic-stats-changed"); + Ok(()) +} + #[tauri::command] async fn get_traffic_stats_for_period( profile_id: String, @@ -1217,6 +1226,7 @@ async fn generate_sample_fingerprint( created_by_email: None, dns_blocklist: None, password_protected: false, + clear_on_close: false, created_at: None, updated_at: None, }; @@ -2044,6 +2054,16 @@ pub fn run() { .await; } + // Clear-on-close for natural exits (user closed the window). + // The explicit kill path in browser_runner.rs handles + // app-driven stops. Must also run before + // `mark_profile_stopped` so a queued sync sees the cleared + // dir rather than re-uploading the wiped browsing data. + if !is_running { + crate::profile::clear_on_close::clear_profile_browsing_data(&profile) + .await; + } + // Notify sync scheduler of running state changes if let Some(scheduler) = sync::get_global_scheduler() { if is_running { @@ -2238,6 +2258,7 @@ pub fn run() { update_profile_vpn, update_profile_tags, update_profile_note, + update_profile_clear_on_close, update_profile_launch_hook, update_profile_window_color, update_profile_proxy_bypass_rules, @@ -2319,7 +2340,10 @@ pub fn run() { get_all_traffic_snapshots, get_profile_traffic_snapshot, clear_all_traffic_stats, + clear_profile_traffic_stats, get_traffic_stats_for_period, + fingerprint_consistency::check_profile_fingerprint_consistency, + fingerprint_consistency::match_profile_fingerprint_to_exit, get_sync_settings, save_sync_settings, set_profile_sync_mode, @@ -2395,6 +2419,10 @@ pub fn run() { // DNS blocklist commands dns_blocklist::get_dns_blocklist_cache_status, dns_blocklist::refresh_dns_blocklists, + dns_blocklist::get_custom_dns_config, + dns_blocklist::set_custom_dns_config, + dns_blocklist::import_custom_dns_rules, + dns_blocklist::export_custom_dns_rules, // Profile password commands set_profile_password, change_profile_password, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index ddfe0ca..7dcaa1f 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -673,6 +673,10 @@ impl McpServer { "proxy_id": { "type": "string", "description": "Optional proxy UUID to assign to this profile" + }, + "vpn_id": { + "type": "string", + "description": "Optional VPN UUID to assign to this profile" } }, "required": ["source_path", "new_profile_name"] @@ -731,6 +735,10 @@ impl McpServer { "type": "array", "items": { "type": "string" }, "description": "Proxy bypass rules (replaces existing rules)" + }, + "clear_on_close": { + "type": "boolean", + "description": "Wipe browsing data (keeping extensions and bookmarks) when the browser exits. Not available for ephemeral or password-protected profiles." } }, "required": ["profile_id"] @@ -2547,6 +2555,14 @@ impl McpServer { })?; } + if let Some(clear_on_close) = arguments.get("clear_on_close").and_then(|v| v.as_bool()) { + pm.update_profile_clear_on_close(app_handle, profile_id, clear_on_close) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to update clear-on-close: {e}"), + })?; + } + Ok(serde_json::json!({ "content": [{ "type": "text", diff --git a/src-tauri/src/profile/clear_on_close.rs b/src-tauri/src/profile/clear_on_close.rs new file mode 100644 index 0000000..9b33803 --- /dev/null +++ b/src-tauri/src/profile/clear_on_close.rs @@ -0,0 +1,292 @@ +//! Clear-on-close: wipe a profile's browsing data when the browser exits, +//! keeping extensions and bookmarks (and the settings files Chromium needs to +//! keep those working). The middle ground between a fully persistent profile +//! and a RAM-backed ephemeral one. + +use std::fs; +use std::path::Path; + +use crate::profile::types::BrowserProfile; + +/// Entries kept inside a Chromium profile directory (one with `Preferences`). +/// Everything else — cookies, History, caches, storage, sessions, autofill, +/// saved logins — is browsing data and gets removed. +const PROFILE_KEEP: &[&str] = &[ + "Bookmarks", + "Bookmarks.bak", + "Extensions", + "Extension State", + "Extension Rules", + "Extension Scripts", + "Extension Cookies", + "Local Extension Settings", + "Managed Extension Settings", + // Preferences hold the extension registry + user settings; deleting them + // disables every installed extension, so they stay. + "Preferences", + "Secure Preferences", +]; + +/// Entries kept at the user-data-dir top level (outside profile subdirs). +const TOP_KEEP: &[&str] = &["Local State", "First Run"]; + +fn is_kept(name: &str) -> bool { + PROFILE_KEEP.contains(&name) || TOP_KEEP.contains(&name) +} + +/// Whether a top-level entry name is one of Chromium's profile directories. +/// +/// Identity must not rest on `Preferences` existing. Chromium writes it lazily, +/// so a crash — or a user deleting a corrupt copy, a standard troubleshooting +/// step since it regenerates — leaves a populated `Default/` without it. Such a +/// directory would then be taken for junk and removed wholesale, destroying the +/// Extensions and Bookmarks this feature exists to preserve. +fn is_profile_dir_name(name: &str) -> bool { + matches!(name, "Default" | "Guest Profile" | "System Profile") + || name + .strip_prefix("Profile ") + .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) +} + +fn remove_entry(path: &Path) { + let result = if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + }; + if let Err(e) = result { + log::warn!("clear-on-close: failed to remove {}: {e}", path.display()); + } +} + +/// Wipe browsing data inside a Chromium profile dir, keeping `PROFILE_KEEP` +/// (and `TOP_KEEP`, harmless at this level). +fn clear_chromium_profile_dir(profile_dir: &Path) -> usize { + let mut cleared = 0usize; + let Ok(entries) = fs::read_dir(profile_dir) else { + return 0; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if is_kept(name) { + continue; + } + remove_entry(&entry.path()); + cleared += 1; + } + cleared +} + +/// Clear browsing data in a Wayfern user-data directory. Handles both +/// layouts: profile content at the root (imported profiles copy a Chromium +/// profile dir directly) and the standard `Default` / `Profile N` subdirs +/// Chromium creates itself. Top-level cache dirs (ShaderCache, GrShaderCache, +/// component_crx_cache, …) are removed; `Local State` stays because it holds +/// the os_crypt key extensions may rely on. +pub fn clear_user_data_dir(user_data_dir: &Path) -> usize { + if !user_data_dir.exists() { + return 0; + } + + // Root itself is a profile dir (legacy/imported layout). + if user_data_dir.join("Preferences").exists() { + return clear_chromium_profile_dir(user_data_dir); + } + + let mut cleared = 0usize; + let Ok(entries) = fs::read_dir(user_data_dir) else { + return 0; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if is_kept(name) { + continue; + } + let path = entry.path(); + if path.is_dir() && (path.join("Preferences").exists() || is_profile_dir_name(name)) { + // A profile subdir (Default / Profile N) — clear inside, keep the dir. + cleared += clear_chromium_profile_dir(&path); + } else { + remove_entry(&path); + cleared += 1; + } + } + cleared +} + +/// Clear a profile's browsing data if its `clear_on_close` flag is set. +/// No-ops for ephemeral (already wiped) and password-protected (dir is +/// re-encrypted; plaintext never persists) profiles. Runs the filesystem work +/// on a blocking thread. +pub async fn clear_profile_browsing_data(profile: &BrowserProfile) { + if !profile.clear_on_close || profile.ephemeral || profile.password_protected { + return; + } + + let profiles_dir = crate::profile::ProfileManager::instance().get_profiles_dir(); + let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir); + let name = profile.name.clone(); + + let cleared = tokio::task::spawn_blocking(move || clear_user_data_dir(&user_data_dir)) + .await + .unwrap_or(0); + + log::info!("clear-on-close: cleared {cleared} browsing-data entries for profile '{name}'"); +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn touch(dir: &Path, name: &str) { + fs::write(dir.join(name), "x").unwrap(); + } + + fn mkdir(dir: &Path, name: &str) { + fs::create_dir_all(dir.join(name)).unwrap(); + } + + #[test] + fn clears_root_profile_layout_keeping_extensions_and_bookmarks() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + touch(dir, "Preferences"); + touch(dir, "Secure Preferences"); + touch(dir, "Bookmarks"); + touch(dir, "History"); + touch(dir, "Web Data"); + touch(dir, "Login Data"); + mkdir(dir, "Extensions"); + mkdir(dir, "Local Extension Settings"); + mkdir(dir, "Cache"); + mkdir(dir, "Network"); + touch(&dir.join("Network"), "Cookies"); + mkdir(dir, "Local Storage"); + + let cleared = clear_user_data_dir(dir); + assert!( + cleared >= 5, + "should clear History/WebData/Login/Cache/Network/LocalStorage, got {cleared}" + ); + + assert!(dir.join("Preferences").exists()); + assert!(dir.join("Bookmarks").exists()); + assert!(dir.join("Extensions").exists()); + assert!(dir.join("Local Extension Settings").exists()); + assert!(!dir.join("History").exists()); + assert!(!dir.join("Web Data").exists()); + assert!(!dir.join("Login Data").exists()); + assert!(!dir.join("Cache").exists()); + assert!(!dir.join("Network").exists(), "Network/Cookies must go"); + assert!(!dir.join("Local Storage").exists()); + } + + #[test] + fn clears_standard_user_data_layout() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + touch(dir, "Local State"); + mkdir(dir, "ShaderCache"); + mkdir(dir, "Default"); + let default = dir.join("Default"); + touch(&default, "Preferences"); + touch(&default, "Bookmarks"); + touch(&default, "History"); + mkdir(&default, "Extensions"); + mkdir(&default, "IndexedDB"); + + clear_user_data_dir(dir); + + assert!(dir.join("Local State").exists()); + assert!(!dir.join("ShaderCache").exists()); + assert!(default.join("Preferences").exists()); + assert!(default.join("Bookmarks").exists()); + assert!(default.join("Extensions").exists()); + assert!(!default.join("History").exists()); + assert!(!default.join("IndexedDB").exists()); + } + + #[test] + fn profile_subdir_without_preferences_keeps_extensions_and_bookmarks() { + // Chromium writes Preferences lazily, so a crash (or a user removing a + // corrupt copy) can leave a populated Default/ without it. Identifying + // profile dirs by Preferences alone would delete the whole directory. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + touch(dir, "Local State"); + mkdir(dir, "Default"); + let default = dir.join("Default"); + touch(&default, "Bookmarks"); + touch(&default, "History"); + mkdir(&default, "Extensions"); + mkdir(&default, "Local Extension Settings"); + mkdir(&default, "IndexedDB"); + + clear_user_data_dir(dir); + + assert!(default.exists(), "the profile dir must survive"); + assert!(default.join("Bookmarks").exists()); + assert!(default.join("Extensions").exists()); + assert!(default.join("Local Extension Settings").exists()); + // Browsing data inside it is still cleared. + assert!(!default.join("History").exists()); + assert!(!default.join("IndexedDB").exists()); + } + + #[test] + fn numbered_profile_dirs_are_recognised() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + mkdir(dir, "Profile 2"); + let p2 = dir.join("Profile 2"); + touch(&p2, "Bookmarks"); + touch(&p2, "History"); + + clear_user_data_dir(dir); + + assert!(p2.join("Bookmarks").exists()); + assert!(!p2.join("History").exists()); + } + + #[test] + fn non_profile_dirs_are_still_removed_wholesale() { + // The permissive branch must not turn into "never delete a directory": + // top-level caches are browsing data and have to go. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + mkdir(dir, "ShaderCache"); + mkdir(dir, "component_crx_cache"); + mkdir(dir, "Profileless"); + + clear_user_data_dir(dir); + + assert!(!dir.join("ShaderCache").exists()); + assert!(!dir.join("component_crx_cache").exists()); + assert!( + !dir.join("Profileless").exists(), + "a name that merely starts with 'Profile' is not a profile dir" + ); + } + + #[test] + fn profile_dir_name_matching() { + assert!(is_profile_dir_name("Default")); + assert!(is_profile_dir_name("Profile 1")); + assert!(is_profile_dir_name("Profile 42")); + assert!(is_profile_dir_name("Guest Profile")); + assert!(is_profile_dir_name("System Profile")); + assert!(!is_profile_dir_name("Profile")); + assert!(!is_profile_dir_name("Profile ")); + assert!(!is_profile_dir_name("Profile abc")); + assert!(!is_profile_dir_name("ShaderCache")); + } + + #[test] + fn missing_dir_is_a_noop() { + let tmp = TempDir::new().unwrap(); + assert_eq!(clear_user_data_dir(&tmp.path().join("nope")), 0); + } +} diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 935dab7..7cb988a 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -213,6 +213,7 @@ impl ProfileManager { created_by_email: None, dns_blocklist: None, password_protected: false, + clear_on_close: false, created_at: None, updated_at: None, }; @@ -299,6 +300,7 @@ impl ProfileManager { created_by_email: None, dns_blocklist, password_protected: false, + clear_on_close: false, created_at: Some( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -746,6 +748,44 @@ impl ProfileManager { Ok(profile) } + pub fn update_profile_clear_on_close( + &self, + _app_handle: &tauri::AppHandle, + profile_id: &str, + clear_on_close: bool, + ) -> Result> { + let profile_uuid = + uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?; + let profiles = self.list_profiles()?; + let mut profile = profiles + .into_iter() + .find(|p| p.id == profile_uuid) + .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; + + // Ephemeral profiles are already wiped on close; password-protected ones + // re-encrypt and never persist plaintext — the flag is meaningless there. + if clear_on_close && (profile.ephemeral || profile.password_protected) { + return Err( + serde_json::json!({ "code": "CLEAR_ON_CLOSE_UNAVAILABLE" }) + .to_string() + .into(), + ); + } + + profile.clear_on_close = clear_on_close; + profile.updated_at = Some(crate::proxy_manager::now_secs()); + + self.save_profile(&profile)?; + + crate::sync::queue_profile_sync_if_eligible(&profile); + + if let Err(e) = events::emit_empty("profiles-changed") { + log::warn!("Warning: Failed to emit profiles-changed event: {e}"); + } + + Ok(profile) + } + pub fn update_profile_window_color( &self, _app_handle: &tauri::AppHandle, @@ -1010,6 +1050,7 @@ impl ProfileManager { created_by_email: None, dns_blocklist: source.dns_blocklist, password_protected: false, + clear_on_close: false, created_at: Some( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1737,6 +1778,17 @@ pub fn update_profile_window_color( .map_err(|e| format!("Failed to update profile window color: {e}")) } +#[tauri::command] +pub fn update_profile_clear_on_close( + app_handle: tauri::AppHandle, + profile_id: String, + clear_on_close: bool, +) -> Result { + ProfileManager::instance() + .update_profile_clear_on_close(&app_handle, &profile_id, clear_on_close) + .map_err(crate::profile_importer::error_to_code_string) +} + /// Validate a launch hook value. Returns `Ok(None)` for "clear the hook" /// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s) /// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload. diff --git a/src-tauri/src/profile/mod.rs b/src-tauri/src/profile/mod.rs index 703c063..be542ff 100644 --- a/src-tauri/src/profile/mod.rs +++ b/src-tauri/src/profile/mod.rs @@ -1,3 +1,4 @@ +pub mod clear_on_close; pub mod encryption; pub mod manager; pub mod password; diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index cdf278e..024325c 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -72,6 +72,10 @@ pub struct BrowserProfile { /// Decryption goes to a RAM-backed ephemeral dir, never to disk. #[serde(default)] pub password_protected: bool, + /// Wipe browsing data (except extensions and bookmarks) when the browser + /// exits. Ignored for ephemeral and password-protected profiles. + #[serde(default)] + pub clear_on_close: bool, /// Profile creation timestamp (epoch seconds, UTC). `None` for legacy /// profiles that pre-date this field — those are treated as ancient by /// any staleness check. diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index d1970fb..e82b353 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -33,6 +33,8 @@ pub struct ImportProfileItem { pub new_profile_name: String, #[serde(default)] pub proxy_id: Option, + #[serde(default)] + pub vpn_id: Option, } fn default_import_browser_type() -> String { @@ -132,6 +134,15 @@ fn emit_import_progress(total: usize, completed: usize, index: usize, name: &str ); } +/// A known Chromium-family browser install location. +struct BrowserSource { + key: &'static str, + dir: PathBuf, + /// Opera-style quirk: the profile (Preferences, Cookies, …) lives at the + /// root of the config dir instead of under `Default/`. + root_profile: bool, +} + pub struct ProfileImporter { base_dirs: BaseDirs, downloaded_browsers_registry: &'static DownloadedBrowsersRegistry, @@ -161,9 +172,50 @@ impl ProfileImporter { // Only Chromium-based sources (mapping to Wayfern) are detected. Gecko-family // sources mapped to Camoufox, which was removed, so they can no longer be // imported. - detected_profiles.extend(self.detect_chrome_profiles()?); - detected_profiles.extend(self.detect_brave_profiles()?); - detected_profiles.extend(self.detect_chromium_profiles()?); + for source in self.browser_sources() { + if source.root_profile { + // Opera-style layout: the user-data root itself is the profile. + if source.dir.join("Preferences").exists() { + detected_profiles.push(DetectedProfile { + browser: source.key.to_string(), + mapped_browser: map_browser_type(source.key).to_string(), + name: format!( + "{} - Default Profile", + self.get_browser_display_name(source.key) + ), + path: source.dir.to_string_lossy().to_string(), + description: "Default profile".to_string(), + }); + } + // Newer Opera builds keep extra profiles under _side_profiles/. + let side = source.dir.join("_side_profiles"); + if let Ok(entries) = fs::read_dir(&side) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && path.join("Preferences").exists() { + let dir_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("profile") + .to_string(); + detected_profiles.push(DetectedProfile { + browser: source.key.to_string(), + mapped_browser: map_browser_type(source.key).to_string(), + name: format!( + "{} - {}", + self.get_browser_display_name(source.key), + dir_name + ), + path: path.to_string_lossy().to_string(), + description: format!("Side profile {dir_name}"), + }); + } + } + } + } else { + detected_profiles.extend(self.scan_chrome_profiles_dir(&source.dir, source.key)?); + } + } let mut seen_paths = HashSet::new(); let unique_profiles: Vec = detected_profiles @@ -174,6 +226,162 @@ impl ProfileImporter { Ok(unique_profiles) } + /// Every Chromium-family browser we know how to find, with its per-OS + /// user-data directory. `root_profile` marks the Opera-style quirk where the + /// profile lives at the root of the config dir instead of `Default/`. + fn browser_sources(&self) -> Vec { + let mut sources: Vec = Vec::new(); + + #[cfg(target_os = "macos")] + { + let support = self + .base_dirs + .home_dir() + .join("Library/Application Support"); + let standard: &[(&str, &str)] = &[ + ("chromium", "Google/Chrome"), + ("chrome-beta", "Google/Chrome Beta"), + ("chrome-dev", "Google/Chrome Dev"), + ("chrome-canary", "Google/Chrome Canary"), + ("chromium", "Chromium"), + ("brave", "BraveSoftware/Brave-Browser"), + ("brave-beta", "BraveSoftware/Brave-Browser-Beta"), + ("brave-nightly", "BraveSoftware/Brave-Browser-Nightly"), + ("edge", "Microsoft Edge"), + ("edge-beta", "Microsoft Edge Beta"), + ("edge-dev", "Microsoft Edge Dev"), + ("vivaldi", "Vivaldi"), + // Arc nests its user-data dir one level down, unlike Chrome. + ("arc", "Arc/User Data"), + ("yandex", "Yandex/YandexBrowser"), + ]; + for (key, rel) in standard { + sources.push(BrowserSource { + key, + dir: support.join(rel), + root_profile: false, + }); + } + for (key, rel) in &[ + ("opera", "com.operasoftware.Opera"), + ("opera-gx", "com.operasoftware.OperaGX"), + ] { + sources.push(BrowserSource { + key, + dir: support.join(rel), + root_profile: true, + }); + } + } + + #[cfg(target_os = "windows")] + { + let local = self.base_dirs.data_local_dir().to_path_buf(); + let standard: &[(&str, &str)] = &[ + ("chromium", "Google/Chrome/User Data"), + ("chrome-beta", "Google/Chrome Beta/User Data"), + ("chrome-dev", "Google/Chrome Dev/User Data"), + // Canary installs as "Chrome SxS" (side-by-side), not "Chrome Canary". + ("chrome-canary", "Google/Chrome SxS/User Data"), + ("chromium", "Chromium/User Data"), + ("brave", "BraveSoftware/Brave-Browser/User Data"), + ("brave-beta", "BraveSoftware/Brave-Browser-Beta/User Data"), + ( + "brave-nightly", + "BraveSoftware/Brave-Browser-Nightly/User Data", + ), + ("edge", "Microsoft/Edge/User Data"), + ("edge-beta", "Microsoft/Edge Beta/User Data"), + ("edge-dev", "Microsoft/Edge Dev/User Data"), + ("vivaldi", "Vivaldi/User Data"), + ("yandex", "Yandex/YandexBrowser/User Data"), + ]; + for (key, rel) in standard { + sources.push(BrowserSource { + key, + dir: local.join(rel), + root_profile: false, + }); + } + // Opera keeps the profile under %APPDATA% (Roaming), not %LOCALAPPDATA%. + let roaming = self.base_dirs.data_dir().to_path_buf(); + for (key, rel) in &[ + ("opera", "Opera Software/Opera Stable"), + ("opera-gx", "Opera Software/Opera GX Stable"), + ] { + sources.push(BrowserSource { + key, + dir: roaming.join(rel), + root_profile: true, + }); + } + // Arc on Windows is MSIX-packaged; the package-family suffix can vary, + // so glob Packages/TheBrowserCompany.Arc_*. + let packages = local.join("Packages"); + if let Ok(entries) = fs::read_dir(&packages) { + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.starts_with("TheBrowserCompany.Arc_") { + sources.push(BrowserSource { + key: "arc", + dir: entry.path().join("LocalCache/Local/Arc/User Data"), + root_profile: false, + }); + } + } + } + } + + #[cfg(target_os = "linux")] + { + let home = self.base_dirs.home_dir().to_path_buf(); + let config = home.join(".config"); + let standard: &[(&str, &str)] = &[ + ("chromium", "google-chrome"), + ("chrome-beta", "google-chrome-beta"), + // The Linux Dev channel dir is google-chrome-unstable. + ("chrome-dev", "google-chrome-unstable"), + ("chromium", "chromium"), + ("brave", "BraveSoftware/Brave-Browser"), + ("brave-beta", "BraveSoftware/Brave-Browser-Beta"), + ("brave-nightly", "BraveSoftware/Brave-Browser-Nightly"), + ("edge", "microsoft-edge"), + ("edge-beta", "microsoft-edge-beta"), + ("edge-dev", "microsoft-edge-dev"), + ("vivaldi", "vivaldi"), + ("yandex", "yandex-browser"), + // The long-standing Linux package ships as the beta channel. + ("yandex", "yandex-browser-beta"), + ]; + for (key, rel) in standard { + sources.push(BrowserSource { + key, + dir: config.join(rel), + root_profile: false, + }); + } + sources.push(BrowserSource { + key: "opera", + dir: config.join("opera"), + root_profile: true, + }); + // Distro-packaged Chromium fallbacks. + sources.push(BrowserSource { + key: "chromium", + dir: home.join("snap/chromium/common/chromium"), + root_profile: false, + }); + sources.push(BrowserSource { + key: "chromium", + dir: home.join(".var/app/org.chromium.Chromium/config/chromium"), + root_profile: false, + }); + } + + sources + } + /// Scan an arbitrary folder for importable Chromium-family profiles. /// Handles three shapes: the folder itself is a profile (has `Preferences`), /// the folder is a user-data dir (`Default` / `Profile N` children), or the @@ -349,93 +557,6 @@ impl ProfileImporter { Ok(()) } - fn detect_chrome_profiles(&self) -> Result, Box> { - let mut profiles = Vec::new(); - - #[cfg(target_os = "macos")] - { - let chrome_dir = self - .base_dirs - .home_dir() - .join("Library/Application Support/Google/Chrome"); - profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?); - } - - #[cfg(target_os = "windows")] - { - let local_app_data = self.base_dirs.data_local_dir(); - let chrome_dir = local_app_data.join("Google/Chrome/User Data"); - profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?); - } - - #[cfg(target_os = "linux")] - { - let chrome_dir = self.base_dirs.home_dir().join(".config/google-chrome"); - profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?); - } - - Ok(profiles) - } - - fn detect_chromium_profiles(&self) -> Result, Box> { - let mut profiles = Vec::new(); - - #[cfg(target_os = "macos")] - { - let chromium_dir = self - .base_dirs - .home_dir() - .join("Library/Application Support/Chromium"); - profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?); - } - - #[cfg(target_os = "windows")] - { - let local_app_data = self.base_dirs.data_local_dir(); - let chromium_dir = local_app_data.join("Chromium/User Data"); - profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?); - } - - #[cfg(target_os = "linux")] - { - let chromium_dir = self.base_dirs.home_dir().join(".config/chromium"); - profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?); - } - - Ok(profiles) - } - - fn detect_brave_profiles(&self) -> Result, Box> { - let mut profiles = Vec::new(); - - #[cfg(target_os = "macos")] - { - let brave_dir = self - .base_dirs - .home_dir() - .join("Library/Application Support/BraveSoftware/Brave-Browser"); - profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?); - } - - #[cfg(target_os = "windows")] - { - let local_app_data = self.base_dirs.data_local_dir(); - let brave_dir = local_app_data.join("BraveSoftware/Brave-Browser/User Data"); - profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?); - } - - #[cfg(target_os = "linux")] - { - let brave_dir = self - .base_dirs - .home_dir() - .join(".config/BraveSoftware/Brave-Browser"); - profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?); - } - - Ok(profiles) - } - fn scan_chrome_profiles_dir( &self, browser_dir: &Path, @@ -491,7 +612,20 @@ impl ProfileImporter { fn get_browser_display_name(&self, browser_type: &str) -> &str { match browser_type { "chromium" => "Chrome/Chromium", + "chrome-beta" => "Chrome Beta", + "chrome-dev" => "Chrome Dev", + "chrome-canary" => "Chrome Canary", "brave" => "Brave", + "brave-beta" => "Brave Beta", + "brave-nightly" => "Brave Nightly", + "edge" => "Microsoft Edge", + "edge-beta" => "Edge Beta", + "edge-dev" => "Edge Dev", + "opera" => "Opera", + "opera-gx" => "Opera GX", + "vivaldi" => "Vivaldi", + "arc" => "Arc", + "yandex" => "Yandex Browser", "zen" => "Zen Browser", "wayfern" => "Wayfern", @@ -528,6 +662,37 @@ impl ProfileImporter { } } + // Gate the paid fingerprint-OS override here rather than at each call site. + // `wayfern_config` is only ever consumed by this function, so a caller that + // forgets the check (the REST and MCP surfaces each had their own copy) + // would bypass the restriction with no compile error. + let fingerprint_os = wayfern_config.as_ref().and_then(|c| c.os.as_deref()); + if !crate::cloud_auth::CLOUD_AUTH + .is_fingerprint_os_allowed(fingerprint_os) + .await + { + return Err( + serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }) + .to_string() + .into(), + ); + } + + // A profile routes through a proxy or a VPN, never both: create_profile_with_group + // rejects it, and at launch browser_runner resolves the proxy first and + // silently ignores the VPN. The importer saves profiles directly, so + // without this it is the one way to persist the invalid combination. + if items + .iter() + .any(|i| i.proxy_id.is_some() && i.vpn_id.is_some()) + { + return Err( + serde_json::json!({ "code": "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE" }) + .to_string() + .into(), + ); + } + let mut taken_names: HashSet = self .profile_manager .list_profiles()? @@ -589,6 +754,7 @@ impl ProfileImporter { &item.browser_type, &final_name, item.proxy_id.clone(), + item.vpn_id.clone(), group_id.clone(), wayfern_config.clone(), ) @@ -640,6 +806,7 @@ impl ProfileImporter { browser_type: &str, new_profile_name: &str, proxy_id: Option, + vpn_id: Option, group_id: Option, wayfern_config: Option, ) -> Result> { @@ -683,11 +850,28 @@ impl ProfileImporter { // Profile dirs can be multiple GB — keep the copy off the async runtime. let copy_source = source_path.to_path_buf(); let copy_dest = new_profile_data_dir.clone(); - let copy_result = tokio::task::spawn_blocking(move || { + let copy_result = match tokio::task::spawn_blocking(move || { Self::copy_directory_recursive(©_source, ©_dest).map_err(|e| e.to_string()) }) .await - .map_err(|e| format!("Profile copy task failed: {e}"))?; + { + Ok(r) => r, + Err(e) => { + // The copy task died (panic, or runtime shutdown mid-import). Clean up + // like every other error path here, or the half-copied — possibly + // multi-GB — directory is orphaned with no metadata pointing at it, so + // nothing ever reclaims it. + let _ = fs::remove_dir_all(&new_profile_uuid_dir); + return Err( + serde_json::json!({ + "code": "INTERNAL_ERROR", + "params": { "detail": format!("Profile copy task failed: {e}") }, + }) + .to_string() + .into(), + ); + } + }; if let Err(e) = copy_result { let _ = fs::remove_dir_all(&new_profile_uuid_dir); return Err( @@ -761,6 +945,7 @@ impl ProfileImporter { created_by_email: None, dns_blocklist: None, password_protected: false, + clear_on_close: false, created_at: None, updated_at: None, }; @@ -799,7 +984,7 @@ impl ProfileImporter { browser: mapped.to_string(), version, proxy_id, - vpn_id: None, + vpn_id, launch_hook: None, process_id: None, last_launch: None, @@ -820,6 +1005,7 @@ impl ProfileImporter { created_by_email: None, dns_blocklist: None, password_protected: false, + clear_on_close: false, created_at: Some( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -924,15 +1110,7 @@ pub async fn import_browser_profiles( duplicate_strategy: Option, wayfern_config: Option, ) -> Result { - let fingerprint_os = wayfern_config.as_ref().and_then(|c| c.os.as_deref()); - - if !crate::cloud_auth::CLOUD_AUTH - .is_fingerprint_os_allowed(fingerprint_os) - .await - { - return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string()); - } - + // The Pro gate for fingerprint OS spoofing lives inside import_profiles. let importer = ProfileImporter::instance(); importer .import_profiles( diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index be15d5c..dc562a4 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -137,6 +137,25 @@ pub fn now_secs() -> u64 { .as_secs() } +/// Keys in `active_proxies` at or above this value are in-flight launch +/// placeholders, not real browser PIDs. Real OS PIDs never reach this range +/// (Linux pid_max caps at 2^22, macOS at ~100k, Windows PIDs are small DWORD +/// multiples of 4), so a placeholder can never shadow a live process ID. +pub const LAUNCH_PLACEHOLDER_PID_MIN: u32 = u32::MAX - 0x00FF_FFFF; + +static NEXT_LAUNCH_PLACEHOLDER_PID: std::sync::atomic::AtomicU32 = + std::sync::atomic::AtomicU32::new(u32::MAX); + +/// Allocate a unique `active_proxies` key for a launch in flight, so concurrent +/// launches can never overwrite each other's placeholder entry. +pub fn next_launch_placeholder_pid() -> u32 { + NEXT_LAUNCH_PLACEHOLDER_PID.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) +} + +pub fn is_launch_placeholder_pid(pid: u32) -> bool { + pid >= LAUNCH_PLACEHOLDER_PID_MIN +} + impl StoredProxy { pub fn new(name: String, proxy_settings: ProxySettings) -> Self { let sync_enabled = crate::sync::is_sync_configured(); @@ -1040,8 +1059,10 @@ impl ProxyManager { format!("Proxy check failed for {proxy_addr}. Could not connect through the proxy.") } - // Build proxy URL string from ProxySettings - fn build_proxy_url(proxy_settings: &ProxySettings) -> String { + // Build proxy URL string from ProxySettings. Credentials are percent-encoded: + // a password containing `/`, `#`, `?` or `@` otherwise breaks the URL + // authority and silently retargets the request at the wrong host. + pub 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) { @@ -1081,9 +1102,17 @@ impl ProxyManager { .map_err(|e| e.to_string()); let ip_result = match proxy_start_result { - Ok(proxy_config) => { + Ok(mut proxy_config) => { let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0)); let config_id = proxy_config.id.clone(); + // Tie the check worker's lifetime to this GUI process: the worker's + // PID watchdog self-exits when browser_pid dies, so if the app is + // killed mid-check the worker follows instead of idling until the + // next app launch. + proxy_config.browser_pid = Some(std::process::id()); + if !crate::proxy_storage::update_proxy_config(&proxy_config) { + log::warn!("Failed to tag check worker {config_id} with app PID for self-expiry"); + } // Wrap in a timeout so the check worker doesn't stay alive indefinitely // if the upstream is slow or unreachable. let result = tokio::time::timeout( @@ -1493,6 +1522,7 @@ impl ProxyManager { profile_id: Option<&str>, bypass_rules: Vec, blocklist_file: Option, + dns_allowlist_mode: bool, // Protocol the local worker serves the browser: "socks5" (Wayfern). Reflected in // the returned ProxySettings.proxy_type so the caller formats the right local proxy URL scheme. local_protocol: &str, @@ -1626,6 +1656,9 @@ impl ProxyManager { // Add blocklist file path if provided if let Some(ref path) = blocklist_file { proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path); + if dns_allowlist_mode { + proxy_cmd = proxy_cmd.arg("--dns-allowlist-mode"); + } } // Tell the worker which protocol to serve the browser (http or socks5) @@ -1696,6 +1729,10 @@ impl ProxyManager { } } if !ready { + // The detached worker is already running with its config on disk, but + // it was never registered in active_proxies — no cleanup pass could + // ever find it, so it must be killed before this error propagates. + let _ = crate::proxy_runner::stop_proxy_process(&proxy_info.id).await; return Err(format!( "Local proxy on 127.0.0.1:{} did not become ready in time", proxy_info.local_port @@ -1868,9 +1905,9 @@ impl ProxyManager { /// Persist the real browser PID onto the worker's on-disk config so the /// detached worker can self-terminate when that browser dies, independent of /// the GUI being alive. Resolved via the profile→proxy_id map rather than the - /// PID-keyed `active_proxies` map: the latter uses a placeholder key 0 during - /// launch that collides across concurrent launches, which could tag a live - /// worker with the wrong (dead) PID and make it self-exit. Safe on the reuse + /// PID-keyed `active_proxies` map: the latter is keyed by a per-launch + /// placeholder until `update_proxy_pid` runs, so it is not a reliable way to + /// find the worker for a profile mid-launch. Safe on the reuse /// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid` /// of 0 (launch failed to report a PID) is ignored so the worker never /// self-exits against a bogus PID. @@ -2090,9 +2127,9 @@ impl ProxyManager { let mut snapshot_pids: std::collections::HashSet = std::collections::HashSet::new(); for (browser_pid, proxy_id, profile_id) in snapshot { snapshot_pids.insert(browser_pid); - // The sentinel PID=0 is used as a placeholder during launch, - // before update_proxy_pid has recorded the real browser PID. - if browser_pid == 0 { + // Launch placeholders (and the legacy 0 sentinel) are not real + // browser PIDs — update_proxy_pid hasn't recorded the real one yet. + if browser_pid == 0 || is_launch_placeholder_pid(browser_pid) { continue; } if system @@ -2758,6 +2795,35 @@ mod tests { assert!(result.unwrap_err().contains("No proxy found for PID 777")); } + #[test] + fn test_launch_placeholder_pids_unique_and_reconcile_independently() { + let a = next_launch_placeholder_pid(); + let b = next_launch_placeholder_pid(); + assert_ne!(a, b); + assert!(is_launch_placeholder_pid(a)); + assert!(is_launch_placeholder_pid(b)); + // Real PIDs (and the legacy 0 sentinel) are never in the placeholder range. + assert!(!is_launch_placeholder_pid(0)); + assert!(!is_launch_placeholder_pid(1)); + assert!(!is_launch_placeholder_pid(4_194_304)); // Linux pid_max + assert!(!is_launch_placeholder_pid(std::process::id())); + + // Two concurrent launches with distinct placeholder keys: finishing + // launch A must not remap or evict launch B's in-flight entry. + let pm = ProxyManager::new(); + pm.insert_active_proxy(a, make_proxy_info("px_launch_a", 9201, Some("prof_a"))); + pm.insert_active_proxy(b, make_proxy_info("px_launch_b", 9202, Some("prof_b"))); + + pm.update_proxy_pid(a, 3001).unwrap(); + assert_eq!(pm.get_active_proxy(3001).unwrap().id, "px_launch_a"); + assert_eq!(pm.get_active_proxy(b).unwrap().id, "px_launch_b"); + + pm.update_proxy_pid(b, 3002).unwrap(); + assert_eq!(pm.get_active_proxy(3002).unwrap().id, "px_launch_b"); + assert!(pm.get_active_proxy(a).is_none()); + assert!(pm.get_active_proxy(b).is_none()); + } + #[test] fn test_profile_proxy_id_mapping_tracks_active_proxy() { let pm = ProxyManager::new(); @@ -2930,6 +2996,7 @@ mod tests { profile_id: None, bypass_rules: Vec::new(), blocklist_file: None, + dns_allowlist_mode: false, local_protocol: None, browser_pid: None, }; @@ -2943,6 +3010,7 @@ mod tests { profile_id: None, bypass_rules: Vec::new(), blocklist_file: None, + dns_allowlist_mode: false, local_protocol: None, browser_pid: None, }; @@ -2984,6 +3052,7 @@ mod tests { profile_id: Some("prof_abc".to_string()), bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()], blocklist_file: None, + dns_allowlist_mode: false, local_protocol: None, browser_pid: None, }; @@ -3304,6 +3373,7 @@ mod tests { profile_id: None, bypass_rules: Vec::new(), blocklist_file: None, + dns_allowlist_mode: false, local_protocol: None, browser_pid: None, }; diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index 45afe86..0acf102 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -160,15 +160,17 @@ pub async fn start_proxy_process( upstream_url: Option, port: Option, ) -> Result> { - start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, None).await + start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, false, None).await } +#[allow(clippy::too_many_arguments)] pub async fn start_proxy_process_with_profile( upstream_url: Option, port: Option, profile_id: Option, bypass_rules: Vec, blocklist_file: Option, + dns_allowlist_mode: bool, local_protocol: Option, ) -> Result> { let id = generate_proxy_id(); @@ -185,6 +187,7 @@ pub async fn start_proxy_process_with_profile( .with_profile_id(profile_id.clone()) .with_bypass_rules(bypass_rules) .with_blocklist_file(blocklist_file) + .with_dns_allowlist_mode(dns_allowlist_mode) .with_local_protocol(local_protocol); save_proxy_config(&config)?; @@ -370,24 +373,21 @@ pub async fn start_proxy_process_with_profile( attempts += 1; if attempts >= max_attempts { // Try to get the config one more time for better error message - if let Some(config) = get_proxy_config(&id) { + let detail = if let Some(config) = get_proxy_config(&id) { // Check if process is still running let process_running = config.pid.map(is_process_running).unwrap_or(false); - return Err( - format!( - "Proxy worker failed to start in time. Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}", - config.id, config.local_url, config.local_port, config.pid, process_running - ) - .into(), - ); - } - return Err( format!( - "Proxy worker failed to start in time. Config not found for id: {}", - id + "Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}", + config.id, config.local_url, config.local_port, config.pid, process_running ) - .into(), - ); + } else { + format!("Config not found for id: {}", id) + }; + // The detached worker (if it did spawn) would otherwise outlive this + // failed start with nothing tracking it — callers only get an error + // string, so this is the last place that can still kill it. + let _ = stop_proxy_process(&id).await; + return Err(format!("Proxy worker failed to start in time. {detail}").into()); } } } diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index 0efcaad..93f6e63 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -1,5 +1,5 @@ use crate::proxy_storage::ProxyConfig; -use crate::traffic_stats::{get_traffic_tracker, init_traffic_tracker}; +use crate::traffic_stats::{get_traffic_tracker, init_traffic_tracker, LiveTrafficTracker}; use http_body_util::{BodyExt, Full}; use hyper::body::Bytes; use hyper::server::conn::http1; @@ -61,6 +61,9 @@ impl BypassMatcher { #[derive(Clone)] pub struct BlocklistMatcher { domains: Arc>, + /// When true the `domains` set is an ALLOW list: a host is blocked unless it + /// (or a parent domain) is present. When false it's a block list (default). + allowlist_mode: bool, } impl Default for BlocklistMatcher { @@ -73,29 +76,39 @@ impl BlocklistMatcher { pub fn new() -> Self { Self { domains: Arc::new(HashSet::new()), + allowlist_mode: false, } } pub fn from_file(path: &str) -> Result> { + Self::from_file_with_mode(path, false) + } + + pub fn from_file_with_mode( + path: &str, + allowlist_mode: bool, + ) -> Result> { let content = std::fs::read_to_string(path)?; let domains: HashSet = content .lines() .filter(|line| !line.starts_with('#') && !line.trim().is_empty()) .map(|line| line.trim().to_lowercase()) .collect(); - log::info!("[blocklist] Loaded {} domains from {}", domains.len(), path); + log::info!( + "[blocklist] Loaded {} domains from {} (mode={})", + domains.len(), + path, + if allowlist_mode { "allow" } else { "block" } + ); Ok(Self { domains: Arc::new(domains), + allowlist_mode, }) } - pub fn is_blocked(&self, host: &str) -> bool { - if self.domains.is_empty() { - return false; - } - let host_lower = host.to_lowercase(); - // Exact match - if self.domains.contains(host_lower.as_str()) { + /// True if `host` (or any parent domain) is in the set. + fn set_contains(&self, host_lower: &str) -> bool { + if self.domains.contains(host_lower) { return true; } // Suffix matching: check parent domains (like uBlock) @@ -108,6 +121,22 @@ impl BlocklistMatcher { } false } + + pub fn is_blocked(&self, host: &str) -> bool { + // Empty set = no filtering in either mode. In allowlist mode an empty list + // would otherwise block everything and brick the browser, so fail open. + if self.domains.is_empty() { + return false; + } + let host_lower = host.to_lowercase(); + let in_set = self.set_contains(&host_lower); + if self.allowlist_mode { + // Allow only listed domains; block everything else. + !in_set + } else { + in_set + } + } } /// Wrapper stream that counts bytes read and written @@ -115,6 +144,9 @@ struct CountingStream { inner: S, bytes_read: Arc, bytes_written: Arc, + // Resolved once per stream: the global tracker is fixed after init, so the + // hot poll paths avoid taking the global RwLock on every packet + tracker: Option>, } impl CountingStream { @@ -123,6 +155,7 @@ impl CountingStream { inner, bytes_read: Arc::new(AtomicU64::new(0)), bytes_written: Arc::new(AtomicU64::new(0)), + tracker: get_traffic_tracker(), } } } @@ -142,7 +175,7 @@ impl AsyncRead for CountingStream { .bytes_read .fetch_add(bytes_read as u64, Ordering::Relaxed); // Update global tracker - count as received (data coming into proxy) - if let Some(tracker) = get_traffic_tracker() { + if let Some(tracker) = &self.tracker { tracker.add_bytes_received(bytes_read as u64); } } @@ -161,7 +194,7 @@ impl AsyncWrite for CountingStream { if let Poll::Ready(Ok(n)) = &result { self.bytes_written.fetch_add(*n as u64, Ordering::Relaxed); // Update global tracker - count as sent (data going out of proxy) - if let Some(tracker) = get_traffic_tracker() { + if let Some(tracker) = &self.tracker { tracker.add_bytes_sent(*n as u64); } } @@ -228,189 +261,27 @@ async fn handle_request( bypass_matcher: BypassMatcher, blocklist_matcher: BlocklistMatcher, ) -> Result>, Infallible> { - // Handle CONNECT method for HTTPS tunneling + // CONNECT cannot be tunneled on the hyper path: hyper owns the connection + // and would keep parsing the post-200 tunnel bytes (TLS) as HTTP. This is + // only reachable when a kept-alive connection that started as plain HTTP + // later sends CONNECT — refuse and close so the browser retries on a fresh + // connection, which the peek path classifies as CONNECT and tunnels. if req.method() == Method::CONNECT { - return handle_connect(req, upstream_url, bypass_matcher, blocklist_matcher).await; + let mut response = Response::new(Full::new(Bytes::from( + "CONNECT is not supported on a reused connection", + ))); + *response.status_mut() = StatusCode::NOT_IMPLEMENTED; + response.headers_mut().insert( + hyper::header::CONNECTION, + hyper::header::HeaderValue::from_static("close"), + ); + return Ok(response); } // Handle regular HTTP requests handle_http(req, upstream_url, bypass_matcher, blocklist_matcher).await } -async fn handle_connect( - req: Request, - upstream_url: Option, - bypass_matcher: BypassMatcher, - blocklist_matcher: BlocklistMatcher, -) -> Result>, Infallible> { - let authority = req.uri().authority().cloned(); - - if let Some(authority) = authority { - let target_addr = format!("{}", authority); - - // Parse target host and port - let (target_host, target_port) = if let Some(colon_pos) = target_addr.find(':') { - let host = &target_addr[..colon_pos]; - let port: u16 = target_addr[colon_pos + 1..].parse().unwrap_or(443); - (host, port) - } else { - (&target_addr[..], 443) - }; - - // Block if domain is in the DNS blocklist (before any connection) - if blocklist_matcher.is_blocked(target_host) { - log::debug!("[blocklist] Blocked CONNECT to {}", target_host); - let mut response = Response::new(Full::new(Bytes::from("Blocked by DNS blocklist"))); - *response.status_mut() = StatusCode::FORBIDDEN; - return Ok(response); - } - - // If no upstream proxy, or bypass rule matches, connect directly - if upstream_url.is_none() - || upstream_url - .as_ref() - .map(|s| s == "DIRECT") - .unwrap_or(false) - || bypass_matcher.should_bypass(target_host) - { - match TcpStream::connect(&target_addr).await { - Ok(_stream) => { - let mut response = Response::new(Full::new(Bytes::from(""))); - *response.status_mut() = StatusCode::from_u16(200).unwrap(); - return Ok(response); - } - Err(e) => { - log::error!("Failed to connect to {}: {}", target_addr, e); - let mut response = - Response::new(Full::new(Bytes::from(format!("Connection failed: {}", e)))); - *response.status_mut() = StatusCode::BAD_GATEWAY; - return Ok(response); - } - } - } - - // Connect through upstream proxy - let upstream = match upstream_url.as_ref().and_then(|u| Url::parse(u).ok()) { - Some(url) => url, - None => { - let mut response = Response::new(Full::new(Bytes::from("Invalid upstream URL"))); - *response.status_mut() = StatusCode::BAD_GATEWAY; - return Ok(response); - } - }; - - let scheme = upstream.scheme(); - match scheme { - "http" | "https" => { - // Use manual CONNECT for HTTP/HTTPS proxies - match connect_via_http_proxy(&upstream, target_host, target_port).await { - Ok(_) => { - let mut response = Response::new(Full::new(Bytes::from(""))); - *response.status_mut() = StatusCode::from_u16(200).unwrap(); - Ok(response) - } - Err(e) => { - log::error!("HTTP proxy CONNECT failed: {}", e); - let mut response = Response::new(Full::new(Bytes::from(format!( - "Proxy connection failed: {}", - e - )))); - *response.status_mut() = StatusCode::BAD_GATEWAY; - Ok(response) - } - } - } - "socks4" | "socks5" => { - // Use async-socks5 for SOCKS proxies - let host = upstream.host_str().unwrap_or("127.0.0.1"); - let port = upstream.port().unwrap_or(1080); - let socks_addr = format!("{}:{}", host, port); - - let (username, password) = upstream_userpass(&upstream); - let auth = (!username.is_empty()).then_some((username.as_str(), password.as_str())); - - match connect_via_socks( - &socks_addr, - target_host, - target_port, - scheme == "socks5", - auth, - ) - .await - { - Ok(_stream) => { - let mut response = Response::new(Full::new(Bytes::from(""))); - *response.status_mut() = StatusCode::from_u16(200).unwrap(); - Ok(response) - } - Err(e) => { - log::error!("SOCKS connection failed: {}", e); - let mut response = Response::new(Full::new(Bytes::from(format!( - "SOCKS connection failed: {}", - e - )))); - *response.status_mut() = StatusCode::BAD_GATEWAY; - Ok(response) - } - } - } - _ => { - let mut response = Response::new(Full::new(Bytes::from("Unsupported upstream scheme"))); - *response.status_mut() = StatusCode::BAD_GATEWAY; - Ok(response) - } - } - } else { - let mut response = Response::new(Full::new(Bytes::from("Bad Request"))); - *response.status_mut() = StatusCode::BAD_REQUEST; - Ok(response) - } -} - -async fn connect_via_http_proxy( - upstream: &Url, - target_host: &str, - target_port: u16, -) -> Result> { - let proxy_host = upstream.host_str().unwrap_or("127.0.0.1"); - let proxy_port = upstream.port().unwrap_or(8080); - let mut stream = tokio::time::timeout( - UPSTREAM_DIAL_TIMEOUT, - TcpStream::connect((proxy_host, proxy_port)), - ) - .await - .map_err(|_| format!("upstream proxy connect to {proxy_host}:{proxy_port} timed out"))??; - - // Add proxy authentication if provided - let mut connect_req = format!( - "CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n", - target_host, target_port, target_host, target_port - ); - - let (username, password) = upstream_userpass(upstream); - if !username.is_empty() { - use base64::{engine::general_purpose, Engine as _}; - let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password)); - connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth)); - } - - connect_req.push_str("\r\n"); - - stream.write_all(connect_req.as_bytes()).await?; - - let mut buffer = [0u8; 4096]; - let n = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, stream.read(&mut buffer)) - .await - .map_err(|_| "upstream proxy CONNECT response timed out")??; - let response = String::from_utf8_lossy(&buffer[..n]); - - if response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200") { - Ok(stream) - } else { - Err(format!("Upstream proxy CONNECT failed: {}", response).into()) - } -} - /// Extract percent-decoded (username, password) from the upstream URL. /// /// `url::Url::username()` / `Url::password()` return percent-encoded ASCII @@ -601,6 +472,132 @@ async fn connect_via_socks( } } +/// A buffered HTTP response read off a raw upstream stream. +struct BufferedHttpResponse { + bytes: Vec, + /// True when the read stopped at `MAX_HTTP_HEADER_BUFFER` / + /// `MAX_HTTP_RESPONSE_BUFFER` rather than at the end of the response, so + /// `bytes` holds only a prefix. Callers must fail the request instead of + /// forwarding it: hyper derives a fresh Content-Length from whatever body it + /// is handed, so a truncated response reaches the browser as a well-formed, + /// self-consistent short one and silently corrupts the download. + truncated: bool, +} + +/// Read a full HTTP response from `stream` into a buffer: headers first +/// (capped at `MAX_HTTP_HEADER_BUFFER` — a peer streaming data that never +/// contains CRLFCRLF must not grow memory unboundedly), then the body per +/// Content-Length or until close, with the total capped at +/// `MAX_HTTP_RESPONSE_BUFFER`. Hitting either cap sets `truncated`. +async fn read_http_response_buffer(stream: &mut S) -> BufferedHttpResponse { + let mut response_buffer = Vec::with_capacity(8192); + let mut temp_buf = [0u8; 4096]; + let mut content_length: Option = None; + let mut is_chunked = false; + let mut truncated = false; + + // Read until we have complete headers + loop { + if response_buffer.len() > MAX_HTTP_HEADER_BUFFER { + log::warn!( + "HTTP response headers exceeded {} bytes without terminating; aborting read", + MAX_HTTP_HEADER_BUFFER + ); + truncated = true; + break; + } + match stream.read(&mut temp_buf).await { + Ok(0) => break, // Connection closed + Ok(n) => { + response_buffer.extend_from_slice(&temp_buf[..n]); + // Check for end of headers (\r\n\r\n) + if let Some(pos) = response_buffer.windows(4).position(|w| w == b"\r\n\r\n") { + // Parse headers + let headers_str = String::from_utf8_lossy(&response_buffer[..pos + 4]); + for line in headers_str.lines() { + let line_lower = line.to_lowercase(); + if line_lower.starts_with("content-length:") { + if let Some(len_str) = line.split(':').nth(1) { + if let Ok(len) = len_str.trim().parse::() { + content_length = Some(len); + } + } + } else if line_lower.starts_with("transfer-encoding:") && line_lower.contains("chunked") + { + is_chunked = true; + } + } + // Read body if Content-Length is specified and we don't have it all + if let Some(cl) = content_length { + let body_start = pos + 4; + let body_received = response_buffer.len() - body_start; + if body_received < cl { + // Read remaining body (but don't use read_exact as connection might close) + let remaining = cl - body_received; + let mut read_so_far = 0; + while read_so_far < remaining { + if response_buffer.len() >= MAX_HTTP_RESPONSE_BUFFER { + log::warn!( + "HTTP response body exceeded {} bytes; refusing to forward a truncated response", + MAX_HTTP_RESPONSE_BUFFER + ); + truncated = true; + break; + } + match stream.read(&mut temp_buf).await { + Ok(0) => break, // Connection closed + Ok(m) => { + let to_read = (remaining - read_so_far).min(m); + response_buffer.extend_from_slice(&temp_buf[..to_read]); + read_so_far += to_read; + if to_read < m { + // More data than needed, might be next response - stop here + break; + } + } + Err(_) => break, + } + } + } + } else if !is_chunked { + // No Content-Length and not chunked - read until connection closes + // But limit to reasonable size to avoid memory issues + loop { + if response_buffer.len() >= MAX_HTTP_RESPONSE_BUFFER { + log::warn!( + "HTTP response exceeded {} bytes; refusing to forward a truncated response", + MAX_HTTP_RESPONSE_BUFFER + ); + truncated = true; + break; + } + match stream.read(&mut temp_buf).await { + Ok(0) => break, // Connection closed + Ok(n) => { + response_buffer.extend_from_slice(&temp_buf[..n]); + } + Err(_) => break, + } + } + } + // Note: Chunked encoding is complex to parse manually, so we'll read what we can + // For full chunked support, we'd need a proper HTTP parser + break; + } + } + Err(e) => { + log::error!("Error reading HTTP response: {}", e); + break; + } + } + } + + BufferedHttpResponse { + bytes: response_buffer, + truncated, + } +} + async fn handle_http_via_socks4( req: Request, upstream_url: &str, @@ -633,18 +630,26 @@ async fn handle_http_via_socks4( let target_port = target_uri.port_u16().unwrap_or(80); // Connect to SOCKS4 proxy - let mut socks_stream = match TcpStream::connect(&socks_addr).await { - Ok(stream) => stream, - Err(e) => { - log::error!("Failed to connect to SOCKS4 proxy {}: {}", socks_addr, e); - let mut response = Response::new(Full::new(Bytes::from(format!( - "Failed to connect to SOCKS4 proxy: {}", - e - )))); - *response.status_mut() = StatusCode::BAD_GATEWAY; - return Ok(response); - } - }; + let mut socks_stream = + match tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, TcpStream::connect(&socks_addr)).await { + Ok(Ok(stream)) => stream, + Ok(Err(e)) => { + log::error!("Failed to connect to SOCKS4 proxy {}: {}", socks_addr, e); + let mut response = Response::new(Full::new(Bytes::from(format!( + "Failed to connect to SOCKS4 proxy: {}", + e + )))); + *response.status_mut() = StatusCode::BAD_GATEWAY; + return Ok(response); + } + Err(_) => { + log::error!("Connect to SOCKS4 proxy {} timed out", socks_addr); + let mut response = + Response::new(Full::new(Bytes::from("Connect to SOCKS4 proxy timed out"))); + *response.status_mut() = StatusCode::GATEWAY_TIMEOUT; + return Ok(response); + } + }; // Build a SOCKS4a CONNECT request. We deliberately do NOT resolve the target // hostname locally: tokio::net::lookup_host would call the HOST resolver @@ -674,14 +679,30 @@ async fn handle_http_via_socks4( // Read SOCKS4 response let mut socks_response = [0u8; 8]; - if let Err(e) = socks_stream.read_exact(&mut socks_response).await { - log::error!("Failed to read SOCKS4 response: {}", e); - let mut response = Response::new(Full::new(Bytes::from(format!( - "Failed to read SOCKS4 response: {}", - e - )))); - *response.status_mut() = StatusCode::BAD_GATEWAY; - return Ok(response); + match tokio::time::timeout( + UPSTREAM_DIAL_TIMEOUT, + socks_stream.read_exact(&mut socks_response), + ) + .await + { + Ok(Ok(_)) => {} + Ok(Err(e)) => { + log::error!("Failed to read SOCKS4 response: {}", e); + let mut response = Response::new(Full::new(Bytes::from(format!( + "Failed to read SOCKS4 response: {}", + e + )))); + *response.status_mut() = StatusCode::BAD_GATEWAY; + return Ok(response); + } + Err(_) => { + log::error!("SOCKS4 handshake response timed out"); + let mut response = Response::new(Full::new(Bytes::from( + "SOCKS4 handshake response timed out", + ))); + *response.status_mut() = StatusCode::GATEWAY_TIMEOUT; + return Ok(response); + } } // Check SOCKS4 response (second byte should be 0x5A for success) @@ -776,84 +797,37 @@ async fn handle_http_via_socks4( } } - // Read HTTP response - let mut response_buffer = Vec::with_capacity(8192); - let mut temp_buf = [0u8; 4096]; - let mut content_length: Option = None; - let mut is_chunked = false; - - // Read until we have complete headers - loop { - match socks_stream.read(&mut temp_buf).await { - Ok(0) => break, // Connection closed - Ok(n) => { - response_buffer.extend_from_slice(&temp_buf[..n]); - // Check for end of headers (\r\n\r\n) - if let Some(pos) = response_buffer.windows(4).position(|w| w == b"\r\n\r\n") { - // Parse headers - let headers_str = String::from_utf8_lossy(&response_buffer[..pos + 4]); - for line in headers_str.lines() { - let line_lower = line.to_lowercase(); - if line_lower.starts_with("content-length:") { - if let Some(len_str) = line.split(':').nth(1) { - if let Ok(len) = len_str.trim().parse::() { - content_length = Some(len); - } - } - } else if line_lower.starts_with("transfer-encoding:") && line_lower.contains("chunked") - { - is_chunked = true; - } - } - // Read body if Content-Length is specified and we don't have it all - if let Some(cl) = content_length { - let body_start = pos + 4; - let body_received = response_buffer.len() - body_start; - if body_received < cl { - // Read remaining body (but don't use read_exact as connection might close) - let remaining = cl - body_received; - let mut read_so_far = 0; - while read_so_far < remaining { - match socks_stream.read(&mut temp_buf).await { - Ok(0) => break, // Connection closed - Ok(m) => { - let to_read = (remaining - read_so_far).min(m); - response_buffer.extend_from_slice(&temp_buf[..to_read]); - read_so_far += to_read; - if to_read < m { - // More data than needed, might be next response - stop here - break; - } - } - Err(_) => break, - } - } - } - } else if !is_chunked { - // No Content-Length and not chunked - read until connection closes - // But limit to reasonable size to avoid memory issues - let max_body_size = 10 * 1024 * 1024; // 10MB max - while response_buffer.len() < max_body_size { - match socks_stream.read(&mut temp_buf).await { - Ok(0) => break, // Connection closed - Ok(n) => { - response_buffer.extend_from_slice(&temp_buf[..n]); - } - Err(_) => break, - } - } - } - // Note: Chunked encoding is complex to parse manually, so we'll read what we can - // For full chunked support, we'd need a proper HTTP parser - break; - } - } - Err(e) => { - log::error!("Error reading HTTP response from SOCKS4: {}", e); - break; - } + // Read HTTP response, bounded in both size and time so a stalled or + // never-terminating upstream cannot pin this task (and its connection + // permit) forever. + let buffered = match tokio::time::timeout( + PLAIN_HTTP_EXCHANGE_TIMEOUT, + read_http_response_buffer(&mut socks_stream), + ) + .await + { + Ok(buffer) => buffer, + Err(_) => { + log::error!("HTTP response via SOCKS4 timed out"); + let mut response = Response::new(Full::new(Bytes::from("Upstream response timed out"))); + *response.status_mut() = StatusCode::GATEWAY_TIMEOUT; + return Ok(response); } + }; + + // A capped read holds only a prefix of the body. Forwarding it would hand the + // browser a complete-looking short response, so fail the request instead. + if buffered.truncated { + log::error!( + "HTTP response via SOCKS4 for {domain} exceeded the buffer cap; refusing to forward a truncated body" + ); + let mut response = Response::new(Full::new(Bytes::from( + "Upstream response too large to buffer", + ))); + *response.status_mut() = StatusCode::BAD_GATEWAY; + return Ok(response); } + let response_buffer = buffered.bytes; // Parse HTTP response let response_str = String::from_utf8_lossy(&response_buffer); @@ -1057,17 +1031,13 @@ async fn handle_http( } // Use reqwest for HTTP/HTTPS/SOCKS5 proxies - use reqwest::Client; - - let client_builder = Client::builder(); let client = if should_bypass { - client_builder.build().unwrap_or_default() + direct_http_client() } else if let Some(ref upstream) = upstream_url { if upstream == "DIRECT" { - client_builder.build().unwrap_or_default() + direct_http_client() } else { - // Build reqwest client with proxy - match build_reqwest_client_with_proxy(upstream) { + match proxied_http_client(upstream) { Ok(c) => c, Err(e) => { log::error!("Failed to create proxy client: {}", e); @@ -1081,7 +1051,7 @@ async fn handle_http( } } } else { - client_builder.build().unwrap_or_default() + direct_http_client() }; // Convert hyper request to reqwest request @@ -1132,7 +1102,20 @@ async fn handle_http( Ok(response) => { let status = response.status(); let headers = response.headers().clone(); - let body = response.bytes().await.unwrap_or_default(); + // Never swallow a body error into an empty body: the status and headers + // are already captured, so an empty `Full` would be forwarded as a + // well-formed short 200 that the browser cannot distinguish from a real + // one (hyper drops the mismatched Content-Length and writes 0). + let body = match response.bytes().await { + Ok(b) => b, + Err(e) => { + log::warn!("Failed to read response body from {domain}: {e}"); + let mut error_response = + Response::new(Full::new(Bytes::from(format!("Response body failed: {e}")))); + *error_response.status_mut() = StatusCode::BAD_GATEWAY; + return Ok(error_response); + } + }; // Record request in traffic tracker let response_size = body.len() as u64; @@ -1163,12 +1146,45 @@ async fn handle_http( } } +/// Shared reqwest client for direct (no-upstream / bypass) plain-HTTP +/// forwarding. reqwest clients hold a connection pool, TLS config and +/// resolver state — building one per request would redo full TCP+TLS setup +/// every time and never reuse upstream connections. +fn direct_http_client() -> reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(UPSTREAM_DIAL_TIMEOUT) + .read_timeout(PLAIN_HTTP_EXCHANGE_TIMEOUT) + .build() + .unwrap_or_default() + }) + .clone() +} + +/// Shared per-upstream reqwest clients. A worker serves exactly one upstream, +/// so this normally holds a single entry. +fn proxied_http_client(upstream_url: &str) -> Result> { + static CLIENTS: OnceLock>> = OnceLock::new(); + let map = CLIENTS.get_or_init(|| Mutex::new(HashMap::new())); + let mut guard = map.lock().unwrap(); + if let Some(client) = guard.get(upstream_url) { + return Ok(client.clone()); + } + let client = build_reqwest_client_with_proxy(upstream_url)?; + guard.insert(upstream_url.to_string(), client.clone()); + Ok(client) +} + fn build_reqwest_client_with_proxy( upstream_url: &str, ) -> Result> { use reqwest::Proxy; - let client_builder = reqwest::Client::builder(); + let client_builder = reqwest::Client::builder() + .connect_timeout(UPSTREAM_DIAL_TIMEOUT) + .read_timeout(PLAIN_HTTP_EXCHANGE_TIMEOUT); // Parse the upstream URL let url = Url::parse(upstream_url)?; @@ -1221,11 +1237,31 @@ pub async fn handle_proxy_connection( return; } + // Classify the connection by its request line. One read is not enough: TCP + // may deliver fewer than the 7 bytes needed to recognise "CONNECT", and a + // misclassified CONNECT goes to hyper, which refuses it with 501 rather than + // tunneling it. Accumulate until the verb is decidable. let mut peek_buffer = [0u8; 16]; - match stream.read(&mut peek_buffer).await { - Ok(0) => {} - Ok(n) => { - let request_start_upper = String::from_utf8_lossy(&peek_buffer[..n.min(7)]).to_uppercase(); + let mut peeked = 0usize; + const CONNECT_VERB_LEN: usize = 7; + loop { + match stream.read(&mut peek_buffer[peeked..]).await { + Ok(0) => break, + Ok(m) => { + peeked += m; + if peeked >= CONNECT_VERB_LEN { + break; + } + } + Err(_) => return, + } + } + + match peeked { + 0 => {} + n => { + let request_start_upper = + String::from_utf8_lossy(&peek_buffer[..n.min(CONNECT_VERB_LEN)]).to_uppercase(); let is_connect = request_start_upper.starts_with("CONNECT"); if is_connect { @@ -1310,7 +1346,6 @@ pub async fn handle_proxy_connection( let _ = http1::Builder::new().serve_connection(io, service).await; } - Err(_) => {} } } @@ -1449,12 +1484,21 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box = None; loop { interval.tick().await; + let snapshot = tracker_clone.get_snapshot(); + if last_written == Some(snapshot) { + continue; + } // Write lightweight session snapshot (only current counters, ~100 bytes) - if let Err(e) = tracker_clone.write_session_snapshot() { - log::debug!("Failed to write session snapshot: {}", e); + match tracker_clone.write_session_snapshot() { + Ok(()) => last_written = Some(snapshot), + Err(e) => log::debug!("Failed to write session snapshot: {}", e), } } }); @@ -1574,7 +1618,7 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box m, Err(e) => { log::error!("[blocklist] Failed to load from {}: {}", path, e); @@ -1731,7 +1775,26 @@ const DIRECT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_se /// load (e.g. two profiles sharing one proxy) the slots exhaust and the browser /// sees `ERR_PROXY_CONNECTION_FAILED` until the profile is restarted. A /// bounded dial fails fast and releases the slot. -const UPSTREAM_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); +pub(crate) const UPSTREAM_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); + +/// Cap on bytes buffered while waiting for the end of the HTTP response +/// headers on the manual plain-HTTP forwarding path. +const MAX_HTTP_HEADER_BUFFER: usize = 64 * 1024; + +/// Cap on the total buffered HTTP response on the manual plain-HTTP +/// forwarding path. +const MAX_HTTP_RESPONSE_BUFFER: usize = 10 * 1024 * 1024; + +/// Budget for a proxied plain-HTTP exchange on the manual (SOCKS4/Shadowsocks) +/// forwarding paths, which buffer the whole response themselves. +/// +/// On the reqwest paths this is applied as a *read* timeout, not a total one: +/// it bounds the gap between successive reads, so a stalled upstream still +/// fails fast and releases its connection-semaphore permit, while a legitimately +/// slow transfer — a large download, an SSE stream, a long-poll — is not killed +/// mid-flight. `ClientBuilder::timeout` would cap the whole exchange including +/// the body and break all three. +const PLAIN_HTTP_EXCHANGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); /// Per-host failure state (last failure instant, consecutive failure count) for /// the direct dial path. Process-global — each worker is its own process. @@ -1853,6 +1916,48 @@ pub(crate) fn log_throttle(key: &str) -> Option { } } +/// Read an upstream proxy's response to our CONNECT request. +/// +/// TCP is a stream, not a sequence of messages, so a single `read` is wrong in +/// both directions: the status line can arrive split from the rest of the +/// headers (a lone `read` would reject a tunnel the upstream actually granted), +/// and the terminating CRLFCRLF can arrive with destination payload appended +/// (those bytes belong to the tunnel). Reads until the header terminator and +/// returns `(headers, bytes_after_headers)`. +async fn read_upstream_connect_response( + stream: &mut TcpStream, +) -> Result<(String, Vec), Box> { + let mut buffer = Vec::with_capacity(1024); + let mut chunk = [0u8; 4096]; + // Only the terminator needs finding, so rescanning can resume from just + // before the previous tail rather than restarting at 0 each read. + let mut scanned = 0usize; + + loop { + if buffer.len() > MAX_HTTP_HEADER_BUFFER { + return Err("upstream proxy CONNECT response headers too large".into()); + } + let n = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, stream.read(&mut chunk)) + .await + .map_err(|_| "upstream proxy CONNECT response timed out")??; + if n == 0 { + return Err("upstream proxy closed the connection during CONNECT".into()); + } + buffer.extend_from_slice(&chunk[..n]); + + if let Some(pos) = buffer[scanned..] + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|p| p + scanned) + { + let header_end = pos + 4; + let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string(); + return Ok((headers, buffer[header_end..].to_vec())); + } + scanned = buffer.len().saturating_sub(3); + } +} + /// Establish a stream to `target_host:target_port`, either directly or through /// the configured upstream proxy. Shared by the HTTP CONNECT path and the /// local SOCKS5 server so every upstream type (direct, HTTP/HTTPS CONNECT, @@ -1906,15 +2011,12 @@ pub(crate) async fn connect_to_target_via_upstream( proxy_stream.write_all(connect_req.as_bytes()).await?; - let mut buffer = [0u8; 4096]; - let n = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, proxy_stream.read(&mut buffer)) - .await - .map_err(|_| "upstream proxy CONNECT response timed out")??; - let response_full = String::from_utf8_lossy(&buffer[..n]).to_string(); - let status_line = response_full.lines().next().unwrap_or("").to_string(); + let (response_headers, coalesced) = + read_upstream_connect_response(&mut proxy_stream).await?; + let status_line = response_headers.lines().next().unwrap_or("").to_string(); - if !response_full.starts_with("HTTP/1.1 200") - && !response_full.starts_with("HTTP/1.0 200") + if !response_headers.starts_with("HTTP/1.1 200") + && !response_headers.starts_with("HTTP/1.0 200") { log::warn!( "Upstream CONNECT to {}:{} via {}:{} rejected: {}", @@ -1924,21 +2026,7 @@ pub(crate) async fn connect_to_target_via_upstream( proxy_port, status_line ); - return Err(format!("Upstream proxy CONNECT failed: {response_full}").into()); - } - - // Detect the buffer-drop race where the upstream returned the - // 200 response coalesced with destination bytes — those bytes - // would otherwise be silently discarded and the browser would - // see a TLS stream missing its first record. - let header_end_in_buffer = response_full.find("\r\n\r\n").map(|i| i + 4); - if let Some(end) = header_end_in_buffer { - if end < n { - log::warn!( - "Upstream CONNECT response coalesced {} byte(s) of payload — these would be dropped without forwarding", - n - end - ); - } + return Err(format!("Upstream proxy CONNECT failed: {status_line}").into()); } log::info!( @@ -1950,7 +2038,24 @@ pub(crate) async fn connect_to_target_via_upstream( status_line ); - Box::new(proxy_stream) + if coalesced.is_empty() { + Box::new(proxy_stream) + } else { + // The upstream packed the destination's first bytes into the same + // segment as its 200. They are tunnel payload, not proxy protocol: + // replay them ahead of the socket so the client sees an unbroken + // stream. Server-speaks-first protocols (SMTP/IMAP/SSH banners) + // reach this reliably. + log::debug!( + "Upstream CONNECT response coalesced {} byte(s) of payload; forwarding", + coalesced.len() + ); + Box::new(PrependReader { + prepended: coalesced, + prepended_pos: 0, + inner: proxy_stream, + }) + } } "socks4" | "socks5" => { let socks_host = upstream.host_str().unwrap_or("127.0.0.1"); @@ -2042,61 +2147,29 @@ pub(crate) async fn tunnel_streams( domain: String, ) { // Wrap streams to count bytes transferred - let counting_client = CountingStream::new(client_stream); - let counting_target = CountingStream::new(target_stream); - - // Get references for final stats - let client_read_counter = counting_client.bytes_read.clone(); - let client_write_counter = counting_client.bytes_written.clone(); - let target_read_counter = counting_target.bytes_read.clone(); - let target_write_counter = counting_target.bytes_written.clone(); - - // Split streams for bidirectional copying - let (mut client_read, mut client_write) = tokio::io::split(counting_client); - let (mut target_read, mut target_write) = tokio::io::split(counting_target); + let mut counting_client = CountingStream::new(client_stream); + let mut counting_target = CountingStream::new(target_stream); log::trace!("Starting bidirectional tunnel"); - // Spawn two tasks to forward data in both directions - let client_to_target = tokio::spawn(async move { - let result = tokio::io::copy(&mut client_read, &mut target_write).await; - match result { - Ok(bytes) => { - log::trace!("Tunneled {bytes} bytes from client->target"); - } - Err(e) => { - log::debug!("Error forwarding client->target: {e:?}"); - } + // Relay both directions in this single task. Spawning one task per + // direction and returning when the first finishes would detach the + // surviving copy, leaving it (and both underlying sockets) alive + // indefinitely when a peer dies without FIN. + match tokio::io::copy_bidirectional(&mut counting_client, &mut counting_target).await { + Ok((to_target, to_client)) => { + log::trace!("Tunneled {to_target} bytes client->target, {to_client} bytes target->client"); } - }); - - let target_to_client = tokio::spawn(async move { - let result = tokio::io::copy(&mut target_read, &mut client_write).await; - match result { - Ok(bytes) => { - log::trace!("Tunneled {bytes} bytes from target->client"); - } - Err(e) => { - log::debug!("Error forwarding target->client: {e:?}"); - } - } - }); - - // Wait for either direction to finish (connection closed) - tokio::select! { - _ = client_to_target => { - log::trace!("Client->target tunnel closed"); - } - _ = target_to_client => { - log::trace!("Target->client tunnel closed"); + Err(e) => { + log::debug!("Tunnel ended with error: {e:?}"); } } // Log final byte counts and update domain stats - let final_sent = - client_read_counter.load(Ordering::Relaxed) + target_write_counter.load(Ordering::Relaxed); - let final_recv = - target_read_counter.load(Ordering::Relaxed) + client_write_counter.load(Ordering::Relaxed); + let final_sent = counting_client.bytes_read.load(Ordering::Relaxed) + + counting_target.bytes_written.load(Ordering::Relaxed); + let final_recv = counting_target.bytes_read.load(Ordering::Relaxed) + + counting_client.bytes_written.load(Ordering::Relaxed); log::trace!("Tunnel closed - sent: {final_sent} bytes, received: {final_recv} bytes"); // Update domain-specific byte counts now that tunnel is complete @@ -2211,6 +2284,33 @@ mod tests { assert!(!matcher.is_blocked("example.com")); } + #[test] + fn test_allowlist_mode_blocks_everything_not_listed() { + let mut matcher = BlocklistMatcher::new(); + let mut domains = HashSet::new(); + domains.insert("example.com".to_string()); + domains.insert("api.trusted.io".to_string()); + matcher.domains = Arc::new(domains); + matcher.allowlist_mode = true; + + // Listed domains (and their subdomains) are allowed. + assert!(!matcher.is_blocked("example.com")); + assert!(!matcher.is_blocked("cdn.example.com")); + assert!(!matcher.is_blocked("api.trusted.io")); + // Everything else is blocked. + assert!(matcher.is_blocked("evil.com")); + assert!(matcher.is_blocked("trusted.io")); // parent of api.trusted.io is NOT allowed + assert!(matcher.is_blocked("google.com")); + } + + #[test] + fn test_allowlist_mode_empty_fails_open() { + let mut matcher = BlocklistMatcher::new(); + matcher.allowlist_mode = true; + // Empty allowlist would block everything and brick the browser — fail open. + assert!(!matcher.is_blocked("anything.com")); + } + #[test] fn test_blocklist_case_insensitive() { let mut matcher = BlocklistMatcher::new(); @@ -2245,6 +2345,166 @@ mod tests { assert_eq!(matcher.domains.len(), 3); } + /// Serve one canned upstream CONNECT reply, written as the given segments so + /// the reader is forced to cope with real TCP framing. + async fn serve_connect_reply( + segments: Vec<&'static [u8]>, + ) -> (TcpStream, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut s, _) = listener.accept().await.unwrap(); + let _ = s.set_nodelay(true); + for seg in segments { + if s.write_all(seg).await.is_err() { + return; + } + let _ = s.flush().await; + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + // Hold the connection open so the reader never sees a premature EOF. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + }); + let client = TcpStream::connect(addr).await.unwrap(); + (client, server) + } + + #[tokio::test] + async fn read_upstream_connect_response_forwards_coalesced_payload() { + // The upstream packs the destination's first bytes into the same segment as + // its 200. Dropping them corrupts the tunnel for any server-speaks-first + // protocol, so they must come back as leftover for the caller to replay. + let (mut client, server) = serve_connect_reply(vec![ + b"HTTP/1.1 200 Connection Established\r\n\r\nSSH-2.0-OpenSSH_9.6", + ]) + .await; + + let (headers, leftover) = read_upstream_connect_response(&mut client).await.unwrap(); + assert!(headers.starts_with("HTTP/1.1 200")); + assert_eq!(leftover, b"SSH-2.0-OpenSSH_9.6"); + server.abort(); + } + + #[tokio::test] + async fn read_upstream_connect_response_accepts_split_status_line() { + // A single read would see only "HTTP/1.1 " here and reject a tunnel the + // upstream actually granted. + let (mut client, server) = serve_connect_reply(vec![ + b"HTTP/1.1 ", + b"200 Connection Established\r\n", + b"Proxy-Agent: squid\r\n\r\n", + ]) + .await; + + let (headers, leftover) = read_upstream_connect_response(&mut client).await.unwrap(); + assert!(headers.starts_with("HTTP/1.1 200")); + assert!(headers.contains("Proxy-Agent: squid")); + assert!( + leftover.is_empty(), + "no payload followed the headers, so nothing should be replayed" + ); + server.abort(); + } + + #[tokio::test] + async fn read_upstream_connect_response_waits_for_terminator_across_segments() { + // The terminating CRLFCRLF straddles two segments. Without a scan that + // spans the boundary the reader would miss it and relay header bytes into + // the tunnel as if they were payload. + let (mut client, server) = serve_connect_reply(vec![ + b"HTTP/1.1 200 OK\r\nProxy-Agent: x\r", + b"\n\r\nPAYLOAD", + ]) + .await; + + let (headers, leftover) = read_upstream_connect_response(&mut client).await.unwrap(); + assert!(headers.ends_with("\r\n\r\n")); + assert_eq!(leftover, b"PAYLOAD"); + server.abort(); + } + + #[tokio::test] + async fn read_upstream_connect_response_errors_on_early_close() { + let (mut client, server) = serve_connect_reply(vec![]).await; + // serve_connect_reply holds the socket open with no data; a closed upstream + // is simulated by dropping the server task and shutting the peer down. + server.abort(); + let _ = client.shutdown().await; + let result = read_upstream_connect_response(&mut client).await; + assert!(result.is_err(), "a CONNECT with no reply must not succeed"); + } + + #[tokio::test] + async fn read_http_response_buffer_caps_endless_header_stream() { + let (mut writer, mut reader) = tokio::io::duplex(16 * 1024); + let feeder = tokio::spawn(async move { + // Stream bytes that never contain CRLFCRLF. + let chunk = [b'a'; 4096]; + loop { + if writer.write_all(&chunk).await.is_err() { + break; + } + } + }); + + let buf = read_http_response_buffer(&mut reader).await; + assert!( + buf.bytes.len() <= MAX_HTTP_HEADER_BUFFER + 4096, + "pre-header buffering must stop at the cap, got {} bytes", + buf.bytes.len() + ); + assert!( + buf.truncated, + "a header stream that never terminates must be reported as truncated" + ); + feeder.abort(); + } + + #[tokio::test] + async fn read_http_response_buffer_reads_content_length_body() { + let (mut writer, mut reader) = tokio::io::duplex(1024); + let resp: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"; + writer.write_all(resp).await.unwrap(); + drop(writer); + + let buf = read_http_response_buffer(&mut reader).await; + assert_eq!(buf.bytes, resp); + assert!(!buf.truncated); + } + + #[tokio::test] + async fn read_http_response_buffer_caps_oversized_content_length_body() { + let (mut writer, mut reader) = tokio::io::duplex(64 * 1024); + let feeder = tokio::spawn(async move { + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", + MAX_HTTP_RESPONSE_BUFFER * 2 + ); + if writer.write_all(header.as_bytes()).await.is_err() { + return; + } + let chunk = [b'b'; 8192]; + loop { + if writer.write_all(&chunk).await.is_err() { + break; + } + } + }); + + let buf = read_http_response_buffer(&mut reader).await; + assert!( + buf.bytes.len() <= MAX_HTTP_RESPONSE_BUFFER + 8192, + "body buffering must stop at the cap, got {} bytes", + buf.bytes.len() + ); + assert!( + buf.truncated, + "a body cut short by the cap must be reported as truncated so the caller \ + fails the request instead of forwarding a short response" + ); + feeder.abort(); + } + #[test] fn test_blocklist_comments_skipped() { let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); diff --git a/src-tauri/src/proxy_storage.rs b/src-tauri/src/proxy_storage.rs index 4198ea1..06b60a7 100644 --- a/src-tauri/src/proxy_storage.rs +++ b/src-tauri/src/proxy_storage.rs @@ -16,6 +16,10 @@ pub struct ProxyConfig { pub bypass_rules: Vec, #[serde(default)] pub blocklist_file: Option, + /// When true, `blocklist_file` is treated as an ALLOW list: the browser may + /// only reach domains in the file; everything else is blocked. + #[serde(default)] + pub dns_allowlist_mode: bool, /// Protocol the local worker serves to the browser: "socks5" (Wayfern/Chromium so QUIC and /// WebRTC UDP can be proxied without leaking the real IP). Independent of /// `upstream_url`, which is the real upstream proxy/VPN this worker dials. @@ -42,6 +46,7 @@ impl ProxyConfig { profile_id: None, bypass_rules: Vec::new(), blocklist_file: None, + dns_allowlist_mode: false, local_protocol: None, browser_pid: None, } @@ -62,6 +67,11 @@ impl ProxyConfig { self } + pub fn with_dns_allowlist_mode(mut self, allowlist_mode: bool) -> Self { + self.dns_allowlist_mode = allowlist_mode; + self + } + pub fn with_local_protocol(mut self, local_protocol: Option) -> Self { self.local_protocol = local_protocol; self @@ -167,11 +177,18 @@ pub fn generate_proxy_id() -> String { } pub fn is_process_running(pid: u32) -> bool { - use sysinfo::{ProcessRefreshKind, RefreshKind, System}; - let system = System::new_with_specifics( - RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), + use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System}; + let pid = sysinfo::Pid::from_u32(pid); + // Refresh only the queried PID with the minimal refresh kind: this is a + // pure existence check, and callers (worker supervisors every 15s, GUI + // cleanup loops) must not pay for a full system process-table scan. + let mut system = System::new(); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + true, + ProcessRefreshKind::nothing(), ); - system.process(sysinfo::Pid::from_u32(pid)).is_some() + system.process(pid).is_some() } #[cfg(test)] diff --git a/src-tauri/src/socks5_local.rs b/src-tauri/src/socks5_local.rs index 4055e4f..2311846 100644 --- a/src-tauri/src/socks5_local.rs +++ b/src-tauri/src/socks5_local.rs @@ -13,13 +13,21 @@ //! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses //! the association) the request is refused, so Chromium falls back to proxied //! TCP rather than sending UDP from the real IP. +//! +//! Both commands enforce the same DNS blocklist and feed the same traffic +//! tracker as the HTTP front-end: CONNECT via `handle_connect`, and every UDP +//! datagram via [`UdpRelayContext`]. Without that, QUIC and WebRTC would be an +//! unfiltered, unmetered side channel around the TCP filter. use crate::proxy_server::{ connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher, }; -use crate::traffic_stats::get_traffic_tracker; +use crate::traffic_stats::{get_traffic_tracker, LiveTrafficTracker}; use async_socks5::{AddrKind, Auth, SocksDatagram}; +use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use std::time::{Duration, Instant}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpStream, UdpSocket}; use url::Url; @@ -37,6 +45,127 @@ const CMD_UDP_ASSOCIATE: u8 = 0x03; // Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack. const UDP_BUF: usize = 65_536; +// How often per-domain UDP byte totals are pushed into the traffic tracker. +// Datagram relay is a per-packet path, so totals accumulate locally and flush +// on this interval rather than taking the tracker's domain write lock per +// packet. Global byte counters are plain atomics and update inline, so live +// bandwidth stays real-time exactly as it does for TCP tunnels. +const UDP_STATS_FLUSH_INTERVAL: Duration = Duration::from_secs(2); + +// Cap on remembered destination->host flows per association. A long-lived +// association fanning out to many peers must not grow this map without bound. +const UDP_FLOW_MAP_MAX: usize = 1024; + +/// Per-association filtering and traffic accounting for the UDP relay loops. +/// +/// UDP has no per-datagram error channel (RFC 1928 §7 has no reply code), so a +/// blocked destination is dropped silently — which is what the browser sees for +/// any unreachable UDP peer, and makes it fall back to proxied TCP. +struct UdpRelayContext { + blocklist_matcher: BlocklistMatcher, + tracker: Option>, + /// Pending per-domain (sent, received) deltas awaiting flush. + pending: HashMap, + /// Destinations already counted as a request, so each is recorded once. + seen: std::collections::HashSet, + /// Maps a peer key back to the destination host we sent to, so reply bytes + /// are attributed to the domain rather than to a bare IP. + flow: HashMap, + last_flush: Instant, +} + +impl UdpRelayContext { + fn new(blocklist_matcher: BlocklistMatcher) -> Self { + Self { + blocklist_matcher, + tracker: get_traffic_tracker(), + pending: HashMap::new(), + seen: std::collections::HashSet::new(), + flow: HashMap::new(), + last_flush: Instant::now(), + } + } + + /// Apply the DNS blocklist to a datagram destination. Mirrors the TCP path + /// exactly: the host string is matched whether it is a domain or an IP + /// literal, so allowlist mode rejects unlisted IP destinations here too. + fn is_blocked(&self, host: &str) -> bool { + self.blocklist_matcher.is_blocked(host) + } + + /// Account a datagram relayed browser -> destination. + fn record_sent(&mut self, host: &str, peer_key: String, bytes: u64) { + let Some(tracker) = &self.tracker else { + return; + }; + tracker.add_bytes_sent(bytes); + if self.seen.insert(host.to_string()) { + // First datagram to this destination: count it once so UDP peers show up + // in the domain list the way CONNECT targets do. + tracker.record_request(host, 0, 0); + } + if self.flow.len() < UDP_FLOW_MAP_MAX { + self.flow.insert(peer_key, host.to_string()); + } + self.pending.entry(host.to_string()).or_insert((0, 0)).0 += bytes; + self.maybe_flush(); + } + + /// Account a datagram relayed destination -> browser. `peer_key` is looked up + /// against the flows recorded by `record_sent`; an unmatched reply (an + /// upstream that reports a different address than we addressed) is attributed + /// to `fallback_host`. + fn record_received(&mut self, peer_key: &str, fallback_host: &str, bytes: u64) { + let Some(tracker) = &self.tracker else { + return; + }; + tracker.add_bytes_received(bytes); + let host = self + .flow + .get(peer_key) + .cloned() + .unwrap_or_else(|| fallback_host.to_string()); + self.pending.entry(host).or_insert((0, 0)).1 += bytes; + self.maybe_flush(); + } + + fn maybe_flush(&mut self) { + if self.last_flush.elapsed() >= UDP_STATS_FLUSH_INTERVAL { + self.flush(); + } + } + + /// Push accumulated per-domain totals into the tracker. + fn flush(&mut self) { + let Some(tracker) = &self.tracker else { + self.pending.clear(); + return; + }; + for (domain, (sent, recv)) in self.pending.drain() { + tracker.update_domain_bytes(&domain, sent, recv); + } + self.last_flush = Instant::now(); + } +} + +/// Host portion of a UDP destination, for blocklist matching. An IP literal is +/// rendered without its port so it matches the same way a CONNECT host does. +fn addrkind_host(addr: &AddrKind) -> String { + match addr { + AddrKind::Ip(s) => s.ip().to_string(), + AddrKind::Domain(domain, _) => domain.clone(), + } +} + +/// Stable key identifying a UDP peer, used to tie replies back to the +/// destination host they belong to. +fn addrkind_key(addr: &AddrKind) -> String { + match addr { + AddrKind::Ip(s) => s.to_string(), + AddrKind::Domain(domain, port) => format!("{domain}:{port}"), + } +} + /// How a UDP ASSOCIATE request must be served for a given upstream so the real /// IP never leaks. #[derive(Debug, PartialEq, Eq)] @@ -108,7 +237,7 @@ pub async fn handle_socks5_connection( .await; } CMD_UDP_ASSOCIATE => { - handle_udp_associate(stream, upstream_url).await; + handle_udp_associate(stream, upstream_url, blocklist_matcher).await; } other => { log::debug!("SOCKS5 unsupported command {other:#04x}"); @@ -294,8 +423,13 @@ async fn handle_connect( /// /// `control` is the TCP control connection; the UDP association lives exactly /// as long as it stays open (RFC 1928 §6), so the relay loop tears down when -/// the browser closes it. -async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option) { +/// the browser closes it. Every relayed datagram is filtered and metered via +/// [`UdpRelayContext`], matching the TCP CONNECT path. +async fn handle_udp_associate( + mut control: TcpStream, + upstream_url: Option, + blocklist_matcher: BlocklistMatcher, +) { let mode = udp_mode(upstream_url.as_deref()); if mode == UdpMode::Refuse { @@ -344,7 +478,7 @@ async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option { // Establish the upstream association FIRST; if the upstream refuses UDP, @@ -367,7 +501,13 @@ async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option unreachable!("handled above"), } @@ -389,10 +529,20 @@ async fn associate_upstream( None }; - let proxy_stream = TcpStream::connect((host, port)).await?; + let proxy_stream = tokio::time::timeout( + crate::proxy_server::UPSTREAM_DIAL_TIMEOUT, + TcpStream::connect((host, port)), + ) + .await + .map_err(|_| format!("upstream SOCKS5 connect to {host}:{port} timed out"))??; let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?; // association_addr None => 0.0.0.0:0 (we accept replies from any peer). - let datagram = SocksDatagram::associate(proxy_stream, bind_sock, auth, None::).await?; + let datagram = tokio::time::timeout( + crate::proxy_server::UPSTREAM_DIAL_TIMEOUT, + SocksDatagram::associate(proxy_stream, bind_sock, auth, None::), + ) + .await + .map_err(|_| "upstream SOCKS5 UDP ASSOCIATE handshake timed out")??; Ok(datagram) } @@ -467,7 +617,12 @@ fn build_udp_response(peer: SocketAddr, data: &[u8]) -> Vec { /// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when /// there is no upstream proxy, so the host IP is the profile's own IP. -async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: UdpSocket) { +async fn run_udp_relay_direct( + mut control: TcpStream, + relay: UdpSocket, + out: UdpSocket, + mut ctx: UdpRelayContext, +) { let mut client_addr: Option = None; let mut from_client = vec![0u8; UDP_BUF]; let mut from_target = vec![0u8; UDP_BUF]; @@ -490,23 +645,37 @@ async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: Udp if header.frag != 0 { continue; // fragmentation unsupported } + let host = addrkind_host(&header.dst); + if ctx.is_blocked(&host) { + log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}"); + continue; + } let payload = &from_client[header.data_offset..n]; + // Resolve after the blocklist check so a blocked host is never even + // looked up, and count bytes against the resolved peer so replies from + // it are attributed back to this host. let dst = match resolve_addr(&header.dst).await { Some(d) => d, None => continue, }; - let _ = out.send_to(payload, dst).await; + if out.send_to(payload, dst).await.is_ok() { + ctx.record_sent(&host, dst.to_string(), payload.len() as u64); + } } // Target -> browser. r = out.recv_from(&mut from_target) => { let Ok((n, peer)) = r else { continue }; if let Some(client) = client_addr { let resp = build_udp_response(peer, &from_target[..n]); - let _ = relay.send_to(&resp, client).await; + if relay.send_to(&resp, client).await.is_ok() { + ctx.record_received(&peer.to_string(), &peer.ip().to_string(), n as u64); + } } } } } + + ctx.flush(); } /// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE. @@ -514,6 +683,7 @@ async fn run_udp_relay_socks5( mut control: TcpStream, relay: UdpSocket, datagram: SocksDatagram, + mut ctx: UdpRelayContext, ) { let mut client_addr: Option = None; let mut from_client = vec![0u8; UDP_BUF]; @@ -536,19 +706,33 @@ async fn run_udp_relay_socks5( if header.frag != 0 { continue; } + let host = addrkind_host(&header.dst); + if ctx.is_blocked(&host) { + log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}"); + continue; + } + let peer_key = addrkind_key(&header.dst); let payload = from_client[header.data_offset..n].to_vec(); - let _ = datagram.send_to(&payload, header.dst).await; + if datagram.send_to(&payload, header.dst).await.is_ok() { + ctx.record_sent(&host, peer_key, payload.len() as u64); + } } // Upstream -> browser. r = datagram.recv_from(&mut from_upstream) => { let Ok((n, peer)) = r else { continue }; if let Some(client) = client_addr { let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]); - let _ = relay.send_to(&resp, client).await; + if relay.send_to(&resp, client).await.is_ok() { + // The upstream usually reports the peer by IP even when we + // addressed a domain, so an unmatched flow falls back to that IP. + ctx.record_received(&addrkind_key(&peer), &addrkind_host(&peer), n as u64); + } } } } } + + ctx.flush(); } /// Resolve a UDP destination to a concrete socket address for direct relay. @@ -637,6 +821,91 @@ mod tests { assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none()); } + #[test] + fn addrkind_host_strips_port_for_blocklist_matching() { + // The blocklist matches CONNECT hosts without a port, so UDP destinations + // must be rendered the same way or IP rules would never match. + assert_eq!( + addrkind_host(&AddrKind::Ip(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + 443 + ))), + "1.2.3.4" + ); + assert_eq!( + addrkind_host(&AddrKind::Domain("ads.example.com".into(), 443)), + "ads.example.com" + ); + } + + #[test] + fn addrkind_key_distinguishes_ports() { + assert_eq!( + addrkind_key(&AddrKind::Domain("example.com".into(), 443)), + "example.com:443" + ); + assert_eq!( + addrkind_key(&AddrKind::Ip(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + 53 + ))), + "1.2.3.4:53" + ); + } + + #[test] + fn udp_relay_context_blocks_like_the_tcp_path() { + let dir = std::env::temp_dir().join(format!("donut-udp-blocklist-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("block.txt"); + std::fs::write(&path, "ads.example.com\n").unwrap(); + + let matcher = BlocklistMatcher::from_file(path.to_str().unwrap()).unwrap(); + let ctx = UdpRelayContext::new(matcher); + assert!(ctx.is_blocked("ads.example.com")); + // Subdomains are blocked by the parent rule, as on the CONNECT path. + assert!(ctx.is_blocked("cdn.ads.example.com")); + assert!(!ctx.is_blocked("example.com")); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn udp_relay_context_allowlist_mode_blocks_unlisted_ip_destinations() { + // Parity check: allowlist mode blocks a bare IP destination over UDP the + // same way is_blocked does for a CONNECT to an IP literal — otherwise QUIC + // to a hardcoded IP would walk straight through a strict allowlist. + let dir = std::env::temp_dir().join(format!("donut-udp-allowlist-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("allow.txt"); + std::fs::write(&path, "trusted.example.com\n").unwrap(); + + let matcher = BlocklistMatcher::from_file_with_mode(path.to_str().unwrap(), true).unwrap(); + let ctx = UdpRelayContext::new(matcher); + assert!(!ctx.is_blocked("trusted.example.com")); + assert!(ctx.is_blocked("evil.example.com")); + assert!(ctx.is_blocked(&addrkind_host(&AddrKind::Ip(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + 443 + ))))); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn udp_relay_context_attributes_replies_to_the_destination_host() { + // No tracker is initialised in tests, so this exercises the flow map that + // decides which domain reply bytes belong to. + let mut ctx = UdpRelayContext::new(BlocklistMatcher::new()); + ctx.flow.insert("1.2.3.4:443".into(), "example.com".into()); + assert_eq!( + ctx.flow.get("1.2.3.4:443").map(String::as_str), + Some("example.com") + ); + // An unknown peer has no flow and falls back to the peer's own host. + assert!(!ctx.flow.contains_key("9.9.9.9:53")); + } + #[test] fn build_udp_response_prefixes_header() { let resp = build_udp_response( diff --git a/src-tauri/src/traffic_stats.rs b/src-tauri/src/traffic_stats.rs index 55d5ce5..a04edfd 100644 --- a/src-tauri/src/traffic_stats.rs +++ b/src-tauri/src/traffic_stats.rs @@ -347,7 +347,12 @@ pub fn save_traffic_stats(stats: &TrafficStats) -> Result<(), Box Option { load_traffic_stats(profile_id) } -/// List all traffic stats files and migrate old proxy-id based files to profile-id based -pub fn list_traffic_stats() -> Vec { +/// Load all traffic stats files keyed by storage key, migrating old +/// proxy-id based files to profile-id based ones. Only writes to disk when a +/// migration or merge actually changed data; the common case is read-only. +fn collect_traffic_stats() -> HashMap { let storage_dir = get_traffic_stats_dir(); if !storage_dir.exists() { - return Vec::new(); + return HashMap::new(); } let mut stats_map: HashMap = HashMap::new(); + let mut dirty_keys: std::collections::HashSet = std::collections::HashSet::new(); let mut files_to_delete: Vec = Vec::new(); if let Ok(entries) = fs::read_dir(&storage_dir) { @@ -399,12 +407,14 @@ pub fn list_traffic_stats() -> Vec { if let Some(existing) = stats_map.get_mut(&key) { // Merge stats from this file into existing merge_traffic_stats(existing, &s); + dirty_keys.insert(key); if is_old_proxy_file { files_to_delete.push(path.clone()); } } else { stats_map.insert(key.clone(), s); if is_old_proxy_file { + dirty_keys.insert(key); files_to_delete.push(path.clone()); } } @@ -414,10 +424,12 @@ pub fn list_traffic_stats() -> Vec { } } - // Save merged stats and delete old files - for stats in stats_map.values() { - if let Err(e) = save_traffic_stats(stats) { - log::warn!("Failed to save merged traffic stats: {}", e); + // Persist only entries actually changed by a merge or migration + for key in &dirty_keys { + if let Some(stats) = stats_map.get(key) { + if let Err(e) = save_traffic_stats(stats) { + log::warn!("Failed to save merged traffic stats: {}", e); + } } } @@ -427,7 +439,12 @@ pub fn list_traffic_stats() -> Vec { } } - stats_map.into_values().collect() + stats_map +} + +/// List all traffic stats files and migrate old proxy-id based files to profile-id based +pub fn list_traffic_stats() -> Vec { + collect_traffic_stats().into_values().collect() } /// Merge traffic stats from source into destination @@ -487,27 +504,82 @@ fn merge_traffic_stats(dest: &mut TrafficStats, src: &TrafficStats) { } } -/// Delete traffic stats by id (profile_id or proxy_id) +/// Delete traffic stats by id (profile_id or proxy_id). +/// +/// Removes the session snapshot and any temp orphans alongside the main file. +/// The snapshot is what a running worker writes every second, and +/// `get_all_traffic_snapshots_realtime` rebuilds an entry straight from it when +/// no main file exists — so clearing only `{id}.json` resurrects the very +/// totals the user asked to erase, and leaves the snapshot's copy on disk. pub fn delete_traffic_stats(id: &str) -> bool { let storage_dir = get_traffic_stats_dir(); - let file_path = storage_dir.join(format!("{id}.json")); + let mut removed = false; - if file_path.exists() { - fs::remove_file(&file_path).is_ok() - } else { - false + for name in [ + format!("{id}.json"), + format!("{id}.json.tmp"), + format!("{id}.session.json"), + format!("{id}.session.json.tmp"), + ] { + let path = storage_dir.join(name); + if path.exists() && secure_remove_file(&path).is_ok() { + removed = true; + } } + + removed } -/// Clear all traffic stats (used when clearing cache) +/// Best-effort secure erase: overwrite the file's bytes with zeros and flush +/// before unlinking, so the traffic history isn't trivially recoverable from +/// the freed blocks. On copy-on-write / SSD storage the OS may still retain +/// old blocks — this is a best-effort mitigation, not a guarantee. +fn secure_remove_file(path: &std::path::Path) -> std::io::Result<()> { + use std::io::Write; + if let Ok(meta) = fs::metadata(path) { + let len = meta.len(); + if len > 0 { + if let Ok(mut f) = fs::OpenOptions::new().write(true).open(path) { + let zeros = vec![0u8; 8192]; + let mut remaining = len; + // The overwrite is best-effort and must never gate the unlink: a write + // failure part-way (ENOSPC on a copy-on-write volume, EIO) would + // otherwise leave the file both un-wiped and un-deleted, which is + // strictly worse than the plain remove this replaced — and the caller + // reports success either way, so the history would silently survive a + // clear. + while remaining > 0 { + let chunk = remaining.min(zeros.len() as u64) as usize; + if f.write_all(&zeros[..chunk]).is_err() { + break; + } + remaining -= chunk as u64; + } + let _ = f.flush(); + let _ = f.sync_all(); + } + } + } + fs::remove_file(path) +} + +/// Clear all traffic stats (used when clearing cache), securely erasing each +/// file first. pub fn clear_all_traffic_stats() -> Result<(), Box> { let storage_dir = get_traffic_stats_dir(); if storage_dir.exists() { for entry in fs::read_dir(&storage_dir)?.flatten() { let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "json") { - let _ = fs::remove_file(&path); + // Wipe the live stats files and any temp orphan left by an interrupted + // atomic save, which still holds a copy of the history. This directory + // holds nothing but traffic stats, so matching every `.json`/`.tmp` also + // catches orphans from older temp-naming schemes. + let is_stats = path + .extension() + .is_some_and(|ext| ext == "json" || ext == "tmp"); + if is_stats { + let _ = secure_remove_file(&path); } } } @@ -584,8 +656,10 @@ impl LiveTrafficTracker { fs::create_dir_all(&storage_dir)?; let session_file = storage_dir.join(format!("{}.session.json", storage_key)); - // Write atomically using a temp file - let temp_file = session_file.with_extension("tmp"); + // Write atomically using a temp file. Named by suffix rather than + // `with_extension`, which would replace `.json` and yield + // `{key}.session.tmp` — a name the cleanup sweeps did not recognise. + let temp_file = storage_dir.join(format!("{}.session.json.tmp", storage_key)); let content = serde_json::to_string(&snapshot)?; fs::write(&temp_file, content)?; fs::rename(&temp_file, &session_file)?; @@ -1035,14 +1109,14 @@ fn load_session_snapshot(profile_id: &str) -> Option { pub fn get_all_traffic_snapshots_realtime() -> Vec { use std::collections::HashMap; - // Start with disk-stored stats - let mut snapshots: HashMap = list_traffic_stats() - .into_iter() - .map(|s| { - let key = s.profile_id.clone().unwrap_or_else(|| s.proxy_id.clone()); - (key, s.to_snapshot()) - }) - .collect(); + // Start with disk-stored stats, keeping last_flush_timestamp per key so the + // session-snapshot merge below doesn't need to re-read the files + let mut snapshots: HashMap = HashMap::new(); + let mut last_flush_by_key: HashMap = HashMap::new(); + for (key, s) in collect_traffic_stats() { + last_flush_by_key.insert(key.clone(), s.last_flush_timestamp); + snapshots.insert(key, s.to_snapshot()); + } // Try to merge in real-time data from active tracker (if in same process) if let Some(tracker) = get_traffic_tracker() { @@ -1068,11 +1142,7 @@ pub fn get_all_traffic_snapshots_realtime() -> Vec { // Only merge session data if it's newer than the last flush // Session snapshots written before the last flush contain bytes already // included in disk totals, so merging them would cause double-counting - let disk_stats = load_traffic_stats(profile_id); - let last_flush = disk_stats - .as_ref() - .map(|s| s.last_flush_timestamp) - .unwrap_or(0); + let last_flush = last_flush_by_key.get(profile_id).copied().unwrap_or(0); if session.timestamp > last_flush { // Session data contains in-memory counters not yet flushed to disk diff --git a/src/app/page.tsx b/src/app/page.tsx index 74ebf58..02bd57b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,14 +3,21 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrent } from "@tauri-apps/plugin-deep-link"; +import { motion } from "motion/react"; import { useOnborda } from "onborda"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { AboutDialog } from "@/components/about-dialog"; import { AccountPage } from "@/components/account-page"; import { CloneProfileDialog } from "@/components/clone-profile-dialog"; import { CloseConfirmDialog } from "@/components/close-confirm-dialog"; import { CommandPalette } from "@/components/command-palette"; import { CommercialTrialModal } from "@/components/commercial-trial-modal"; +import { + type ConsistencyResult, + ConsistencyWarningDialog, + isConsistencyWarningSuppressed, +} from "@/components/consistency-warning-dialog"; import { CookieCopyDialog } from "@/components/cookie-copy-dialog"; import { CookieManagementDialog } from "@/components/cookie-management-dialog"; import { CreateProfileDialog } from "@/components/create-profile-dialog"; @@ -60,6 +67,7 @@ import { useVpnEvents } from "@/hooks/use-vpn-events"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; import { translateBackendError } from "@/lib/backend-errors"; import { getEntitlements } from "@/lib/entitlements"; +import { MOTION_EASE_OUT } from "@/lib/motion"; import { ONBOARDING_TOUR_FINISHED_EVENT, setOnboardingActive, @@ -324,6 +332,11 @@ export default function Home() { const [currentProfileForSync, setCurrentProfileForSync] = useState(null); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [aboutDialogOpen, setAboutDialogOpen] = useState(false); + const [consistencyWarning, setConsistencyWarning] = useState<{ + profile: BrowserProfile; + result: ConsistencyResult; + } | null>(null); // Owned by page.tsx so the command palette can request opening the profile // info dialog. ProfilesDataTable consumes it through controlled props. const [profileInfoDialog, setProfileInfoDialog] = @@ -917,6 +930,24 @@ export default function Home() { profile, }); console.log("Successfully launched profile:", result.name); + + // Non-blocking: after a successful launch, check that the proxy exit + // node's timezone/country agrees with the fingerprint. A mismatch is a + // strong anti-bot tell even though the real device never leaks. + if (profile.proxy_id && !isConsistencyWarningSuppressed(profile.id)) { + void invoke( + "check_profile_fingerprint_consistency", + { profileId: profile.id }, + ) + .then((res) => { + if (res.checked && !res.consistent) { + setConsistencyWarning({ profile, result: res }); + } + }) + .catch((e) => { + console.warn("Consistency check failed:", e); + }); + } } catch (err: unknown) { console.error("Failed to launch browser:", err); const errorMessage = err instanceof Error ? err.message : String(err); @@ -1580,12 +1611,24 @@ export default function Home() { pageTitle={subPageTitle} />
- + { + setAboutDialogOpen(true); + }} + />
{currentPage === "profiles" && ( -
- {isLoading && groupsData.length === 0 ? null : null} + { setSyncLeaderProfile(profile); }} + onCreateProfile={() => { + setCreateProfileDialogOpen(true); + }} + onImportProfiles={() => { + handleRailNavigate("import"); + }} /> -
+ )} {currentPage === "shortcuts" && ( - + + + )} {settingsDialogOpen && ( @@ -1760,6 +1816,29 @@ export default function Home() { handleRailNavigate("profiles"); setProfileInfoDialog(profile); }} + onCreateProfile={() => { + setCreateProfileDialogOpen(true); + }} + onOpenAbout={() => { + setAboutDialogOpen(true); + }} + /> + + { + setAboutDialogOpen(false); + }} + /> + + { + setConsistencyWarning(null); + }} + profileName={consistencyWarning?.profile.name ?? ""} + profileId={consistencyWarning?.profile.id ?? ""} + result={consistencyWarning?.result ?? null} /> {pendingUrls.map((pendingUrl) => ( diff --git a/src/components/about-dialog.tsx b/src/components/about-dialog.tsx new file mode 100644 index 0000000..35ee159 --- /dev/null +++ b/src/components/about-dialog.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useReducedMotion } from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { launchDonutClone } from "@/lib/donut-physics"; +import { Logo } from "./icons/logo"; +import { RippleButton } from "./ui/ripple"; + +interface AboutDialogProps { + isOpen: boolean; + onClose: () => void; +} + +interface SystemInfo { + app_version: string; + os: string; + arch: string; + portable: boolean; +} + +// Flywheel: each click adds spin; past this speed the donut escapes the +// dialog and bounces around the window (shared physics with the rail egg). +const SPIN_PER_CLICK = 540; // deg/s +const ESCAPE_VELOCITY = 2200; // deg/s +const SPIN_FRICTION = 1.1; // fraction of velocity lost per second + +export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { + const { t } = useTranslation(); + const reducedMotion = useReducedMotion(); + const [systemInfo, setSystemInfo] = useState(null); + const [logoFlown, setLogoFlown] = useState(false); + + const logoRef = useRef(null); + const rotationRef = useRef(0); + const velocityRef = useRef(0); + const rafRef = useRef(0); + const lastTimeRef = useRef(0); + const cancelLaunchRef = useRef<(() => void) | null>(null); + + useEffect(() => { + if (!isOpen) return; + invoke("get_system_info") + .then(setSystemInfo) + .catch(() => { + setSystemInfo(null); + }); + }, [isOpen]); + + const stopSpin = useCallback(() => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = 0; + velocityRef.current = 0; + }, []); + + const spinFrame = useCallback( + (time: number) => { + const el = logoRef.current; + if (!el) { + rafRef.current = 0; + return; + } + const dt = Math.min((time - lastTimeRef.current) / 1000, 0.05); + lastTimeRef.current = time; + + rotationRef.current += velocityRef.current * dt; + velocityRef.current *= Math.max(0, 1 - SPIN_FRICTION * dt); + el.style.transform = `rotate(${rotationRef.current}deg)`; + + if (velocityRef.current >= ESCAPE_VELOCITY) { + // The flywheel wins: the donut tears loose and joins the bounce sim, + // keeping its spin. + stopSpin(); + setLogoFlown(true); + cancelLaunchRef.current = launchDonutClone(el, { + initialVX: Math.random() > 0.5 ? 420 : -420, + initialVY: -750, + spinSpeed: ESCAPE_VELOCITY, + onExit: () => { + cancelLaunchRef.current = null; + }, + }); + return; + } + + if (velocityRef.current > 5) { + rafRef.current = requestAnimationFrame(spinFrame); + } else { + rafRef.current = 0; + velocityRef.current = 0; + } + }, + [stopSpin], + ); + + const handleLogoClick = useCallback(() => { + if (reducedMotion || logoFlown) return; + velocityRef.current += SPIN_PER_CLICK; + if (!rafRef.current) { + lastTimeRef.current = performance.now(); + rafRef.current = requestAnimationFrame(spinFrame); + } + }, [reducedMotion, logoFlown, spinFrame]); + + const handleClose = useCallback(() => { + stopSpin(); + cancelLaunchRef.current?.(); + cancelLaunchRef.current = null; + rotationRef.current = 0; + if (logoRef.current) { + logoRef.current.style.transform = ""; + logoRef.current.style.visibility = ""; + } + setLogoFlown(false); + onClose(); + }, [stopSpin, onClose]); + + useEffect(() => { + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + cancelLaunchRef.current?.(); + }; + }, []); + + return ( + + + + {t("about.title")} + + +
+ + +
+

Donut Browser

+ {systemInfo && ( + <> +

+ {t("about.version", { version: systemInfo.app_version })} + {systemInfo.portable && ( + + {t("about.portableBadge")} + + )} +

+

+ {systemInfo.os} {systemInfo.arch} +

+ + )} +
+ +

+ {t("about.licenseNotice")} +

+ +
+ + +
+
+ +
+ + {t("common.buttons.close")} + +
+
+
+ ); +} diff --git a/src/components/account-page.tsx b/src/components/account-page.tsx index 7f93138..1ec48b0 100644 --- a/src/components/account-page.tsx +++ b/src/components/account-page.tsx @@ -199,314 +199,316 @@ export function AccountPage({ return ( -
- - - - {t("account.tabs.account")} - - - {t("account.tabs.selfHosted")} - - +
+
+ + + + {t("account.tabs.account")} + + + {t("account.tabs.selfHosted")} + + - -
-
-
- + +
+
+
+ +
+
+ {isLoggedIn && user ? ( + <> +

+ {user.email} +

+

+ {t("account.plan", { + plan: user.plan, + period: user.planPeriod ?? "—", + })} +

+ + ) : ( + <> +

+ {t("account.signedOut")} +

+

+ {t("account.signedOutDescription")} +

+ + )} +
-
- {isLoggedIn && user ? ( - <> -

- {user.email} -

-

- {t("account.plan", { - plan: user.plan, - period: user.planPeriod ?? "—", - })} -

- - ) : ( - <> -

- {t("account.signedOut")} -

-

- {t("account.signedOutDescription")} -

- - )} -
-
- {isLoggedIn && user && ( -
-
-

- {t("account.fields.plan")} -

-

- {user.plan} -

-
-
-

- {t("account.fields.status")} -

-

{user.subscriptionStatus ?? "—"}

-
- {user.teamRole && ( + {isLoggedIn && user && ( +

- {t("account.fields.teamRole")} + {t("account.fields.plan")}

-

{user.teamRole}

-
- )} - {user.planPeriod && ( -
-

- {t("account.fields.period")} +

+ {user.plan}

-

{user.planPeriod}

- )} - {typeof user.deviceOrdinal === "number" && (

- {t("account.fields.device")} + {t("account.fields.status")}

- {t("account.deviceOrdinal", { - ordinal: user.deviceOrdinal, - count: user.deviceCount ?? user.deviceOrdinal, - })} + {user.subscriptionStatus ?? "—"}

+ {user.teamRole && ( +
+

+ {t("account.fields.teamRole")} +

+

{user.teamRole}

+
+ )} + {user.planPeriod && ( +
+

+ {t("account.fields.period")} +

+

{user.planPeriod}

+
+ )} + {typeof user.deviceOrdinal === "number" && ( +
+

+ {t("account.fields.device")} +

+

+ {t("account.deviceOrdinal", { + ordinal: user.deviceOrdinal, + count: user.deviceCount ?? user.deviceOrdinal, + })} +

+
+ )} +
+ )} + + {isLoggedIn && + user && + getEntitlements(user).browserAutomation && + user.isPrimaryDevice === false && ( +

+ {t("account.automationPrimaryOnly")} +

+ )} + {isLoggedIn && + user && + getEntitlements(user).browserAutomation && + user.isPrimaryDevice === true && + (user.deviceCount ?? 1) > 1 && ( +

+ {t("account.automationActiveHere")} +

)} -
- )} - {isLoggedIn && - user && - getEntitlements(user).browserAutomation && - user.isPrimaryDevice === false && ( -

- {t("account.automationPrimaryOnly")} -

- )} - {isLoggedIn && - user && - getEntitlements(user).browserAutomation && - user.isPrimaryDevice === true && - (user.deviceCount ?? 1) > 1 && ( -

- {t("account.automationActiveHere")} -

- )} - -
- {isLoggedIn ? ( - <> +
+ {isLoggedIn ? ( + <> + + { + void handleLogout(); + }} + className="h-8 gap-1.5 text-xs" + > + + {t("account.logout")} + + + ) : ( - { - void handleLogout(); - }} - className="h-8 gap-1.5 text-xs" - > - - {t("account.logout")} - - - ) : ( - - )} + )} +
-
- + - - {selfHostedDisabled ? ( - // Defensive: the tab trigger is disabled while the user is - // logged in, so this branch shouldn't be reachable via UI — - // but if state flips mid-render (e.g. a cloud login finishes - // while the tab is open), show the explanation instead of - // a silent empty card. -

- {t("account.selfHosted.disabledWhileLoggedIn")} -

- ) : ( -
-
-

- {t("account.selfHosted.title")} -

-

- {t("account.selfHosted.description")} -

-
+ + {selfHostedDisabled ? ( + // Defensive: the tab trigger is disabled while the user is + // logged in, so this branch shouldn't be reachable via UI — + // but if state flips mid-render (e.g. a cloud login finishes + // while the tab is open), show the explanation instead of + // a silent empty card. +

+ {t("account.selfHosted.disabledWhileLoggedIn")} +

+ ) : ( +
+
+

+ {t("account.selfHosted.title")} +

+

+ {t("account.selfHosted.description")} +

+
-
- - { - setServerUrl(e.target.value); - setConnectionStatus("unknown"); - }} - autoComplete="off" - spellCheck={false} - /> -
- -
- -
+
+ { - setToken(e.target.value); + setServerUrl(e.target.value); setConnectionStatus("unknown"); }} autoComplete="off" spellCheck={false} - className="pr-9" /> -
-
-
- - {t("account.selfHosted.connectionStatus")} - - {connectionStatus === "connected" && ( - - {t("sync.status.connected")} - - )} - {connectionStatus === "error" && ( - - {t("sync.status.error")} - - )} - {connectionStatus === "testing" && ( - - {t("sync.status.syncing")} - - )} - {connectionStatus === "unknown" && ( - - {t("account.selfHosted.statusUnknown")} - - )} -
+
+ +
+ { + setToken(e.target.value); + setConnectionStatus("unknown"); + }} + autoComplete="off" + spellCheck={false} + className="pr-9" + /> + +
+
-
- void handleTestConnection()} - className="h-8 text-xs" - > - {t("account.selfHosted.testConnection")} - - void handleSaveSelfHosted()} - className="h-8 text-xs" - > - {t("common.buttons.save")} - - {hasConfig && ( -
+ +
+ void handleDisconnectSelfHosted()} + variant="outline" + isLoading={isTestingConnection} + disabled={!serverUrl || isSavingSelfHosted} + onClick={() => void handleTestConnection()} className="h-8 text-xs" > - {t("account.selfHosted.disconnect")} - - )} + {t("account.selfHosted.testConnection")} + + void handleSaveSelfHosted()} + className="h-8 text-xs" + > + {t("common.buttons.save")} + + {hasConfig && ( + + )} +
-
- )} -
- + )} + + +
diff --git a/src/components/client-providers.tsx b/src/components/client-providers.tsx index ff6c60e..30499ba 100644 --- a/src/components/client-providers.tsx +++ b/src/components/client-providers.tsx @@ -1,5 +1,6 @@ "use client"; +import { MotionConfig } from "motion/react"; import { useEffect } from "react"; import { I18nProvider } from "@/components/i18n-provider"; import { OnboardingProvider } from "@/components/onboarding-provider"; @@ -17,11 +18,17 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { return ( - - - {children} - - + {/* reducedMotion="user" makes every motion/react animation honor the + OS prefers-reduced-motion setting: transforms are skipped, opacity + cross-fades are kept. The CSS-side media query in globals.css only + covers CSS transitions — this covers the JS-driven ones. */} + + + + {children} + + + ); diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index 7183c22..1d20142 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -5,12 +5,14 @@ import { FaDownload } from "react-icons/fa"; import { FiWifi } from "react-icons/fi"; import { GoGear } from "react-icons/go"; import { + LuBadgeInfo, LuCircleStop, LuCloud, LuInfo, LuKeyboard, LuPlay, LuPlug, + LuPlus, LuPuzzle, LuUser, LuUsers, @@ -53,6 +55,8 @@ interface CommandPaletteProps { onLaunchProfile: (profile: BrowserProfile) => void; onKillProfile: (profile: BrowserProfile) => void; onShowProfileInfo: (profile: BrowserProfile) => void; + onCreateProfile: () => void; + onOpenAbout: () => void; } const ICONS: Record> = { @@ -122,6 +126,8 @@ export function CommandPalette({ onLaunchProfile, onKillProfile, onShowProfileInfo, + onCreateProfile, + onOpenAbout, }: CommandPaletteProps) { const { t } = useTranslation(); @@ -251,6 +257,14 @@ export function CommandPalette({ + { + dispatch(onCreateProfile); + }} + > + + {t("commandPalette.actions.createProfile")} + {byGroup("actions").map((s) => { const Icon = ICONS[s.id]; return ( @@ -268,6 +282,14 @@ export function CommandPalette({ ); })} + { + dispatch(onOpenAbout); + }} + > + + {t("commandPalette.actions.about")} + diff --git a/src/components/consistency-warning-dialog.tsx b/src/components/consistency-warning-dialog.tsx new file mode 100644 index 0000000..64b16e9 --- /dev/null +++ b/src/components/consistency-warning-dialog.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuTriangleAlert } from "react-icons/lu"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { translateBackendError } from "@/lib/backend-errors"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { RippleButton } from "./ui/ripple"; + +export interface ConsistencyResult { + consistent: boolean; + checked: boolean; + exit_ip: string | null; + exit_country_code: string | null; + exit_timezone: string | null; + fingerprint_timezone: string | null; + fingerprint_language: string | null; + mismatches: string[]; +} + +const GLOBAL_DISABLE_KEY = "consistency-warn-disabled"; +const perProfileKey = (id: string) => `consistency-warn-skip-${id}`; + +export function isConsistencyWarningEnabled(): boolean { + try { + return localStorage.getItem(GLOBAL_DISABLE_KEY) !== "1"; + } catch { + return true; + } +} + +export function isConsistencyWarningSuppressed(profileId: string): boolean { + try { + return ( + localStorage.getItem(GLOBAL_DISABLE_KEY) === "1" || + localStorage.getItem(perProfileKey(profileId)) === "1" + ); + } catch { + return false; + } +} + +interface ConsistencyWarningDialogProps { + isOpen: boolean; + onClose: () => void; + profileName: string; + profileId: string; + result: ConsistencyResult | null; +} + +export function ConsistencyWarningDialog({ + isOpen, + onClose, + profileName, + profileId, + result, +}: ConsistencyWarningDialogProps) { + const { t } = useTranslation(); + const [dontWarnAgain, setDontWarnAgain] = useState(false); + const [isMatching, setIsMatching] = useState(false); + + const handleClose = () => { + if (dontWarnAgain) { + try { + localStorage.setItem(perProfileKey(profileId), "1"); + } catch { + // localStorage unavailable — nothing to persist + } + } + setDontWarnAgain(false); + onClose(); + }; + + const mismatches = result?.mismatches ?? []; + const exitIp = result?.exit_ip ?? null; + + const handleMatch = async () => { + if (!exitIp) { + return; + } + setIsMatching(true); + try { + await invoke("match_profile_fingerprint_to_exit", { + profileId, + exitIp, + }); + showSuccessToast(t("consistencyWarning.matchSuccess")); + handleClose(); + } catch (e) { + showErrorToast(translateBackendError(t, e)); + } finally { + setIsMatching(false); + } + }; + + return ( + + + + + + {t("consistencyWarning.title")} + + + +
+

+ {t("consistencyWarning.intro", { name: profileName })} +

+ +
+ {mismatches.includes("timezone") && ( +
+

+ {t("consistencyWarning.timezoneTitle")} +

+

+ {t("consistencyWarning.timezoneDetail", { + exit: result?.exit_timezone ?? "?", + fingerprint: result?.fingerprint_timezone ?? "?", + })} +

+
+ )} + {mismatches.includes("language") && ( +
+

+ {t("consistencyWarning.languageTitle")} +

+

+ {t("consistencyWarning.languageDetail", { + country: result?.exit_country_code ?? "?", + fingerprint: result?.fingerprint_language ?? "?", + })} +

+
+ )} +
+ +

+ {t("consistencyWarning.explainer")} +

+ + +
+ +
+ + {t("common.buttons.close")} + + {exitIp && ( + + {isMatching + ? t("consistencyWarning.matching") + : t("consistencyWarning.matchToProxy")} + + )} +
+
+
+ ); +} diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index d00800e..226829e 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -53,6 +53,7 @@ import { useBrowserDownload } from "@/hooks/use-browser-download"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { getBrowserIcon } from "@/lib/browser-utils"; +import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels"; import { cn } from "@/lib/utils"; import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types"; @@ -1183,21 +1184,14 @@ export function CreateProfileDialog({ {t("dnsBlocklist.none")} - - {t("dnsBlocklist.light")} - - - {t("dnsBlocklist.normal")} - - - {t("dnsBlocklist.pro")} - - - {t("dnsBlocklist.proPlus")} - - - {t("dnsBlocklist.ultimate")} - + {DNS_BLOCKLIST_LEVELS.map((level) => ( + + {t(level.labelKey)} + + ))}
diff --git a/src/components/dns-blocklist-dialog.tsx b/src/components/dns-blocklist-dialog.tsx index 2ee3bbc..5d51883 100644 --- a/src/components/dns-blocklist-dialog.tsx +++ b/src/components/dns-blocklist-dialog.tsx @@ -1,9 +1,22 @@ "use client"; import { invoke } from "@tauri-apps/api/core"; +import { + open as openDialog, + save as saveDialog, +} from "@tauri-apps/plugin-dialog"; +import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuRefreshCw } from "react-icons/lu"; +import { toast } from "sonner"; +import { AnimatedSwitch } from "@/components/ui/animated-switch"; +import { + AnimatedTabs, + AnimatedTabsContent, + AnimatedTabsList, + AnimatedTabsTrigger, +} from "@/components/ui/animated-tabs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -12,6 +25,11 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { translateBackendError } from "@/lib/backend-errors"; +import { dnsBlocklistLabelKey } from "@/lib/dns-blocklist-levels"; +import { LoadingButton } from "./loading-button"; interface BlocklistCacheStatus { level: string; @@ -23,11 +41,25 @@ interface BlocklistCacheStatus { is_cached: boolean; } +interface CustomDnsConfig { + sources: string[]; + block_domains: string[]; + allow_domains: string[]; + allowlist_mode: boolean; + updated_at: number | null; +} + interface DnsBlocklistDialogProps { isOpen: boolean; onClose: () => void; } +const linesToArray = (v: string) => + v + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + export function DnsBlocklistDialog({ isOpen, onClose, @@ -36,6 +68,12 @@ export function DnsBlocklistDialog({ const [statuses, setStatuses] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); + const [sources, setSources] = useState(""); + const [blockDomains, setBlockDomains] = useState(""); + const [allowDomains, setAllowDomains] = useState(""); + const [allowlistMode, setAllowlistMode] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const loadStatuses = useCallback(async () => { try { const result = await invoke( @@ -47,11 +85,24 @@ export function DnsBlocklistDialog({ } }, []); + const loadCustomConfig = useCallback(async () => { + try { + const config = await invoke("get_custom_dns_config"); + setSources(config.sources.join("\n")); + setBlockDomains(config.block_domains.join("\n")); + setAllowDomains(config.allow_domains.join("\n")); + setAllowlistMode(config.allowlist_mode); + } catch (e) { + console.error("Failed to load custom DNS config:", e); + } + }, []); + useEffect(() => { if (isOpen) { void loadStatuses(); + void loadCustomConfig(); } - }, [isOpen, loadStatuses]); + }, [isOpen, loadStatuses, loadCustomConfig]); const handleRefreshAll = async () => { setIsRefreshing(true); @@ -65,6 +116,67 @@ export function DnsBlocklistDialog({ } }; + const handleSaveCustom = async () => { + setIsSaving(true); + try { + const config = await invoke("set_custom_dns_config", { + sources: linesToArray(sources), + blockDomains: linesToArray(blockDomains), + allowDomains: linesToArray(allowDomains), + allowlistMode, + }); + setSources(config.sources.join("\n")); + setBlockDomains(config.block_domains.join("\n")); + setAllowDomains(config.allow_domains.join("\n")); + setAllowlistMode(config.allowlist_mode); + toast.success(t("dnsBlocklist.custom.saved")); + } catch (e) { + toast.error(translateBackendError(t, e)); + } finally { + setIsSaving(false); + } + }; + + const handleImport = async () => { + try { + const selected = await openDialog({ + multiple: false, + filters: [{ name: "Rules", extensions: ["json", "txt"] }], + }); + if (!selected || typeof selected !== "string") return; + const content = await readTextFile(selected); + const format = selected.toLowerCase().endsWith(".json") ? "json" : "txt"; + const config = await invoke("import_custom_dns_rules", { + content, + format, + }); + setSources(config.sources.join("\n")); + setBlockDomains(config.block_domains.join("\n")); + setAllowDomains(config.allow_domains.join("\n")); + setAllowlistMode(config.allowlist_mode); + toast.success(t("dnsBlocklist.custom.imported")); + } catch (e) { + toast.error(translateBackendError(t, e)); + } + }; + + const handleExport = async (format: "json" | "txt") => { + try { + const content = await invoke("export_custom_dns_rules", { + format, + }); + const path = await saveDialog({ + defaultPath: `donut-dns-rules.${format}`, + filters: [{ name: format.toUpperCase(), extensions: [format] }], + }); + if (!path) return; + await writeTextFile(path, content); + toast.success(t("dnsBlocklist.custom.exported")); + } catch (e) { + toast.error(translateBackendError(t, e)); + } + }; + const formatSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; @@ -78,69 +190,185 @@ export function DnsBlocklistDialog({ return ( !open && onClose()}> - - + + {t("dnsBlocklist.title")} -

- {t("dnsBlocklist.settingsDescription")} -

+ + + + {t("dnsBlocklist.tabBlocklists")} + + + {t("dnsBlocklist.tabCustom")} + + -
- {statuses.map((status) => ( -
-
-
- - {status.display_name} - - {status.is_cached ? ( - status.is_fresh ? ( - - {t("dnsBlocklist.fresh")} - + +

+ {t("dnsBlocklist.settingsDescription")} +

+ {statuses.map((status) => ( +
+
+
+ + {t(dnsBlocklistLabelKey(status.level))} + + {status.is_cached ? ( + status.is_fresh ? ( + + {t("dnsBlocklist.fresh")} + + ) : ( + + {t("dnsBlocklist.stale")} + + ) ) : ( - - {t("dnsBlocklist.stale")} + + {t("dnsBlocklist.notCached")} - ) - ) : ( - - {t("dnsBlocklist.notCached")} - + )} +
+ {status.is_cached && ( +
+ {status.entry_count.toLocaleString()}{" "} + {t("dnsBlocklist.domains")} ·{" "} + {formatSize(status.file_size_bytes)} ·{" "} + {formatDate(status.last_updated)} +
)}
- {status.is_cached && ( -
- {status.entry_count.toLocaleString()}{" "} - {t("dnsBlocklist.domains")} ·{" "} - {formatSize(status.file_size_bytes)} ·{" "} - {formatDate(status.last_updated)} -
- )}
-
- ))} -
+ ))} + + - + +

+ {t("dnsBlocklist.custom.description")} +

+ +
+
+

+ {t("dnsBlocklist.custom.allowlistModeLabel")} +

+

+ {allowlistMode + ? t("dnsBlocklist.custom.allowlistModeOn") + : t("dnsBlocklist.custom.allowlistModeOff")} +

+
+ setAllowlistMode(v === true)} + aria-label={t("dnsBlocklist.custom.allowlistModeLabel")} + /> +
+ + {!allowlistMode && ( +
+ +