refactor: cleanup

This commit is contained in:
zhom
2026-06-07 17:45:47 +04:00
parent 6b31c937ea
commit 15f3aa03f7
31 changed files with 762 additions and 252 deletions
+118 -6
View File
@@ -58,13 +58,25 @@ pub struct ApiProfileResponse {
pub struct CreateProfileRequest {
pub name: String,
pub browser: String,
pub version: String,
/// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded
/// version of the chosen browser. A concrete version must already be
/// downloaded; the create path does not fetch new versions.
#[serde(default)]
pub version: Option<String>,
pub proxy_id: Option<String>,
pub vpn_id: Option<String>,
pub launch_hook: Option<String>,
pub release_type: Option<String>,
/// Camoufox fingerprint/config. Send only when `browser` is `"camoufox"`.
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
/// generated automatically at creation. Provide a `fingerprint` field to
/// pin a specific one.
#[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>,
/// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`.
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
/// generated automatically at creation. Provide a `fingerprint` field to
/// pin a specific one.
#[schema(value_type = Object)]
pub wayfern_config: Option<serde_json::Value>,
pub group_id: Option<String>,
@@ -74,7 +86,9 @@ pub struct CreateProfileRequest {
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateProfileRequest {
pub name: Option<String>,
pub browser: Option<String>,
// No `browser` field: a profile's engine is fixed at creation (changing it
// would invalidate the generated fingerprint and on-disk profile dir).
// Accepting it here only to silently ignore it misled API clients.
pub version: Option<String>,
pub proxy_id: Option<String>,
pub vpn_id: Option<String>,
@@ -508,8 +522,14 @@ async fn auth_middleware(
}
};
// Compare tokens
if token != stored_token {
// Constant-time comparison so the auth check doesn't leak the shared-prefix
// length via timing. `ConstantTimeEq` on equal-length byte slices; differing
// lengths simply compare unequal.
use subtle::ConstantTimeEq;
let token_bytes = token.as_bytes();
let stored_bytes = stored_token.as_bytes();
let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into();
if !matches {
log::warn!("[api] Rejected {path}: token mismatch");
return Err(StatusCode::UNAUTHORIZED);
}
@@ -694,14 +714,24 @@ async fn get_profile(
}
}
/// Create a profile.
///
/// - `browser` must be `"wayfern"` or `"camoufox"`; any other value is rejected
/// with 400.
/// - `version` is optional: omit it or pass `"latest"` to use the newest
/// already-downloaded version of that browser. The version must be present
/// locally (this endpoint does not download new versions); 400 if none is.
/// - Omitting the matching `wayfern_config`/`camoufox_config`, or passing an
/// empty object `{}`, generates a fresh fingerprint automatically.
#[utoipa::path(
post,
path = "/v1/profiles",
request_body = CreateProfileRequest,
responses(
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
(status = 400, description = "Bad request"),
(status = 400, description = "Invalid browser, or no downloaded version available"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Selected proxy requires payment"),
(status = 500, description = "Internal server error")
),
security(
@@ -715,6 +745,34 @@ async fn create_profile(
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
// Only Wayfern and Camoufox profiles are launchable; the rest of the system
// (fingerprint generation, launch, run) supports nothing else. Reject anything
// else up front — otherwise the profile is created with no fingerprint and an
// unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP
// create_profile validation.
if request.browser != "wayfern" && request.browser != "camoufox" {
return Err(StatusCode::BAD_REQUEST);
}
// Resolve the version. Omitted, empty, or "latest" means "newest version
// already downloaded for this browser". The create path generates the
// fingerprint by launching that binary, so the version must be present
// locally — we don't fetch new versions here. 400 if none is downloaded.
let version = match request.version.as_deref() {
Some(v) if !v.is_empty() && v != "latest" => v.to_string(),
_ => {
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
let mut versions = registry.get_downloaded_versions(&request.browser);
// browsers is a HashMap, so keys are unordered — sort newest-first by
// semver before taking the latest.
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
match versions.into_iter().next() {
Some(v) => v,
None => return Err(StatusCode::BAD_REQUEST),
}
}
};
// Parse camoufox config if provided
let camoufox_config = if let Some(config) = &request.camoufox_config {
serde_json::from_value(config.clone()).ok()
@@ -747,7 +805,7 @@ async fn create_profile(
&state.app_handle,
&request.name,
&request.browser,
&request.version,
&version,
request.release_type.as_deref().unwrap_or("stable"),
request.proxy_id.clone(),
request.vpn_id.clone(),
@@ -2090,3 +2148,57 @@ async fn refresh_wayfern_token(
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
Ok(Json(WayfernTokenResponse { token }))
}
#[cfg(test)]
mod tests {
use super::*;
// Removing `browser` from UpdateProfileRequest, and rejecting invalid
// `browser` values on create, must NOT make the API reject requests that
// carry extra/unknown fields — old clients still send them. serde ignores
// unknown fields by default; these tests lock that in so a future
// `#[serde(deny_unknown_fields)]` can't silently break compatibility.
#[test]
fn update_profile_request_ignores_unknown_fields() {
// `browser` is no longer a field, plus a wholly unknown field. Both must
// be accepted and ignored, not rejected.
let json = r#"{"name": "p", "browser": "wayfern", "totally_unknown": 123}"#;
let parsed: UpdateProfileRequest =
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
assert_eq!(parsed.name.as_deref(), Some("p"));
}
#[test]
fn create_profile_request_ignores_unknown_fields() {
let json = r#"{"name": "p", "browser": "wayfern", "version": "latest", "future_field": true}"#;
let parsed: CreateProfileRequest =
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
assert_eq!(parsed.browser, "wayfern");
}
#[test]
fn create_profile_request_allows_omitting_version_and_configs() {
// Minimal body: no version, no wayfern_config/camoufox_config. Must
// deserialize (version resolves to latest-downloaded at the handler; an
// absent config triggers fresh-fingerprint generation).
let json = r#"{"name": "p", "browser": "wayfern"}"#;
let parsed: CreateProfileRequest =
serde_json::from_str(json).expect("version and configs are optional");
assert_eq!(parsed.browser, "wayfern");
assert!(parsed.version.is_none());
assert!(parsed.wayfern_config.is_none());
assert!(parsed.camoufox_config.is_none());
}
#[test]
fn create_profile_browser_validation_matches_supported_engines() {
// The handler rejects anything that isn't a launchable engine; this is the
// same predicate it uses, kept in lockstep with MCP's create_profile.
let is_valid = |b: &str| b == "wayfern" || b == "camoufox";
assert!(is_valid("wayfern"));
assert!(is_valid("camoufox"));
assert!(!is_valid("chromium"));
assert!(!is_valid("firefox"));
assert!(!is_valid(""));
}
}
+26 -2
View File
@@ -1016,7 +1016,7 @@ impl CloudAuthManager {
return Ok(());
}
let token = self
let result = self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
// Bound the request: without a timeout, an unreachable
@@ -1050,7 +1050,31 @@ impl CloudAuthManager {
Ok(result.token)
}
})
.await?;
.await;
let token = match result {
Ok(token) => token,
Err(e) => {
// The backend returns 403 (ForbiddenException) for paid-feature blocks:
// token-reuse throttle, "active subscription required", and the
// primary-device restriction (see donutbrowser-infra wayfern.service.ts).
// This is distinct from a 401 (dead access token) — the session is still
// valid, the user is just temporarily/conditionally not entitled. So we
// do NOT invalidate the session. Instead: drop the stale wayfern token so
// no browser launches half-authenticated, re-fetch the profile so the
// cached plan reflects the backend's real state (it may have changed),
// and signal the UI so the user learns why automation stopped working.
if e.contains("(403") || e.contains("Forbidden") {
log::warn!("Wayfern token blocked by backend (403): {e}");
self.clear_wayfern_token().await;
if let Err(fetch_err) = self.fetch_profile().await {
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
}
let _ = crate::events::emit_empty("wayfern-paid-blocked");
}
return Err(e);
}
};
let mut wt = self.wayfern_token.lock().await;
*wt = Some(token);
+23 -7
View File
@@ -339,8 +339,16 @@ impl McpServer {
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "));
let valid =
path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str());
// Constant-time comparison to avoid leaking the token prefix via timing.
use subtle::ConstantTimeEq;
let expected = state.token.as_bytes();
let ct_eq = |t: Option<&str>| {
t.is_some_and(|t| {
let b = t.as_bytes();
b.len() == expected.len() && b.ct_eq(expected).into()
})
};
let valid = ct_eq(path_token) || ct_eq(header_token);
if !valid {
return Err(StatusCode::UNAUTHORIZED);
@@ -1671,11 +1679,10 @@ impl McpServer {
"connect_vpn" => self.handle_connect_vpn(arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
// Fingerprint management — viewing and editing both require a paid plan.
"get_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
self.handle_get_profile_fingerprint(arguments).await
}
// Fingerprint management — viewing is free everywhere (matches the REST
// API and the get_profile tool, which already expose the config); only
// editing requires a paid plan.
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
"update_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
self.handle_update_profile_fingerprint(arguments).await
@@ -2592,6 +2599,15 @@ impl McpServer {
message: "Missing proxy_type".to_string(),
})?;
// The tool schema declares an enum, but JSON-Schema enums are advisory only;
// enforce it here so a bad value can't produce a non-functional proxy.
if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5") {
return Err(McpError {
code: -32602,
message: "proxy_type must be one of: http, https, socks4, socks5".to_string(),
});
}
let host = arguments
.get("host")
.and_then(|v| v.as_str())
+34 -19
View File
@@ -3,6 +3,38 @@ use crate::profile::BrowserProfile;
use std::path::Path;
use std::process::Command;
/// True if a process command line refers to `profile_path` as a real browser
/// profile/data-dir argument, NOT merely a substring. A bare `contains` match
/// force-killed unrelated processes that happened to mention the path (editors,
/// `tail`, a terminal that `cd`'d there, or another profile whose path has this
/// one as a prefix). Mirrors the precise matching in browser_runner/wayfern_manager.
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
for (i, arg) in args.iter().enumerate() {
// Exact argument equality (Firefox/Camoufox: `-profile <path>`; some launchers
// pass the path as its own arg).
if *arg == profile_path {
return true;
}
// `--user-data-dir=<path>` (Chromium/Wayfern) or `-profile=<path>`.
if let Some(val) = arg
.strip_prefix("--user-data-dir=")
.or_else(|| arg.strip_prefix("-profile="))
{
if val == profile_path {
return true;
}
}
// Flag followed by the path as the next argument.
if (*arg == "-profile" || *arg == "--user-data-dir")
&& args.get(i + 1).is_some_and(|next| *next == profile_path)
{
return true;
}
}
false
}
// Platform-specific modules
#[cfg(target_os = "macos")]
#[allow(dead_code)]
@@ -215,16 +247,7 @@ pub mod macos {
continue;
}
// Check if any command line argument contains the profile path
let has_profile = cmd.iter().any(|arg| {
if let Some(arg_str) = arg.to_str() {
arg_str.contains(profile_path)
} else {
false
}
});
if has_profile {
if cmd_matches_profile_path(cmd, profile_path) {
pids.push(pid.as_u32());
}
}
@@ -832,15 +855,7 @@ pub mod linux {
continue;
}
let has_profile = cmd.iter().any(|arg| {
if let Some(arg_str) = arg.to_str() {
arg_str.contains(profile_path)
} else {
false
}
});
if has_profile {
if cmd_matches_profile_path(cmd, profile_path) {
pids.push(pid.as_u32());
}
}
+16 -1
View File
@@ -1035,7 +1035,7 @@ impl ProfileManager {
fs::create_dir_all(&dest_dir)?;
}
let new_profile = BrowserProfile {
let mut new_profile = BrowserProfile {
id: new_id,
name: clone_name,
browser: source.browser,
@@ -1071,6 +1071,21 @@ impl ProfileManager {
updated_at: Some(crate::proxy_manager::now_secs()),
};
// Donut: a clone must NOT be linkable to its source. The source
// wayfern_config embeds the persisted fingerprint JSON (including the
// canvas_noise_seed), so copying it verbatim makes the clone emit
// BYTE-IDENTICAL canvas/WebGL/audio readback hashes and identical device
// signals as the source — trivially linkable if both run concurrently. Clear
// the fingerprint so the launch path mints a fresh one (a new
// canvas_noise_seed via RandBytes + an independent device fingerprint),
// exactly as create_profile does when fingerprint.is_none(). NOTE: the
// user-data-dir copy above still duplicates cookies/localStorage/TLS state —
// a separate storage-linkage vector the user must clear if they want full
// isolation between a clone and its source.
if let Some(cfg) = new_profile.wayfern_config.as_mut() {
cfg.fingerprint = None;
}
self.save_profile(&new_profile)?;
if let Err(e) = events::emit_empty("profiles-changed") {
+33 -11
View File
@@ -774,6 +774,17 @@ impl ProxyManager {
list
}
/// Insert/replace a stored proxy in the in-memory map. Used by sync's
/// download_proxy after it writes the file to disk, mirroring how
/// download_group/download_vpn/download_extension keep their managers'
/// in-memory state in sync. Without this, get_stored_proxies (which reads
/// only the map) never sees a downloaded proxy until restart, so sync keeps
/// re-downloading it indefinitely.
pub fn upsert_stored_proxy(&self, proxy: StoredProxy) {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.insert(proxy.id.clone(), proxy);
}
// Get a stored proxy by ID
// Update a stored proxy
@@ -1730,12 +1741,18 @@ impl ProxyManager {
.arg("--id")
.arg(&proxy_id);
let output = proxy_cmd.output().await.unwrap();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("Proxy stop error: {stderr}");
// We still return Ok since we've already removed the proxy from our tracking
// A failed spawn (sidecar missing, permission denied, fd exhaustion) must
// not panic the cleanup task — the proxy is already removed from tracking,
// so degrade gracefully like the non-success branch below.
match proxy_cmd.output().await {
Ok(output) if !output.status.success() => {
log::warn!(
"Proxy stop error: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(_) => {}
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
}
// Clear profile-to-proxy mapping if it references this proxy
@@ -1795,11 +1812,16 @@ impl ProxyManager {
.arg("--id")
.arg(&proxy_id);
let output = proxy_cmd.output().await.unwrap();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("Proxy stop error: {stderr}");
// Don't panic if the sidecar can't be spawned — still clear the mapping.
match proxy_cmd.output().await {
Ok(output) if !output.status.success() => {
log::warn!(
"Proxy stop error: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(_) => {}
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
}
// Clear profile-to-proxy mapping
+25 -41
View File
@@ -509,47 +509,20 @@ async fn handle_http_via_socks4(
}
};
// Resolve target host to IP (SOCKS4 requires IP addresses)
let target_ip = match tokio::net::lookup_host((target_host, target_port)).await {
Ok(mut addrs) => {
if let Some(addr) = addrs.next() {
match addr.ip() {
std::net::IpAddr::V4(ipv4) => ipv4.octets(),
std::net::IpAddr::V6(_) => {
log::error!("SOCKS4 does not support IPv6");
let mut response = Response::new(Full::new(Bytes::from(
"SOCKS4 does not support IPv6 addresses",
)));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
}
} else {
log::error!("Failed to resolve target host: {}", target_host);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to resolve target host: {}",
target_host
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
}
Err(e) => {
log::error!("Failed to resolve target host {}: {}", target_host, e);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to resolve target host: {}",
e
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
};
// Build SOCKS4 CONNECT request
// Build a SOCKS4a CONNECT request. We deliberately do NOT resolve the target
// hostname locally: tokio::net::lookup_host would call the HOST resolver
// (getaddrinfo), leaking the destination domain to the host's DNS server and
// defeating the per-profile proxy. SOCKS4a has the PROXY resolve the name —
// send the sentinel IP 0.0.0.x (x != 0), then the NULL-terminated userid, then
// the NULL-terminated hostname. (Most SOCKS4 proxies support 4a; a legacy
// SOCKS4-only proxy without remote DNS cannot be used leak-free for plaintext
// HTTP — prefer SOCKS5 there.)
let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT
socks_request.extend_from_slice(&target_port.to_be_bytes());
socks_request.extend_from_slice(&target_ip);
socks_request.push(0); // NULL terminator for userid
socks_request.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 => SOCKS4a remote-DNS marker
socks_request.push(0); // empty userid, NULL-terminated
socks_request.extend_from_slice(target_host.as_bytes()); // hostname for the proxy to resolve
socks_request.push(0); // NULL-terminated hostname
// Send SOCKS4 CONNECT request
if let Err(e) = socks_stream.write_all(&socks_request).await {
@@ -1071,8 +1044,19 @@ fn build_reqwest_client_with_proxy(
Proxy::http(upstream_url)?
}
"socks5" => {
// For SOCKS5, reqwest supports it directly
Proxy::all(upstream_url)?
// Donut: force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5
// upstream. reqwest maps the bare `socks5` scheme to DnsResolve::Local,
// which resolves the destination hostname on the HOST (getaddrinfo) BEFORE
// connecting — leaking the destination domain to the host's DNS resolver
// and defeating the per-profile proxy. The `socks5h` scheme maps to
// DnsResolve::Proxy, so the proxy resolves the hostname and nothing leaks.
// (The CONNECT/HTTPS path already does remote DNS via connect_via_socks's
// AddrKind::Domain.)
let remote_dns_url = match upstream_url.strip_prefix("socks5://") {
Some(rest) => format!("socks5h://{rest}"),
None => upstream_url.to_string(),
};
Proxy::all(remote_dns_url)?
}
"socks4" => {
// SOCKS4 is handled manually in handle_http_via_socks4
+7
View File
@@ -1597,6 +1597,13 @@ impl SyncEngine {
))
})?;
// Keep the in-memory cache in sync with disk. Without this, get_stored_proxies
// (which reads only the in-memory map) never sees the downloaded proxy until
// restart, so check_for_missing_synced_entities/sync_proxy treat it as
// missing every pass and re-download it forever. Mirrors download_group/
// download_vpn/download_extension.
proxy_manager.upsert_stored_proxy(proxy.clone());
// Emit event for UI update
if let Some(_handle) = app_handle {
let _ = events::emit("stored-proxies-changed", ());
+6 -1
View File
@@ -651,7 +651,12 @@ impl WayfernManager {
"--disable-session-crashed-bubble".to_string(),
"--hide-crash-restore-bubble".to_string(),
"--disable-infobars".to_string(),
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns".to_string(),
// Prefetch* / NoStatePrefetch: cross-site Speculation-Rules prefetch uses
// an isolated NetworkContext that defaults to DIRECT egress (real host IP
// leaks past the per-profile proxy). Disabling via a LAUNCH FLAG cannot be
// re-enabled by an imported/synced network_prediction_options pref (which a
// compile-time pref default could be).
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns,Prefetch,PrefetchProxy,SpeculationRulesPrefetchFuture,NoStatePrefetch".to_string(),
"--use-mock-keychain".to_string(),
"--password-store=basic".to_string(),
];