mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 23:01:26 +02:00
feat: proxy change-IP link
This commit is contained in:
1 parent
69d3c90a7c
commit
3f07a41fac
25 files changed
+925
-51
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
+86
-30
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>(
|
||||
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({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{rotatableProxy && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="soft"
|
||||
className="shrink-0"
|
||||
data-slot="profile-proxy-rotate"
|
||||
title={t("proxies.rotation.actionTooltip")}
|
||||
disabled={isSaving}
|
||||
onClick={() => void rotateProxyIps([rotatableProxy], t as never)}
|
||||
>
|
||||
<LuRefreshCcwDot className="size-3.5" />
|
||||
{t("proxies.rotation.action")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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<StoredProxy>(
|
||||
"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({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-rotation-url">
|
||||
{t("proxies.rotation.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-rotation-url"
|
||||
value={form.rotation_url}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, rotation_url: e.target.value });
|
||||
}}
|
||||
placeholder={t("proxies.rotation.placeholder")}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxies.rotation.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex gap-1">
|
||||
<ProxyCheckButton proxy={proxy} />
|
||||
{proxy.rotation_url && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="proxy-rotate"
|
||||
aria-label={t("proxies.rotation.action")}
|
||||
onClick={() => {
|
||||
void rotateProxyIps([proxy], t);
|
||||
}}
|
||||
>
|
||||
<LuRefreshCcwDot className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("proxies.rotation.actionTooltip")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1386,6 +1408,19 @@ export function ProxyManagementDialog({
|
||||
>
|
||||
<LuRefreshCw />
|
||||
</DataTableActionBarAction>
|
||||
<DataTableActionBarAction
|
||||
tooltip={t("proxies.rotation.action")}
|
||||
onClick={() =>
|
||||
void rotateProxyIps(
|
||||
proxiesTable
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original),
|
||||
t,
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuRefreshCcwDot />
|
||||
</DataTableActionBarAction>
|
||||
<DataTableActionBarAction
|
||||
tooltip={t("common.buttons.delete")}
|
||||
onClick={() => {
|
||||
|
||||
@@ -695,6 +695,18 @@
|
||||
"skippedProxies_other": "{{count}} Proxys übersprungen, die noch von Profilen verwendet werden",
|
||||
"skippedVpns_one": "{{count}} VPN übersprungen, das noch von einem Profil verwendet wird",
|
||||
"skippedVpns_other": "{{count}} VPNs übersprungen, die noch von Profilen verwendet werden"
|
||||
},
|
||||
"rotation": {
|
||||
"label": "Link zum IP-Wechsel",
|
||||
"placeholder": "https://provider.example/change-ip?key=…",
|
||||
"hint": "Optional. Ein Link Ihres Anbieters, der diesem Proxy eine neue IP gibt. Jedes Profil, das den Proxy nutzt, wechselt zur neuen IP.",
|
||||
"action": "IP wechseln",
|
||||
"actionTooltip": "Eine neue IP beim Anbieter anfordern. Jedes Profil, das diesen Proxy nutzt, wechselt zu ihr.",
|
||||
"requested": "Neue IP für {{name}} angefordert",
|
||||
"requestedMany_one": "Neue IP für {{count}} Proxy angefordert",
|
||||
"requestedMany_other": "Neue IP für {{count}} Proxys angefordert",
|
||||
"failedFor": "{{name}}: {{error}}",
|
||||
"noneSelected": "Keiner dieser Proxys hat einen Link zum IP-Wechsel. Fügen Sie einen beim Bearbeiten eines Proxys hinzu."
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -2470,7 +2482,12 @@
|
||||
"profileStatusNotFound": "Dieser Status existiert nicht mehr.",
|
||||
"profileStatusColorInvalid": "Wählen Sie eine Farbe für den Status.",
|
||||
"profileStatusNameTooLong": "Ein Statusname darf höchstens 40 Zeichen haben.",
|
||||
"profileStatusLimitReached": "Sie haben 100 Status, das Maximum. Löschen Sie einen, um einen weiteren hinzuzufügen."
|
||||
"profileStatusLimitReached": "Sie haben 100 Status, das Maximum. Löschen Sie einen, um einen weiteren hinzuzufügen.",
|
||||
"proxyRotationUrlInvalid": "Der Link zum IP-Wechsel muss eine vollständige http://- oder https://-URL sein.",
|
||||
"proxyRotationUrlMissing": "Dieser Proxy hat keinen Link zum IP-Wechsel. Fügen Sie einen beim Bearbeiten des Proxys hinzu.",
|
||||
"proxyRotationFailed": "Der Anbieter hat den IP-Wechsel abgelehnt (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "Der Anbieter hat innerhalb von 30 Sekunden nicht auf den IP-Wechsel geantwortet.",
|
||||
"proxyRotationUnreachable": "Donut konnte den Link zum IP-Wechsel des Anbieters nicht erreichen."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
|
||||
@@ -695,6 +695,18 @@
|
||||
"skippedProxies_other": "Skipped {{count}} proxies that are still in use by profiles",
|
||||
"skippedVpns_one": "Skipped {{count}} VPN that is still in use by a profile",
|
||||
"skippedVpns_other": "Skipped {{count}} VPNs that are still in use by profiles"
|
||||
},
|
||||
"rotation": {
|
||||
"label": "Change IP link",
|
||||
"placeholder": "https://provider.example/change-ip?key=…",
|
||||
"hint": "Optional. A link from your provider that gives this proxy a new IP. Every profile that uses the proxy moves to the new IP.",
|
||||
"action": "Change IP",
|
||||
"actionTooltip": "Get a new IP from the provider. Every profile that uses this proxy moves to it.",
|
||||
"requested": "New IP requested for {{name}}",
|
||||
"requestedMany_one": "New IP requested for {{count}} proxy",
|
||||
"requestedMany_other": "New IP requested for {{count}} proxies",
|
||||
"failedFor": "{{name}}: {{error}}",
|
||||
"noneSelected": "None of these proxies has a change IP link. Add one when you edit a proxy."
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -2470,7 +2482,12 @@
|
||||
"profileStatusNotFound": "This status no longer exists.",
|
||||
"profileStatusColorInvalid": "Pick a color for the status.",
|
||||
"profileStatusNameTooLong": "A status name can have at most 40 characters.",
|
||||
"profileStatusLimitReached": "You have 100 statuses, the maximum. Delete one to add another."
|
||||
"profileStatusLimitReached": "You have 100 statuses, the maximum. Delete one to add another.",
|
||||
"proxyRotationUrlInvalid": "The change IP link must be a full http:// or https:// URL.",
|
||||
"proxyRotationUrlMissing": "This proxy has no change IP link. Add one when you edit the proxy.",
|
||||
"proxyRotationFailed": "The provider refused the IP change (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "The provider did not answer the IP change within 30 seconds.",
|
||||
"proxyRotationUnreachable": "Donut could not reach the provider's change IP link."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
|
||||
@@ -699,6 +699,19 @@
|
||||
"skippedVpns_one": "Se omitió {{count}} VPN que todavía usa un perfil",
|
||||
"skippedVpns_many": "Se omitieron {{count}} VPN que todavía usan algunos perfiles",
|
||||
"skippedVpns_other": "Se omitieron {{count}} VPN que todavía usan algunos perfiles"
|
||||
},
|
||||
"rotation": {
|
||||
"label": "Enlace para cambiar IP",
|
||||
"placeholder": "https://provider.example/change-ip?key=…",
|
||||
"hint": "Opcional. Un enlace de tu proveedor que da a este proxy una IP nueva. Todos los perfiles que usan el proxy pasan a la IP nueva.",
|
||||
"action": "Cambiar IP",
|
||||
"actionTooltip": "Pedir una IP nueva al proveedor. Todos los perfiles que usan este proxy pasan a ella.",
|
||||
"requested": "IP nueva solicitada para {{name}}",
|
||||
"requestedMany_one": "IP nueva solicitada para {{count}} proxy",
|
||||
"requestedMany_many": "IP nueva solicitada para {{count}} proxies",
|
||||
"requestedMany_other": "IP nueva solicitada para {{count}} proxies",
|
||||
"failedFor": "{{name}}: {{error}}",
|
||||
"noneSelected": "Ninguno de estos proxies tiene un enlace para cambiar IP. Añade uno al editar un proxy."
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -2480,7 +2493,12 @@
|
||||
"profileStatusNotFound": "Este estado ya no existe.",
|
||||
"profileStatusColorInvalid": "Elige un color para el estado.",
|
||||
"profileStatusNameTooLong": "El nombre de un estado puede tener como máximo 40 caracteres.",
|
||||
"profileStatusLimitReached": "Tienes 100 estados, el máximo. Elimina uno para añadir otro."
|
||||
"profileStatusLimitReached": "Tienes 100 estados, el máximo. Elimina uno para añadir otro.",
|
||||
"proxyRotationUrlInvalid": "El enlace para cambiar IP debe ser una URL completa http:// o https://.",
|
||||
"proxyRotationUrlMissing": "Este proxy no tiene enlace para cambiar IP. Añade uno al editar el proxy.",
|
||||
"proxyRotationFailed": "El proveedor rechazó el cambio de IP (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "El proveedor no respondió al cambio de IP en 30 segundos.",
|
||||
"proxyRotationUnreachable": "Donut no pudo acceder al enlace para cambiar IP del proveedor."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
|
||||
@@ -699,6 +699,19 @@
|
||||
"skippedVpns_one": "{{count}} VPN ignoré car encore utilisé par un profil",
|
||||
"skippedVpns_many": "{{count}} VPN ignorés car encore utilisés par des profils",
|
||||
"skippedVpns_other": "{{count}} VPN ignorés car encore utilisés par des profils"
|
||||
},
|
||||
"rotation": {
|
||||
"label": "Lien de changement d'IP",
|
||||
"placeholder": "https://provider.example/change-ip?key=…",
|
||||
"hint": "Facultatif. Un lien de votre fournisseur qui donne une nouvelle IP à ce proxy. Tous les profils qui utilisent le proxy passent à la nouvelle IP.",
|
||||
"action": "Changer d'IP",
|
||||
"actionTooltip": "Demander une nouvelle IP au fournisseur. Tous les profils qui utilisent ce proxy y passent.",
|
||||
"requested": "Nouvelle IP demandée pour {{name}}",
|
||||
"requestedMany_one": "Nouvelle IP demandée pour {{count}} proxy",
|
||||
"requestedMany_many": "Nouvelle IP demandée pour {{count}} proxys",
|
||||
"requestedMany_other": "Nouvelle IP demandée pour {{count}} proxys",
|
||||
"failedFor": "{{name}} : {{error}}",
|
||||
"noneSelected": "Aucun de ces proxys n'a de lien de changement d'IP. Ajoutez-en un en modifiant un proxy."
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -2480,7 +2493,12 @@
|
||||
"profileStatusNotFound": "Ce statut n'existe plus.",
|
||||
"profileStatusColorInvalid": "Choisissez une couleur pour le statut.",
|
||||
"profileStatusNameTooLong": "Le nom d'un statut ne peut pas dépasser 40 caractères.",
|
||||
"profileStatusLimitReached": "Vous avez 100 statuts, le maximum. Supprimez-en un pour en ajouter un autre."
|
||||
"profileStatusLimitReached": "Vous avez 100 statuts, le maximum. Supprimez-en un pour en ajouter un autre.",
|
||||
"proxyRotationUrlInvalid": "Le lien de changement d'IP doit être une URL http:// ou https:// complète.",
|
||||
"proxyRotationUrlMissing": "Ce proxy n'a pas de lien de changement d'IP. Ajoutez-en un en modifiant le proxy.",
|
||||
"proxyRotationFailed": "Le fournisseur a refusé le changement d'IP (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "Le fournisseur n'a pas répondu au changement d'IP en 30 secondes.",
|
||||
"proxyRotationUnreachable": "Donut n'a pas pu joindre le lien de changement d'IP du fournisseur."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
|
||||
@@ -695,6 +695,18 @@
|
||||
"skippedProxies_other": "プロファイルで使用中のため、{{count}} 件のプロキシをスキップしました",
|
||||
"skippedVpns_one": "プロファイルで使用中のため、{{count}} 件の VPN をスキップしました",
|
||||
"skippedVpns_other": "プロファイルで使用中のため、{{count}} 件の VPN をスキップしました"
|
||||
},
|
||||
"rotation": {
|
||||
"label": "IP 変更リンク",
|
||||
"placeholder": "https://provider.example/change-ip?key=…",
|
||||
"hint": "任意。このプロキシに新しい IP を割り当てるプロバイダーのリンクです。このプロキシを使うすべてのプロファイルが新しい IP に移ります。",
|
||||
"action": "IP を変更",
|
||||
"actionTooltip": "プロバイダーから新しい IP を取得します。このプロキシを使うすべてのプロファイルがその IP に移ります。",
|
||||
"requested": "{{name}} の新しい IP をリクエストしました",
|
||||
"requestedMany_one": "{{count}} 件のプロキシの新しい IP をリクエストしました",
|
||||
"requestedMany_other": "{{count}} 件のプロキシの新しい IP をリクエストしました",
|
||||
"failedFor": "{{name}}: {{error}}",
|
||||
"noneSelected": "これらのプロキシには IP 変更リンクがありません。プロキシの編集時に追加してください。"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -2470,7 +2482,12 @@
|
||||
"profileStatusNotFound": "このステータスはもう存在しません。",
|
||||
"profileStatusColorInvalid": "ステータスの色を選択してください。",
|
||||
"profileStatusNameTooLong": "ステータス名は 40 文字以内にしてください。",
|
||||
"profileStatusLimitReached": "ステータスが上限の 100 個に達しています。追加するには 1 つ削除してください。"
|
||||
"profileStatusLimitReached": "ステータスが上限の 100 個に達しています。追加するには 1 つ削除してください。",
|
||||
"proxyRotationUrlInvalid": "IP 変更リンクは完全な http:// または https:// URL にしてください。",
|
||||
"proxyRotationUrlMissing": "このプロキシには IP 変更リンクがありません。プロキシの編集時に追加してください。",
|
||||
"proxyRotationFailed": "プロバイダーが IP 変更を拒否しました (HTTP {{status}})。",
|
||||
"proxyRotationTimeout": "プロバイダーが 30 秒以内に IP 変更に応答しませんでした。",
|
||||
"proxyRotationUnreachable": "プロバイダーの IP 変更リンクに接続できませんでした。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
|
||||
@@ -695,6 +695,18 @@
|
||||
"skippedProxies_other": "프로필에서 사용 중이어서 프록시 {{count}}개를 건너뛰었습니다",
|
||||
"skippedVpns_one": "프로필에서 사용 중이어서 VPN {{count}}개를 건너뛰었습니다",
|
||||
"skippedVpns_other": "프로필에서 사용 중이어서 VPN {{count}}개를 건너뛰었습니다"
|
||||
},
|
||||
"rotation": {
|
||||
"label": "IP 변경 링크",
|
||||
"placeholder": "https://provider.example/change-ip?key=…",
|
||||
"hint": "선택 사항. 이 프록시에 새 IP를 주는 공급자의 링크입니다. 이 프록시를 쓰는 모든 프로필이 새 IP로 이동합니다.",
|
||||
"action": "IP 변경",
|
||||
"actionTooltip": "공급자에게서 새 IP를 받습니다. 이 프록시를 쓰는 모든 프로필이 그 IP로 이동합니다.",
|
||||
"requested": "{{name}}의 새 IP를 요청했습니다",
|
||||
"requestedMany_one": "프록시 {{count}}개의 새 IP를 요청했습니다",
|
||||
"requestedMany_other": "프록시 {{count}}개의 새 IP를 요청했습니다",
|
||||
"failedFor": "{{name}}: {{error}}",
|
||||
"noneSelected": "이 프록시들에는 IP 변경 링크가 없습니다. 프록시를 편집할 때 추가하세요."
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -2470,7 +2482,12 @@
|
||||
"profileStatusNotFound": "이 상태는 더 이상 존재하지 않습니다.",
|
||||
"profileStatusColorInvalid": "상태 색상을 선택하세요.",
|
||||
"profileStatusNameTooLong": "상태 이름은 최대 40자까지 가능합니다.",
|
||||
"profileStatusLimitReached": "상태가 최대치인 100개입니다. 추가하려면 하나를 삭제하세요."
|
||||
"profileStatusLimitReached": "상태가 최대치인 100개입니다. 추가하려면 하나를 삭제하세요.",
|
||||
"proxyRotationUrlInvalid": "IP 변경 링크는 전체 http:// 또는 https:// URL이어야 합니다.",
|
||||
"proxyRotationUrlMissing": "이 프록시에는 IP 변경 링크가 없습니다. 프록시를 편집할 때 추가하세요.",
|
||||
"proxyRotationFailed": "공급자가 IP 변경을 거부했습니다 (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "공급자가 30초 안에 IP 변경에 응답하지 않았습니다.",
|
||||
"proxyRotationUnreachable": "공급자의 IP 변경 링크에 연결할 수 없습니다."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
|
||||
@@ -699,6 +699,19 @@
|
||||
"skippedVpns_one": "{{count}} VPN ignorada por ainda estar em uso por um perfil",
|
||||
"skippedVpns_many": "{{count}} VPNs ignoradas por ainda estarem em uso por perfis",
|
||||
"skippedVpns_other": "{{count}} VPNs ignoradas por ainda estarem em uso por perfis"
|
||||
},
|
||||
"rotation": {
|
||||
"label": "Link de troca de IP",
|
||||
"placeholder": "https://provider.example/change-ip?key=…",
|
||||
"hint": "Opcional. Um link do seu provedor que dá um IP novo a este proxy. Todos os perfis que usam o proxy passam para o IP novo.",
|
||||
"action": "Trocar IP",
|
||||
"actionTooltip": "Pedir um IP novo ao provedor. Todos os perfis que usam este proxy passam para ele.",
|
||||
"requested": "IP novo solicitado para {{name}}",
|
||||
"requestedMany_one": "IP novo solicitado para {{count}} proxy",
|
||||
"requestedMany_many": "IP novo solicitado para {{count}} proxies",
|
||||
"requestedMany_other": "IP novo solicitado para {{count}} proxies",
|
||||
"failedFor": "{{name}}: {{error}}",
|
||||
"noneSelected": "Nenhum destes proxies tem link de troca de IP. Adicione um ao editar um proxy."
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -2480,7 +2493,12 @@
|
||||
"profileStatusNotFound": "Este status não existe mais.",
|
||||
"profileStatusColorInvalid": "Escolha uma cor para o status.",
|
||||
"profileStatusNameTooLong": "O nome de um status pode ter no máximo 40 caracteres.",
|
||||
"profileStatusLimitReached": "Você tem 100 status, o máximo. Exclua um para adicionar outro."
|
||||
"profileStatusLimitReached": "Você tem 100 status, o máximo. Exclua um para adicionar outro.",
|
||||
"proxyRotationUrlInvalid": "O link de troca de IP precisa ser uma URL completa http:// ou https://.",
|
||||
"proxyRotationUrlMissing": "Este proxy não tem link de troca de IP. Adicione um ao editar o proxy.",
|
||||
"proxyRotationFailed": "O provedor recusou a troca de IP (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "O provedor não respondeu à troca de IP em 30 segundos.",
|
||||
"proxyRotationUnreachable": "O Donut não conseguiu acessar o link de troca de IP do provedor."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
|
||||
@@ -703,6 +703,20 @@
|
||||
"skippedVpns_few": "Пропущено {{count}} VPN, которые ещё используются профилями",
|
||||
"skippedVpns_many": "Пропущено {{count}} VPN, которые ещё используются профилями",
|
||||
"skippedVpns_other": "Пропущено {{count}} VPN, которые ещё используются профилями"
|
||||
},
|
||||
"rotation": {
|
||||
"label": "Ссылка для смены IP",
|
||||
"placeholder": "https://provider.example/change-ip?key=…",
|
||||
"hint": "Необязательно. Ссылка провайдера, которая даёт этому прокси новый IP. Все профили с этим прокси переходят на новый IP.",
|
||||
"action": "Сменить IP",
|
||||
"actionTooltip": "Получить новый IP у провайдера. Все профили с этим прокси перейдут на него.",
|
||||
"requested": "Запрошен новый IP для {{name}}",
|
||||
"requestedMany_one": "Запрошен новый IP для {{count}} прокси",
|
||||
"requestedMany_few": "Запрошены новые IP для {{count}} прокси",
|
||||
"requestedMany_many": "Запрошены новые IP для {{count}} прокси",
|
||||
"requestedMany_other": "Запрошены новые IP для {{count}} прокси",
|
||||
"failedFor": "{{name}}: {{error}}",
|
||||
"noneSelected": "Ни у одного из этих прокси нет ссылки для смены IP. Добавьте её при редактировании прокси."
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -2490,7 +2504,12 @@
|
||||
"profileStatusNotFound": "Этого статуса больше нет.",
|
||||
"profileStatusColorInvalid": "Выберите цвет статуса.",
|
||||
"profileStatusNameTooLong": "Название статуса может содержать не более 40 символов.",
|
||||
"profileStatusLimitReached": "У вас 100 статусов, это максимум. Удалите один, чтобы добавить другой."
|
||||
"profileStatusLimitReached": "У вас 100 статусов, это максимум. Удалите один, чтобы добавить другой.",
|
||||
"proxyRotationUrlInvalid": "Ссылка для смены IP должна быть полным URL с http:// или https://.",
|
||||
"proxyRotationUrlMissing": "У этого прокси нет ссылки для смены IP. Добавьте её при редактировании прокси.",
|
||||
"proxyRotationFailed": "Провайдер отклонил смену IP (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "Провайдер не ответил на смену IP за 30 секунд.",
|
||||
"proxyRotationUnreachable": "Donut не удалось открыть ссылку провайдера для смены IP."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
|
||||
@@ -695,6 +695,18 @@
|
||||
"skippedProxies_other": "Bir profil tarafından hâlâ kullanıldığı için {{count}} proxy atlandı",
|
||||
"skippedVpns_one": "Bir profil tarafından hâlâ kullanıldığı için {{count}} VPN atlandı",
|
||||
"skippedVpns_other": "Bir profil tarafından hâlâ kullanıldığı için {{count}} VPN atlandı"
|
||||
},
|
||||
"rotation": {
|
||||
"label": "IP değiştirme bağlantısı",
|
||||
"placeholder": "https://provider.example/change-ip?key=…",
|
||||
"hint": "İsteğe bağlı. Sağlayıcınızın bu proxy'ye yeni bir IP veren bağlantısı. Proxy'yi kullanan her profil yeni IP'ye geçer.",
|
||||
"action": "IP değiştir",
|
||||
"actionTooltip": "Sağlayıcıdan yeni bir IP alın. Bu proxy'yi kullanan her profil ona geçer.",
|
||||
"requested": "{{name}} için yeni IP istendi",
|
||||
"requestedMany_one": "{{count}} proxy için yeni IP istendi",
|
||||
"requestedMany_other": "{{count}} proxy için yeni IP istendi",
|
||||
"failedFor": "{{name}}: {{error}}",
|
||||
"noneSelected": "Bu proxy'lerin hiçbirinde IP değiştirme bağlantısı yok. Bir proxy'yi düzenlerken ekleyin."
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -2470,7 +2482,12 @@
|
||||
"profileStatusNotFound": "Bu durum artık yok.",
|
||||
"profileStatusColorInvalid": "Durum için bir renk seçin.",
|
||||
"profileStatusNameTooLong": "Durum adı en fazla 40 karakter olabilir.",
|
||||
"profileStatusLimitReached": "En fazla sayı olan 100 durumunuz var. Yenisini eklemek için birini silin."
|
||||
"profileStatusLimitReached": "En fazla sayı olan 100 durumunuz var. Yenisini eklemek için birini silin.",
|
||||
"proxyRotationUrlInvalid": "IP değiştirme bağlantısı tam bir http:// veya https:// URL'si olmalıdır.",
|
||||
"proxyRotationUrlMissing": "Bu proxy'nin IP değiştirme bağlantısı yok. Proxy'yi düzenlerken ekleyin.",
|
||||
"proxyRotationFailed": "Sağlayıcı IP değişikliğini reddetti (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "Sağlayıcı IP değişikliğine 30 saniye içinde yanıt vermedi.",
|
||||
"proxyRotationUnreachable": "Donut, sağlayıcının IP değiştirme bağlantısına ulaşamadı."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiller",
|
||||
|
||||
@@ -695,6 +695,18 @@
|
||||
"skippedProxies_other": "Đã bỏ qua {{count}} proxy vẫn đang được hồ sơ sử dụng",
|
||||
"skippedVpns_one": "Đã bỏ qua {{count}} VPN vẫn đang được hồ sơ sử dụng",
|
||||
"skippedVpns_other": "Đã bỏ qua {{count}} VPN vẫn đang được hồ sơ sử dụng"
|
||||
},
|
||||
"rotation": {
|
||||
"label": "Liên kết đổi IP",
|
||||
"placeholder": "https://provider.example/change-ip?key=…",
|
||||
"hint": "Không bắt buộc. Liên kết từ nhà cung cấp để cấp IP mới cho proxy này. Mọi profile dùng proxy sẽ chuyển sang IP mới.",
|
||||
"action": "Đổi IP",
|
||||
"actionTooltip": "Lấy IP mới từ nhà cung cấp. Mọi profile dùng proxy này sẽ chuyển sang IP đó.",
|
||||
"requested": "Đã yêu cầu IP mới cho {{name}}",
|
||||
"requestedMany_one": "Đã yêu cầu IP mới cho {{count}} proxy",
|
||||
"requestedMany_other": "Đã yêu cầu IP mới cho {{count}} proxy",
|
||||
"failedFor": "{{name}}: {{error}}",
|
||||
"noneSelected": "Không proxy nào trong số này có liên kết đổi IP. Hãy thêm khi chỉnh sửa proxy."
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -2470,7 +2482,12 @@
|
||||
"profileStatusNotFound": "Trạng thái này không còn tồn tại.",
|
||||
"profileStatusColorInvalid": "Hãy chọn màu cho trạng thái.",
|
||||
"profileStatusNameTooLong": "Tên trạng thái tối đa 40 ký tự.",
|
||||
"profileStatusLimitReached": "Bạn đã có 100 trạng thái, mức tối đa. Hãy xóa một trạng thái để thêm mới."
|
||||
"profileStatusLimitReached": "Bạn đã có 100 trạng thái, mức tối đa. Hãy xóa một trạng thái để thêm mới.",
|
||||
"proxyRotationUrlInvalid": "Liên kết đổi IP phải là URL http:// hoặc https:// đầy đủ.",
|
||||
"proxyRotationUrlMissing": "Proxy này chưa có liên kết đổi IP. Hãy thêm khi chỉnh sửa proxy.",
|
||||
"proxyRotationFailed": "Nhà cung cấp từ chối đổi IP (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "Nhà cung cấp không phản hồi yêu cầu đổi IP trong 30 giây.",
|
||||
"proxyRotationUnreachable": "Donut không thể truy cập liên kết đổi IP của nhà cung cấp."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
|
||||
@@ -695,6 +695,18 @@
|
||||
"skippedProxies_other": "已跳过 {{count}} 个仍被配置文件使用的代理",
|
||||
"skippedVpns_one": "已跳过 {{count}} 个仍被配置文件使用的 VPN",
|
||||
"skippedVpns_other": "已跳过 {{count}} 个仍被配置文件使用的 VPN"
|
||||
},
|
||||
"rotation": {
|
||||
"label": "更换 IP 链接",
|
||||
"placeholder": "https://provider.example/change-ip?key=…",
|
||||
"hint": "可选。服务商提供的链接,可为此代理更换新 IP。使用此代理的所有配置文件都会换到新 IP。",
|
||||
"action": "更换 IP",
|
||||
"actionTooltip": "向服务商获取新 IP。使用此代理的所有配置文件都会换到该 IP。",
|
||||
"requested": "已为 {{name}} 请求新 IP",
|
||||
"requestedMany_one": "已为 {{count}} 个代理请求新 IP",
|
||||
"requestedMany_other": "已为 {{count}} 个代理请求新 IP",
|
||||
"failedFor": "{{name}}:{{error}}",
|
||||
"noneSelected": "这些代理都没有更换 IP 链接。请在编辑代理时添加。"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -2470,7 +2482,12 @@
|
||||
"profileStatusNotFound": "此状态已不存在。",
|
||||
"profileStatusColorInvalid": "请为状态选择颜色。",
|
||||
"profileStatusNameTooLong": "状态名称最多 40 个字符。",
|
||||
"profileStatusLimitReached": "状态数量已达上限 100 个。请删除一个后再添加。"
|
||||
"profileStatusLimitReached": "状态数量已达上限 100 个。请删除一个后再添加。",
|
||||
"proxyRotationUrlInvalid": "更换 IP 链接必须是完整的 http:// 或 https:// URL。",
|
||||
"proxyRotationUrlMissing": "此代理没有更换 IP 链接。请在编辑代理时添加。",
|
||||
"proxyRotationFailed": "服务商拒绝了更换 IP(HTTP {{status}})。",
|
||||
"proxyRotationTimeout": "服务商在 30 秒内未响应更换 IP。",
|
||||
"proxyRotationUnreachable": "Donut 无法访问服务商的更换 IP 链接。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
|
||||
@@ -30,6 +30,11 @@ export type BackendErrorCode =
|
||||
| "PROFILE_STATUS_COLOR_INVALID"
|
||||
| "PROFILE_STATUS_NAME_TOO_LONG"
|
||||
| "PROFILE_STATUS_LIMIT_REACHED"
|
||||
| "PROXY_ROTATION_URL_INVALID"
|
||||
| "PROXY_ROTATION_URL_MISSING"
|
||||
| "PROXY_ROTATION_TIMEOUT"
|
||||
| "PROXY_ROTATION_UNREACHABLE"
|
||||
| "PROXY_ROTATION_FAILED"
|
||||
| "COOKIE_DB_LOCKED"
|
||||
| "COOKIE_DB_UNAVAILABLE"
|
||||
| "COOKIE_IMPORT_BROWSER_RUNNING"
|
||||
@@ -376,6 +381,18 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.profileStatusNameTooLong");
|
||||
case "PROFILE_STATUS_LIMIT_REACHED":
|
||||
return t("backendErrors.profileStatusLimitReached");
|
||||
case "PROXY_ROTATION_URL_INVALID":
|
||||
return t("backendErrors.proxyRotationUrlInvalid");
|
||||
case "PROXY_ROTATION_URL_MISSING":
|
||||
return t("backendErrors.proxyRotationUrlMissing");
|
||||
case "PROXY_ROTATION_TIMEOUT":
|
||||
return t("backendErrors.proxyRotationTimeout");
|
||||
case "PROXY_ROTATION_UNREACHABLE":
|
||||
return t("backendErrors.proxyRotationUnreachable");
|
||||
case "PROXY_ROTATION_FAILED":
|
||||
return t("backendErrors.proxyRotationFailed", {
|
||||
status: parsed.params?.status ?? "",
|
||||
});
|
||||
case "COOKIE_DB_LOCKED":
|
||||
return t("backendErrors.cookieDbLocked");
|
||||
case "COOKIE_DB_UNAVAILABLE":
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { StoredProxy } from "@/types";
|
||||
|
||||
/** `RotationResult` in `src-tauri/src/proxy_rotation.rs`. */
|
||||
interface RotationResult {
|
||||
proxy_id: string;
|
||||
status: number | null;
|
||||
elapsed_ms: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** A link worth sending to the backend, which checks it again. */
|
||||
export function looksLikeRotationUrl(value: string): boolean {
|
||||
return /^https?:\/\/\S+$/i.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the providers of these proxies for new IPs and say what happened. Proxies
|
||||
* without a change-IP link are left out.
|
||||
*/
|
||||
export async function rotateProxyIps(
|
||||
proxies: StoredProxy[],
|
||||
t: TFunction,
|
||||
): Promise<void> {
|
||||
const rotatable = proxies.filter((proxy) => proxy.rotation_url);
|
||||
if (rotatable.length === 0) {
|
||||
showErrorToast(t("proxies.rotation.noneSelected"));
|
||||
return;
|
||||
}
|
||||
if (rotatable.length === 1) {
|
||||
const [proxy] = rotatable;
|
||||
try {
|
||||
await invoke<RotationResult>("rotate_proxy_ip", { proxyId: proxy.id });
|
||||
showSuccessToast(t("proxies.rotation.requested", { name: proxy.name }));
|
||||
} catch (error) {
|
||||
showErrorToast(
|
||||
t("proxies.rotation.failedFor", {
|
||||
name: proxy.name,
|
||||
error: translateBackendError(t, error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const results = await invoke<RotationResult[]>("rotate_proxy_ips", {
|
||||
proxyIds: rotatable.map((proxy) => proxy.id),
|
||||
});
|
||||
const succeeded = results.filter((result) => !result.error).length;
|
||||
if (succeeded > 0) {
|
||||
showSuccessToast(t("proxies.rotation.requestedMany", { count: succeeded }));
|
||||
}
|
||||
for (const result of results) {
|
||||
if (!result.error) continue;
|
||||
const name =
|
||||
rotatable.find((proxy) => proxy.id === result.proxy_id)?.name ??
|
||||
result.proxy_id;
|
||||
showErrorToast(
|
||||
t("proxies.rotation.failedFor", {
|
||||
name,
|
||||
error: translateBackendError(t, result.error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -329,6 +329,8 @@ export interface StoredProxy {
|
||||
geo_region?: string;
|
||||
geo_city?: string;
|
||||
geo_isp?: string;
|
||||
/** The provider's change-IP link; see `rotateProxyIps`. */
|
||||
rotation_url?: string | null;
|
||||
}
|
||||
|
||||
export interface LocationItem {
|
||||
|
||||
Reference in new issue
Block a user