diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index c11b96e0..b935d2ae 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -98,6 +98,9 @@ export const commandCoverage = { "create_stored_proxy", "get_stored_proxies", "update_stored_proxy", + "proxy_rotation::set_proxy_rotation_url", + "proxy_rotation::rotate_proxy_ip", + "proxy_rotation::rotate_proxy_ips", "delete_stored_proxy", "check_proxy_validity", "validate_vless_uri", diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index 42bc9915..1823c434 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -88,6 +88,47 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a assert.equal(updatedProxy.name, "Updated Proxy"); assert.equal(updatedProxy.updated_at >= proxy.updated_at, true); + // The provider's change-IP link: checked when set, called on request. + assert.match( + await app.invokeError("rotate_proxy_ip", { proxyId: proxy.id }), + /PROXY_ROTATION_URL_MISSING/, + ); + assert.match( + await app.invokeError("set_proxy_rotation_url", { + proxyId: proxy.id, + rotationUrl: "ftp://provider.example/change-ip", + }), + /PROXY_ROTATION_URL_INVALID/, + ); + const rotationUrl = `${process.env.DONUT_E2E_FIXTURE_URL}/change-ip?key=e2e`; + const linked = await app.invoke("set_proxy_rotation_url", { + proxyId: proxy.id, + rotationUrl: ` ${rotationUrl} `, + }); + assert.equal(linked.rotation_url, rotationUrl); + const rotated = await app.invoke("rotate_proxy_ip", { + proxyId: proxy.id, + }); + assert.equal(rotated.status, 200); + assert.equal(rotated.error, null); + const rotations = await app.invoke("rotate_proxy_ips", { + proxyIds: [proxy.id, proxy.id, "missing-proxy"], + }); + assert.deepEqual( + rotations.map((result) => [result.proxy_id, result.status]), + [ + [proxy.id, 200], + ["missing-proxy", null], + ], + "duplicates are called once and each proxy gets its own result", + ); + assert.match(rotations[1].error, /PROXY_NOT_FOUND/); + const unlinked = await app.invoke("set_proxy_rotation_url", { + proxyId: proxy.id, + rotationUrl: "", + }); + assert.equal(unlinked.rotation_url, null); + const parsed = await app.invoke("parse_txt_proxies", { content: [ "http://one.example:8080", diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 3466e837..d34e4116 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -839,6 +839,58 @@ test("profile table filters, range selection, view settings, and keyboard edits ); }); +test("a proxy with a change-IP link offers Change IP on its row", async () => { + await withApp("ui-proxy-rotation", async (app) => { + const linked = await app.invoke("create_stored_proxy", { + name: "Rotating mobile proxy", + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + port: 9, + username: null, + password: null, + }, + }); + await app.invoke("set_proxy_rotation_url", { + proxyId: linked.id, + rotationUrl: `${process.env.DONUT_E2E_FIXTURE_URL}/change-ip?key=ui`, + }); + const plain = await app.invoke("create_stored_proxy", { + name: "Static proxy", + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + port: 9, + username: null, + password: null, + }, + }); + await app.pressShortcut({ + key: "n", + ...(process.platform === "darwin" ? { meta: true } : { ctrl: true }), + }); + await app.waitFor( + async () => (await tableRows(app, "proxies")).length === 2, + { description: "proxy table" }, + ); + const rotateIn = (id) => + `[data-table-id="proxies"] tr[data-table-row="${id}"] [data-slot="proxy-rotate"]`; + assert.equal( + await app.execute( + "return Boolean(document.querySelector(arguments[0]));", + [rotateIn(plain.id)], + ), + false, + "a proxy without a link has no Change IP action", + ); + await app.clickSelector(rotateIn(linked.id)); + await app.waitForText( + en.proxies.rotation.requested.replace("{{name}}", linked.name), + ); + await app.capture("proxy-rotation-requested"); + }); +}); + test("management tables share search, filters, selection, and saved views", async () => { await withApp("ui-management-tables", async (app) => { const proxies = []; diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 972d2069..1b60150f 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -264,6 +264,20 @@ struct ApiProxyResponse { name: String, #[schema(value_type = Object)] proxy_settings: ProxySettings, + /// The provider's "change IP" link, or `null`. Call + /// `POST /v1/proxies/{id}/rotate` to use it. + rotation_url: Option, +} + +impl From for ApiProxyResponse { + fn from(proxy: crate::proxy_manager::StoredProxy) -> Self { + Self { + id: proxy.id, + name: proxy.name, + proxy_settings: proxy.proxy_settings, + rotation_url: proxy.rotation_url, + } + } } #[derive(Debug, Deserialize, ToSchema)] @@ -271,6 +285,9 @@ struct CreateProxyRequest { name: String, #[schema(value_type = Object)] proxy_settings: ProxySettings, + /// Optional http(s) "change IP" link from the proxy provider. + #[serde(default)] + rotation_url: Option, } #[derive(Debug, Deserialize, ToSchema)] @@ -278,6 +295,10 @@ struct UpdateProxyRequest { name: Option, #[schema(value_type = Option)] proxy_settings: Option, + /// Omitted or `null` leaves the "change IP" link unchanged; an empty string + /// clears it. + #[serde(default)] + rotation_url: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -705,6 +726,7 @@ struct ImportProxiesResponse { create_proxy, import_proxies_api, update_proxy, + rotate_proxy, delete_proxy, get_vpns, get_vpn, @@ -747,6 +769,7 @@ struct ImportProxiesResponse { ApiStatusResponse, StatusRequest, ApiProxyResponse, + crate::proxy_rotation::RotationResult, CreateProxyRequest, UpdateProxyRequest, ApiVpnResponse, @@ -1039,6 +1062,7 @@ fn build_v1_router() -> Router { .routes(routes!(get_proxies, create_proxy)) .routes(routes!(import_proxies_api)) .routes(routes!(get_proxy, update_proxy, delete_proxy)) + .routes(routes!(rotate_proxy)) .routes(routes!(get_vpns, create_vpn)) .routes(routes!(import_vpn)) .routes(routes!(export_vpn)) @@ -2253,14 +2277,7 @@ async fn get_proxies( ) -> Result>, StatusCode> { let proxies = PROXY_MANAGER.get_stored_proxies(); Ok(Json( - proxies - .into_iter() - .map(|p| ApiProxyResponse { - id: p.id, - name: p.name, - proxy_settings: p.proxy_settings, - }) - .collect(), + proxies.into_iter().map(ApiProxyResponse::from).collect(), )) } @@ -2287,11 +2304,7 @@ async fn get_proxy( ) -> Result, StatusCode> { let proxies = PROXY_MANAGER.get_stored_proxies(); if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) { - Ok(Json(ApiProxyResponse { - id: proxy.id, - name: proxy.name, - proxy_settings: proxy.proxy_settings, - })) + Ok(Json(ApiProxyResponse::from(proxy))) } else { Err(StatusCode::NOT_FOUND) } @@ -2316,6 +2329,8 @@ async fn create_proxy( State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { + let rotation_url = crate::proxy_rotation::checked_rotation_url(request.rotation_url) + .map_err(manager_error_response)?; let result = PROXY_MANAGER.create_stored_proxy( &state.app_handle, request.name.clone(), @@ -2323,11 +2338,11 @@ async fn create_proxy( ); match result { - Ok(proxy) => Ok(Json(ApiProxyResponse { - id: proxy.id, - name: proxy.name, - proxy_settings: proxy.proxy_settings, - })), + Ok(proxy) if rotation_url.is_some() => PROXY_MANAGER + .set_rotation_url(&proxy.id, rotation_url) + .map(|proxy| Json(ApiProxyResponse::from(proxy))) + .map_err(manager_error_response), + Ok(proxy) => Ok(Json(ApiProxyResponse::from(proxy))), Err(e) => Err(manager_error_response(e)), } } @@ -2394,11 +2409,7 @@ async fn import_proxies_api( proxies: result .proxies .into_iter() - .map(|p| ApiProxyResponse { - id: p.id, - name: p.name, - proxy_settings: p.proxy_settings, - }) + .map(ApiProxyResponse::from) .collect(), })) } @@ -2427,19 +2438,60 @@ async fn update_proxy( State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { + // Checked first, so a bad link leaves the proxy as it was. + let rotation_url = request + .rotation_url + .map(|url| crate::proxy_rotation::checked_rotation_url(Some(url))) + .transpose() + .map_err(manager_error_response)?; let result = PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings); match result { - Ok(proxy) => Ok(Json(ApiProxyResponse { - id: proxy.id, - name: proxy.name, - proxy_settings: proxy.proxy_settings, - })), + Ok(_) if rotation_url.is_some() => PROXY_MANAGER + .set_rotation_url(&id, rotation_url.flatten()) + .map(|proxy| Json(ApiProxyResponse::from(proxy))) + .map_err(manager_error_response), + Ok(proxy) => Ok(Json(ApiProxyResponse::from(proxy))), Err(e) => Err(manager_error_response(e)), } } +#[utoipa::path( + post, + path = "/v1/proxies/{id}/rotate", + params( + ("id" = String, Path, description = "Proxy ID") + ), + responses( + (status = 200, description = "The provider accepted the IP change. Every profile behind the proxy moves to the new IP.", body = crate::proxy_rotation::RotationResult), + (status = 400, description = "The proxy has no change-IP link (PROXY_ROTATION_URL_MISSING)"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Proxy not found"), + (status = 502, description = "The provider refused or did not answer (PROXY_ROTATION_FAILED, PROXY_ROTATION_TIMEOUT, PROXY_ROTATION_UNREACHABLE, SYSTEM_PROXY_UNREACHABLE)") + ), + security( + ("bearer_auth" = []) + ), + tag = "proxies" +)] +async fn rotate_proxy( + Path(id): Path, +) -> Result, (StatusCode, String)> { + crate::proxy_rotation::rotate(&id) + .await + .map(Json) + .map_err(|e| { + if e.contains("PROXY_NOT_FOUND") { + (StatusCode::NOT_FOUND, e) + } else if e.contains("PROXY_ROTATION_URL_MISSING") { + (StatusCode::BAD_REQUEST, e) + } else { + (StatusCode::BAD_GATEWAY, e) + } + }) +} + #[utoipa::path( delete, path = "/v1/proxies/{id}", @@ -5149,8 +5201,12 @@ mod url_guard_tests { .split("struct ") .skip(1) .filter(|chunk| { - let head = &chunk[..chunk.len().min(600)]; - head.contains("url: Option") || head.contains("url: String") + // A field named exactly `url`: what a browser is sent to. Links with + // their own name (`rotation_url`) are checked where they are used. + chunk[..chunk.len().min(600)].lines().any(|line| { + let field = line.trim().trim_start_matches("pub "); + field.starts_with("url: Option") || field.starts_with("url: String") + }) }) .map(|chunk| chunk.split(['{', ' ', '<']).next().unwrap_or("?")) .collect(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0fd9fa03..8a8c74fc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -116,6 +116,7 @@ mod profile_statuses; mod profile_templates; mod proxy_distribution; mod proxy_manager; +mod proxy_rotation; pub mod proxy_runner; pub mod proxy_server; pub mod proxy_storage; @@ -3337,6 +3338,9 @@ pub fn run_with_builder( profile_statuses::update_profile_status, profile_statuses::delete_profile_status, profile_statuses::set_profiles_status, + proxy_rotation::set_proxy_rotation_url, + proxy_rotation::rotate_proxy_ip, + proxy_rotation::rotate_proxy_ips, check_browser_status, kill_browser_profile, rename_profile, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 251c31eb..2f448fc5 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -488,8 +488,8 @@ const SECRET_EXPORT_TOOLS: &[&str] = &["export_proxies"]; /// Strip the fields of a stored proxy a remote caller must not see. /// /// A bridge caller holds an account credential, not this machine. The proxy -/// password, the VLESS URI (which carries the credential inline) and the -/// dynamic list URL (which usually carries an API key) are secrets of the +/// password, the VLESS URI (which carries the credential inline), the dynamic +/// list URL and the change-IP link (which usually carry an API key) are secrets of the /// machine, not of the account, and an agent needs none of them to pick a /// proxy by id. Present values are replaced rather than removed so the shape /// an agent has learned stays the same, and absent ones stay absent. @@ -506,11 +506,10 @@ fn redact_proxy_secrets(proxy: &mut serde_json::Value) { } } if let Some(map) = proxy.as_object_mut() { - if map.get("dynamic_proxy_url").is_some_and(|v| !v.is_null()) { - map.insert( - "dynamic_proxy_url".to_string(), - serde_json::Value::from(REDACTED), - ); + for field in ["dynamic_proxy_url", "rotation_url"] { + if map.get(field).is_some_and(|v| !v.is_null()) { + map.insert(field.to_string(), serde_json::Value::from(REDACTED)); + } } } } @@ -2715,6 +2714,20 @@ impl McpServer { "required": [] }), }, + McpTool { + name: "rotate_proxy_ip".to_string(), + description: "Give a stored proxy a new exit IP by calling its provider's change-IP link. Every profile that uses the proxy moves to the new IP, and their open connections may drop. Fails when the proxy has no change-IP link.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "proxy_id": { + "type": "string", + "description": "The id of the stored proxy" + } + }, + "required": ["proxy_id"] + }), + }, McpTool { name: "get_profile_status".to_string(), description: "Check whether a browser profile is running and can be driven. Returns is_running (true when the browser can be driven, wherever it is), location ('local', 'remote' or 'stopped'), is_running_locally, and remote_session_id when it is running on the remote fleet.".to_string(), @@ -4519,6 +4532,29 @@ impl McpServer { "delete_profile" => self.handle_delete_profile(arguments).await, "list_tags" => self.handle_list_tags().await, "list_proxies" => self.handle_list_proxies().await, + "rotate_proxy_ip" => { + let proxy_id = arguments + .get("proxy_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing proxy_id".to_string(), + data: None, + })?; + let result = crate::proxy_rotation::rotate(proxy_id) + .await + .map_err(|e| McpError { + code: -32000, + message: format!("Proxy IP change failed: {e}"), + data: None, + })?; + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&result).unwrap_or_default() + }] + })) + } "get_profile_status" => self.handle_get_profile_status(arguments).await, // Group management "list_groups" => self.handle_list_groups().await, @@ -9891,7 +9927,7 @@ mod tests { // tool was added or removed on purpose. assert_eq!( tools.len(), - 94, + 95, "the tool list is a published contract; update this number deliberately" ); @@ -11173,13 +11209,15 @@ mod tests { "vless_uri": "vless://uuid@host:443?security=reality#name" }, "geo_country": "DE", - "dynamic_proxy_url": "https://lists.example/rotate?key=SECRET" + "dynamic_proxy_url": "https://lists.example/rotate?key=SECRET", + "rotation_url": "https://provider.example/change-ip?token=ROTATE" }); redact_proxy_secrets(&mut proxy); assert_eq!(proxy["proxy_settings"]["password"], "[redacted]"); assert_eq!(proxy["proxy_settings"]["vless_uri"], "[redacted]"); assert_eq!(proxy["dynamic_proxy_url"], "[redacted]"); + assert_eq!(proxy["rotation_url"], "[redacted]"); // Everything an agent needs to choose a proxy survives. assert_eq!(proxy["id"], "p1"); assert_eq!(proxy["name"], "Berlin"); @@ -11189,6 +11227,7 @@ mod tests { assert_eq!(proxy["geo_country"], "DE"); let text = proxy.to_string(); assert!(!text.contains("hunter2") && !text.contains("SECRET") && !text.contains("vless://")); + assert!(!text.contains("ROTATE")); // A proxy with no secrets is left exactly as it was: no field is invented // just to say it was redacted. diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 24cfa41d..390451d2 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -207,6 +207,11 @@ pub struct StoredProxy { pub dynamic_proxy_url: Option, #[serde(default)] pub dynamic_proxy_format: Option, + /// The provider's "change IP" link. Calling it gives the proxy a new exit + /// address. It usually carries an API key, so it is handled like the + /// password: never logged, redacted for remote MCP callers. + #[serde(default)] + pub rotation_url: Option, } /// Current unix time in whole seconds. Used to stamp `updated_at` on edits. @@ -325,6 +330,7 @@ impl StoredProxy { geo_isp: None, dynamic_proxy_url: None, dynamic_proxy_format: None, + rotation_url: None, } } @@ -705,6 +711,7 @@ impl ProxyManager { geo_isp: None, dynamic_proxy_url: None, dynamic_proxy_format: None, + rotation_url: None, }; stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone()); drop(stored_proxies); @@ -898,6 +905,7 @@ impl ProxyManager { geo_isp: isp, dynamic_proxy_url: None, dynamic_proxy_format: None, + rotation_url: None, }; { @@ -1096,6 +1104,45 @@ impl ProxyManager { Ok(updated_proxy) } + /// Set or clear a proxy's "change IP" link. Cloud proxies are refused: + /// the cloud owns their configuration. + pub fn set_rotation_url( + &self, + proxy_id: &str, + rotation_url: Option, + ) -> Result { + let updated = { + let mut stored_proxies = self.stored_proxies.lock().unwrap(); + let proxy = stored_proxies + .get_mut(proxy_id) + .ok_or_else(|| crate::backend_error("PROXY_NOT_FOUND"))?; + if proxy.is_cloud_managed || proxy.is_cloud_derived { + return Err(crate::backend_error("CANNOT_MODIFY_CLOUD_MANAGED_PROXY")); + } + if proxy.rotation_url == rotation_url { + return Ok(proxy.clone()); + } + proxy.rotation_url = rotation_url; + proxy.updated_at = Some(now_secs()); + proxy.clone() + }; + self + .save_proxy(&updated) + .map_err(|e| crate::wrap_backend_error(e, "Failed to save the proxy"))?; + if let Err(e) = events::emit_empty("proxies-changed") { + log::warn!("Event emit failed event=proxies-changed err=\"{e}\""); + } + if updated.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let id = updated.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_proxy_sync(id).await; + }); + } + } + Ok(updated) + } + /// Update the in-memory `sync_enabled` / `last_sync` fields of a stored /// proxy and persist the change to disk. Returns the updated proxy or /// `Err` if the proxy isn't found / is cloud-managed. @@ -4341,6 +4388,7 @@ mod tests { geo_isp: None, dynamic_proxy_url: None, dynamic_proxy_format: None, + rotation_url: None, }; // Before migration diff --git a/src-tauri/src/proxy_rotation.rs b/src-tauri/src/proxy_rotation.rs new file mode 100644 index 00000000..5dd9aba7 --- /dev/null +++ b/src-tauri/src/proxy_rotation.rs @@ -0,0 +1,254 @@ +//! A proxy provider's "change IP" link: opening it gives the proxy a new exit +//! address. Mobile and some residential providers hand one out per port. +//! +//! The link is called from this machine, not through the proxy: providers +//! accept it from anywhere, and a rotating proxy may be unreachable through +//! itself while it rotates. Every profile behind the proxy moves with it. + +use crate::proxy_manager::PROXY_MANAGER; +use serde::Serialize; +use std::time::{Duration, Instant}; + +const ROTATION_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_URL_CHARS: usize = 2048; +const BULK_CONCURRENCY: usize = 4; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq, utoipa::ToSchema)] +pub struct RotationResult { + pub proxy_id: String, + /// The provider's HTTP status, when it answered. + pub status: Option, + pub elapsed_ms: u64, + /// A coded backend error when the IP change was not confirmed. + pub error: Option, +} + +/// Blank clears; anything else must be an http(s) URL. +pub fn checked_rotation_url(value: Option) -> Result, String> { + let Some(value) = value + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + else { + return Ok(None); + }; + let valid = value.chars().count() <= MAX_URL_CHARS + && !value.chars().any(char::is_whitespace) + && url::Url::parse(&value) + .is_ok_and(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some()); + if valid { + Ok(Some(value)) + } else { + Err(crate::backend_error("PROXY_ROTATION_URL_INVALID")) + } +} + +fn client() -> Result { + reqwest::Client::builder() + .timeout(ROTATION_TIMEOUT) + .build() + .map_err(|e| crate::backend_error_with_detail("PROXY_ROTATION_FAILED", e)) +} + +async fn call(client: &reqwest::Client, proxy_id: &str) -> RotationResult { + let refused = |code: &str| RotationResult { + proxy_id: proxy_id.to_string(), + status: None, + elapsed_ms: 0, + error: Some(crate::backend_error(code)), + }; + match PROXY_MANAGER + .get_stored_proxies() + .into_iter() + .find(|proxy| proxy.id == proxy_id) + { + None => refused("PROXY_NOT_FOUND"), + Some(proxy) => match proxy.rotation_url { + Some(url) => request(client, proxy_id, &url).await, + None => refused("PROXY_ROTATION_URL_MISSING"), + }, + } +} + +async fn request(client: &reqwest::Client, proxy_id: &str, url: &str) -> RotationResult { + let started = Instant::now(); + let mut result = RotationResult { + proxy_id: proxy_id.to_string(), + status: None, + elapsed_ms: 0, + error: None, + }; + // The URL is never logged: providers put the account key in it. + match client.get(url).send().await { + Ok(response) => { + let status = response.status(); + result.status = Some(status.as_u16()); + if !status.is_success() { + result.error = Some( + serde_json::json!({ + "code": "PROXY_ROTATION_FAILED", + "params": { "status": status.as_u16().to_string() } + }) + .to_string(), + ); + } + } + Err(e) => { + result.error = Some(crate::system_proxy::explain(&e).unwrap_or_else(|| { + if e.is_timeout() { + crate::backend_error("PROXY_ROTATION_TIMEOUT") + } else { + crate::backend_error("PROXY_ROTATION_UNREACHABLE") + } + })); + } + } + result.elapsed_ms = started.elapsed().as_millis() as u64; + match (&result.error, result.status) { + (None, Some(status)) => log::info!( + "Proxy IP change requested proxy={proxy_id} status={status} elapsed_ms={}", + result.elapsed_ms + ), + (Some(_), status) => log::warn!( + "Proxy IP change failed proxy={proxy_id} status={} elapsed_ms={}", + status.map_or_else(|| "none".to_string(), |s| s.to_string()), + result.elapsed_ms + ), + (None, None) => {} + } + result +} + +pub async fn rotate(proxy_id: &str) -> Result { + let result = call(&client()?, proxy_id).await; + match result.error { + Some(error) => Err(error), + None => Ok(result), + } +} + +/// Several proxies at once, a few in flight at a time. Each gets its own +/// result, so one failing provider does not hide the others. +pub async fn rotate_many(proxy_ids: Vec) -> Result, String> { + use futures_util::StreamExt; + let client = client()?; + let mut seen = std::collections::HashSet::new(); + let unique: Vec = proxy_ids + .into_iter() + .filter(|id| seen.insert(id.clone())) + .collect(); + Ok( + futures_util::stream::iter(unique) + .map(|id| { + let client = client.clone(); + async move { call(&client, &id).await } + }) + .buffered(BULK_CONCURRENCY) + .collect() + .await, + ) +} + +#[tauri::command] +pub fn set_proxy_rotation_url( + proxy_id: String, + rotation_url: Option, +) -> Result { + let rotation_url = checked_rotation_url(rotation_url)?; + PROXY_MANAGER.set_rotation_url(&proxy_id, rotation_url) +} + +#[tauri::command] +pub async fn rotate_proxy_ip(proxy_id: String) -> Result { + rotate(&proxy_id).await +} + +#[tauri::command] +pub async fn rotate_proxy_ips(proxy_ids: Vec) -> Result, String> { + rotate_many(proxy_ids).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rotation_urls_must_be_http_links() { + assert_eq!(checked_rotation_url(None), Ok(None)); + assert_eq!(checked_rotation_url(Some(" ".to_string())), Ok(None)); + assert_eq!( + checked_rotation_url(Some( + " https://api.provider.example/rotate?key=abc ".to_string() + )), + Ok(Some( + "https://api.provider.example/rotate?key=abc".to_string() + )) + ); + for bad in [ + "ftp://provider.example/rotate", + "provider.example/rotate", + "https://", + "https://provider.example/rotate key", + "javascript:alert(1)", + ] { + assert!( + checked_rotation_url(Some(bad.to_string())) + .unwrap_err() + .contains("PROXY_ROTATION_URL_INVALID"), + "{bad}" + ); + } + assert!(checked_rotation_url(Some(format!("https://p.example/{}", "a".repeat(2100)))).is_err()); + } + + /// Answers every request with `status` and counts the requests. + async fn provider(status: u16) -> (String, std::sync::Arc) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!( + "http://{}/rotate?key=secret", + listener.local_addr().unwrap() + ); + let hits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = hits.clone(); + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf).await; + let _ = stream + .write_all( + format!("HTTP/1.1 {status} X\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .as_bytes(), + ) + .await; + } + }); + (url, hits) + } + + #[tokio::test] + async fn a_provider_answer_decides_the_result() { + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + + let (url, hits) = provider(200).await; + let ok = request(&client, "p1", &url).await; + assert_eq!((ok.status, ok.error.as_deref()), (Some(200), None)); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 1); + + let (url, _) = provider(429).await; + let refused = request(&client, "p1", &url).await; + assert_eq!(refused.status, Some(429)); + let error = refused.error.unwrap(); + assert!(error.contains("PROXY_ROTATION_FAILED") && error.contains("429")); + + let closed = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = closed.local_addr().unwrap().port(); + drop(closed); + let unreachable = request(&client, "p1", &format!("http://127.0.0.1:{port}/rotate")).await; + assert_eq!(unreachable.status, None); + assert!(unreachable + .error + .unwrap() + .contains("PROXY_ROTATION_UNREACHABLE")); + } +} diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 8d4d5d21..d78757ac 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -29,6 +29,7 @@ import { LuLockOpen, LuPlus, LuPuzzle, + LuRefreshCcwDot, LuRefreshCw, LuSettings, LuShield, @@ -91,6 +92,7 @@ import { getProfileIcon, getProfileNativeOs } from "@/lib/browser-utils"; import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels"; import { formatRelativeTime } from "@/lib/flag-utils"; import { MOTION_EASE_OUT } from "@/lib/motion"; +import { rotateProxyIps } from "@/lib/proxy-rotation"; import type { RemoteHandoffState } from "@/lib/remote-sessions"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { cn } from "@/lib/utils"; @@ -1751,6 +1753,9 @@ function NetworkSectionInline({ const [vpnId, setVpnId] = React.useState( profile.vpn_id ?? null, ); + const rotatableProxy = storedProxies.find( + (p) => p.id === proxyId && p.rotation_url, + ); const dnsServerId = React.useId(); const [savedDnsServer, setSavedDnsServer] = React.useState( profile.dns_server ?? "", @@ -1863,6 +1868,20 @@ function NetworkSectionInline({ ))} + {rotatableProxy && ( + + )}
diff --git a/src/components/proxy-form-dialog.tsx b/src/components/proxy-form-dialog.tsx index b225b065..0057d0de 100644 --- a/src/components/proxy-form-dialog.tsx +++ b/src/components/proxy-form-dialog.tsx @@ -25,6 +25,7 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { translateBackendError } from "@/lib/backend-errors"; +import { looksLikeRotationUrl } from "@/lib/proxy-rotation"; import { isFirstHopEncrypted, isXrayProxyType, @@ -42,6 +43,7 @@ interface ProxyFormData { username: string; password: string; vless_uri: string; + rotation_url: string; } interface ProxyFormDialogProps { @@ -58,6 +60,7 @@ const DEFAULT_FORM: ProxyFormData = { username: "", password: "", vless_uri: "", + rotation_url: "", }; /** @@ -175,6 +178,7 @@ export function ProxyFormDialog({ username: editingProxy.proxy_settings.username ?? "", password: editingProxy.proxy_settings.password ?? "", vless_uri: editingProxy.proxy_settings.vless_uri ?? "", + rotation_url: editingProxy.rotation_url ?? "", }); }, [editingProxy, isOpen, resetForm]); @@ -215,6 +219,12 @@ export function ProxyFormDialog({ return; } + const rotationUrl = form.rotation_url.trim(); + if (rotationUrl && !looksLikeRotationUrl(rotationUrl)) { + toast.error(t("backendErrors.proxyRotationUrlInvalid")); + return; + } + setIsSubmitting(true); try { const payload = { @@ -234,9 +244,24 @@ export function ProxyFormDialog({ proxyId: editingProxy.id, ...payload, }); + if (rotationUrl !== (editingProxy.rotation_url ?? "")) { + await invoke("set_proxy_rotation_url", { + proxyId: editingProxy.id, + rotationUrl: rotationUrl || null, + }); + } toast.success(t("toasts.success.proxyUpdated")); } else { - await invoke("create_stored_proxy", payload); + const created = await invoke( + "create_stored_proxy", + payload, + ); + if (rotationUrl) { + await invoke("set_proxy_rotation_url", { + proxyId: created.id, + rotationUrl, + }); + } toast.success(t("toasts.success.proxyCreated")); } @@ -602,6 +627,26 @@ export function ProxyFormDialog({ )} )} + +
+ + { + setForm({ ...form, rotation_url: e.target.value }); + }} + placeholder={t("proxies.rotation.placeholder")} + spellCheck={false} + autoComplete="off" + disabled={isSubmitting} + /> +

+ {t("proxies.rotation.hint")} +

+
diff --git a/src/components/proxy-management-dialog.tsx b/src/components/proxy-management-dialog.tsx index 47f9feb6..23b8689d 100644 --- a/src/components/proxy-management-dialog.tsx +++ b/src/components/proxy-management-dialog.tsx @@ -15,6 +15,7 @@ import { GoPlus } from "react-icons/go"; import { LuDownload, LuPencil, + LuRefreshCcwDot, LuRefreshCw, LuTrash2, LuUpload, @@ -63,6 +64,7 @@ import { useProfileReferences } from "@/hooks/use-profile-references"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { parseBackendError, translateBackendError } from "@/lib/backend-errors"; +import { rotateProxyIps } from "@/lib/proxy-rotation"; import { isFirstHopEncrypted, proxyProtocolToken } from "@/lib/proxy-string"; import { canonicalProxyType } from "@/lib/proxy-type"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; @@ -655,6 +657,26 @@ export function ProxyManagementDialog({ return (
+ {proxy.rotation_url && ( + + + + + +

{t("proxies.rotation.actionTooltip")}

+
+
+ )}