mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-28 15:18:48 +02:00
refactor: cleanup
This commit is contained in:
+95
-18
@@ -6,9 +6,9 @@ use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::tag_manager::TAG_MANAGER;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{header, HeaderMap, Method, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{Json, Response},
|
||||
response::{IntoResponse, Json, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
@@ -494,14 +494,16 @@ impl ApiServer {
|
||||
);
|
||||
listener
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to bind to any port: {e}")),
|
||||
Err(e) => {
|
||||
return Err(crate::backend_error_with_detail("API_PORT_UNAVAILABLE", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local address: {e}"))?
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
|
||||
.port();
|
||||
|
||||
// Create router with OpenAPI documentation
|
||||
@@ -538,8 +540,7 @@ impl ApiServer {
|
||||
let api = ApiDoc::openapi();
|
||||
|
||||
let v1_routes = v1_routes
|
||||
// Inert chokepoint (innermost → runs after auth) for the future per-hour
|
||||
// automation request limit. See rate_limit_middleware.
|
||||
// Innermost so only authenticated automation requests consume quota.
|
||||
.layer(middleware::from_fn(rate_limit_middleware))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
@@ -692,18 +693,47 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
|
||||
response
|
||||
}
|
||||
|
||||
/// Chokepoint for the future per-hour automation request limit. The limit
|
||||
/// (`requests_per_hour`, default 100) is already plumbed through entitlements;
|
||||
/// this middleware is intentionally inert today — it resolves the limit but
|
||||
/// never blocks. To enforce, count authenticated requests per rolling hour and
|
||||
/// return `StatusCode::TOO_MANY_REQUESTS` once the limit (when > 0) is exceeded.
|
||||
async fn rate_limit_middleware(
|
||||
request: axum::extract::Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let _requests_per_hour = crate::cloud_auth::CLOUD_AUTH.requests_per_hour().await;
|
||||
// TODO(rate-limit): enforce `_requests_per_hour` for automation routes.
|
||||
Ok(next.run(request).await)
|
||||
fn is_automation_request(method: &Method, path: &str) -> bool {
|
||||
if method != Method::POST {
|
||||
return false;
|
||||
}
|
||||
|
||||
if matches!(path, "/v1/profiles/batch/run" | "/v1/profiles/batch/stop") {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(profile_action) = path.strip_prefix("/v1/profiles/") else {
|
||||
return false;
|
||||
};
|
||||
let mut segments = profile_action.split('/');
|
||||
matches!(
|
||||
(segments.next(), segments.next(), segments.next()),
|
||||
(Some(_), Some("run" | "open-url" | "kill"), None)
|
||||
)
|
||||
}
|
||||
|
||||
async fn rate_limit_middleware(request: axum::extract::Request, next: Next) -> Response {
|
||||
if !is_automation_request(request.method(), request.uri().path()) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
match crate::automation_rate_limiter::check_automation_rate_limit().await {
|
||||
crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } => {
|
||||
log::warn!(
|
||||
"[api] Rejected {}: automation rate limit exceeded; retry in {}s",
|
||||
request.uri().path(),
|
||||
retry_after_secs
|
||||
);
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[(header::RETRY_AFTER, retry_after_secs.to_string())],
|
||||
"automation request rate limit exceeded",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
crate::automation_rate_limiter::RateLimitOutcome::Unlimited
|
||||
| crate::automation_rate_limiter::RateLimitOutcome::Allowed { .. } => next.run(request).await,
|
||||
}
|
||||
}
|
||||
|
||||
// Global API server instance
|
||||
@@ -2036,6 +2066,7 @@ async fn delete_extension_group_api(
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 409, description = "Profile is locked by another team member"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -2124,6 +2155,7 @@ async fn run_profile(
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -2165,6 +2197,7 @@ async fn open_url_in_profile(
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -2217,6 +2250,7 @@ async fn kill_profile(
|
||||
(status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -2312,6 +2346,7 @@ async fn batch_run_profiles(
|
||||
(status = 200, description = "Batch stop completed; inspect per-profile results", body = BatchStopResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -2672,6 +2707,35 @@ mod tests {
|
||||
assert!(!is_valid(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_only_classifies_browser_automation_routes() {
|
||||
for path in [
|
||||
"/v1/profiles/profile-id/run",
|
||||
"/v1/profiles/profile-id/open-url",
|
||||
"/v1/profiles/profile-id/kill",
|
||||
"/v1/profiles/batch/run",
|
||||
"/v1/profiles/batch/stop",
|
||||
] {
|
||||
assert!(
|
||||
is_automation_request(&Method::POST, path),
|
||||
"automation route was not limited: {path}"
|
||||
);
|
||||
}
|
||||
|
||||
for (method, path) in [
|
||||
(Method::GET, "/v1/profiles/profile-id/run"),
|
||||
(Method::POST, "/v1/profiles"),
|
||||
(Method::POST, "/v1/profiles/import"),
|
||||
(Method::GET, "/v1/profiles"),
|
||||
(Method::GET, "/openapi.json"),
|
||||
] {
|
||||
assert!(
|
||||
!is_automation_request(&method, path),
|
||||
"free or non-mutating route was limited: {method} {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_required(spec: &serde_json::Value, schema: &str) -> Vec<String> {
|
||||
spec["components"]["schemas"][schema]["required"]
|
||||
.as_array()
|
||||
@@ -2764,5 +2828,18 @@ mod tests {
|
||||
!paths.keys().any(|p| p.contains("wayfern-token")),
|
||||
"wayfern-token endpoints were removed and must stay out of the spec"
|
||||
);
|
||||
|
||||
for path in [
|
||||
"/v1/profiles/{id}/run",
|
||||
"/v1/profiles/{id}/open-url",
|
||||
"/v1/profiles/{id}/kill",
|
||||
"/v1/profiles/batch/run",
|
||||
"/v1/profiles/batch/stop",
|
||||
] {
|
||||
assert!(
|
||||
paths[path]["post"]["responses"].get("429").is_some(),
|
||||
"automation route is missing its 429 response: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
|
||||
const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RateLimitOutcome {
|
||||
Unlimited,
|
||||
Allowed { remaining: u64 },
|
||||
Limited { retry_after_secs: u64 },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AutomationRateLimiter {
|
||||
requests: HashMap<String, VecDeque<Instant>>,
|
||||
}
|
||||
|
||||
impl AutomationRateLimiter {
|
||||
fn check_at(&mut self, identity: &str, requests_per_hour: u64, now: Instant) -> RateLimitOutcome {
|
||||
if requests_per_hour == 0 {
|
||||
return RateLimitOutcome::Unlimited;
|
||||
}
|
||||
|
||||
self.requests.retain(|_, requests| {
|
||||
while requests
|
||||
.front()
|
||||
.is_some_and(|started| now.duration_since(*started) >= RATE_LIMIT_WINDOW)
|
||||
{
|
||||
requests.pop_front();
|
||||
}
|
||||
!requests.is_empty()
|
||||
});
|
||||
|
||||
let requests = self.requests.entry(identity.to_string()).or_default();
|
||||
if requests.len() as u64 >= requests_per_hour {
|
||||
let retry_after_secs = requests
|
||||
.front()
|
||||
.map(|started| {
|
||||
let remaining = RATE_LIMIT_WINDOW.saturating_sub(now.duration_since(*started));
|
||||
remaining
|
||||
.as_secs()
|
||||
.saturating_add(u64::from(remaining.subsec_nanos() > 0))
|
||||
.max(1)
|
||||
})
|
||||
.unwrap_or(1);
|
||||
return RateLimitOutcome::Limited { retry_after_secs };
|
||||
}
|
||||
|
||||
requests.push_back(now);
|
||||
RateLimitOutcome::Allowed {
|
||||
remaining: requests_per_hour.saturating_sub(requests.len() as u64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static AUTOMATION_RATE_LIMITER: LazyLock<Mutex<AutomationRateLimiter>> =
|
||||
LazyLock::new(|| Mutex::new(AutomationRateLimiter::default()));
|
||||
|
||||
pub async fn check_automation_rate_limit() -> RateLimitOutcome {
|
||||
let Some((identity, requests_per_hour)) = CLOUD_AUTH.automation_rate_limit().await else {
|
||||
return RateLimitOutcome::Unlimited;
|
||||
};
|
||||
|
||||
AUTOMATION_RATE_LIMITER
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.check_at(&identity, requests_per_hour, Instant::now())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rolling_window_limits_per_identity_and_recovers() {
|
||||
let mut limiter = AutomationRateLimiter::default();
|
||||
let now = Instant::now();
|
||||
|
||||
assert_eq!(
|
||||
limiter.check_at("user-a", 2, now),
|
||||
RateLimitOutcome::Allowed { remaining: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.check_at("user-a", 2, now + Duration::from_secs(1)),
|
||||
RateLimitOutcome::Allowed { remaining: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.check_at("user-a", 2, now + Duration::from_secs(2)),
|
||||
RateLimitOutcome::Limited {
|
||||
retry_after_secs: 3598
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
limiter.check_at("user-b", 2, now + Duration::from_secs(2)),
|
||||
RateLimitOutcome::Allowed { remaining: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.check_at("user-a", 2, now + RATE_LIMIT_WINDOW),
|
||||
RateLimitOutcome::Allowed { remaining: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.check_at(
|
||||
"user-a",
|
||||
2,
|
||||
now + RATE_LIMIT_WINDOW + Duration::from_secs(1)
|
||||
),
|
||||
RateLimitOutcome::Allowed { remaining: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.check_at(
|
||||
"user-a",
|
||||
2,
|
||||
now + RATE_LIMIT_WINDOW * 2 + Duration::from_secs(1)
|
||||
),
|
||||
RateLimitOutcome::Allowed { remaining: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_limit_is_unlimited_and_does_not_consume_capacity() {
|
||||
let mut limiter = AutomationRateLimiter::default();
|
||||
let now = Instant::now();
|
||||
|
||||
assert_eq!(
|
||||
limiter.check_at("user-a", 0, now),
|
||||
RateLimitOutcome::Unlimited
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.check_at("user-a", 1, now),
|
||||
RateLimitOutcome::Allowed { remaining: 0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+19
-154
@@ -23,8 +23,7 @@ pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
|
||||
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
|
||||
|
||||
/// Default per-hour cap on local automation API / MCP requests. Mirrors the
|
||||
/// backend's DEFAULT_REQUESTS_PER_HOUR. Not enforced yet — see the inert
|
||||
/// rate-limit chokepoints in api_server / mcp_server.
|
||||
/// backend's DEFAULT_REQUESTS_PER_HOUR.
|
||||
const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
|
||||
|
||||
/// Capability + limit set the account is entitled to, derived from its plan.
|
||||
@@ -828,14 +827,24 @@ impl CloudAuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-hour cap on automation requests (0 when automation is unavailable).
|
||||
/// Carried for the future local rate limiter; read by the inert chokepoints.
|
||||
pub async fn requests_per_hour(&self) -> i64 {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.requests_per_hour)
|
||||
.unwrap_or(0)
|
||||
/// Identity and positive per-hour cap for the shared REST/MCP automation
|
||||
/// limiter. No active automation entitlement means no limiter entry; the
|
||||
/// capability gates still reject paid operations independently.
|
||||
pub async fn automation_rate_limit(&self) -> Option<(String, u64)> {
|
||||
#[cfg(feature = "e2e")]
|
||||
if crate::e2e_automation_enabled() {
|
||||
if let Ok(limit) = std::env::var("DONUT_E2E_REQUESTS_PER_HOUR") {
|
||||
if let Ok(limit) = limit.parse::<u64>() {
|
||||
if limit > 0 {
|
||||
return Some(("e2e-automation".to_string(), limit));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let state = self.get_user().await?;
|
||||
let limit = state.user.entitlements().requests_per_hour;
|
||||
(limit > 0).then_some((state.user.id, limit as u64))
|
||||
}
|
||||
|
||||
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
|
||||
@@ -1040,126 +1049,6 @@ impl CloudAuthManager {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch region list for a country from the cloud backend
|
||||
pub async fn fetch_regions(&self, country: &str) -> Result<Vec<LocationItem>, String> {
|
||||
let country = country.to_string();
|
||||
self
|
||||
.api_call_with_retry(move |access_token| {
|
||||
let url = format!(
|
||||
"{CLOUD_API_URL}/api/proxy/locations/regions?country={}",
|
||||
country
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
async move {
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch regions: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Regions fetch failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<Vec<LocationItem>>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse regions: {e}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch city list for a country, optionally filtered by region
|
||||
pub async fn fetch_cities(
|
||||
&self,
|
||||
country: &str,
|
||||
region: Option<&str>,
|
||||
) -> Result<Vec<LocationItem>, String> {
|
||||
let country = country.to_string();
|
||||
let region = region.map(|s| s.to_string());
|
||||
self
|
||||
.api_call_with_retry(move |access_token| {
|
||||
let mut url = format!(
|
||||
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}",
|
||||
country
|
||||
);
|
||||
if let Some(ref r) = region {
|
||||
url.push_str(&format!("®ion={}", r));
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
async move {
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch cities: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Cities fetch failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<Vec<LocationItem>>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse cities: {e}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch ISP list for a country, optionally filtered by region and city
|
||||
pub async fn fetch_isps(
|
||||
&self,
|
||||
country: &str,
|
||||
region: Option<&str>,
|
||||
city: Option<&str>,
|
||||
) -> Result<Vec<LocationItem>, String> {
|
||||
let country = country.to_string();
|
||||
let region = region.map(|s| s.to_string());
|
||||
let city = city.map(|s| s.to_string());
|
||||
self
|
||||
.api_call_with_retry(move |access_token| {
|
||||
let mut url = format!(
|
||||
"{CLOUD_API_URL}/api/proxy/locations/isps?country={}",
|
||||
country
|
||||
);
|
||||
if let Some(ref r) = region {
|
||||
url.push_str(&format!("®ion={}", r));
|
||||
}
|
||||
if let Some(ref c) = city {
|
||||
url.push_str(&format!("&city={}", c));
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
async move {
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch ISPs: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("ISPs fetch failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<Vec<LocationItem>>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse ISPs: {e}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Request a wayfern token from the cloud API. Only succeeds for paid users.
|
||||
pub async fn request_wayfern_token(&self) -> Result<(), String> {
|
||||
if !self.has_active_paid_subscription().await {
|
||||
@@ -1459,30 +1348,6 @@ pub async fn cloud_get_countries() -> Result<Vec<LocationItem>, String> {
|
||||
CLOUD_AUTH.fetch_countries().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_regions(country: String) -> Result<Vec<LocationItem>, String> {
|
||||
CLOUD_AUTH.fetch_regions(&country).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_cities(
|
||||
country: String,
|
||||
region: Option<String>,
|
||||
) -> Result<Vec<LocationItem>, String> {
|
||||
CLOUD_AUTH.fetch_cities(&country, region.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_isps(
|
||||
country: String,
|
||||
region: Option<String>,
|
||||
city: Option<String>,
|
||||
) -> Result<Vec<LocationItem>, String> {
|
||||
CLOUD_AUTH
|
||||
.fetch_isps(&country, region.as_deref(), city.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_cloud_location_proxy(
|
||||
name: String,
|
||||
|
||||
+53
-24
@@ -15,6 +15,14 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
// to the confirmation dialog.
|
||||
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub(crate) fn backend_error(code: &str) -> String {
|
||||
serde_json::json!({ "code": code }).to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Display) -> String {
|
||||
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string()
|
||||
}
|
||||
|
||||
fn e2e_automation_enabled() -> bool {
|
||||
#[cfg(feature = "e2e")]
|
||||
{
|
||||
@@ -39,6 +47,7 @@ mod api_server;
|
||||
mod app_auto_updater;
|
||||
pub mod app_dirs;
|
||||
mod auto_updater;
|
||||
mod automation_rate_limiter;
|
||||
mod browser;
|
||||
mod browser_runner;
|
||||
mod browser_version_manager;
|
||||
@@ -271,15 +280,14 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prefix a command error with context, but pass structured `{"code": ...}`
|
||||
/// backend errors through untouched — the frontend can only translate a code
|
||||
/// when the JSON is the entire message (see src/lib/backend-errors.ts).
|
||||
/// Preserve structured backend errors and wrap lower-level diagnostics in the
|
||||
/// generic structured error shape expected by the frontend.
|
||||
pub(crate) fn wrap_backend_error(e: impl std::fmt::Display, context: &str) -> String {
|
||||
let msg = e.to_string();
|
||||
if msg.starts_with('{') {
|
||||
msg
|
||||
} else {
|
||||
format!("{context}: {msg}")
|
||||
backend_error_with_detail("INTERNAL_ERROR", format!("{context}: {msg}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,14 +539,14 @@ async fn get_mcp_config(app_handle: tauri::AppHandle) -> Result<Option<McpConfig
|
||||
|
||||
let port = mcp_server
|
||||
.get_port()
|
||||
.ok_or("MCP server port not available")?;
|
||||
.ok_or_else(|| backend_error("MCP_CONFIGURATION_UNAVAILABLE"))?;
|
||||
|
||||
let settings_manager = settings_manager::SettingsManager::instance();
|
||||
let token = settings_manager
|
||||
.get_mcp_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get MCP token: {e}"))?
|
||||
.ok_or("MCP token not found")?;
|
||||
.map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?
|
||||
.ok_or_else(|| backend_error("MCP_CONFIGURATION_UNAVAILABLE"))?;
|
||||
|
||||
Ok(Some(McpConfig { port, token }))
|
||||
}
|
||||
@@ -739,13 +747,15 @@ fn update_claude_extensions_registry(
|
||||
|
||||
async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result<String, String> {
|
||||
let mcp_server = mcp_server::McpServer::instance();
|
||||
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
|
||||
let port = mcp_server
|
||||
.get_port()
|
||||
.ok_or_else(|| backend_error("MCP_SERVER_NOT_RUNNING"))?;
|
||||
let settings_manager = settings_manager::SettingsManager::instance();
|
||||
let token = settings_manager
|
||||
.get_mcp_token(app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get MCP token: {e}"))?
|
||||
.ok_or("MCP token not found")?;
|
||||
.map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?
|
||||
.ok_or_else(|| backend_error("MCP_CONFIGURATION_UNAVAILABLE"))?;
|
||||
Ok(format!("http://127.0.0.1:{port}/mcp/{token}"))
|
||||
}
|
||||
|
||||
@@ -761,24 +771,28 @@ async fn list_mcp_agents() -> Result<Vec<mcp_integrations::McpAgentInfo>, String
|
||||
#[tauri::command]
|
||||
async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> {
|
||||
if !mcp_integrations::agent_exists(&agent_id) {
|
||||
return Err(format!("Unknown agent: {agent_id}"));
|
||||
return Err(backend_error("MCP_AGENT_UNKNOWN"));
|
||||
}
|
||||
if agent_id == "claude-desktop" {
|
||||
return add_mcp_to_claude_desktop_internal(&app_handle).await;
|
||||
}
|
||||
let url = current_mcp_url(&app_handle).await?;
|
||||
mcp_integrations::install_generic(&agent_id, &url)
|
||||
let result = if agent_id == "claude-desktop" {
|
||||
add_mcp_to_claude_desktop_internal(&app_handle).await
|
||||
} else {
|
||||
let url = current_mcp_url(&app_handle).await?;
|
||||
mcp_integrations::install_generic(&agent_id, &url)
|
||||
};
|
||||
result.map_err(|e| backend_error_with_detail("MCP_AGENT_INSTALL_FAILED", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> {
|
||||
if !mcp_integrations::agent_exists(&agent_id) {
|
||||
return Err(format!("Unknown agent: {agent_id}"));
|
||||
return Err(backend_error("MCP_AGENT_UNKNOWN"));
|
||||
}
|
||||
if agent_id == "claude-desktop" {
|
||||
return remove_mcp_from_claude_desktop_internal();
|
||||
}
|
||||
mcp_integrations::uninstall_generic(&agent_id)
|
||||
let result = if agent_id == "claude-desktop" {
|
||||
remove_mcp_from_claude_desktop_internal()
|
||||
} else {
|
||||
mcp_integrations::uninstall_generic(&agent_id)
|
||||
};
|
||||
result.map_err(|e| backend_error_with_detail("MCP_AGENT_REMOVE_FAILED", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2432,9 +2446,6 @@ pub fn run_with_builder(
|
||||
cloud_auth::cloud_logout,
|
||||
cloud_auth::cloud_get_proxy_usage,
|
||||
cloud_auth::cloud_get_countries,
|
||||
cloud_auth::cloud_get_regions,
|
||||
cloud_auth::cloud_get_cities,
|
||||
cloud_auth::cloud_get_isps,
|
||||
cloud_auth::create_cloud_location_proxy,
|
||||
cloud_auth::restart_sync_service,
|
||||
cloud_auth::cloud_get_wayfern_token,
|
||||
@@ -2481,6 +2492,24 @@ pub fn run_with_builder(
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn backend_error_helpers_preserve_codes_and_structure_diagnostics() {
|
||||
let coded = super::backend_error("PROFILE_NOT_FOUND");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&coded).unwrap()["code"],
|
||||
"PROFILE_NOT_FOUND"
|
||||
);
|
||||
assert_eq!(super::wrap_backend_error(&coded, "ignored"), coded);
|
||||
|
||||
let wrapped = super::wrap_backend_error("disk unavailable", "Failed to save");
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(&wrapped).unwrap();
|
||||
assert_eq!(parsed["code"], "INTERNAL_ERROR");
|
||||
assert_eq!(
|
||||
parsed["params"]["detail"],
|
||||
"Failed to save: disk unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_unused_tauri_commands() {
|
||||
check_unused_commands(false); // Run in strict mode for CI
|
||||
|
||||
+117
-44
@@ -184,19 +184,17 @@ impl McpServer {
|
||||
|
||||
pub async fn start(&self, app_handle: AppHandle) -> Result<u16, String> {
|
||||
if !WayfernTermsManager::instance().is_terms_accepted() {
|
||||
return Err(
|
||||
"Wayfern Terms and Conditions must be accepted before starting MCP server".to_string(),
|
||||
);
|
||||
return Err(crate::backend_error("WAYFERN_TERMS_REQUIRED"));
|
||||
}
|
||||
|
||||
if self.is_running() {
|
||||
return Err("MCP server is already running".to_string());
|
||||
return Err(crate::backend_error("MCP_SERVER_ALREADY_RUNNING"));
|
||||
}
|
||||
|
||||
let settings_manager = SettingsManager::instance();
|
||||
let settings = settings_manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
|
||||
|
||||
// Get or generate token
|
||||
let existing_token = settings_manager
|
||||
@@ -211,12 +209,16 @@ impl McpServer {
|
||||
settings_manager
|
||||
.generate_mcp_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to generate MCP token: {e}"))?
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
|
||||
};
|
||||
|
||||
// Determine port (use saved port, or try default, or random)
|
||||
let preferred_port = settings.mcp_port.unwrap_or(DEFAULT_MCP_PORT);
|
||||
let actual_port = self.bind_to_available_port(preferred_port).await?;
|
||||
let listener = self.bind_to_available_port(preferred_port).await?;
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
|
||||
.port();
|
||||
|
||||
// Save port if it changed
|
||||
if settings.mcp_port != Some(actual_port) {
|
||||
@@ -224,7 +226,7 @@ impl McpServer {
|
||||
new_settings.mcp_port = Some(actual_port);
|
||||
settings_manager
|
||||
.save_settings(&new_settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))?;
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
|
||||
}
|
||||
|
||||
// Store state
|
||||
@@ -244,31 +246,31 @@ impl McpServer {
|
||||
server: McpServer::instance(),
|
||||
token,
|
||||
};
|
||||
tokio::spawn(Self::run_http_server(actual_port, http_state, shutdown_rx));
|
||||
tokio::spawn(Self::run_http_server(listener, http_state, shutdown_rx));
|
||||
|
||||
log::info!("[mcp] Server started on port {}", actual_port);
|
||||
Ok(actual_port)
|
||||
}
|
||||
|
||||
async fn bind_to_available_port(&self, preferred: u16) -> Result<u16, String> {
|
||||
async fn bind_to_available_port(&self, preferred: u16) -> Result<TcpListener, String> {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], preferred));
|
||||
if TcpListener::bind(addr).await.is_ok() {
|
||||
return Ok(preferred);
|
||||
if let Ok(listener) = TcpListener::bind(addr).await {
|
||||
return Ok(listener);
|
||||
}
|
||||
|
||||
for _ in 0..10 {
|
||||
let port = 51000 + (rand::random::<u16>() % 1000);
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
if TcpListener::bind(addr).await.is_ok() {
|
||||
return Ok(port);
|
||||
if let Ok(listener) = TcpListener::bind(addr).await {
|
||||
return Ok(listener);
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not find available port for MCP server".to_string())
|
||||
Err(crate::backend_error("MCP_PORT_UNAVAILABLE"))
|
||||
}
|
||||
|
||||
async fn run_http_server(
|
||||
port: u16,
|
||||
listener: TcpListener,
|
||||
state: McpHttpState,
|
||||
shutdown_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
) {
|
||||
@@ -286,28 +288,17 @@ impl McpServer {
|
||||
.delete(Self::handle_mcp_delete),
|
||||
)
|
||||
.route("/health", get(Self::handle_health))
|
||||
// Inert chokepoint (innermost → runs after auth) for the future per-hour
|
||||
// automation request limit. See rate_limit_middleware.
|
||||
.layer(middleware::from_fn(Self::rate_limit_middleware))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
Self::auth_middleware,
|
||||
))
|
||||
.with_state(state);
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
|
||||
let server = async {
|
||||
match TcpListener::bind(addr).await {
|
||||
Ok(listener) => {
|
||||
log::info!("[mcp] Server listening on http://127.0.0.1:{}/mcp", port);
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
log::error!("[mcp] Server error: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[mcp] Failed to bind on port {}: {}", port, e);
|
||||
}
|
||||
let port = listener.local_addr().map(|addr| addr.port()).unwrap_or(0);
|
||||
let server = async move {
|
||||
log::info!("[mcp] Server listening on http://127.0.0.1:{}/mcp", port);
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
log::error!("[mcp] Server error: {}", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,17 +310,6 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chokepoint for the future per-hour automation request limit, mirroring the
|
||||
/// REST API's. The limit (`requests_per_hour`, default 100) is plumbed through
|
||||
/// entitlements; this is intentionally inert today — it resolves the limit but
|
||||
/// never blocks. To enforce, count authenticated tool calls per rolling hour
|
||||
/// and return StatusCode::TOO_MANY_REQUESTS once the limit (when > 0) is hit.
|
||||
async fn rate_limit_middleware(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
|
||||
let _requests_per_hour = CLOUD_AUTH.requests_per_hour().await;
|
||||
// TODO(rate-limit): enforce `_requests_per_hour` for MCP tool calls.
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
async fn auth_middleware(
|
||||
State(state): State<McpHttpState>,
|
||||
req: Request<Body>,
|
||||
@@ -476,14 +456,65 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
if Self::is_automation_tool_call(&request) {
|
||||
if let crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } =
|
||||
crate::automation_rate_limiter::check_automation_rate_limit().await
|
||||
{
|
||||
log::warn!(
|
||||
"[mcp] Rejected tools/call: automation rate limit exceeded; retry in {}s",
|
||||
retry_after_secs
|
||||
);
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[(header::RETRY_AFTER, retry_after_secs.to_string())],
|
||||
"automation request rate limit exceeded",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let response = state.server.handle_request(request).await;
|
||||
Json(response).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_automation_tool_call(request: &McpRequest) -> bool {
|
||||
if request.method != "tools/call" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(tool_name) = request
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|params| params.get("name"))
|
||||
.and_then(|name| name.as_str())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
matches!(
|
||||
tool_name,
|
||||
"run_profile"
|
||||
| "kill_profile"
|
||||
| "batch_run_profiles"
|
||||
| "batch_stop_profiles"
|
||||
| "start_sync_session"
|
||||
| "navigate"
|
||||
| "screenshot"
|
||||
| "evaluate_javascript"
|
||||
| "click_element"
|
||||
| "type_text"
|
||||
| "get_page_content"
|
||||
| "get_page_info"
|
||||
| "get_interactive_elements"
|
||||
| "click_by_index"
|
||||
| "type_by_index"
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> Result<(), String> {
|
||||
if !self.is_running() {
|
||||
return Err("MCP server is not running".to_string());
|
||||
return Err(crate::backend_error("MCP_SERVER_NOT_RUNNING"));
|
||||
}
|
||||
|
||||
let mut inner = self.inner.lock().await;
|
||||
@@ -5528,4 +5559,46 @@ mod tests {
|
||||
let server = McpServer::new();
|
||||
assert!(!server.is_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_only_classifies_browser_automation_tools() {
|
||||
let request = |method: &str, name: Option<&str>| McpRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: Some(serde_json::json!(1)),
|
||||
method: method.to_string(),
|
||||
params: name.map(|name| serde_json::json!({ "name": name, "arguments": {} })),
|
||||
};
|
||||
|
||||
for name in [
|
||||
"run_profile",
|
||||
"kill_profile",
|
||||
"batch_run_profiles",
|
||||
"batch_stop_profiles",
|
||||
"start_sync_session",
|
||||
"navigate",
|
||||
"screenshot",
|
||||
"evaluate_javascript",
|
||||
"click_element",
|
||||
"type_text",
|
||||
"get_page_content",
|
||||
"get_page_info",
|
||||
"get_interactive_elements",
|
||||
"click_by_index",
|
||||
"type_by_index",
|
||||
] {
|
||||
assert!(
|
||||
McpServer::is_automation_tool_call(&request("tools/call", Some(name))),
|
||||
"automation tool was not limited: {name}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(!McpServer::is_automation_tool_call(&request(
|
||||
"tools/call",
|
||||
Some("list_profiles")
|
||||
)));
|
||||
assert!(!McpServer::is_automation_tool_call(&request(
|
||||
"tools/list",
|
||||
None
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user