mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-24 13:20:57 +02:00
refactor: ui refresh
This commit is contained in:
+23
-12
@@ -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:?}"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(®ion.to_uppercase())?;
|
||||
if languages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let primary = primary_subtag(language);
|
||||
Some(
|
||||
languages
|
||||
.iter()
|
||||
.any(|l| primary_subtag(&l.language).eq_ignore_ascii_case(primary)),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn from_region(&self, region: &str) -> Result<Locale, GeolocationError> {
|
||||
let region_upper = region.to_uppercase();
|
||||
|
||||
+31
-3
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod clear_on_close;
|
||||
pub mod encryption;
|
||||
pub mod manager;
|
||||
pub mod password;
|
||||
|
||||
@@ -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
@@ -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(©_source, ©_dest).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Profile copy task failed: {e}"))?;
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// The copy task died (panic, or runtime shutdown mid-import). Clean up
|
||||
// like every other error path here, or the half-copied — possibly
|
||||
// multi-GB — directory is orphaned with no metadata pointing at it, so
|
||||
// nothing ever reclaims it.
|
||||
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "INTERNAL_ERROR",
|
||||
"params": { "detail": format!("Profile copy task failed: {e}") },
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
if let Err(e) = copy_result {
|
||||
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
|
||||
return Err(
|
||||
@@ -761,6 +945,7 @@ impl ProfileImporter {
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
clear_on_close: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
@@ -799,7 +984,7 @@ impl ProfileImporter {
|
||||
browser: mapped.to_string(),
|
||||
version,
|
||||
proxy_id,
|
||||
vpn_id: None,
|
||||
vpn_id,
|
||||
launch_hook: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
@@ -820,6 +1005,7 @@ impl ProfileImporter {
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
clear_on_close: false,
|
||||
created_at: Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -924,15 +1110,7 @@ pub async fn import_browser_profiles(
|
||||
duplicate_strategy: Option<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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+633
-373
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user