refactor: ui refresh

This commit is contained in:
zhom
2026-07-18 11:42:07 +04:00
parent 4f7910dd23
commit f1664b2950
64 changed files with 7472 additions and 2827 deletions
+23 -12
View File
@@ -39,6 +39,7 @@ pub struct ApiProfile {
pub is_running: bool,
pub proxy_bypass_rules: Vec<String>,
pub vpn_id: Option<String>,
pub clear_on_close: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -94,6 +95,9 @@ pub struct UpdateProfileRequest {
pub proxy_bypass_rules: Option<Vec<String>>,
/// One of "Disabled", "Regular", "Encrypted".
pub sync_mode: Option<String>,
/// Wipe browsing data (keeping extensions and bookmarks) when the browser
/// exits. Rejected (400) for ephemeral or password-protected profiles.
pub clear_on_close: Option<bool>,
}
#[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<Json<ApiProfilesResponse>, 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:?}"
+1
View File
@@ -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,
}
+8
View File
@@ -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::<String>("blocklist-file").cloned();
let dns_allowlist_mode = start_matches.get_flag("dns-allowlist-mode");
let local_protocol = start_matches.get_one::<String>("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
+1
View File
@@ -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,
};
+87 -80
View File
@@ -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<Option<String>, String> {
) -> Result<(Option<String>, 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)
}
+459 -1
View File
@@ -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<String>,
#[serde(default)]
pub block_domains: Vec<String>,
#[serde(default)]
pub allow_domains: Vec<String>,
/// 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<u64>,
}
fn normalize_domain(raw: &str) -> Option<String> {
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<PathBuf, String> {
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<PathBuf, String> {
use std::collections::HashSet;
let config = CustomDnsConfig::load();
let mut domains: HashSet<String> = 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<PathBuf> {
@@ -289,6 +574,102 @@ pub async fn refresh_dns_blocklists() -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub async fn get_custom_dns_config() -> Result<CustomDnsConfig, String> {
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<CustomDnsConfig, String> {
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<String>,
block_domains: Vec<String>,
allow_domains: Vec<String>,
allowlist_mode: bool,
) -> Result<CustomDnsConfig, String> {
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<CustomDnsConfig, String> {
let config = match format.as_str() {
"json" => serde_json::from_str::<CustomDnsConfig>(&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<String, String> {
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() {
+1
View File
@@ -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,
}
+381
View File
@@ -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<String>,
country_code: Option<String>,
ip: Option<String>,
}
lazy_static::lazy_static! {
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = 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<String>,
pub exit_country_code: Option<String>,
pub exit_timezone: Option<String>,
pub fingerprint_timezone: Option<String>,
pub fingerprint_language: Option<String>,
/// One of "timezone", "language" — the dimensions that disagree.
pub mismatches: Vec<String>,
}
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<String> {
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<bool> {
crate::geolocation::locale_selector()?.region_speaks(cc, language)
}
/// Extract (timezone, language) from a profile's stored fingerprint JSON.
fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<String>) {
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::<serde_json::Value>(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<ConsistencyResult, String> {
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<ConsistencyResult, String> {
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);
}
}
+44
View File
@@ -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<Option<LocaleSelector>> = 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<bool> {
let languages = self.territories.get(&region.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<Locale, GeolocationError> {
let region_upper = region.to_uppercase();
+31 -3
View File
@@ -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,
+16
View File
@@ -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",
+292
View File
@@ -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);
}
}
+52
View File
@@ -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<BrowserProfile, Box<dyn std::error::Error>> {
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<BrowserProfile, String> {
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.
+1
View File
@@ -1,3 +1,4 @@
pub mod clear_on_close;
pub mod encryption;
pub mod manager;
pub mod password;
+4
View File
@@ -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.
+280 -102
View File
@@ -33,6 +33,8 @@ pub struct ImportProfileItem {
pub new_profile_name: String,
#[serde(default)]
pub proxy_id: Option<String>,
#[serde(default)]
pub vpn_id: Option<String>,
}
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<DetectedProfile> = 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<BrowserSource> {
let mut sources: Vec<BrowserSource> = 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<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
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<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
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<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
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<String> = 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<String>,
vpn_id: Option<String>,
group_id: Option<String>,
wayfern_config: Option<WayfernConfig>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
@@ -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(&copy_source, &copy_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<DuplicateStrategy>,
wayfern_config: Option<WayfernConfig>,
) -> Result<ProfileImportBatchResult, String> {
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(
+79 -9
View File
@@ -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<String>,
blocklist_file: Option<String>,
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<u32> = 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,
};
+15 -15
View File
@@ -160,15 +160,17 @@ pub async fn start_proxy_process(
upstream_url: Option<String>,
port: Option<u16>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, 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<String>,
port: Option<u16>,
profile_id: Option<String>,
bypass_rules: Vec<String>,
blocklist_file: Option<String>,
dns_allowlist_mode: bool,
local_protocol: Option<String>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
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());
}
}
}
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -16,6 +16,10 @@ pub struct ProxyConfig {
pub bypass_rules: Vec<String>,
#[serde(default)]
pub blocklist_file: Option<String>,
/// 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<String>) -> 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)]
+282 -13
View File
@@ -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<Arc<LiveTrafficTracker>>,
/// Pending per-domain (sent, received) deltas awaiting flush.
pending: HashMap<String, (u64, u64)>,
/// Destinations already counted as a request, so each is recorded once.
seen: std::collections::HashSet<String>,
/// 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<String, String>,
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<String>) {
/// 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<String>,
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<Strin
return;
}
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
run_udp_relay_direct(control, relay, out).await;
run_udp_relay_direct(control, relay, out, UdpRelayContext::new(blocklist_matcher)).await;
}
UdpMode::Socks5Upstream => {
// 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<Strin
return;
}
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
run_udp_relay_socks5(control, relay, datagram).await;
run_udp_relay_socks5(
control,
relay,
datagram,
UdpRelayContext::new(blocklist_matcher),
)
.await;
}
UdpMode::Refuse => 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::<AddrKind>).await?;
let datagram = tokio::time::timeout(
crate::proxy_server::UPSTREAM_DIAL_TIMEOUT,
SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>),
)
.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<u8> {
/// 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<SocketAddr> = 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<TcpStream>,
mut ctx: UdpRelayContext,
) {
let mut client_addr: Option<SocketAddr> = 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(
+103 -33
View File
@@ -347,7 +347,12 @@ pub fn save_traffic_stats(stats: &TrafficStats) -> Result<(), Box<dyn std::error
let key = get_stats_storage_key(stats);
let file_path = storage_dir.join(format!("{key}.json"));
let content = serde_json::to_string(stats)?;
fs::write(&file_path, content)?;
// Write atomically via temp file + rename so readers never observe a
// partially-written file (same pattern as write_session_snapshot)
let temp_path = storage_dir.join(format!("{key}.json.tmp"));
fs::write(&temp_path, content)?;
fs::rename(&temp_path, &file_path)?;
Ok(())
}
@@ -370,15 +375,18 @@ pub fn load_traffic_stats_by_profile(profile_id: &str) -> Option<TrafficStats> {
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<TrafficStats> {
/// 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<String, TrafficStats> {
let storage_dir = get_traffic_stats_dir();
if !storage_dir.exists() {
return Vec::new();
return HashMap::new();
}
let mut stats_map: HashMap<String, TrafficStats> = HashMap::new();
let mut dirty_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut files_to_delete: Vec<std::path::PathBuf> = Vec::new();
if let Ok(entries) = fs::read_dir(&storage_dir) {
@@ -399,12 +407,14 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
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<TrafficStats> {
}
}
// 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<TrafficStats> {
}
}
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<TrafficStats> {
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<dyn std::error::Error>> {
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<SessionSnapshot> {
pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> {
use std::collections::HashMap;
// Start with disk-stored stats
let mut snapshots: HashMap<String, TrafficSnapshot> = 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<String, TrafficSnapshot> = HashMap::new();
let mut last_flush_by_key: HashMap<String, u64> = 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<TrafficSnapshot> {
// 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