diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 2dc89ec..63584d6 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -40,6 +40,49 @@ pub struct ApiProfile { pub proxy_bypass_rules: Vec, pub vpn_id: Option, pub clear_on_close: bool, + /// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`. + /// Settable via `PUT /v1/profiles/{id}`; exposed here so a caller can read + /// back what it set, and so a remote-launch caller can tell whether the + /// profile is actually available in cloud storage. + pub sync_mode: String, + /// Convenience form of `sync_mode` — true for Regular or Encrypted. + pub cloud_sync_enabled: bool, + /// OS the profile was created on (`"macos"`, `"windows"`, `"linux"`). + /// `null` when neither `host_os` nor the browser config records one. + pub host_os: Option, + /// True when the profile belongs to a different OS than this machine. + /// Such a profile cannot be launched locally, and must only ever run on a + /// remote host of its own OS — Chromium profile state is OS-specific. + pub is_cross_os: bool, +} + +impl From<&crate::profile::types::BrowserProfile> for ApiProfile { + /// Single conversion for every profile-returning route. Previously open-coded + /// at three call sites, which is how `sync_mode` came to be settable but not + /// readable: a field added to the struct had to be remembered three times. + fn from(profile: &crate::profile::types::BrowserProfile) -> Self { + Self { + id: profile.id.to_string(), + name: profile.name.clone(), + browser: profile.browser.clone(), + version: profile.version.clone(), + proxy_id: profile.proxy_id.clone(), + launch_hook: profile.launch_hook.clone(), + process_id: profile.process_id, + last_launch: profile.last_launch, + release_type: profile.release_type.clone(), + group_id: profile.group_id.clone(), + tags: profile.tags.clone(), + is_running: profile.process_id.is_some(), + proxy_bypass_rules: profile.proxy_bypass_rules.clone(), + vpn_id: profile.vpn_id.clone(), + clear_on_close: profile.clear_on_close, + sync_mode: format!("{:?}", profile.sync_mode), + cloud_sync_enabled: profile.is_sync_enabled(), + host_os: profile.resolved_os().map(|os| os.to_string()), + is_cross_os: profile.is_cross_os(), + } + } } #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -215,6 +258,22 @@ struct RunProfileResponse { headless: bool, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct RunRemoteRequest { + /// Optional URL to open once the remote browser is up. + pub url: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct RunRemoteResponse { + pub profile_id: String, + /// Remote session id, for polling or closing the session. + pub session_id: String, + /// Operating system the session was scheduled onto — always the profile's own. + pub platform: String, + pub status: String, +} + #[derive(Debug, Deserialize, ToSchema)] struct RunProfileRequest { url: Option, @@ -395,6 +454,8 @@ struct ImportProxiesResponse { DownloadBrowserRequest, DownloadBrowserResponse, RunProfileResponse, + RunRemoteRequest, + RunRemoteResponse, RunProfileRequest, BatchRunRequest, BatchRunResult, @@ -511,6 +572,7 @@ impl ApiServer { .routes(routes!(get_profiles, create_profile)) .routes(routes!(get_profile, update_profile, delete_profile)) .routes(routes!(run_profile)) + .routes(routes!(run_profile_remote)) .routes(routes!(open_url_in_profile)) .routes(routes!(kill_profile)) .routes(routes!(batch_run_profiles)) @@ -860,26 +922,7 @@ async fn get_profiles() -> Result, StatusCode> { let profile_manager = ProfileManager::instance(); match profile_manager.list_profiles() { Ok(profiles) => { - let api_profiles: Vec = profiles - .iter() - .map(|profile| ApiProfile { - id: profile.id.to_string(), - name: profile.name.clone(), - browser: profile.browser.clone(), - version: profile.version.clone(), - proxy_id: profile.proxy_id.clone(), - launch_hook: profile.launch_hook.clone(), - process_id: profile.process_id, - last_launch: profile.last_launch, - release_type: profile.release_type.clone(), - group_id: profile.group_id.clone(), - tags: profile.tags.clone(), - is_running: profile.process_id.is_some(), // Simple check based on process_id - proxy_bypass_rules: profile.proxy_bypass_rules.clone(), - vpn_id: profile.vpn_id.clone(), - clear_on_close: profile.clear_on_close, - }) - .collect(); + let api_profiles: Vec = profiles.iter().map(ApiProfile::from).collect(); Ok(Json(ApiProfilesResponse { profiles: api_profiles, @@ -916,23 +959,7 @@ async fn get_profile( Ok(profiles) => { if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) { Ok(Json(ApiProfileResponse { - profile: ApiProfile { - id: profile.id.to_string(), - name: profile.name.clone(), - browser: profile.browser.clone(), - version: profile.version.clone(), - proxy_id: profile.proxy_id.clone(), - launch_hook: profile.launch_hook.clone(), - process_id: profile.process_id, - last_launch: profile.last_launch, - release_type: profile.release_type.clone(), - group_id: profile.group_id.clone(), - tags: profile.tags.clone(), - is_running: profile.process_id.is_some(), // Simple check based on process_id - proxy_bypass_rules: profile.proxy_bypass_rules.clone(), - vpn_id: profile.vpn_id.clone(), - clear_on_close: profile.clear_on_close, - }, + profile: ApiProfile::from(profile), })) } else { Err(StatusCode::NOT_FOUND) @@ -1081,23 +1108,7 @@ async fn create_profile( } Ok(Json(ApiProfileResponse { - profile: ApiProfile { - id: profile.id.to_string(), - name: profile.name, - browser: profile.browser, - version: profile.version, - proxy_id: profile.proxy_id, - launch_hook: profile.launch_hook, - process_id: profile.process_id, - last_launch: profile.last_launch, - release_type: profile.release_type, - group_id: profile.group_id, - tags: profile.tags, - is_running: false, - proxy_bypass_rules: profile.proxy_bypass_rules, - vpn_id: profile.vpn_id, - clear_on_close: profile.clear_on_close, - }, + profile: ApiProfile::from(&profile), })) } Err(e) => Err(( @@ -2141,6 +2152,111 @@ async fn run_profile( } } +// API Handler - Launch this profile on a REMOTE VM of its own operating system +#[utoipa::path( + post, + path = "/v1/profiles/{id}/run-remote", + params( + ("id" = String, Path, description = "Profile ID") + ), + request_body = RunRemoteRequest, + responses( + (status = 200, description = "Remote session started", body = RunRemoteResponse), + (status = 400, description = "Profile does not have cloud sync enabled"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 404, description = "Profile not found"), + (status = 409, description = "Profile is locked by another session"), + (status = 429, description = "Automation request rate limit exceeded"), + (status = 503, description = "No remote capacity for this operating system"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "profiles" +)] +async fn run_profile_remote( + Path(id): Path, + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, String)> { + if !crate::cloud_auth::CLOUD_AUTH + .can_use_browser_automation() + .await + { + return Err((StatusCode::PAYMENT_REQUIRED, String::new())); + } + + let profile_manager = ProfileManager::instance(); + let profiles = profile_manager + .list_profiles() + .map_err(manager_error_response)?; + let profile = profiles + .iter() + .find(|p| p.id.to_string() == id) + .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; + + // The profile must exist in cloud storage before a remote host can open it — + // the VM pulls it from donut-sync, and a profile that has never synced would + // launch an empty browser and then push that emptiness back over the real one. + if let Err(reason) = remote_launch_precondition(profile) { + return Err((StatusCode::BAD_REQUEST, reason)); + } + + // Deliberately NO is_cross_os() guard here. Local /run refuses a foreign + // profile because this machine is the wrong OS; running it remotely on a host + // of its OWN OS is precisely what this endpoint exists for. + let outcome = + crate::remote_session::start_remote_session(state.app_handle.clone(), profile, request.url) + .await + .map_err(remote_session_error_response)?; + + Ok(Json(RunRemoteResponse { + profile_id: profile.id.to_string(), + session_id: outcome.session_id, + platform: outcome.platform, + status: outcome.status, + })) +} + +/// Whether a profile may be launched on a remote host. +/// +/// Extracted so the rule is unit-testable without a running app: it is the one +/// gate between "the user asked" and "a browser opens somewhere else holding +/// their cookies". +pub fn remote_launch_precondition( + profile: &crate::profile::types::BrowserProfile, +) -> Result<(), String> { + if !profile.is_sync_enabled() { + return Err( + "profile does not have cloud sync enabled; a remote host has no way to \ + obtain it" + .to_string(), + ); + } + if profile.resolved_os().is_none() { + return Err( + "profile has no recorded operating system, so it cannot be scheduled \ + onto a matching host" + .to_string(), + ); + } + Ok(()) +} + +fn remote_session_error_response( + err: crate::remote_session::RemoteSessionError, +) -> (StatusCode, String) { + use crate::remote_session::RemoteSessionError; + match err { + RemoteSessionError::NoCapacity(m) => (StatusCode::SERVICE_UNAVAILABLE, m), + RemoteSessionError::Conflict(m) => (StatusCode::CONFLICT, m), + RemoteSessionError::NotAuthorised(m) => (StatusCode::PAYMENT_REQUIRED, m), + RemoteSessionError::Other(m) => (StatusCode::INTERNAL_SERVER_ERROR, m), + } +} + // API Handler - Open URL in existing browser #[utoipa::path( post, @@ -2660,6 +2776,111 @@ async fn check_browser_downloaded( #[cfg(test)] mod tests { use super::*; + use crate::profile::types::{BrowserProfile, SyncMode}; + + fn profile_with(sync_mode: SyncMode, host_os: Option<&str>) -> BrowserProfile { + BrowserProfile { + id: uuid::Uuid::nil(), + name: "p".to_string(), + browser: "wayfern".to_string(), + version: "latest".to_string(), + sync_mode, + host_os: host_os.map(|s| s.to_string()), + ..Default::default() + } + } + + // Cloud sync has been settable through PUT /v1/profiles/{id} but was absent + // from every profile RESPONSE, so a caller could turn it on and never + // confirm it. A remote-launch caller must be able to see this before it can + // decide whether the profile exists in cloud storage at all. + // /run-remote exists precisely so a profile can run on a host of ITS OWN OS + // when this machine is the wrong one. The gate is cloud sync: a remote host + // obtains the profile from donut-sync, so a profile that has never synced + // would launch an empty browser and push that emptiness over the real one. + #[test] + fn remote_launch_requires_cloud_sync() { + let err = remote_launch_precondition(&profile_with(SyncMode::Disabled, Some("macos"))) + .expect_err("a non-synced profile must be refused"); + assert!(err.contains("cloud sync"), "unhelpful message: {err}"); + + for mode in [SyncMode::Regular, SyncMode::Encrypted] { + assert!( + remote_launch_precondition(&profile_with(mode, Some("macos"))).is_ok(), + "a synced profile must be allowed" + ); + } + } + + #[test] + fn remote_launch_requires_a_known_operating_system() { + // Without one there is no way to pick a matching host, and guessing would + // be the cross-OS mismatch this whole design exists to prevent. + assert!(remote_launch_precondition(&profile_with(SyncMode::Regular, None)).is_err()); + } + + #[test] + fn remote_launch_allows_a_cross_os_profile() { + let host = crate::profile::types::get_host_os(); + let other = if host == "windows" { + "macos" + } else { + "windows" + }; + let foreign = profile_with(SyncMode::Regular, Some(other)); + + assert!( + foreign.is_cross_os(), + "test setup: profile should be foreign" + ); + // Local /run refuses this; running it remotely on a host of its own OS is + // exactly what /run-remote is for. + assert!(remote_launch_precondition(&foreign).is_ok()); + } + + #[test] + fn api_profile_exposes_cloud_sync_state() { + let disabled = ApiProfile::from(&profile_with(SyncMode::Disabled, None)); + assert_eq!(disabled.sync_mode, "Disabled"); + assert!(!disabled.cloud_sync_enabled); + + let regular = ApiProfile::from(&profile_with(SyncMode::Regular, None)); + assert_eq!(regular.sync_mode, "Regular"); + assert!(regular.cloud_sync_enabled); + + let encrypted = ApiProfile::from(&profile_with(SyncMode::Encrypted, None)); + assert_eq!(encrypted.sync_mode, "Encrypted"); + assert!(encrypted.cloud_sync_enabled); + } + + // A profile must only ever run on its own operating system: Chromium's + // on-disk state is OS-specific, so replaying a macOS profile on Windows is a + // mismatch no amount of user-agent spoofing repairs. + #[test] + fn api_profile_reports_its_operating_system() { + let host = crate::profile::types::get_host_os(); + let same = ApiProfile::from(&profile_with(SyncMode::Regular, Some(&host))); + assert_eq!(same.host_os.as_deref(), Some(host.as_str())); + assert!(!same.is_cross_os); + + let other = if host == "windows" { + "macos" + } else { + "windows" + }; + let foreign = ApiProfile::from(&profile_with(SyncMode::Regular, Some(other))); + assert_eq!(foreign.host_os.as_deref(), Some(other)); + assert!(foreign.is_cross_os); + } + + #[test] + fn api_profile_without_a_recorded_os_is_not_cross_os() { + // An older profile that predates host_os must stay locally launchable + // rather than being treated as foreign. + let unknown = ApiProfile::from(&profile_with(SyncMode::Disabled, None)); + assert_eq!(unknown.host_os, None); + assert!(!unknown.is_cross_os); + } // Removing `browser` from UpdateProfileRequest, and rejecting invalid // `browser` values on create, must NOT make the API reject requests that diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 448e5fc..2eaea01 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -72,6 +72,7 @@ mod proxy_manager; pub mod proxy_runner; pub mod proxy_server; pub mod proxy_storage; +mod remote_session; mod settings_manager; pub mod socks5_local; pub mod sync; diff --git a/src-tauri/src/remote_session.rs b/src-tauri/src/remote_session.rs new file mode 100644 index 0000000..e0a7d00 --- /dev/null +++ b/src-tauri/src/remote_session.rs @@ -0,0 +1,232 @@ +//! Launching a profile on a remote VM. +//! +//! The desktop app never talks to the Wayfern manager directly. It asks +//! donutbrowser-infra, which holds the service-account credentials and is the +//! only party that can mint a donut-sync token scoped to this user's namespace. +//! That indirection is the point: a desktop client that could call the manager +//! itself would need credentials capable of launching sessions for anyone. + +use crate::profile::types::BrowserProfile; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +/// Why a remote launch failed, mapped to the status the local API should return. +#[derive(Debug)] +pub enum RemoteSessionError { + /// No host of the profile's OS has a free slot right now. + NoCapacity(String), + /// The profile is already open somewhere — locally or in another session. + Conflict(String), + /// The user's plan does not cover remote automation. + NotAuthorised(String), + Other(String), +} + +impl std::fmt::Display for RemoteSessionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoCapacity(m) | Self::Conflict(m) | Self::NotAuthorised(m) | Self::Other(m) => { + write!(f, "{m}") + } + } + } +} + +/// What the backend returns when a session starts. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteSessionOutcome { + pub session_id: String, + pub platform: String, + pub status: String, +} + +#[derive(Debug, Serialize)] +struct StartRemoteRequest { + profile_id: String, + /// The profile's own OS. The backend refuses to schedule it anywhere else. + platform: String, + /// Set when the caller wants a page opened once the browser is up. + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + /// De-duplicates retries so a flaky network cannot open two browsers against + /// one profile. + idempotency_key: String, +} + +/// Map a backend status onto a typed error. +/// +/// Kept separate from the request so the mapping is testable: getting 503 +/// wrong would turn "come back in a minute" into "something is broken", +/// and getting 409 wrong would hide the fact that the profile is already open. +pub fn classify_backend_status(status: u16, body: &str) -> RemoteSessionError { + let message = if body.is_empty() { + format!("remote session request failed with HTTP {status}") + } else { + body.to_string() + }; + match status { + 503 => RemoteSessionError::NoCapacity(message), + 409 => RemoteSessionError::Conflict(message), + 401..=403 => RemoteSessionError::NotAuthorised(message), + _ => RemoteSessionError::Other(message), + } +} + +/// Build the idempotency key for one launch attempt. +/// +/// Derived from the profile and a caller-supplied nonce rather than random, so +/// a retry of the SAME user action de-duplicates while a genuinely new launch +/// does not. +pub fn idempotency_key(profile_id: &str, nonce: &str) -> String { + format!("run-remote:{profile_id}:{nonce}") +} + +/// Ask donutbrowser-infra to start a remote session for this profile. +/// +/// Goes through `api_call_with_retry` so an expired access token is refreshed +/// and the request retried once, rather than surfacing to the user as a +/// spurious "not signed in". +pub async fn start_remote_session( + _app: AppHandle, + profile: &BrowserProfile, + url: Option, +) -> Result { + let platform = profile + .resolved_os() + .ok_or_else(|| { + RemoteSessionError::Other("profile has no recorded operating system".to_string()) + })? + .to_string(); + let profile_id = profile.id.to_string(); + + // One key for this user action: a retry inside api_call_with_retry must + // de-duplicate rather than open a second browser on the same profile. + let key = idempotency_key(&profile_id, &uuid::Uuid::new_v4().to_string()); + let endpoint = format!("{}/api/remote-sessions", crate::cloud_auth::CLOUD_API_URL); + + crate::cloud_auth::CLOUD_AUTH + .api_call_with_retry(|token| { + let endpoint = endpoint.clone(); + let body = StartRemoteRequest { + profile_id: profile_id.clone(), + platform: platform.clone(), + url: url.clone(), + idempotency_key: key.clone(), + }; + async move { + let response = reqwest::Client::new() + .post(&endpoint) + .bearer_auth(token) + .json(&body) + .send() + .await + .map_err(|e| format!("reach backend: {e}"))?; + + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + let text = response.text().await.unwrap_or_default(); + // Encode the status so api_call_with_retry can spot a 401, and so + // classify_backend_status can recover the kind afterwards. + return Err(format!("({status}) {text}")); + } + response + .json::() + .await + .map_err(|e| format!("decode response: {e}")) + } + }) + .await + .map_err(|e| classify_error_string(&e)) +} + +/// Recover a typed error from `api_call_with_retry`'s string. +/// +/// That helper flattens everything to `String` to do its 401 sniffing, so the +/// status is re-parsed here rather than lost — a 503 surfacing as a generic +/// failure would tell the user their fleet is broken when it is merely busy. +pub fn classify_error_string(message: &str) -> RemoteSessionError { + if let Some(rest) = message.strip_prefix('(') { + if let Some((code, tail)) = rest.split_once(')') { + if let Ok(status) = code.trim().parse::() { + return classify_backend_status(status, tail.trim()); + } + } + } + RemoteSessionError::Other(message.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_capacity_is_distinguished_from_a_real_failure() { + // 503 means "come back in a minute", not "something is broken" — conflating + // them would make a busy fleet look like an outage to the user. + assert!(matches!( + classify_backend_status(503, "no macos host free"), + RemoteSessionError::NoCapacity(_) + )); + assert!(matches!( + classify_backend_status(500, "boom"), + RemoteSessionError::Other(_) + )); + } + + #[test] + fn conflict_is_surfaced_so_the_user_learns_the_profile_is_open() { + assert!(matches!( + classify_backend_status(409, "profile already has a live session"), + RemoteSessionError::Conflict(_) + )); + } + + #[test] + fn payment_and_auth_failures_map_to_not_authorised() { + for status in [401u16, 402, 403] { + assert!( + matches!( + classify_backend_status(status, ""), + RemoteSessionError::NotAuthorised(_) + ), + "status {status} should be NotAuthorised" + ); + } + } + + #[test] + fn an_empty_body_still_produces_a_useful_message() { + let err = classify_backend_status(500, ""); + assert!(err.to_string().contains("500")); + } + + #[test] + fn a_status_encoded_error_string_round_trips_to_its_kind() { + // api_call_with_retry flattens everything to String to sniff for 401s; the + // status must survive that or a busy fleet looks like a broken one. + assert!(matches!( + classify_error_string("(503) no macos host free"), + RemoteSessionError::NoCapacity(_) + )); + assert!(matches!( + classify_error_string("(409) already running"), + RemoteSessionError::Conflict(_) + )); + } + + #[test] + fn an_unencoded_error_string_is_not_misread_as_a_status() { + assert!(matches!( + classify_error_string("reach backend: connection refused"), + RemoteSessionError::Other(_) + )); + } + + #[test] + fn idempotency_key_is_stable_for_one_attempt_and_distinct_across_attempts() { + let a = idempotency_key("p1", "nonce-1"); + assert_eq!(a, idempotency_key("p1", "nonce-1")); + assert_ne!(a, idempotency_key("p1", "nonce-2")); + assert_ne!(a, idempotency_key("p2", "nonce-1")); + } +}