mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-08 04:08:46 +02:00
feat: prevent launch with inconsistent geodata
This commit is contained in:
@@ -2392,9 +2392,7 @@ async fn run_profile(
|
||||
state.app_handle.clone(),
|
||||
profile.clone(),
|
||||
url,
|
||||
Some(remote_debugging_port),
|
||||
headless,
|
||||
true,
|
||||
crate::browser_runner::LaunchOptions::automation(Some(remote_debugging_port), headless),
|
||||
)
|
||||
.await
|
||||
.map_err(manager_error_response)?;
|
||||
@@ -3469,7 +3467,12 @@ async fn open_url_in_profile(
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
|
||||
browser_runner
|
||||
.open_url_with_profile(state.app_handle.clone(), id, request.url)
|
||||
.open_url_with_profile(
|
||||
state.app_handle.clone(),
|
||||
id,
|
||||
request.url,
|
||||
crate::launch_gate::FingerprintGate::Advisory,
|
||||
)
|
||||
.await
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
@@ -3626,9 +3629,7 @@ async fn batch_run_profiles(
|
||||
state.app_handle.clone(),
|
||||
profile.clone(),
|
||||
request.url.clone(),
|
||||
Some(port),
|
||||
headless,
|
||||
true,
|
||||
crate::browser_runner::LaunchOptions::automation(Some(port), headless),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
+272
-65
@@ -172,12 +172,15 @@ impl BrowserRunner {
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolve the upstream a launch will use.
|
||||
///
|
||||
/// Deliberately does NOT fire the launch hook: that moved below the gate, so
|
||||
/// a launch the user blocks and then retries calls the user's webhook once
|
||||
/// rather than once per attempt.
|
||||
async fn resolve_launch_proxy(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
Self::fire_launch_hook(profile);
|
||||
|
||||
self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await
|
||||
@@ -210,9 +213,9 @@ impl BrowserRunner {
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
url: Option<String>,
|
||||
_local_proxy_settings: Option<&ProxySettings>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
gate: &crate::launch_gate::FingerprintGate,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Handle Wayfern profiles using WayfernManager
|
||||
if profile.browser == "wayfern" {
|
||||
@@ -277,12 +280,61 @@ impl BrowserRunner {
|
||||
upstream_proxy = Some(worker.local_proxy_settings());
|
||||
}
|
||||
|
||||
/// Stops a VPN worker this launch started, if the launch then fails.
|
||||
///
|
||||
/// `created` is the whole point: `start_vpn_worker` reuses a live worker
|
||||
/// for the same VPN, so an unconditional stop would sever the tunnel a
|
||||
/// *different* profile is browsing through the moment this one is
|
||||
/// cancelled. The in-use check is a second belt for a worker adopted by a
|
||||
/// browser that started between the two points.
|
||||
struct VpnLaunchGuard {
|
||||
worker_id: Option<String>,
|
||||
vpn_id: String,
|
||||
created: bool,
|
||||
profile_name: String,
|
||||
}
|
||||
impl Drop for VpnLaunchGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(worker_id) = self.worker_id.take() else {
|
||||
return;
|
||||
};
|
||||
if !self.created {
|
||||
return;
|
||||
}
|
||||
log::warn!(
|
||||
"Launch failed after VPN worker start for profile {}; stopping worker",
|
||||
self.profile_name
|
||||
);
|
||||
let vpn_id = self.vpn_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Serialize against worker startup for the whole check-then-stop.
|
||||
// Without it another launch can adopt this worker between the
|
||||
// in-use check and the kill, and lose its tunnel a moment later.
|
||||
let _adopt_guard = crate::vpn_worker_runner::lock_vpn_starts().await;
|
||||
if crate::vpn_worker_runner::vpn_id_in_use_by_running_browser(&vpn_id) {
|
||||
log::info!("VPN {vpn_id} is still in use by a running browser; leaving it up");
|
||||
return;
|
||||
}
|
||||
if let Err(error) = crate::vpn_worker_runner::stop_vpn_worker(&worker_id).await {
|
||||
log::warn!("Failed to stop VPN worker after failed launch: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut vpn_launch_guard: Option<VpnLaunchGuard> = None;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
if let Some(ref vpn_id) = profile.vpn_id {
|
||||
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
|
||||
Ok(vpn_worker) => {
|
||||
if let Some(port) = vpn_worker.local_port {
|
||||
match crate::vpn_worker_runner::start_vpn_worker_tracked(vpn_id).await {
|
||||
Ok(started) => {
|
||||
vpn_launch_guard = Some(VpnLaunchGuard {
|
||||
worker_id: Some(started.config.id.clone()),
|
||||
vpn_id: vpn_id.clone(),
|
||||
created: started.created,
|
||||
profile_name: profile.name.clone(),
|
||||
});
|
||||
if let Some(port) = started.config.local_port {
|
||||
upstream_proxy = Some(ProxySettings {
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
@@ -295,12 +347,33 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to start VPN worker: {e}").into());
|
||||
return Err(crate::backend_error_with_detail("VPN_WORKER_START_FAILED", e).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The gate sits exactly here on purpose. By this line the upstream is
|
||||
// fully normalized across all three transports — VLESS and VPN are
|
||||
// authenticated loopback workers, a stored proxy is its resolved
|
||||
// settings — so one probe covers every profile. And it is still ahead of
|
||||
// the local proxy worker, the decrypted profile copy, the extension
|
||||
// unpack, and the browser process, so a blocked launch has nothing to
|
||||
// undo beyond the two workers whose guards are already armed above.
|
||||
//
|
||||
// Run concurrently with the blocklist compile so the added wall clock is
|
||||
// max(), not sum().
|
||||
let (blocklist, gate_result) = tokio::join!(
|
||||
Self::resolve_blocklist_file(profile),
|
||||
crate::launch_gate::enforce_fingerprint_gate(profile, upstream_proxy.as_ref(), gate),
|
||||
);
|
||||
gate_result.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
let (blocklist_file, dns_allowlist_mode) = blocklist?;
|
||||
|
||||
// Past the gate: this launch is really happening, so tell the user's
|
||||
// webhook exactly once.
|
||||
Self::fire_launch_hook(profile);
|
||||
|
||||
log::info!(
|
||||
"Starting local proxy for Wayfern profile: {} (upstream: {})",
|
||||
profile.name,
|
||||
@@ -313,7 +386,6 @@ 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, 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.
|
||||
@@ -553,11 +625,14 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// The browser and both detached routing workers now share one verified
|
||||
// The browser and every detached routing worker now share one verified
|
||||
// process identity, so later profile-persistence failures must not tear
|
||||
// down a live route.
|
||||
proxy_launch_guard.armed = false;
|
||||
xray_launch_guard.worker_id = None;
|
||||
if let Some(guard) = vpn_launch_guard.as_mut() {
|
||||
guard.worker_id = None;
|
||||
}
|
||||
|
||||
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
|
||||
// applied, which may be UPGRADED from the stored one (e.g. when the
|
||||
@@ -694,6 +769,7 @@ impl BrowserRunner {
|
||||
url: Option<String>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
gate: &crate::launch_gate::FingerprintGate,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Wayfern starts (and PID-reconciles) its own local proxy
|
||||
// inside `launch_browser_internal`, so we hand it None here rather than
|
||||
@@ -703,9 +779,9 @@ impl BrowserRunner {
|
||||
app_handle,
|
||||
profile,
|
||||
url,
|
||||
None,
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
gate,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -716,6 +792,7 @@ impl BrowserRunner {
|
||||
profile: &BrowserProfile,
|
||||
url: Option<String>,
|
||||
internal_proxy_settings: Option<&ProxySettings>,
|
||||
gate: &crate::launch_gate::FingerprintGate,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!(
|
||||
"launch_or_open_url called for profile: {} (ID: {})",
|
||||
@@ -796,14 +873,7 @@ impl BrowserRunner {
|
||||
} else {
|
||||
log::info!("Launching new browser instance - browser not running");
|
||||
self
|
||||
.launch_browser_internal(
|
||||
app_handle.clone(),
|
||||
&final_profile,
|
||||
url,
|
||||
internal_proxy_settings,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.launch_browser_internal(app_handle.clone(), &final_profile, url, None, false, gate)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -1270,6 +1340,7 @@ impl BrowserRunner {
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
url: String,
|
||||
gate: crate::launch_gate::FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
// Get the profile by name
|
||||
let profiles = self
|
||||
@@ -1319,21 +1390,30 @@ impl BrowserRunner {
|
||||
// dropped event stream plus an unreachable backend) fell straight through
|
||||
// to a local launch on a profile a host was writing to.
|
||||
crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?;
|
||||
crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
|
||||
log::info!("Opening URL with selected profile");
|
||||
|
||||
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
|
||||
self
|
||||
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
|
||||
if let Err(e) = self
|
||||
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None, &gate)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::info!(
|
||||
"Failed to open URL with selected profile: {}",
|
||||
crate::log_redaction::text(&e.to_string())
|
||||
);
|
||||
format!("Failed to open URL with profile: {e}")
|
||||
})?;
|
||||
{
|
||||
log::info!(
|
||||
"Failed to open URL with selected profile: {}",
|
||||
crate::log_redaction::text(&e.to_string())
|
||||
);
|
||||
// This path takes the team lock too, and a blocked launch never records a
|
||||
// process_id for the status sweep to release it from.
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
// Pass structured errors through untouched: the gate's block carries the
|
||||
// mismatch detail the dialog renders, and wrapping it in English would
|
||||
// reach the user as raw JSON.
|
||||
return Err(crate::wrap_backend_error(
|
||||
e,
|
||||
"Failed to open URL with profile",
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("Successfully opened URL with selected profile");
|
||||
Ok(())
|
||||
@@ -1345,18 +1425,115 @@ pub async fn launch_browser_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: BrowserProfile,
|
||||
url: Option<String>,
|
||||
consent_token: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
launch_browser_profile_impl(app_handle, profile, url, None, false, false).await
|
||||
let options = LaunchOptions {
|
||||
gate: match consent_token {
|
||||
Some(token) => crate::launch_gate::FingerprintGate::Consented(token),
|
||||
None => crate::launch_gate::FingerprintGate::Enforce,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
launch_browser_profile_impl(app_handle, profile, url, options).await
|
||||
}
|
||||
|
||||
/// How one launch should behave.
|
||||
///
|
||||
/// A struct rather than four trailing positional arguments: `headless` and
|
||||
/// `force_new` are already passed adjacently as bare booleans, so a fifth would
|
||||
/// compile everywhere while silently inverting behavior wherever the order was
|
||||
/// got wrong.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LaunchOptions {
|
||||
pub remote_debugging_port: Option<u16>,
|
||||
pub headless: bool,
|
||||
pub force_new: bool,
|
||||
pub gate: crate::launch_gate::FingerprintGate,
|
||||
}
|
||||
|
||||
impl LaunchOptions {
|
||||
/// Automation defaults: report, never block, never probe. A headless client
|
||||
/// has no dialog to answer and cannot regenerate its fingerprint mid-run, so
|
||||
/// a hard failure would turn a warning into an outage for a whole fleet.
|
||||
pub fn automation(remote_debugging_port: Option<u16>, headless: bool) -> Self {
|
||||
Self {
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
force_new: true,
|
||||
gate: crate::launch_gate::FingerprintGate::Advisory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the team lock a launch attempt took before it failed.
|
||||
///
|
||||
/// Until the gate existed, failing here was rare enough that leaking was merely
|
||||
/// untidy. Cancelling a blocked launch is now an ordinary outcome, and the lock
|
||||
/// renews itself on a 30s heartbeat while only ever being released via a stored
|
||||
/// `process_id` — which a launch that never spawned does not have. So a leak
|
||||
/// leaves the profile reading as locked to the whole team until the app quits.
|
||||
///
|
||||
/// `acquired` is threaded from `acquire_team_lock_if_needed` so this releases
|
||||
/// only what this call took, never a lock a REST handler up the stack owns.
|
||||
///
|
||||
/// Several of these error paths are reachable while a browser for the profile
|
||||
/// is genuinely still running — `PROFILE_RUNNING`, or a failure to open a URL
|
||||
/// in an existing window. That browser owns the lock and the running mark, so
|
||||
/// releasing either would strand it: the team would see the profile as free
|
||||
/// while someone is typing in it, and `mark_profile_stopped` would queue a sync
|
||||
/// of a profile directory being written to. Hence the liveness check.
|
||||
async fn unwind_launch(profile: &BrowserProfile, acquired_team_lock: bool) {
|
||||
if browser_is_running_for(&profile.id.to_string()) {
|
||||
log::debug!(
|
||||
"Not unwinding launch state for {}: a browser is still running for it",
|
||||
profile.name
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if acquired_team_lock {
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
}
|
||||
// Otherwise this mark sticks for the rest of the session and silently defers
|
||||
// every sync of the profile. Safe here precisely because nothing is running.
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
scheduler
|
||||
.mark_profile_stopped(&profile.id.to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a live browser process is recorded for this profile right now.
|
||||
/// Re-read from disk: the caller's copy predates the launch attempt.
|
||||
fn browser_is_running_for(profile_id: &str) -> bool {
|
||||
BrowserRunner::instance()
|
||||
.profile_manager
|
||||
.list_profiles()
|
||||
.ok()
|
||||
.and_then(|profiles| {
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.map(|p| {
|
||||
p.process_id
|
||||
.is_some_and(crate::proxy_storage::is_process_running)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn launch_browser_profile_impl(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: BrowserProfile,
|
||||
url: Option<String>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
force_new: bool,
|
||||
options: LaunchOptions,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let LaunchOptions {
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
force_new,
|
||||
gate,
|
||||
} = options;
|
||||
log::info!(
|
||||
"Launch request received for profile: {} (ID: {})",
|
||||
profile.name,
|
||||
@@ -1379,7 +1556,7 @@ pub async fn launch_browser_profile_impl(
|
||||
crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?;
|
||||
|
||||
// Team lock check: if profile is sync-enabled and user is on a team, acquire lock
|
||||
crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
|
||||
// Notify sync scheduler that profile is now running and queue sync for when it stops
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
@@ -1403,6 +1580,7 @@ pub async fn launch_browser_profile_impl(
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap_or_else(|| profile.clone()),
|
||||
Err(e) => {
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -1419,15 +1597,24 @@ pub async fn launch_browser_profile_impl(
|
||||
profile_for_launch.id
|
||||
);
|
||||
|
||||
if force_new
|
||||
&& browser_runner
|
||||
if force_new {
|
||||
let already_running = match browser_runner
|
||||
.check_browser_status(app_handle.clone(), &profile_for_launch)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::wrap_backend_error(error, "Failed to check browser status before launch")
|
||||
})?
|
||||
{
|
||||
return Err(crate::backend_error("PROFILE_RUNNING"));
|
||||
{
|
||||
Ok(running) => running,
|
||||
Err(error) => {
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
return Err(crate::wrap_backend_error(
|
||||
error,
|
||||
"Failed to check browser status before launch",
|
||||
));
|
||||
}
|
||||
};
|
||||
if already_running {
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
return Err(crate::backend_error("PROFILE_RUNNING"));
|
||||
}
|
||||
}
|
||||
|
||||
// Launch browser or open URL in existing instance. Wayfern starts its
|
||||
@@ -1445,39 +1632,54 @@ pub async fn launch_browser_profile_impl(
|
||||
url,
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
&gate,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
browser_runner
|
||||
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None)
|
||||
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None, &gate)
|
||||
.await
|
||||
};
|
||||
let updated_profile = launch_result.map_err(|e| {
|
||||
log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
|
||||
let updated_profile = match launch_result {
|
||||
Ok(updated) => updated,
|
||||
Err(e) => {
|
||||
log::info!(
|
||||
"Browser launch failed for profile: {}, error: {}",
|
||||
profile_for_launch.name,
|
||||
e
|
||||
);
|
||||
|
||||
// Emit a failure event to clear loading states in the frontend
|
||||
#[derive(serde::Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
let payload = RunningChangedPayload {
|
||||
id: profile_for_launch.id.to_string(),
|
||||
is_running: false,
|
||||
};
|
||||
|
||||
if let Err(e) = events::emit("profile-running-changed", &payload) {
|
||||
log::warn!("Warning: Failed to emit profile running changed event: {e}");
|
||||
}
|
||||
|
||||
// Check if this is an architecture compatibility issue
|
||||
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
|
||||
if io_error.kind() == std::io::ErrorKind::Other && io_error.to_string().contains("Exec format error") {
|
||||
return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH);
|
||||
// Emit a failure event to clear loading states in the frontend
|
||||
#[derive(serde::Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
let payload = RunningChangedPayload {
|
||||
id: profile_for_launch.id.to_string(),
|
||||
is_running: false,
|
||||
};
|
||||
|
||||
if let Err(e) = events::emit("profile-running-changed", &payload) {
|
||||
log::warn!("Warning: Failed to emit profile running changed event: {e}");
|
||||
}
|
||||
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
|
||||
// Check if this is an architecture compatibility issue
|
||||
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
|
||||
if io_error.kind() == std::io::ErrorKind::Other
|
||||
&& io_error.to_string().contains("Exec format error")
|
||||
{
|
||||
return Err(format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH));
|
||||
}
|
||||
}
|
||||
return Err(crate::wrap_backend_error(
|
||||
e,
|
||||
"Failed to launch browser or open URL",
|
||||
));
|
||||
}
|
||||
crate::wrap_backend_error(e, "Failed to launch browser or open URL")
|
||||
})?;
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Browser launch completed for profile: {} (ID: {})",
|
||||
@@ -1610,10 +1812,15 @@ pub async fn open_url_with_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
url: String,
|
||||
consent_token: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
let gate = match consent_token {
|
||||
Some(token) => crate::launch_gate::FingerprintGate::Consented(token),
|
||||
None => crate::launch_gate::FingerprintGate::Enforce,
|
||||
};
|
||||
browser_runner
|
||||
.open_url_with_profile(app_handle, profile_id, url)
|
||||
.open_url_with_profile(app_handle, profile_id, url, gate)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
+36
-11
@@ -28,9 +28,9 @@ const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
|
||||
|
||||
/// Capability + limit set the account is entitled to, derived from its plan.
|
||||
/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these
|
||||
/// flags instead of a single "is paid?" boolean, so a plan like the future
|
||||
/// "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
|
||||
/// data here.
|
||||
/// flags instead of a single "is paid?" boolean, so a plan like "solo" (cloud
|
||||
/// backup + nightly cookie bot, no automation, no fingerprint editing, no
|
||||
/// hands-on remote session) is just data here.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entitlements {
|
||||
#[serde(default)]
|
||||
@@ -49,6 +49,12 @@ pub struct Entitlements {
|
||||
/// together.
|
||||
#[serde(rename = "cookieBot", default)]
|
||||
pub cookie_bot: bool,
|
||||
/// Whether the plan may open a HANDS-ON remote session. Distinct from
|
||||
/// `cookie_bot`: solo funds a nightly bot out of its remote hours but may not
|
||||
/// drive a remote browser itself, so anything that offers interactive remote
|
||||
/// control must read THIS rather than `remote_browser_hours > 0`.
|
||||
#[serde(rename = "remoteInteractive", default)]
|
||||
pub remote_interactive: bool,
|
||||
#[serde(rename = "profileLimit", default)]
|
||||
pub profile_limit: i64,
|
||||
#[serde(rename = "requestsPerHour", default)]
|
||||
@@ -77,16 +83,29 @@ fn derive_entitlements(
|
||||
cloud_backup: false,
|
||||
team_collaboration: false,
|
||||
cookie_bot: false,
|
||||
remote_interactive: false,
|
||||
profile_limit: 0,
|
||||
requests_per_hour: 0,
|
||||
remote_browser_hours: 0,
|
||||
};
|
||||
}
|
||||
// pro and any unrecognized paid plan -> pro-level (never team).
|
||||
let (browser_automation, cross_os_fingerprints, cloud_backup, team_collaboration) = match plan {
|
||||
"starter" => (false, true, true, false),
|
||||
"team" | "enterprise" => (true, true, true, true),
|
||||
_ => (true, true, true, false),
|
||||
// Tuple order: (browser_automation, cross_os_fingerprints, cloud_backup,
|
||||
// team_collaboration, cookie_bot, remote_interactive).
|
||||
//
|
||||
// pro and any unrecognized paid plan -> pro-level (never team). Solo is the
|
||||
// one row where cookie_bot and browser_automation disagree, which is why
|
||||
// cookie_bot can no longer be derived from browser_automation below.
|
||||
let (
|
||||
browser_automation,
|
||||
cross_os_fingerprints,
|
||||
cloud_backup,
|
||||
team_collaboration,
|
||||
cookie_bot,
|
||||
remote_interactive,
|
||||
) = match plan {
|
||||
"solo" => (false, false, true, false, true, false),
|
||||
"team" | "enterprise" => (true, true, true, true, true, true),
|
||||
_ => (true, true, true, false, true, true),
|
||||
};
|
||||
Entitlements {
|
||||
active,
|
||||
@@ -94,9 +113,8 @@ fn derive_entitlements(
|
||||
cross_os_fingerprints,
|
||||
cloud_backup,
|
||||
team_collaboration,
|
||||
// A bot run IS remote automation on leased hardware, so the two capabilities
|
||||
// never diverge: a plan that cannot drive a browser cannot warm one either.
|
||||
cookie_bot: browser_automation,
|
||||
cookie_bot,
|
||||
remote_interactive,
|
||||
profile_limit,
|
||||
requests_per_hour: if browser_automation {
|
||||
DEFAULT_REQUESTS_PER_HOUR
|
||||
@@ -155,6 +173,13 @@ impl CloudUser {
|
||||
/// locally from the plan fields (keeps older cached state / backends working).
|
||||
pub fn entitlements(&self) -> Entitlements {
|
||||
if let Some(e) = &self.entitlements {
|
||||
// Returned verbatim, INCLUDING the `#[serde(default)]` false that a
|
||||
// backend older than this release leaves on `cookie_bot` /
|
||||
// `remote_interactive`. Repairing it here is impossible anyway — serde's
|
||||
// default erases the difference between "sent false" and "not sent" — and
|
||||
// it is not this layer's job: nothing in Rust gates on either flag, and
|
||||
// `getEntitlements()` in `src/lib/entitlements.ts` fills both gaps at the
|
||||
// single point every UI consumer already goes through.
|
||||
return e.clone();
|
||||
}
|
||||
derive_entitlements(
|
||||
|
||||
@@ -86,6 +86,62 @@ fn find_zip_start(data: &[u8]) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Read and parse an extension archive's `manifest.json`. Handles the CRX3
|
||||
/// header by seeking to the embedded ZIP. Shared with
|
||||
/// `vpn_extension_detect`, which classifies from the raw manifest rather than
|
||||
/// from the metadata subset persisted on `Extension`.
|
||||
pub(crate) fn read_manifest_from_archive(
|
||||
file_data: &[u8],
|
||||
file_type: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut contents = String::new();
|
||||
{
|
||||
let mut file = archive.by_name("manifest.json").ok()?;
|
||||
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
|
||||
}
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
/// Resolve a `__MSG_key__` placeholder against the archive's default locale
|
||||
/// messages. Chromium extensions routinely localize `name`/`description`, and
|
||||
/// showing the raw placeholder in a warning dialog reads as a bug.
|
||||
pub(crate) fn resolve_archive_i18n(
|
||||
file_data: &[u8],
|
||||
file_type: &str,
|
||||
manifest: &serde_json::Value,
|
||||
value: &str,
|
||||
) -> Option<String> {
|
||||
let key = crate::vpn_extension_detect::message_placeholder_key(value)?;
|
||||
let default_locale = manifest.get("default_locale")?.as_str()?;
|
||||
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut contents = String::new();
|
||||
{
|
||||
let mut file = archive
|
||||
.by_name(&format!("_locales/{default_locale}/messages.json"))
|
||||
.ok()?;
|
||||
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
|
||||
}
|
||||
let messages: serde_json::Value = serde_json::from_str(&contents).ok()?;
|
||||
crate::vpn_extension_detect::lookup_message(&messages, &key)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn extract_manifest_metadata(
|
||||
file_data: &[u8],
|
||||
@@ -97,39 +153,11 @@ fn extract_manifest_metadata(
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
|
||||
let mut archive = match zip::ZipArchive::new(cursor) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") {
|
||||
let mut contents = String::new();
|
||||
if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() {
|
||||
Some(contents)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let manifest_content = match manifest_content {
|
||||
Some(c) => c,
|
||||
let manifest = match read_manifest_from_archive(file_data, file_type) {
|
||||
Some(v) => v,
|
||||
None => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let name = manifest
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! 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.
|
||||
//! Measures a proxy's exit node and compares it to a profile's fingerprint.
|
||||
//!
|
||||
//! Resolve the exit IP through the upstream, geolocate it with the bundled
|
||||
//! MaxMind database (the same source the fingerprint generator uses), then
|
||||
//! compare its timezone and country against the 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.
|
||||
//!
|
||||
//! This module only measures. Deciding what a mismatch *means* for a launch —
|
||||
//! block, warn, or ignore — belongs to `launch_gate`, which calls
|
||||
//! `probe_and_check_consistency` before the browser is spawned. Launches never
|
||||
//! rewrite the fingerprint silently, so a real mismatch always surfaces.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -19,20 +23,68 @@ use crate::proxy_manager::PROXY_MANAGER;
|
||||
/// on every launch is wasteful.
|
||||
const EXIT_CACHE_TTL_SECS: u64 = 30 * 60;
|
||||
|
||||
/// Ceiling on a single exit probe. `fetch_public_ip` races six endpoints with
|
||||
/// a 10s timeout each, which is fine for a background check but far longer
|
||||
/// than a user will wait staring at a launch that has not started yet.
|
||||
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedExit {
|
||||
fetched_at: u64,
|
||||
/// The proxy URL this exit was measured through. Editing a stored proxy keeps
|
||||
/// The endpoint 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,
|
||||
///
|
||||
/// Never a loopback URL: the Xray/VPN workers a launch spins up get a fresh
|
||||
/// random port and credentials each time, so keying on those would miss on
|
||||
/// every relaunch and re-probe forever.
|
||||
identity: String,
|
||||
timezone: Option<String>,
|
||||
country_code: Option<String>,
|
||||
ip: Option<String>,
|
||||
}
|
||||
|
||||
/// Identity of the exit a profile routes through, stable across worker
|
||||
/// restarts.
|
||||
///
|
||||
/// `scope` keys the cache; `identity` detects that the endpoint behind that
|
||||
/// key changed. Cloud-derived proxies inject a per-profile sticky-session id,
|
||||
/// so two profiles sharing one stored proxy correctly get different identities
|
||||
/// and never inherit each other's verdict.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExitCacheKey {
|
||||
pub scope: String,
|
||||
pub identity: String,
|
||||
}
|
||||
|
||||
/// Resolve the cache identity from the profile's *stored* configuration.
|
||||
///
|
||||
/// Deliberately not derived from the normalized upstream the launcher passes
|
||||
/// to the probe: for VLESS and VPN that upstream is a loopback worker whose
|
||||
/// port and credentials are regenerated per launch.
|
||||
pub fn exit_cache_key(profile: &BrowserProfile) -> Option<ExitCacheKey> {
|
||||
if let Some(proxy_id) = &profile.proxy_id {
|
||||
let settings = PROXY_MANAGER
|
||||
.resolve_proxy_for_profile(proxy_id, &profile.id.to_string())
|
||||
.or_else(|| PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))?;
|
||||
// build_proxy_url returns the VLESS URI verbatim for vless proxies, so one
|
||||
// call covers every transport.
|
||||
return Some(ExitCacheKey {
|
||||
scope: format!("proxy:{proxy_id}"),
|
||||
identity: crate::proxy_manager::ProxyManager::build_proxy_url(&settings),
|
||||
});
|
||||
}
|
||||
if let Some(vpn_id) = &profile.vpn_id {
|
||||
return Some(ExitCacheKey {
|
||||
scope: format!("vpn:{vpn_id}"),
|
||||
identity: vpn_id.clone(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
@@ -54,7 +106,7 @@ pub struct ConsistencyResult {
|
||||
}
|
||||
|
||||
impl ConsistencyResult {
|
||||
fn skip() -> Self {
|
||||
pub fn skip() -> Self {
|
||||
Self {
|
||||
consistent: true,
|
||||
checked: false,
|
||||
@@ -68,18 +120,15 @@ impl ConsistencyResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL for handing this proxy to reqwest. VLESS is reached through the
|
||||
/// authenticated loopback Xray-core worker already serving the profile.
|
||||
fn proxy_url(settings: &crate::browser::ProxySettings, profile_id: Option<&str>) -> Option<String> {
|
||||
/// Whether this upstream can carry a probe request at all.
|
||||
///
|
||||
/// Shadowsocks and anything else reqwest cannot dial directly is skipped
|
||||
/// rather than guessed at.
|
||||
fn probe_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),
|
||||
crate::proxy_manager::ProxyManager::build_probe_proxy_url(settings),
|
||||
),
|
||||
"vless" => profile_id
|
||||
.and_then(crate::xray_worker_storage::find_xray_worker_by_profile_id)
|
||||
.map(|worker| {
|
||||
crate::proxy_manager::ProxyManager::build_proxy_url(&worker.local_proxy_settings())
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -119,120 +168,174 @@ fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<Strin
|
||||
(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(
|
||||
/// A mutex whose poison is not fatal.
|
||||
///
|
||||
/// A panic anywhere under this lock used to brick the check process-wide.
|
||||
/// That was tolerable when a failed check only skipped a warning; now a launch
|
||||
/// consults it, so a poisoned lock must degrade rather than propagate.
|
||||
fn exit_cache() -> std::sync::MutexGuard<'static, HashMap<String, CachedExit>> {
|
||||
EXIT_CACHE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Compare a measured exit against a profile's fingerprint. Pure — no I/O.
|
||||
pub fn compare_exit_to_fingerprint(
|
||||
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 profile_id = profile.id.to_string();
|
||||
let Some(url) = proxy_url(&settings, Some(&profile_id)) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let cache_identity = if settings.proxy_type.eq_ignore_ascii_case("vless") {
|
||||
settings.vless_uri.clone().unwrap_or_else(|| url.clone())
|
||||
} else {
|
||||
url.clone()
|
||||
};
|
||||
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
|
||||
// Serve a fresh cached exit lookup for this proxy if we have one, but only if
|
||||
// it was measured through the proxy's current endpoint and credentials.
|
||||
let cached = {
|
||||
let cache = EXIT_CACHE.lock().unwrap();
|
||||
cache
|
||||
.get(proxy_id)
|
||||
.filter(|c| {
|
||||
c.proxy_url == cache_identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
|
||||
})
|
||||
.cloned()
|
||||
};
|
||||
|
||||
let (exit_tz, exit_cc, exit_ip) = if let Some(c) = cached {
|
||||
(c.timezone, c.country_code, c.ip)
|
||||
} else {
|
||||
// Resolve the exit IP through the proxy, then geolocate it with the SAME
|
||||
// bundled MaxMind database the fingerprint generator (and the on-demand
|
||||
// match) use. Using one geo source everywhere means the check can never
|
||||
// disagree with what generation produced — a second source (e.g. ip-api)
|
||||
// routinely reports a different IANA zone for the same IP in multi-zone
|
||||
// countries, which would flag correctly-generated fingerprints and would
|
||||
// leave the "match to proxy" fix unable to satisfy the check.
|
||||
let exit_ip = crate::ip_utils::fetch_public_ip(Some(&url))
|
||||
.await
|
||||
.map_err(|e| format!("exit-node lookup failed: {e}"))?;
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => {
|
||||
let tz = Some(geo.timezone);
|
||||
let cc = geo.locale.region.clone();
|
||||
let ip = Some(exit_ip);
|
||||
EXIT_CACHE.lock().unwrap().insert(
|
||||
proxy_id.clone(),
|
||||
CachedExit {
|
||||
fetched_at: now,
|
||||
proxy_url: cache_identity,
|
||||
timezone: tz.clone(),
|
||||
country_code: cc.clone(),
|
||||
ip: ip.clone(),
|
||||
},
|
||||
);
|
||||
(tz, cc, ip)
|
||||
}
|
||||
// Reached the exit but couldn't place it (database missing, or a private
|
||||
// exit IP). Skip rather than warn on an unknown location — the same
|
||||
// database gates fingerprint geo, so there's nothing to disagree with.
|
||||
Err(e) => {
|
||||
log::debug!("Consistency check: could not geolocate exit IP: {e}");
|
||||
return Ok(ConsistencyResult::skip());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exit_timezone: Option<String>,
|
||||
exit_country_code: Option<String>,
|
||||
exit_ip: Option<String>,
|
||||
) -> ConsistencyResult {
|
||||
let (fp_tz, fp_lang) = fingerprint_locale(profile);
|
||||
let mut mismatches = Vec::new();
|
||||
|
||||
if let (Some(exit), Some(fp)) = (&exit_tz, &fp_tz) {
|
||||
if let (Some(exit), Some(fp)) = (&exit_timezone, &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 let (Some(cc), Some(lang)) = (&exit_country_code, &fp_lang) {
|
||||
if language_matches_country(cc, lang) == Some(false) {
|
||||
mismatches.push("language".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ConsistencyResult {
|
||||
ConsistencyResult {
|
||||
consistent: mismatches.is_empty(),
|
||||
checked: true,
|
||||
exit_ip,
|
||||
exit_country_code: exit_cc,
|
||||
exit_timezone: exit_tz,
|
||||
exit_country_code,
|
||||
exit_timezone,
|
||||
fingerprint_timezone: fp_tz,
|
||||
fingerprint_language: fp_lang,
|
||||
mismatches,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_profile_fingerprint_consistency(
|
||||
profile_id: String,
|
||||
/// Look up a still-valid cached exit for this profile.
|
||||
fn cached_exit(key: &ExitCacheKey) -> Option<CachedExit> {
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
exit_cache()
|
||||
.get(&key.scope)
|
||||
.filter(|c| {
|
||||
c.identity == key.identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Cache-only check. Never performs I/O, so it is safe to call before a launch
|
||||
/// and for every profile in a bulk run. Returns an unchecked result on a miss.
|
||||
pub fn check_profile_consistency_cached(profile: &BrowserProfile) -> ConsistencyResult {
|
||||
let Some(key) = exit_cache_key(profile) else {
|
||||
return ConsistencyResult::skip();
|
||||
};
|
||||
let Some(cached) = cached_exit(&key) else {
|
||||
return ConsistencyResult::skip();
|
||||
};
|
||||
compare_exit_to_fingerprint(profile, cached.timezone, cached.country_code, cached.ip)
|
||||
}
|
||||
|
||||
/// Drop any cached exit for this profile, so the next check re-measures.
|
||||
pub fn invalidate_exit_cache(profile: &BrowserProfile) {
|
||||
if let Some(key) = exit_cache_key(profile) {
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the exit through an already-normalized upstream and compare it to
|
||||
/// the fingerprint.
|
||||
///
|
||||
/// `upstream` is what the launcher will actually hand the browser — a loopback
|
||||
/// worker for VLESS and VPN, the resolved endpoint for a stored proxy — so one
|
||||
/// code path covers every transport. `None` means a genuine direct connection,
|
||||
/// which has nothing to disagree with.
|
||||
pub async fn probe_and_check_consistency(
|
||||
profile: &BrowserProfile,
|
||||
upstream: Option<&crate::browser::ProxySettings>,
|
||||
key: &ExitCacheKey,
|
||||
) -> 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
|
||||
if let Some(cached) = cached_exit(key) {
|
||||
return Ok(compare_exit_to_fingerprint(
|
||||
profile,
|
||||
cached.timezone,
|
||||
cached.country_code,
|
||||
cached.ip,
|
||||
));
|
||||
}
|
||||
|
||||
let Some(settings) = upstream else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let Some(url) = probe_url(settings) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Bounded independently of fetch_public_ip's own per-request timeout: that
|
||||
// one races six endpoints and can add up to far longer than a user will wait
|
||||
// in front of a launch.
|
||||
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(Some(&url)))
|
||||
.await
|
||||
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
|
||||
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
|
||||
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => {
|
||||
let tz = Some(geo.timezone);
|
||||
let cc = geo.locale.region.clone();
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs(),
|
||||
identity: key.identity.clone(),
|
||||
timezone: tz.clone(),
|
||||
country_code: cc.clone(),
|
||||
ip: Some(exit_ip.clone()),
|
||||
},
|
||||
);
|
||||
Ok(compare_exit_to_fingerprint(profile, tz, cc, Some(exit_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}");
|
||||
Ok(ConsistencyResult::skip())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the exit this machine reaches without any proxy, and compare it to
|
||||
/// the fingerprint.
|
||||
///
|
||||
/// Used when a profile declares a route that did not materialize: the browser
|
||||
/// is about to connect directly, so the direct exit is the one that matters.
|
||||
/// Deliberately NOT cached — a direct exit belongs to this machine's network,
|
||||
/// not to any stored proxy, and it changes without a config edit.
|
||||
pub async fn probe_direct_and_check(profile: &BrowserProfile) -> Result<ConsistencyResult, String> {
|
||||
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(None))
|
||||
.await
|
||||
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
|
||||
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
|
||||
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => Ok(compare_exit_to_fingerprint(
|
||||
profile,
|
||||
Some(geo.timezone),
|
||||
geo.locale.region.clone(),
|
||||
Some(exit_ip),
|
||||
)),
|
||||
Err(e) => {
|
||||
log::debug!("Consistency check: could not geolocate direct exit IP: {e}");
|
||||
Ok(ConsistencyResult::skip())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite a profile's stored fingerprint so its geolocation (timezone,
|
||||
@@ -280,6 +383,10 @@ pub async fn match_profile_fingerprint_to_exit(
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
// The stored verdict was computed against the fingerprint we just rewrote.
|
||||
// Leaving it would re-block the very launch this fix exists to unblock.
|
||||
invalidate_exit_cache(&profile);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -344,55 +451,218 @@ mod tests {
|
||||
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(),
|
||||
fn settings(
|
||||
proxy_type: &str,
|
||||
user: Option<&str>,
|
||||
pass: Option<&str>,
|
||||
) -> crate::browser::ProxySettings {
|
||||
crate::browser::ProxySettings {
|
||||
proxy_type: proxy_type.into(),
|
||||
host: "gw.provider.io".into(),
|
||||
port: 8080,
|
||||
username: Some("u".into()),
|
||||
password: Some("p".into()),
|
||||
username: user.map(str::to_string),
|
||||
password: pass.map(str::to_string),
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(proxy_url(&http, None).as_deref(), Some("http://u:p@h:8080"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_url_percent_encodes_credentials_and_skips_shadowsocks() {
|
||||
assert_eq!(
|
||||
probe_url(&settings("http", Some("u"), Some("p"))).as_deref(),
|
||||
Some("http://u:p@gw.provider.io:8080")
|
||||
);
|
||||
|
||||
// A password with URL-reserved characters must not break the authority —
|
||||
// unencoded, the `/` truncates the host and reqwest targets `u` instead.
|
||||
let reserved = crate::browser::ProxySettings {
|
||||
proxy_type: "http".into(),
|
||||
host: "gw.provider.io".into(),
|
||||
port: 8080,
|
||||
username: Some("user".into()),
|
||||
password: Some("ab/cd@ef".into()),
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(
|
||||
proxy_url(&reserved, None).as_deref(),
|
||||
probe_url(&settings("http", Some("user"), Some("ab/cd@ef"))).as_deref(),
|
||||
Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080")
|
||||
);
|
||||
|
||||
// Username-only proxies keep their auth.
|
||||
let user_only = crate::browser::ProxySettings {
|
||||
proxy_type: "socks5".into(),
|
||||
host: "h".into(),
|
||||
port: 1080,
|
||||
username: Some("justuser".into()),
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(
|
||||
proxy_url(&user_only, None).as_deref(),
|
||||
Some("socks5://justuser@h:1080")
|
||||
probe_url(&settings("socks4", Some("justuser"), None)).as_deref(),
|
||||
Some("socks4://justuser@gw.provider.io:8080")
|
||||
);
|
||||
|
||||
let ss = crate::browser::ProxySettings {
|
||||
proxy_type: "ss".into(),
|
||||
host: "h".into(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
// Shadowsocks cannot carry a reqwest probe, so it is skipped rather than
|
||||
// guessed at.
|
||||
assert_eq!(probe_url(&settings("ss", None, None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_url_uses_socks5h_so_dns_resolves_at_the_exit() {
|
||||
let url = probe_url(&settings("socks5", Some("u"), Some("p"))).unwrap();
|
||||
assert!(
|
||||
url.starts_with("socks5h://"),
|
||||
"probe must not resolve the echo host locally, got {url}"
|
||||
);
|
||||
// The browser-facing builder is deliberately left alone.
|
||||
assert!(
|
||||
crate::proxy_manager::ProxyManager::build_proxy_url(&settings(
|
||||
"socks5",
|
||||
Some("u"),
|
||||
Some("p")
|
||||
))
|
||||
.starts_with("socks5://")
|
||||
);
|
||||
}
|
||||
|
||||
fn profile_with_fingerprint(timezone: &str, language: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "p".into(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(proxy_url(&ss, None), None);
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(
|
||||
serde_json::json!({ "timezone": timezone, "language": language }).to_string(),
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_flags_a_timezone_mismatch() {
|
||||
let profile = profile_with_fingerprint("America/New_York", "en-US");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
Some("1.2.3.4".into()),
|
||||
);
|
||||
assert!(result.checked);
|
||||
assert!(!result.consistent);
|
||||
assert!(result.mismatches.contains(&"timezone".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_accepts_a_matching_exit() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
Some("1.2.3.4".into()),
|
||||
);
|
||||
assert!(result.consistent, "{:?}", result.mismatches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_is_case_insensitive_on_timezone() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("europe/berlin".into()),
|
||||
Some("DE".into()),
|
||||
None,
|
||||
);
|
||||
assert!(result.consistent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_skips_dimensions_the_fingerprint_does_not_declare() {
|
||||
// A profile with no fingerprint has nothing to contradict; it must not be
|
||||
// reported as a mismatch and so must never block a launch.
|
||||
let profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
None,
|
||||
);
|
||||
assert!(result.consistent);
|
||||
assert!(result.mismatches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_check_reports_unchecked_without_a_proxy_or_vpn() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = check_profile_consistency_cached(&profile);
|
||||
assert!(!result.checked);
|
||||
assert!(result.consistent, "an unchecked profile must never block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_key_is_absent_without_a_proxy_or_vpn() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
assert_eq!(exit_cache_key(&profile), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_key_scopes_a_vpn_profile_by_vpn_id() {
|
||||
let mut profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
profile.vpn_id = Some("vpn-abc".into());
|
||||
let key = exit_cache_key(&profile).expect("vpn profiles must be cacheable");
|
||||
assert_eq!(key.scope, "vpn:vpn-abc");
|
||||
assert_eq!(key.identity, "vpn-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_entry_is_ignored_once_the_endpoint_identity_changes() {
|
||||
let key = ExitCacheKey {
|
||||
scope: "proxy:test-identity-change".into(),
|
||||
identity: "http://old@host:1".into(),
|
||||
};
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs(),
|
||||
identity: key.identity.clone(),
|
||||
timezone: Some("Europe/Berlin".into()),
|
||||
country_code: Some("DE".into()),
|
||||
ip: Some("1.2.3.4".into()),
|
||||
},
|
||||
);
|
||||
assert!(cached_exit(&key).is_some());
|
||||
|
||||
// Editing a stored proxy keeps its id but changes the endpoint; the old
|
||||
// measurement must not be reused for the new one.
|
||||
let rotated = ExitCacheKey {
|
||||
identity: "http://new@host:2".into(),
|
||||
..key.clone()
|
||||
};
|
||||
assert!(cached_exit(&rotated).is_none());
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_entry_expires_after_the_ttl() {
|
||||
let key: ExitCacheKey = ExitCacheKey {
|
||||
scope: "proxy:test-ttl".into(),
|
||||
identity: "http://host:1".into(),
|
||||
};
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs() - EXIT_CACHE_TTL_SECS - 1,
|
||||
identity: key.identity.clone(),
|
||||
timezone: Some("Europe/Berlin".into()),
|
||||
country_code: Some("DE".into()),
|
||||
ip: None,
|
||||
},
|
||||
);
|
||||
assert!(cached_exit(&key).is_none());
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_survives_a_poisoned_lock() {
|
||||
// A panic under this lock must degrade the check, not brick every
|
||||
// subsequent launch that consults it.
|
||||
let _ = std::thread::spawn(|| {
|
||||
let _guard = EXIT_CACHE.lock().unwrap();
|
||||
panic!("poison the cache");
|
||||
})
|
||||
.join();
|
||||
assert!(EXIT_CACHE.is_poisoned());
|
||||
exit_cache().remove("nonexistent-scope");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
//! The pre-spawn launch gate.
|
||||
//!
|
||||
//! Two findings can stop a launch being what the user expects:
|
||||
//!
|
||||
//! * a **VPN/proxy extension** in the profile, which can override the proxy
|
||||
//! Donut configured and silently move the browser's exit away from the one
|
||||
//! the fingerprint was generated for — a warning, since Donut cannot tell
|
||||
//! from outside whether it is actually routing anything;
|
||||
//! * a measured **exit/fingerprint mismatch**, which is a hard block: the
|
||||
//! browser does not start until the user explicitly proceeds.
|
||||
//!
|
||||
//! The enforcing half runs inside `browser_runner::launch_browser_internal`,
|
||||
//! after the upstream has been normalized (so VLESS and VPN profiles are
|
||||
//! reachable at all) and before the local proxy starts or the browser spawns.
|
||||
//! `get_profile_pre_launch_checks` is the cheap, local-only half the UI calls
|
||||
//! first, so a profile whose exit is already known blocks without starting a
|
||||
//! single worker.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::fingerprint_consistency::{self, ConsistencyResult};
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::vpn_extension_detect::{self, DetectedVpnExtension};
|
||||
|
||||
/// How long a "launch anyway" decision stays redeemable. Long enough to read
|
||||
/// the dialog, short enough that a token cannot sit around across a session.
|
||||
const CONSENT_TTL_SECS: u64 = 10 * 60;
|
||||
|
||||
/// What the gate is allowed to do on this launch.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum FingerprintGate {
|
||||
/// Block on a measured mismatch. The default, and what the GUI uses.
|
||||
#[default]
|
||||
Enforce,
|
||||
/// Measure only from cache and report; never block, never probe the network.
|
||||
/// Automation runs here: a headless client has no dialog to answer and
|
||||
/// cannot regenerate its fingerprint mid-run, so a hard failure would turn a
|
||||
/// warning into an outage for a whole fleet.
|
||||
Advisory,
|
||||
/// The user already said "launch anyway" and handed back a token.
|
||||
Consented(String),
|
||||
}
|
||||
|
||||
struct PendingConsent {
|
||||
profile_id: String,
|
||||
fingerprint_hash: String,
|
||||
exit_identity: String,
|
||||
issued_at: u64,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CONSENTS: Mutex<HashMap<String, PendingConsent>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn consents() -> std::sync::MutexGuard<'static, HashMap<String, PendingConsent>> {
|
||||
CONSENTS.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn random_token() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
let mut bytes = [0u8; 16];
|
||||
rng.fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Issue a single-use token authorizing one launch of this exact
|
||||
/// (profile, fingerprint, exit) combination.
|
||||
///
|
||||
/// A plain `bypass: bool` cannot express this: a "proceed" the user granted
|
||||
/// while looking at proxy A would silently authorize a launch through proxy B
|
||||
/// if they changed it before the retry landed.
|
||||
pub fn mint_consent(profile: &BrowserProfile, exit_identity: &str) -> String {
|
||||
let token = random_token();
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
let mut store = consents();
|
||||
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
|
||||
store.insert(
|
||||
token.clone(),
|
||||
PendingConsent {
|
||||
profile_id: profile.id.to_string(),
|
||||
fingerprint_hash: crate::launch_gate_prefs::fingerprint_hash(profile),
|
||||
exit_identity: exit_identity.to_string(),
|
||||
issued_at: now,
|
||||
},
|
||||
);
|
||||
token
|
||||
}
|
||||
|
||||
/// Redeem a consent token. Single use — a redeemed token is removed whether or
|
||||
/// not it validated, so a leaked token cannot be replayed.
|
||||
pub fn redeem_consent(
|
||||
token: &str,
|
||||
profile: &BrowserProfile,
|
||||
exit_identity: &str,
|
||||
) -> Result<(), String> {
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
let pending = {
|
||||
let mut store = consents();
|
||||
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
|
||||
store.remove(token)
|
||||
};
|
||||
|
||||
let Some(pending) = pending else {
|
||||
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
|
||||
};
|
||||
if pending.profile_id != profile.id.to_string()
|
||||
|| pending.fingerprint_hash != crate::launch_gate_prefs::fingerprint_hash(profile)
|
||||
|| pending.exit_identity != exit_identity
|
||||
{
|
||||
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mismatch_error(result: &ConsistencyResult, token: &str) -> String {
|
||||
serde_json::json!({
|
||||
"code": "FINGERPRINT_EXIT_MISMATCH",
|
||||
"params": {
|
||||
"token": token,
|
||||
"exitIp": result.exit_ip.clone().unwrap_or_default(),
|
||||
"exitCountry": result.exit_country_code.clone().unwrap_or_default(),
|
||||
"exitTimezone": result.exit_timezone.clone().unwrap_or_default(),
|
||||
"fingerprintTimezone": result.fingerprint_timezone.clone().unwrap_or_default(),
|
||||
"fingerprintLanguage": result.fingerprint_language.clone().unwrap_or_default(),
|
||||
"mismatches": result.mismatches.join(","),
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn gate_disabled() -> bool {
|
||||
crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|s| s.fingerprint_gate_disabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn extension_warning_disabled() -> bool {
|
||||
crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|s| s.vpn_extension_warning_disabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Identity used for consent and acknowledgement when the browser will connect
|
||||
/// directly. Distinct from any proxy identity, so accepting a direct-exit
|
||||
/// mismatch never disarms the gate for a proxied one.
|
||||
const DIRECT_EXIT_IDENTITY: &str = "direct";
|
||||
|
||||
/// Gate a launch that will connect directly despite the profile declaring a
|
||||
/// route. Measures the exit the browser will really use.
|
||||
async fn enforce_direct_exit(
|
||||
profile: &BrowserProfile,
|
||||
gate: &FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, DIRECT_EXIT_IDENTITY) {
|
||||
return Ok(());
|
||||
}
|
||||
if let FingerprintGate::Consented(token) = gate {
|
||||
return redeem_consent(token, profile, DIRECT_EXIT_IDENTITY);
|
||||
}
|
||||
// Automation never probes; without a cache to consult there is nothing to say.
|
||||
if matches!(gate, FingerprintGate::Advisory) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = match fingerprint_consistency::probe_direct_and_check(profile).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Fingerprint gate: direct exit probe failed for profile {}, allowing launch: {e}",
|
||||
profile.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if !result.checked || result.consistent {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = mint_consent(profile, DIRECT_EXIT_IDENTITY);
|
||||
Err(mismatch_error(&result, &token))
|
||||
}
|
||||
|
||||
/// The enforcing gate. Called from the launch pipeline once the upstream is
|
||||
/// normalized and before anything expensive or user-visible happens.
|
||||
///
|
||||
/// Fails **open** on every degradation — probe failure, timeout, missing geo
|
||||
/// database, private exit IP. The gate blocks only on a positively measured
|
||||
/// mismatch; a flaky IP-echo endpoint must never make profiles unlaunchable.
|
||||
pub async fn enforce_fingerprint_gate(
|
||||
profile: &BrowserProfile,
|
||||
upstream: Option<&crate::browser::ProxySettings>,
|
||||
gate: &FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
// A profile that declares no route is genuinely direct: the browser's exit is
|
||||
// this machine, which is what an un-proxied fingerprint should describe.
|
||||
//
|
||||
// But a profile that DOES declare one and still arrives here with no upstream
|
||||
// is about to go direct anyway — a deleted or unresolvable proxy resolves to
|
||||
// `None` and the launch continues. That is the exact leak this gate exists to
|
||||
// stop, so it must be measured, not waved through.
|
||||
let declares_route = profile.proxy_id.is_some() || profile.vpn_id.is_some();
|
||||
if upstream.is_none() && !declares_route {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(key) = fingerprint_consistency::exit_cache_key(profile) else {
|
||||
// Declares a route we can no longer resolve at all (e.g. the stored proxy
|
||||
// was deleted). Nothing identifies the exit, so measure the direct one.
|
||||
if declares_route {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} declares a proxy/VPN that did not resolve; \
|
||||
measuring the direct exit it will actually use",
|
||||
profile.name
|
||||
);
|
||||
}
|
||||
return enforce_direct_exit(profile, gate).await;
|
||||
};
|
||||
if gate_disabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ack first: a persisted acknowledgement already permits this launch, so a
|
||||
// stale token must not turn it into a hard failure.
|
||||
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, &key.identity) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let FingerprintGate::Consented(token) = gate {
|
||||
redeem_consent(token, profile, &key.identity)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The route is known but produced no usable upstream (e.g. a VPN worker that
|
||||
// came up without a local port). The browser still launches, direct.
|
||||
if upstream.is_none() {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} has a route that yielded no upstream; measuring the direct exit",
|
||||
profile.name
|
||||
);
|
||||
return enforce_direct_exit(profile, gate).await;
|
||||
}
|
||||
|
||||
let result = if matches!(gate, FingerprintGate::Advisory) {
|
||||
// Automation: answer from a warm cache or say nothing. Probing here would
|
||||
// add seconds to every profile in a batch run.
|
||||
fingerprint_consistency::check_profile_consistency_cached(profile)
|
||||
} else {
|
||||
match fingerprint_consistency::probe_and_check_consistency(profile, upstream, &key).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Fingerprint gate: exit probe failed for profile {}, allowing launch: {e}",
|
||||
profile.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !result.checked || result.consistent {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Only now is the extension scan worth its disk walk. A confirmed
|
||||
// proxy-permission extension can redirect the browser's traffic away from the
|
||||
// upstream we just measured, so the measurement describes an exit the browser
|
||||
// may not take. Report it, but do not hard-block on a number known to be
|
||||
// unreliable.
|
||||
let measurement_unreliable =
|
||||
vpn_extension_detect::has_confirmed(&vpn_extension_detect::scan_profile(profile));
|
||||
|
||||
if matches!(gate, FingerprintGate::Advisory) || measurement_unreliable {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} launching with a {} exit mismatch ({})",
|
||||
profile.name,
|
||||
if measurement_unreliable {
|
||||
"unverifiable"
|
||||
} else {
|
||||
"known"
|
||||
},
|
||||
result.mismatches.join(", ")
|
||||
);
|
||||
if let Err(e) = crate::events::emit("fingerprint-consistency-warning", &result) {
|
||||
log::warn!("Failed to emit fingerprint consistency warning: {e}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = mint_consent(profile, &key.identity);
|
||||
Err(mismatch_error(&result, &token))
|
||||
}
|
||||
|
||||
/// Everything the UI needs to decide whether to stop a launch, answered
|
||||
/// without touching the network or starting any worker.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PreLaunchChecks {
|
||||
pub vpn_extensions: Vec<DetectedVpnExtension>,
|
||||
pub scan_state: String,
|
||||
/// Cache-only; `checked` is false when the exit has not been measured yet.
|
||||
pub consistency: ConsistencyResult,
|
||||
/// True when the enforcing gate will still probe during the launch, so the
|
||||
/// UI can say the check is not finished rather than implying it passed.
|
||||
pub exit_probe_pending: bool,
|
||||
/// A confirmed proxy-permission extension is present, so any exit
|
||||
/// measurement describes a route the browser may not take.
|
||||
pub exit_measurement_unreliable: bool,
|
||||
/// Present only when a cached mismatch is already blocking, so "launch
|
||||
/// anyway" can proceed without a second round trip.
|
||||
pub consent_token: Option<String>,
|
||||
}
|
||||
|
||||
fn load_profile(profile_id: &str) -> Result<BrowserProfile, String> {
|
||||
crate::profile::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaunchChecks, String> {
|
||||
let profile = load_profile(&profile_id)?;
|
||||
|
||||
let scan = if extension_warning_disabled() {
|
||||
vpn_extension_detect::ExtensionScan {
|
||||
extensions: Vec::new(),
|
||||
scan_state: "scanned".to_string(),
|
||||
}
|
||||
} else {
|
||||
vpn_extension_detect::scan_profile(&profile)
|
||||
};
|
||||
|
||||
// Drop anything the user has already acknowledged for this profile, so the
|
||||
// dialog only ever opens for something new.
|
||||
let vpn_extensions: Vec<DetectedVpnExtension> = scan
|
||||
.extensions
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
!crate::launch_gate_prefs::extensions_acked(&profile_id, std::slice::from_ref(&e.key))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let exit_measurement_unreliable = vpn_extension_detect::has_confirmed(&scan);
|
||||
|
||||
let disabled = gate_disabled();
|
||||
let key = fingerprint_consistency::exit_cache_key(&profile);
|
||||
|
||||
let consistency = if disabled {
|
||||
ConsistencyResult::skip()
|
||||
} else {
|
||||
fingerprint_consistency::check_profile_consistency_cached(&profile)
|
||||
};
|
||||
|
||||
let already_acked = key
|
||||
.as_ref()
|
||||
.is_some_and(|k| crate::launch_gate_prefs::fingerprint_ack_matches(&profile, &k.identity));
|
||||
|
||||
let blocking = consistency.checked && !consistency.consistent && !already_acked;
|
||||
let consent_token = match (&key, blocking) {
|
||||
(Some(k), true) => Some(mint_consent(&profile, &k.identity)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(PreLaunchChecks {
|
||||
vpn_extensions,
|
||||
scan_state: scan.scan_state,
|
||||
consistency: if blocking {
|
||||
consistency
|
||||
} else {
|
||||
ConsistencyResult::skip()
|
||||
},
|
||||
exit_probe_pending: !disabled && !already_acked && key.is_some() && !blocking,
|
||||
exit_measurement_unreliable,
|
||||
consent_token,
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist "don't ask me again" choices from the gate dialog.
|
||||
#[tauri::command]
|
||||
pub async fn ack_launch_gate(
|
||||
profile_id: String,
|
||||
ack_fingerprint: bool,
|
||||
ack_extension_keys: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let profile = load_profile(&profile_id)?;
|
||||
|
||||
if ack_fingerprint {
|
||||
if let Some(key) = fingerprint_consistency::exit_cache_key(&profile) {
|
||||
crate::launch_gate_prefs::ack_fingerprint(&profile, &key.identity);
|
||||
}
|
||||
}
|
||||
crate::launch_gate_prefs::ack_extensions(&profile_id, &ack_extension_keys);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile_with(fingerprint: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(fingerprint.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_authorizes_exactly_one_launch() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_ok());
|
||||
// Replaying it must fail, so a leaked token cannot re-authorize.
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_for_a_different_profile() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let other = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &other, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_after_the_fingerprint_changes() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
|
||||
let mut regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
regenerated.id = profile.id;
|
||||
assert!(redeem_consent(&token, ®enerated, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_after_the_exit_changes() {
|
||||
// The reason a bare `bypass: bool` is not enough: consent granted for one
|
||||
// proxy must not authorize a launch through another.
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &profile, "http://other:2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_token_is_rejected() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let err = redeem_consent("deadbeef", &profile, "http://gw:1").unwrap_err();
|
||||
assert!(err.contains("LAUNCH_CONSENT_EXPIRED"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_tokens_are_swept_and_rejected() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
// Back-date it past the TTL.
|
||||
{
|
||||
let mut store = consents();
|
||||
if let Some(pending) = store.get_mut(&token) {
|
||||
pending.issued_at = crate::proxy_manager::now_secs() - CONSENT_TTL_SECS - 1;
|
||||
}
|
||||
}
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_error_carries_the_details_the_dialog_renders() {
|
||||
let result = ConsistencyResult {
|
||||
consistent: false,
|
||||
checked: true,
|
||||
exit_ip: Some("1.2.3.4".into()),
|
||||
exit_country_code: Some("DE".into()),
|
||||
exit_timezone: Some("Europe/Berlin".into()),
|
||||
fingerprint_timezone: Some("America/New_York".into()),
|
||||
fingerprint_language: Some("en-US".into()),
|
||||
mismatches: vec!["timezone".into(), "language".into()],
|
||||
};
|
||||
let encoded = mismatch_error(&result, "tok");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&encoded).unwrap();
|
||||
assert_eq!(parsed["code"], "FINGERPRINT_EXIT_MISMATCH");
|
||||
assert_eq!(parsed["params"]["token"], "tok");
|
||||
assert_eq!(parsed["params"]["exitTimezone"], "Europe/Berlin");
|
||||
assert_eq!(parsed["params"]["fingerprintTimezone"], "America/New_York");
|
||||
// params values must be strings for the frontend's interpolation.
|
||||
assert_eq!(parsed["params"]["mismatches"], "timezone,language");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_allows_a_direct_connection_without_measuring() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert!(
|
||||
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_allows_a_profile_with_no_proxy_or_vpn() {
|
||||
// No exit identity means nothing to compare against, so the launch must
|
||||
// proceed rather than block on an unmeasurable profile.
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let upstream = crate::browser::ProxySettings {
|
||||
proxy_type: "socks5".into(),
|
||||
host: "127.0.0.1".into(),
|
||||
port: 1080,
|
||||
username: None,
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
};
|
||||
assert!(
|
||||
enforce_fingerprint_gate(&profile, Some(&upstream), &FingerprintGate::Enforce)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Persisted "I know, launch it anyway" acknowledgements for the launch gate.
|
||||
//!
|
||||
//! Deliberately NOT synced. An acknowledgement is a statement about this
|
||||
//! machine's operator ("I understand this profile's exit disagrees with its
|
||||
//! fingerprint"), not a property of the profile. Syncing it would let one
|
||||
//! teammate disarm another's gate, and writing it into profile metadata would
|
||||
//! bump `updated_at` and make a local dismissal look like a remote edit.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FingerprintAck {
|
||||
/// Hash of the fingerprint that was acknowledged.
|
||||
pub fingerprint_hash: String,
|
||||
/// Exit endpoint identity it was acknowledged against.
|
||||
pub exit_identity: String,
|
||||
pub acked_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LaunchGatePrefs {
|
||||
#[serde(default)]
|
||||
pub fingerprint_acks: HashMap<String, FingerprintAck>,
|
||||
/// Profile id -> acknowledged extension keys.
|
||||
#[serde(default)]
|
||||
pub vpn_extension_acks: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Serializes read-modify-write so two concurrent acknowledgements in a bulk
|
||||
/// run cannot clobber each other.
|
||||
static ref PREFS_LOCK: Mutex<()> = Mutex::new(());
|
||||
}
|
||||
|
||||
fn prefs_file() -> PathBuf {
|
||||
crate::app_dirs::data_subdir().join("launch_gate_prefs.json")
|
||||
}
|
||||
|
||||
pub fn load() -> LaunchGatePrefs {
|
||||
let Ok(content) = std::fs::read_to_string(prefs_file()) else {
|
||||
return LaunchGatePrefs::default();
|
||||
};
|
||||
serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
log::warn!("Failed to parse launch gate prefs, ignoring them: {e}");
|
||||
LaunchGatePrefs::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn save(prefs: &LaunchGatePrefs) {
|
||||
let path = prefs_file();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("Failed to create launch gate prefs dir: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_string_pretty(prefs) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&path, json) {
|
||||
log::warn!("Failed to write launch gate prefs: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Failed to serialize launch gate prefs: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(mutate: impl FnOnce(&mut LaunchGatePrefs)) {
|
||||
let _guard = PREFS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut prefs = load();
|
||||
mutate(&mut prefs);
|
||||
save(&prefs);
|
||||
}
|
||||
|
||||
/// Stable digest of a profile's stored fingerprint, so an acknowledgement stops
|
||||
/// applying the moment the fingerprint is regenerated or matched to a new exit.
|
||||
pub fn fingerprint_hash(profile: &BrowserProfile) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let fingerprint = profile
|
||||
.wayfern_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.fingerprint.as_deref())
|
||||
.unwrap_or("");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(fingerprint.as_bytes());
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record that the user accepted this exact (fingerprint, exit) mismatch.
|
||||
pub fn ack_fingerprint(profile: &BrowserProfile, exit_identity: &str) {
|
||||
let ack = FingerprintAck {
|
||||
fingerprint_hash: fingerprint_hash(profile),
|
||||
exit_identity: exit_identity.to_string(),
|
||||
acked_at: crate::proxy_manager::now_secs(),
|
||||
};
|
||||
let profile_id = profile.id.to_string();
|
||||
update(|prefs| {
|
||||
prefs.fingerprint_acks.insert(profile_id, ack);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the user already accepted the mismatch this profile currently has.
|
||||
///
|
||||
/// Bound to both the fingerprint and the exit endpoint on purpose: the old
|
||||
/// per-profile "don't warn again" flag never expired, so one dismissal left a
|
||||
/// profile unprotected forever, including after its proxy was swapped for one
|
||||
/// in a different country.
|
||||
pub fn fingerprint_ack_matches(profile: &BrowserProfile, exit_identity: &str) -> bool {
|
||||
let prefs = load();
|
||||
prefs
|
||||
.fingerprint_acks
|
||||
.get(&profile.id.to_string())
|
||||
.is_some_and(|ack| {
|
||||
ack.fingerprint_hash == fingerprint_hash(profile) && ack.exit_identity == exit_identity
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ack_extensions(profile_id: &str, keys: &[String]) {
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
let profile_id = profile_id.to_string();
|
||||
let keys = keys.to_vec();
|
||||
update(|prefs| {
|
||||
let entry = prefs.vpn_extension_acks.entry(profile_id).or_default();
|
||||
for key in keys {
|
||||
if !entry.contains(&key) {
|
||||
entry.push(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// True when every one of these extensions has already been acknowledged for
|
||||
/// this profile. Installing a *different* VPN extension later re-warns, because
|
||||
/// its key is not in the acknowledged set.
|
||||
pub fn extensions_acked(profile_id: &str, keys: &[String]) -> bool {
|
||||
if keys.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let prefs = load();
|
||||
let Some(acked) = prefs.vpn_extension_acks.get(profile_id) else {
|
||||
return false;
|
||||
};
|
||||
keys.iter().all(|k| acked.contains(k))
|
||||
}
|
||||
|
||||
/// Drop everything remembered for a profile, for use when it is deleted.
|
||||
pub fn forget_profile(profile_id: &str) {
|
||||
let profile_id = profile_id.to_string();
|
||||
update(|prefs| {
|
||||
prefs.fingerprint_acks.remove(&profile_id);
|
||||
prefs.vpn_extension_acks.remove(&profile_id);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile_with(fingerprint: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(fingerprint.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_hash_changes_with_the_fingerprint() {
|
||||
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let b = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
assert_ne!(fingerprint_hash(&a), fingerprint_hash(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_hash_is_stable_for_the_same_fingerprint() {
|
||||
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let b = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert_eq!(fingerprint_hash(&a), fingerprint_hash(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_without_a_fingerprint_still_hashes() {
|
||||
let profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!fingerprint_hash(&profile).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acks_round_trip_and_rearm_on_change() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
|
||||
ack_fingerprint(&profile, "http://gw:1");
|
||||
assert!(fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
|
||||
// Swapping the proxy re-arms the gate: the mismatch the user accepted is
|
||||
// not the mismatch they now have.
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://other:2"));
|
||||
|
||||
// Regenerating the fingerprint re-arms it too.
|
||||
let regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
let mut same_id = regenerated.clone();
|
||||
same_id.id = profile.id;
|
||||
assert!(!fingerprint_ack_matches(&same_id, "http://gw:1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_acks_are_per_key() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile_id = uuid::Uuid::new_v4().to_string();
|
||||
let nord = vec!["crx:aaaa".to_string()];
|
||||
let other = vec!["crx:bbbb".to_string()];
|
||||
|
||||
assert!(!extensions_acked(&profile_id, &nord));
|
||||
ack_extensions(&profile_id, &nord);
|
||||
assert!(extensions_acked(&profile_id, &nord));
|
||||
|
||||
// A different extension installed later must warn again.
|
||||
assert!(!extensions_acked(&profile_id, &other));
|
||||
assert!(!extensions_acked(
|
||||
&profile_id,
|
||||
&[nord[0].clone(), other[0].clone()]
|
||||
));
|
||||
|
||||
// Nothing to acknowledge is trivially acknowledged, so an empty scan never
|
||||
// opens the dialog.
|
||||
assert!(extensions_acked(&profile_id, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_profile_clears_both_kinds_of_ack() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let profile_id = profile.id.to_string();
|
||||
ack_fingerprint(&profile, "http://gw:1");
|
||||
ack_extensions(&profile_id, &["crx:aaaa".to_string()]);
|
||||
|
||||
forget_profile(&profile_id);
|
||||
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
assert!(!extensions_acked(&profile_id, &["crx:aaaa".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_prefs_file_is_ignored_rather_than_fatal() {
|
||||
let dir = tempfile::tempdir().expect("tempdir").keep();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(dir.clone());
|
||||
std::fs::create_dir_all(dir.join("data")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("data").join("launch_gate_prefs.json"),
|
||||
"{ not json",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Must not panic, and must fail closed (nothing acknowledged).
|
||||
let prefs = load();
|
||||
assert!(prefs.fingerprint_acks.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,8 @@ mod geolocation;
|
||||
mod group_manager;
|
||||
mod human_typing;
|
||||
mod ip_utils;
|
||||
mod launch_gate;
|
||||
mod launch_gate_prefs;
|
||||
mod log_redaction;
|
||||
mod platform_browser;
|
||||
mod profile;
|
||||
@@ -95,6 +97,7 @@ mod tag_manager;
|
||||
mod team_lock;
|
||||
mod version_updater;
|
||||
pub mod vpn;
|
||||
mod vpn_extension_detect;
|
||||
pub mod vpn_worker_runner;
|
||||
pub mod vpn_worker_storage;
|
||||
pub mod xray;
|
||||
@@ -2673,8 +2676,9 @@ pub fn run_with_builder(
|
||||
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,
|
||||
launch_gate::get_profile_pre_launch_checks,
|
||||
launch_gate::ack_launch_gate,
|
||||
get_sync_settings,
|
||||
save_sync_settings,
|
||||
set_profile_sync_mode,
|
||||
|
||||
@@ -2493,9 +2493,7 @@ impl McpServer {
|
||||
app_handle.clone(),
|
||||
profile.clone(),
|
||||
url.map(|s| s.to_string()),
|
||||
None,
|
||||
headless,
|
||||
true,
|
||||
crate::browser_runner::LaunchOptions::automation(None, headless),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
@@ -2651,9 +2649,7 @@ impl McpServer {
|
||||
app_handle.clone(),
|
||||
profile.clone(),
|
||||
url.map(|s| s.to_string()),
|
||||
None,
|
||||
headless,
|
||||
true,
|
||||
crate::browser_runner::LaunchOptions::automation(None, headless),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -41,7 +41,7 @@ fn is_kept(name: &str) -> bool {
|
||||
/// step since it regenerates — leaves a populated `Default/` without it. Such a
|
||||
/// directory would then be treated as stale and removed wholesale, destroying the
|
||||
/// Extensions and Bookmarks this feature exists to preserve.
|
||||
fn is_profile_dir_name(name: &str) -> bool {
|
||||
pub(crate) fn is_profile_dir_name(name: &str) -> bool {
|
||||
matches!(name, "Default" | "Guest Profile" | "System Profile")
|
||||
|| name
|
||||
.strip_prefix("Profile ")
|
||||
|
||||
@@ -479,6 +479,10 @@ impl ProfileManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Launch-gate acknowledgements are keyed by profile id and are not synced,
|
||||
// so nothing else would ever clean them up.
|
||||
crate::launch_gate_prefs::forget_profile(profile_id);
|
||||
|
||||
// Remember sync mode before deleting local files
|
||||
let was_sync_enabled = profile.is_sync_enabled();
|
||||
|
||||
|
||||
@@ -1152,6 +1152,21 @@ impl ProxyManager {
|
||||
url
|
||||
}
|
||||
|
||||
/// Proxy URL for a diagnostic probe made by the app itself (reqwest), as
|
||||
/// opposed to `build_proxy_url`, which feeds the browser.
|
||||
///
|
||||
/// SOCKS5 becomes `socks5h://` so the probe endpoint's hostname resolves at
|
||||
/// the exit rather than on this machine. Resolving locally would leak the
|
||||
/// real DNS and, behind a split-horizon resolver, can reach a different host
|
||||
/// than the browser would.
|
||||
pub fn build_probe_proxy_url(proxy_settings: &ProxySettings) -> String {
|
||||
let url = Self::build_proxy_url(proxy_settings);
|
||||
if proxy_settings.proxy_type.eq_ignore_ascii_case("socks5") {
|
||||
return url.replacen("socks5://", "socks5h://", 1);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
// Check if a proxy is valid by routing through a temporary donut-proxy process.
|
||||
// This tests the exact same code path the browser uses.
|
||||
// Falls back to direct reqwest check if the proxy worker fails to start.
|
||||
|
||||
@@ -54,6 +54,14 @@ pub struct AppSettings {
|
||||
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
|
||||
#[serde(default)]
|
||||
pub window_resize_warning_dismissed: bool,
|
||||
/// Stop blocking launches whose proxy exit disagrees with the fingerprint.
|
||||
/// Lives here rather than in localStorage because the Rust launch path is
|
||||
/// what enforces the block and cannot read the frontend's storage.
|
||||
#[serde(default)]
|
||||
pub fingerprint_gate_disabled: bool,
|
||||
/// Stop warning about VPN/proxy extensions found in a profile.
|
||||
#[serde(default)]
|
||||
pub vpn_extension_warning_disabled: bool,
|
||||
#[serde(default)]
|
||||
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
|
||||
#[serde(default)]
|
||||
@@ -96,6 +104,8 @@ impl Default for AppSettings {
|
||||
mcp_token: None,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
fingerprint_gate_disabled: false,
|
||||
vpn_extension_warning_disabled: false,
|
||||
onboarding_completed: false,
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
@@ -1190,6 +1200,8 @@ mod tests {
|
||||
mcp_token: None,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
fingerprint_gate_disabled: false,
|
||||
vpn_extension_warning_disabled: false,
|
||||
onboarding_completed: false,
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
|
||||
@@ -331,7 +331,7 @@ impl SyncProgressTracker {
|
||||
/// Check if sync is configured (cloud or self-hosted)
|
||||
pub fn is_sync_configured() -> bool {
|
||||
// Cloud backup is a plan capability. Every paid plan (incl. the future
|
||||
// "starter" tier) grants it, but gating on the capability — not just "is paid"
|
||||
// "solo" tier) grants it, but gating on the capability — not just "is paid"
|
||||
// — keeps this correct if a plan without cloud backup is ever added.
|
||||
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
|
||||
return true;
|
||||
|
||||
@@ -168,7 +168,7 @@ impl SynchronizerManager {
|
||||
);
|
||||
|
||||
// Launch leader first so it gets focus
|
||||
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None)
|
||||
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch leader: {e}"))?;
|
||||
|
||||
@@ -179,7 +179,7 @@ impl SynchronizerManager {
|
||||
let ah = app_handle.clone();
|
||||
let fp = fp.clone();
|
||||
set.spawn(async move {
|
||||
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None)
|
||||
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None, None)
|
||||
.await
|
||||
.map_err(|e| (fp.name.clone(), e.to_string()))
|
||||
});
|
||||
|
||||
@@ -313,14 +313,17 @@ fn lock_conflict_error(
|
||||
}
|
||||
|
||||
/// Acquire profile lock if profile is sync-enabled and user has a paid subscription.
|
||||
/// Returns whether a lock was actually taken, so a caller that unwinds a failed
|
||||
/// launch releases only what it acquired. Releasing unconditionally would drop
|
||||
/// a lock a REST handler up the stack still owns.
|
||||
pub async fn acquire_team_lock_if_needed(
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<bool, String> {
|
||||
if !profile.is_sync_enabled() {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Ensure lock manager is connected
|
||||
@@ -340,7 +343,10 @@ pub async fn acquire_team_lock_if_needed(
|
||||
));
|
||||
}
|
||||
|
||||
PROFILE_LOCK.acquire_lock(&profile.id.to_string()).await
|
||||
PROFILE_LOCK
|
||||
.acquire_lock(&profile.id.to_string())
|
||||
.await
|
||||
.map(|()| true)
|
||||
}
|
||||
|
||||
/// Release profile lock if profile is sync-enabled and user has a paid subscription.
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
//! Enumerates extensions the user installed from inside the browser, by
|
||||
//! walking the Chromium profile directory on disk.
|
||||
//!
|
||||
//! Depends only on `std`, `serde_json`, the sibling `rules` module, and the
|
||||
//! shared profile-directory-name predicate, so the directory-layout handling —
|
||||
//! the riskiest part of detection — stays testable without app state.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::rules::{
|
||||
classify, keyword_hit, lookup_message, manifest_str, message_placeholder_key, signal_labels,
|
||||
signals_from_manifest, version_dir_sort_key, DetectedVpnExtension,
|
||||
};
|
||||
|
||||
/// Upper bound on extension directories walked per profile. A launch must not
|
||||
/// stall behind a pathological profile; whatever was found is still reported,
|
||||
/// flagged `partial`.
|
||||
const MAX_EXTENSION_DIRS: usize = 300;
|
||||
/// Manifests are a few KiB. Anything past this is not a manifest we can use.
|
||||
const MAX_MANIFEST_BYTES: u64 = 512 * 1024;
|
||||
/// Wall-clock ceiling for the whole scan. This runs on the launch path.
|
||||
const SCAN_DEADLINE: Duration = Duration::from_millis(750);
|
||||
|
||||
/// Chromium profile directories to search inside a user-data dir.
|
||||
///
|
||||
/// Two layouts are real here: Donut launches Wayfern with only
|
||||
/// `--user-data-dir`, so Chromium uses `Default/`; but an imported profile is
|
||||
/// copied in as the profile directory itself, putting `Extensions/` at the
|
||||
/// root. Checking only one layout misses every profile of the other kind.
|
||||
fn candidate_profile_dirs(user_data_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
if user_data_dir.join("Preferences").exists() || user_data_dir.join("Extensions").is_dir() {
|
||||
dirs.push(user_data_dir.to_path_buf());
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(user_data_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if crate::profile::clear_on_close::is_profile_dir_name(name)
|
||||
|| path.join("Preferences").exists()
|
||||
{
|
||||
dirs.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dirs.sort();
|
||||
dirs.dedup();
|
||||
dirs
|
||||
}
|
||||
|
||||
fn read_json_file(path: &Path, max_bytes: Option<u64>) -> Option<serde_json::Value> {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
if max_bytes.is_some_and(|cap| metadata.len() > cap) {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
|
||||
}
|
||||
|
||||
fn resolve_dir_i18n(
|
||||
version_dir: &Path,
|
||||
manifest: &serde_json::Value,
|
||||
value: &str,
|
||||
) -> Option<String> {
|
||||
let key = message_placeholder_key(value)?;
|
||||
let default_locale = manifest.get("default_locale")?.as_str()?;
|
||||
let messages = read_json_file(
|
||||
&version_dir
|
||||
.join("_locales")
|
||||
.join(default_locale)
|
||||
.join("messages.json"),
|
||||
Some(MAX_MANIFEST_BYTES),
|
||||
)?;
|
||||
lookup_message(&messages, &key)
|
||||
}
|
||||
|
||||
/// What the profile's preference files say about installed extensions.
|
||||
///
|
||||
/// Read-only on purpose: `Secure Preferences` is MAC-protected, and rewriting
|
||||
/// it invalidates the signature, which disables every extension in the profile.
|
||||
#[derive(Default)]
|
||||
struct PreferenceExtensions {
|
||||
/// Explicitly disabled. A disabled extension cannot touch the proxy, so
|
||||
/// warning about it would be a false alarm.
|
||||
disabled: HashSet<String>,
|
||||
/// Unpacked ("Load unpacked" / developer mode) extensions, which live
|
||||
/// OUTSIDE `Extensions/` and are therefore invisible to the directory walk.
|
||||
/// Sideloading is exactly how someone gets a VPN extension in without the
|
||||
/// Web Store, so missing these would leave the obvious hole open.
|
||||
unpacked: Vec<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
fn preference_extensions(profile_dir: &Path) -> PreferenceExtensions {
|
||||
let mut out = PreferenceExtensions::default();
|
||||
for file in ["Secure Preferences", "Preferences"] {
|
||||
// Preference files are legitimately large, so they skip the manifest cap.
|
||||
let Some(prefs) = read_json_file(&profile_dir.join(file), None) else {
|
||||
continue;
|
||||
};
|
||||
let Some(settings) = prefs
|
||||
.get("extensions")
|
||||
.and_then(|e| e.get("settings"))
|
||||
.and_then(|s| s.as_object())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (id, entry) in settings {
|
||||
// Chromium's Extension::State: 0 = disabled.
|
||||
if entry.get("state").and_then(serde_json::Value::as_i64) == Some(0) {
|
||||
out.disabled.insert(id.clone());
|
||||
continue;
|
||||
}
|
||||
// A packed extension's `path` is relative to Extensions/; an unpacked
|
||||
// one records an absolute path elsewhere on disk.
|
||||
if let Some(path) = entry.get("path").and_then(|p| p.as_str()) {
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_absolute() {
|
||||
out.unpacked.push((id.clone(), path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Walk a user-data dir for VPN/proxy extensions.
|
||||
///
|
||||
/// Returns false when the walk was cut short by a cap or the deadline, so the
|
||||
/// caller can report the scan as incomplete rather than clean.
|
||||
pub(super) fn scan_browser_extensions(
|
||||
user_data_dir: &Path,
|
||||
out: &mut Vec<DetectedVpnExtension>,
|
||||
started: Instant,
|
||||
) -> bool {
|
||||
let mut walked = 0usize;
|
||||
|
||||
for profile_dir in candidate_profile_dirs(user_data_dir) {
|
||||
// Read preferences first: unpacked extensions live outside Extensions/, so
|
||||
// a profile that has only sideloaded ones has no Extensions/ dir at all and
|
||||
// must not be skipped before they are considered.
|
||||
let prefs = preference_extensions(&profile_dir);
|
||||
let disabled = &prefs.disabled;
|
||||
|
||||
for (crx_id, unpacked_dir) in &prefs.unpacked {
|
||||
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
walked += 1;
|
||||
if let Some(found) = detect_in_version_dir(crx_id, unpacked_dir) {
|
||||
out.push(found);
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(profile_dir.join("Extensions")) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
walked += 1;
|
||||
|
||||
let ext_dir = entry.path();
|
||||
if !ext_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(crx_id) = ext_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(str::to_string)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if disabled.contains(&crx_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Several versions can coexist on disk; Chromium runs the highest.
|
||||
let Ok(version_entries) = std::fs::read_dir(&ext_dir) else {
|
||||
continue;
|
||||
};
|
||||
let mut versions: Vec<PathBuf> = version_entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
versions.sort_by_key(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(version_dir_sort_key)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let Some(version_dir) = versions.last() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(found) = detect_in_version_dir(&crx_id, version_dir) {
|
||||
out.push(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Classify the extension whose unpacked files live in `version_dir`.
|
||||
/// Shared by the packed walk and the unpacked (developer-mode) entries.
|
||||
fn detect_in_version_dir(crx_id: &str, version_dir: &Path) -> Option<DetectedVpnExtension> {
|
||||
let manifest = read_json_file(&version_dir.join("manifest.json"), Some(MAX_MANIFEST_BYTES))?;
|
||||
|
||||
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| crx_id.to_string());
|
||||
let name = resolve_dir_i18n(version_dir, &manifest, &raw_name).unwrap_or_else(|| {
|
||||
if message_placeholder_key(&raw_name).is_some() {
|
||||
crx_id.to_string()
|
||||
} else {
|
||||
raw_name.clone()
|
||||
}
|
||||
});
|
||||
let description = manifest_str(&manifest, "description").and_then(|d| {
|
||||
resolve_dir_i18n(version_dir, &manifest, &d).or(if message_placeholder_key(&d).is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(d)
|
||||
})
|
||||
});
|
||||
|
||||
let signals = signals_from_manifest(&manifest);
|
||||
let keyword = keyword_hit(&name, description.as_deref());
|
||||
let confidence = classify(&signals, keyword)?;
|
||||
|
||||
Some(DetectedVpnExtension {
|
||||
key: format!("crx:{crx_id}"),
|
||||
name,
|
||||
version: manifest_str(&manifest, "version"),
|
||||
source: "browser".to_string(),
|
||||
confidence: confidence.to_string(),
|
||||
signals: signal_labels(&signals, keyword),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn write(path: &Path, contents: &str) {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
const VPN_MANIFEST: &str = r#"{"name":"Turbo VPN","version":"2.1.0","permissions":["proxy"]}"#;
|
||||
const CRX_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_accepts_the_default_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Default").join("Preferences"), "{}");
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.join("Default")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_accepts_the_imported_root_layout() {
|
||||
// profile_importer copies a Chromium profile dir in as the root, so
|
||||
// Preferences and Extensions/ sit at the top level.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Preferences"), "{}");
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.to_path_buf()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_finds_named_profile_dirs_without_preferences() {
|
||||
// Chromium writes Preferences lazily, so a populated Default/ can lack it.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("Profile 2").join("Extensions")).unwrap();
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.join("Profile 2")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_a_vpn_extension_in_the_default_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
|
||||
assert_eq!(out[0].name, "Turbo VPN");
|
||||
assert_eq!(out[0].confidence, "confirmed");
|
||||
assert_eq!(out[0].source, "browser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_a_vpn_extension_in_the_imported_root_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Preferences"), "{}");
|
||||
write(
|
||||
&root
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reads_the_highest_version_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let ext = root.join("Default").join("Extensions").join(CRX_ID);
|
||||
// Lexicographically "1.9.0_0" > "1.10.0_0"; numerically it is not.
|
||||
write(
|
||||
&ext.join("1.9.0_0").join("manifest.json"),
|
||||
r#"{"name":"Old","version":"1.9.0","permissions":["storage"]}"#,
|
||||
);
|
||||
write(
|
||||
&ext.join("1.10.0_0").join("manifest.json"),
|
||||
r#"{"name":"New VPN","version":"1.10.0","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].version.as_deref(), Some("1.10.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_skips_extensions_chromium_has_disabled() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let profile = root.join("Default");
|
||||
write(
|
||||
&profile
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
write(
|
||||
&profile.join("Secure Preferences"),
|
||||
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":0}}}}}}}}"#),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"a disabled extension cannot touch the proxy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_keeps_enabled_extensions_listed_in_preferences() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let profile = root.join("Default");
|
||||
write(
|
||||
&profile
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
write(
|
||||
&profile.join("Secure Preferences"),
|
||||
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":1}}}}}}}}"#),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_resolves_a_localized_extension_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let version_dir = root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0");
|
||||
write(
|
||||
&version_dir.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"en","permissions":["proxy"]}"#,
|
||||
);
|
||||
write(
|
||||
&version_dir
|
||||
.join("_locales")
|
||||
.join("en")
|
||||
.join("messages.json"),
|
||||
r#"{"appName":{"message":"Nord VPN"}}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out[0].name, "Nord VPN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_falls_back_to_the_crx_id_when_a_placeholder_cannot_be_resolved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out[0].name, CRX_ID, "never show a raw __MSG_ placeholder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_ignores_an_ordinary_extension() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("1.0.0_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"Dark Reader","version":"1.0.0","permissions":["storage","activeTab"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reports_incomplete_when_the_deadline_has_passed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
// A start time already past the deadline stands in for a slow disk.
|
||||
let expired = Instant::now() - SCAN_DEADLINE - Duration::from_millis(10);
|
||||
assert!(!scan_browser_extensions(root, &mut out, expired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_survives_a_malformed_manifest() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
"{ not json",
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_an_unpacked_developer_mode_extension() {
|
||||
// Sideloading via "Load unpacked" is exactly how a VPN extension gets in
|
||||
// without the Web Store, and those files live outside Extensions/.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let unpacked = root.join("somewhere-else").join("my-vpn");
|
||||
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&format!(
|
||||
r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":1,"path":"{}"}}}}}}}}"#,
|
||||
unpacked.to_string_lossy()
|
||||
),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1, "unpacked extensions must not be invisible");
|
||||
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
|
||||
assert_eq!(out[0].confidence, "confirmed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_ignores_a_disabled_unpacked_extension() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let unpacked = root.join("somewhere-else").join("my-vpn");
|
||||
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&format!(
|
||||
r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":0,"path":"{}"}}}}}}}}"#,
|
||||
unpacked.to_string_lossy()
|
||||
),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_relative_paths_are_not_treated_as_unpacked() {
|
||||
// A packed extension records a path relative to Extensions/; following it
|
||||
// as if absolute would read the wrong place (or nothing).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&format!(
|
||||
r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":1,"path":"{CRX_ID}/2.1.0_0"}}}}}}}}"#
|
||||
),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_tolerates_a_missing_user_data_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(
|
||||
&tmp.path().join("nope"),
|
||||
&mut out,
|
||||
Instant::now()
|
||||
));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Detects VPN/proxy browser extensions present in a profile.
|
||||
//!
|
||||
//! An extension holding Chromium's `proxy` permission can override the proxy
|
||||
//! Donut passes on the command line, so the browser's real exit stops being the
|
||||
//! one Donut measured and generated the fingerprint against. That produces
|
||||
//! exactly the geo/timezone/language mismatch the fingerprint exists to avoid,
|
||||
//! except Donut cannot observe it from the outside — hence a launch-time
|
||||
//! warning rather than a measurement.
|
||||
//!
|
||||
//! Two sources, deliberately both: Donut-managed extensions live in the app's
|
||||
//! own store and are handed to Chromium via `--load-extension` from *outside*
|
||||
//! the profile directory, while extensions the user installed from the Web
|
||||
//! Store live *inside* it. Neither set appears in the other.
|
||||
|
||||
mod browser_scan;
|
||||
mod rules;
|
||||
|
||||
// `message_placeholder_key`/`lookup_message` are shared with
|
||||
// `extension_manager`, which resolves the same placeholders out of a zip.
|
||||
use rules::{classify, keyword_hit, manifest_str, signal_labels, signals_from_manifest};
|
||||
pub use rules::{lookup_message, message_placeholder_key, DetectedVpnExtension};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExtensionScan {
|
||||
pub extensions: Vec<DetectedVpnExtension>,
|
||||
/// `scanned` | `partial` | `encrypted` | `ephemeral` | `missing`.
|
||||
///
|
||||
/// Reported honestly so the dialog can say the scan was incomplete rather
|
||||
/// than implying a clean profile it never managed to read.
|
||||
pub scan_state: String,
|
||||
}
|
||||
|
||||
/// Donut-managed extensions, reached through the profile's extension group.
|
||||
///
|
||||
/// Read live from the stored archive rather than from the metadata cached on
|
||||
/// `Extension`, so replacing an extension's file cannot leave a stale verdict
|
||||
/// behind. N is the group size — typically a handful.
|
||||
fn scan_donut_extensions(profile: &BrowserProfile, out: &mut Vec<DetectedVpnExtension>) {
|
||||
let Some(group_id) = &profile.extension_group_id else {
|
||||
return;
|
||||
};
|
||||
let Ok(manager) = crate::extension_manager::EXTENSION_MANAGER.lock() else {
|
||||
log::warn!("VPN extension scan: extension manager lock poisoned, skipping managed extensions");
|
||||
return;
|
||||
};
|
||||
let Ok(group) = manager.get_group(group_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for ext_id in &group.extension_ids {
|
||||
let Ok(ext) = manager.get_extension(ext_id) else {
|
||||
continue;
|
||||
};
|
||||
let path = manager.get_file_dir_public(ext_id).join(&ext.file_name);
|
||||
let Ok(data) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(manifest) =
|
||||
crate::extension_manager::read_manifest_from_archive(&data, &ext.file_type)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| ext.name.clone());
|
||||
let name =
|
||||
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &raw_name)
|
||||
.unwrap_or_else(|| {
|
||||
// An unresolvable placeholder is not a name — fall back to the one
|
||||
// the extension carries in Donut.
|
||||
if message_placeholder_key(&raw_name).is_some() {
|
||||
ext.name.clone()
|
||||
} else {
|
||||
raw_name.clone()
|
||||
}
|
||||
});
|
||||
let description = manifest_str(&manifest, "description").and_then(|d| {
|
||||
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &d).or(
|
||||
if message_placeholder_key(&d).is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(d)
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
let signals = signals_from_manifest(&manifest);
|
||||
let keyword = keyword_hit(&name, description.as_deref());
|
||||
let Some(confidence) = classify(&signals, keyword) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
out.push(DetectedVpnExtension {
|
||||
key: format!("donut:{ext_id}"),
|
||||
name,
|
||||
version: manifest_str(&manifest, "version").or_else(|| ext.version.clone()),
|
||||
source: "donut".to_string(),
|
||||
confidence: confidence.to_string(),
|
||||
signals: signal_labels(&signals, keyword),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a profile for VPN/proxy extensions from both sources.
|
||||
///
|
||||
/// Never fails: an unreadable profile reports whatever it could see plus a
|
||||
/// `scan_state` explaining why the picture is incomplete.
|
||||
pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
|
||||
let started = Instant::now();
|
||||
let mut extensions = Vec::new();
|
||||
|
||||
scan_donut_extensions(profile, &mut extensions);
|
||||
|
||||
let profiles_dir = crate::app_dirs::profiles_dir();
|
||||
let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
|
||||
// `get_effective_profile_path` only returns the decrypted RAM copy while the
|
||||
// profile is unlocked; locked, it falls back to the on-disk directory, which
|
||||
// exists but is ciphertext. Walking that finds nothing — so the state has to
|
||||
// be decided on whether we actually got a readable copy, not on the path
|
||||
// existing, or a locked profile reports as verified-clean.
|
||||
let has_plaintext_dir = !(profile.password_protected || profile.ephemeral)
|
||||
|| crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_some();
|
||||
|
||||
let scan_state = if !has_plaintext_dir {
|
||||
if profile.password_protected {
|
||||
"encrypted"
|
||||
} else {
|
||||
"ephemeral"
|
||||
}
|
||||
} else if !user_data_dir.is_dir() {
|
||||
// Never launched, so there is no profile directory to inspect yet.
|
||||
"missing"
|
||||
} else if browser_scan::scan_browser_extensions(&user_data_dir, &mut extensions, started) {
|
||||
"scanned"
|
||||
} else {
|
||||
"partial"
|
||||
};
|
||||
|
||||
// The two sources are disjoint by construction, but an imported profile can
|
||||
// carry its own copy of an extension Donut also manages. Collapse only on an
|
||||
// exact name+version match, and never on an unresolved `__MSG_` placeholder —
|
||||
// those are not identities and would fold unrelated extensions into one row.
|
||||
let mut seen = HashSet::new();
|
||||
extensions.retain(|e| {
|
||||
message_placeholder_key(&e.name).is_some() || seen.insert((e.name.clone(), e.version.clone()))
|
||||
});
|
||||
|
||||
ExtensionScan {
|
||||
extensions,
|
||||
scan_state: scan_state.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when at least one detection is `confirmed` — the extension holds the
|
||||
/// `proxy` permission and can actually redirect the browser's traffic.
|
||||
pub fn has_confirmed(scan: &ExtensionScan) -> bool {
|
||||
scan.extensions.iter().any(|e| e.confidence == "confirmed")
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Pure classification rules for VPN/proxy extension detection.
|
||||
//!
|
||||
//! Deliberately free of crate-internal dependencies (`std` + `serde_json`
|
||||
//! only): these rules are the heart of the feature and the part most worth
|
||||
//! testing in isolation, so nothing here may reach for app state, the
|
||||
//! filesystem, or the network. Enumerating the two extension sources and
|
||||
//! reading them off disk lives in the parent module.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Substrings that corroborate a request-blocking extension being a VPN.
|
||||
/// Matched case-insensitively against name + description.
|
||||
const KEYWORDS: &[&str] = &[
|
||||
"vpn",
|
||||
"proxy",
|
||||
"tunnel",
|
||||
"unblock",
|
||||
"wireguard",
|
||||
"shadowsocks",
|
||||
"socks",
|
||||
];
|
||||
|
||||
/// Matched as a whole token rather than a substring — too short to be safe
|
||||
/// inside other words ("warped", "warpaint").
|
||||
const TOKEN_KEYWORDS: &[&str] = &["warp"];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DetectedVpnExtension {
|
||||
/// Stable acknowledgement identity: `donut:<uuid>` or `crx:<32-char-id>`.
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub version: Option<String>,
|
||||
/// `"donut"` (managed by Donut) or `"browser"` (installed inside the profile).
|
||||
pub source: String,
|
||||
/// `"confirmed"` or `"likely"`.
|
||||
pub confidence: String,
|
||||
/// Why it matched, for the dialog's detail line.
|
||||
pub signals: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ManifestSignals {
|
||||
pub proxy_permission: bool,
|
||||
pub optional_proxy_permission: bool,
|
||||
pub declarative_net_request: bool,
|
||||
pub web_request_blocking: bool,
|
||||
pub broad_host_permissions: bool,
|
||||
}
|
||||
|
||||
fn string_list<'a>(manifest: &'a serde_json::Value, key: &str) -> Vec<&'a str> {
|
||||
manifest
|
||||
.get(key)
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_broad_host(pattern: &str) -> bool {
|
||||
matches!(pattern, "<all_urls>" | "*://*/*")
|
||||
}
|
||||
|
||||
pub fn signals_from_manifest(manifest: &serde_json::Value) -> ManifestSignals {
|
||||
let permissions = string_list(manifest, "permissions");
|
||||
let optional_permissions = string_list(manifest, "optional_permissions");
|
||||
let host_permissions = string_list(manifest, "host_permissions");
|
||||
let optional_host_permissions = string_list(manifest, "optional_host_permissions");
|
||||
|
||||
let has = |list: &[&str], name: &str| list.contains(&name);
|
||||
|
||||
// MV2 keeps host patterns inside `permissions`; MV3 splits them into
|
||||
// `host_permissions`. Look in both so one manifest version isn't silently
|
||||
// under-detected.
|
||||
let all_hosts: Vec<&str> = permissions
|
||||
.iter()
|
||||
.chain(host_permissions.iter())
|
||||
.chain(optional_host_permissions.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
let broad = all_hosts.iter().any(|p| is_broad_host(p))
|
||||
|| (all_hosts.contains(&"http://*/*") && all_hosts.contains(&"https://*/*"));
|
||||
|
||||
ManifestSignals {
|
||||
proxy_permission: has(&permissions, "proxy"),
|
||||
optional_proxy_permission: has(&optional_permissions, "proxy"),
|
||||
declarative_net_request: has(&permissions, "declarativeNetRequest")
|
||||
|| has(&permissions, "declarativeNetRequestWithHostAccess"),
|
||||
web_request_blocking: has(&permissions, "webRequest")
|
||||
&& has(&permissions, "webRequestBlocking"),
|
||||
broad_host_permissions: broad,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keyword_hit(name: &str, description: Option<&str>) -> bool {
|
||||
let mut haystack = name.to_lowercase();
|
||||
if let Some(d) = description {
|
||||
haystack.push(' ');
|
||||
haystack.push_str(&d.to_lowercase());
|
||||
}
|
||||
if KEYWORDS.iter().any(|k| haystack.contains(k)) {
|
||||
return true;
|
||||
}
|
||||
haystack
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.any(|token| TOKEN_KEYWORDS.contains(&token))
|
||||
}
|
||||
|
||||
/// Classify an extension from its manifest signals.
|
||||
///
|
||||
/// The `proxy` permission is the only signal that *proves* the capability: it
|
||||
/// is what Chromium requires to call `chrome.proxy`, and it stays in
|
||||
/// `permissions` under both manifest versions because it is an API permission,
|
||||
/// not a host pattern.
|
||||
///
|
||||
/// The request-blocking tier additionally requires a keyword, and that
|
||||
/// corroboration is not optional: `declarativeNetRequest` plus `<all_urls>`
|
||||
/// describes every content blocker in the ecosystem, so without it the warning
|
||||
/// fires on uBlock Origin — which would teach users to dismiss the dialog on
|
||||
/// sight, destroying the value of the mismatch block that shares it.
|
||||
pub fn classify(signals: &ManifestSignals, keyword: bool) -> Option<&'static str> {
|
||||
if signals.proxy_permission {
|
||||
return Some("confirmed");
|
||||
}
|
||||
if signals.optional_proxy_permission {
|
||||
return Some("likely");
|
||||
}
|
||||
if (signals.declarative_net_request || signals.web_request_blocking)
|
||||
&& signals.broad_host_permissions
|
||||
&& keyword
|
||||
{
|
||||
return Some("likely");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn signal_labels(signals: &ManifestSignals, keyword: bool) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if signals.proxy_permission {
|
||||
out.push("permissions:proxy".to_string());
|
||||
}
|
||||
if signals.optional_proxy_permission {
|
||||
out.push("optionalPermissions:proxy".to_string());
|
||||
}
|
||||
if signals.declarative_net_request {
|
||||
out.push("declarativeNetRequest".to_string());
|
||||
}
|
||||
if signals.web_request_blocking {
|
||||
out.push("webRequestBlocking".to_string());
|
||||
}
|
||||
if signals.broad_host_permissions {
|
||||
out.push("broadHostPermissions".to_string());
|
||||
}
|
||||
if keyword {
|
||||
out.push("keyword".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `__MSG_someKey__` -> `someKey`.
|
||||
pub fn message_placeholder_key(value: &str) -> Option<String> {
|
||||
value
|
||||
.strip_prefix("__MSG_")
|
||||
.and_then(|rest| rest.strip_suffix("__"))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Chromium's `messages.json` shape: `{ "key": { "message": "..." } }`, with
|
||||
/// keys compared case-insensitively.
|
||||
pub fn lookup_message(messages: &serde_json::Value, key: &str) -> Option<String> {
|
||||
let obj = messages.as_object()?;
|
||||
obj
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||
.and_then(|(_, v)| v.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn manifest_str(manifest: &serde_json::Value, key: &str) -> Option<String> {
|
||||
manifest
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Sort key for an extension version directory (`1.10.0_0`), compared
|
||||
/// numerically so `1.10.0` sorts above `1.9.0` where a lexicographic compare
|
||||
/// would put it below.
|
||||
pub fn version_dir_sort_key(name: &str) -> Vec<u64> {
|
||||
name
|
||||
.split(['.', '_'])
|
||||
.map(|part| part.parse::<u64>().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn signals_of(manifest: serde_json::Value) -> ManifestSignals {
|
||||
signals_from_manifest(&manifest)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_confirms_on_proxy_permission() {
|
||||
let s = signals_of(json!({ "permissions": ["proxy", "storage"] }));
|
||||
assert!(s.proxy_permission);
|
||||
assert_eq!(classify(&s, false), Some("confirmed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_confirms_proxy_permission_in_mv2() {
|
||||
// `proxy` is an API permission, so MV3's host_permissions split does not
|
||||
// move it — the same key works for both manifest versions.
|
||||
let s = signals_of(json!({
|
||||
"manifest_version": 2,
|
||||
"permissions": ["proxy", "<all_urls>", "webRequest"]
|
||||
}));
|
||||
assert_eq!(classify(&s, false), Some("confirmed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_likely_on_optional_proxy() {
|
||||
let s = signals_of(json!({ "optional_permissions": ["proxy"] }));
|
||||
assert_eq!(classify(&s, false), Some("likely"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_ignores_content_blocker() {
|
||||
// The regression guard: a content blocker declares exactly these and is
|
||||
// not a VPN. Firing here would train users to dismiss the dialog.
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["<all_urls>"]
|
||||
}));
|
||||
assert!(s.declarative_net_request && s.broad_host_permissions);
|
||||
assert_eq!(classify(&s, keyword_hit("uBlock Origin", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_likely_on_dnr_plus_keyword() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["<all_urls>"]
|
||||
}));
|
||||
assert_eq!(
|
||||
classify(&s, keyword_hit("Free VPN Proxy", None)),
|
||||
Some("likely")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_ignores_keyword_only() {
|
||||
// A name alone proves nothing; without a capability signal this is noise.
|
||||
let s = signals_of(json!({ "permissions": ["storage"] }));
|
||||
assert_eq!(classify(&s, keyword_hit("VPN Deals Finder", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_requires_broad_hosts_for_the_blocking_tier() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["https://example.com/*"]
|
||||
}));
|
||||
assert_eq!(classify(&s, keyword_hit("Some VPN", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_hosts_detected_from_split_http_and_https() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["webRequest", "webRequestBlocking"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"]
|
||||
}));
|
||||
assert!(s.broad_host_permissions);
|
||||
assert!(s.web_request_blocking);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_hosts_detected_from_mv2_permissions_array() {
|
||||
// MV2 puts host patterns in `permissions`; the split-out key is absent.
|
||||
let s = signals_of(json!({
|
||||
"manifest_version": 2,
|
||||
"permissions": ["webRequest", "webRequestBlocking", "<all_urls>"]
|
||||
}));
|
||||
assert!(s.broad_host_permissions);
|
||||
assert_eq!(classify(&s, keyword_hit("Turbo VPN", None)), Some("likely"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_matching_is_substring_but_token_bound_for_short_terms() {
|
||||
assert!(keyword_hit("TouchVPN", None));
|
||||
assert!(keyword_hit("Unblock Sites", None));
|
||||
assert!(keyword_hit("Cloudflare WARP", None));
|
||||
// "warp" only matches as a whole token, so this must not hit.
|
||||
assert!(!keyword_hit("Time Warped Clock", None));
|
||||
assert!(keyword_hit(
|
||||
"Anything",
|
||||
Some("a fast tunnel for your browser")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_placeholder_round_trip() {
|
||||
assert_eq!(
|
||||
message_placeholder_key("__MSG_appName__").as_deref(),
|
||||
Some("appName")
|
||||
);
|
||||
assert_eq!(message_placeholder_key("Plain Name"), None);
|
||||
let messages = json!({ "appName": { "message": "Nord VPN" } });
|
||||
assert_eq!(
|
||||
lookup_message(&messages, "appName").as_deref(),
|
||||
Some("Nord VPN")
|
||||
);
|
||||
// Chromium compares message keys case-insensitively.
|
||||
assert_eq!(
|
||||
lookup_message(&messages, "APPNAME").as_deref(),
|
||||
Some("Nord VPN")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_dirs_sort_numerically_not_lexicographically() {
|
||||
let mut dirs = ["1.9.0_0", "1.10.0_0", "1.2.0_0"];
|
||||
dirs.sort_by_key(|d| version_dir_sort_key(d));
|
||||
assert_eq!(dirs.last(), Some(&"1.10.0_0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_manifest_yields_no_signals() {
|
||||
// Arrays of non-strings, wrong types, and missing keys must not panic.
|
||||
let s = signals_of(json!({ "permissions": [1, 2, {"a": "b"}], "host_permissions": "nope" }));
|
||||
assert_eq!(s, ManifestSignals::default());
|
||||
assert_eq!(classify(&s, true), None);
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,50 @@ async fn wait_for_vpn_worker_ready(
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes worker startup for a given VPN, so two concurrent launches cannot
|
||||
/// both observe "no worker" and both believe they created it. Benign until a
|
||||
/// launch guard may stop one on failure; then double-ownership means a
|
||||
/// cancelled launch tears down a tunnel another profile is using. Mirrors
|
||||
/// `xray_worker_runner::XRAY_START_LOCK`.
|
||||
static VPN_START_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// A started VPN worker plus whether *this* call spawned it.
|
||||
pub struct VpnWorkerStart {
|
||||
pub config: VpnWorkerConfig,
|
||||
/// False when an already-running worker was adopted. Only the creator may
|
||||
/// stop it while unwinding a failed launch.
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// Whether any profile with a live browser process is routing through this VPN.
|
||||
///
|
||||
/// Extracted from the startup sweep so the launch guard and the sweep agree on
|
||||
/// what "in use" means instead of each carrying its own copy.
|
||||
pub fn vpn_id_in_use_by_running_browser(vpn_id: &str) -> bool {
|
||||
let Ok(profiles) = crate::profile::ProfileManager::instance().list_profiles() else {
|
||||
// Unable to tell — assume in use rather than tear down a live tunnel.
|
||||
return true;
|
||||
};
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|p| p.process_id.is_some_and(is_process_running))
|
||||
.any(|p| p.vpn_id.as_deref() == Some(vpn_id))
|
||||
}
|
||||
|
||||
/// Hold the start lock across an adopt-sensitive section (a launch guard
|
||||
/// deciding whether to stop a worker it created).
|
||||
pub async fn lock_vpn_starts() -> tokio::sync::MutexGuard<'static, ()> {
|
||||
VPN_START_LOCK.lock().await
|
||||
}
|
||||
|
||||
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
|
||||
start_vpn_worker_tracked(vpn_id).await.map(|s| s.config)
|
||||
}
|
||||
|
||||
pub async fn start_vpn_worker_tracked(
|
||||
vpn_id: &str,
|
||||
) -> Result<VpnWorkerStart, Box<dyn std::error::Error>> {
|
||||
let _start_guard = VPN_START_LOCK.lock().await;
|
||||
crate::proxy_runner::ensure_sidecar_version().await?;
|
||||
|
||||
for config in list_vpn_worker_configs() {
|
||||
@@ -117,10 +160,18 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
if let Some(pid) = existing.pid {
|
||||
if is_process_running(pid) {
|
||||
if vpn_worker_accepting_connections(&existing).await {
|
||||
return Ok(existing);
|
||||
return Ok(VpnWorkerStart {
|
||||
config: existing,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
return wait_for_vpn_worker_ready(&existing.id).await;
|
||||
return wait_for_vpn_worker_ready(&existing.id)
|
||||
.await
|
||||
.map(|config| VpnWorkerStart {
|
||||
config,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Worker config exists but process is dead, clean up
|
||||
@@ -263,7 +314,12 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
drop(child);
|
||||
}
|
||||
|
||||
wait_for_vpn_worker_ready(&id).await
|
||||
wait_for_vpn_worker_ready(&id)
|
||||
.await
|
||||
.map(|config| VpnWorkerStart {
|
||||
config,
|
||||
created: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
|
||||
Reference in New Issue
Block a user