feat: proxy change-IP link

This commit is contained in:
zhom committed 2026-10-12 00:32:08 +04:00
1 parent 69d3c90a7c
commit 3f07a41fac
25 files changed
+925 -51

No files matched your search

+86 -30
View File
@@ -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<String>,
}
impl From<crate::proxy_manager::StoredProxy> 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<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -278,6 +295,10 @@ struct UpdateProxyRequest {
name: Option<String>,
#[schema(value_type = Option<Object>)]
proxy_settings: Option<ProxySettings>,
/// Omitted or `null` leaves the "change IP" link unchanged; an empty string
/// clears it.
#[serde(default)]
rotation_url: Option<String>,
}
#[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<ApiServerState> {
.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<Json<Vec<ApiProxyResponse>>, 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<Json<ApiProxyResponse>, 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<ApiServerState>,
Json(request): Json<CreateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, (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<ApiServerState>,
Json(request): Json<UpdateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, (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<String>,
) -> Result<Json<crate::proxy_rotation::RotationResult>, (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<String>") || 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<String>") || field.starts_with("url: String")
})
})
.map(|chunk| chunk.split(['{', ' ', '<']).next().unwrap_or("?"))
.collect();
+4
View File
@@ -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,
+48 -9
View File
@@ -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.
+48
View File
@@ -207,6 +207,11 @@ pub struct StoredProxy {
pub dynamic_proxy_url: Option<String>,
#[serde(default)]
pub dynamic_proxy_format: Option<String>,
/// 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<String>,
}
/// 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<String>,
) -> Result<StoredProxy, String> {
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
+254
View File
@@ -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<u16>,
pub elapsed_ms: u64,
/// A coded backend error when the IP change was not confirmed.
pub error: Option<String>,
}
/// Blank clears; anything else must be an http(s) URL.
pub fn checked_rotation_url(value: Option<String>) -> Result<Option<String>, 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, String> {
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<RotationResult, String> {
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<String>) -> Result<Vec<RotationResult>, String> {
use futures_util::StreamExt;
let client = client()?;
let mut seen = std::collections::HashSet::new();
let unique: Vec<String> = 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<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
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<RotationResult, String> {
rotate(&proxy_id).await
}
#[tauri::command]
pub async fn rotate_proxy_ips(proxy_ids: Vec<String>) -> Result<Vec<RotationResult>, 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<std::sync::atomic::AtomicUsize>) {
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"));
}
}