mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
fix(proxy): restore request timeout and fix proxy hot-reload issues
- Add URL scheme validation in build_client (http/https/socks5/socks5h) - Restore per-request timeout for speedtest, stream_check, usage_script, forwarder - Fix set_global_proxy_url to validate before persisting to DB - Mask proxy credentials in all log outputs - Fix forwarder hot-reload by fetching client on each request
This commit is contained in:
@@ -28,18 +28,20 @@ pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> R
|
||||
Some(url.as_str())
|
||||
};
|
||||
|
||||
// 1. 保存到数据库
|
||||
// 1. 先验证并构建新的 HTTP 客户端(如果失败则不落库)
|
||||
http_client::update_proxy(url_opt)?;
|
||||
|
||||
// 2. 验证成功后再保存到数据库
|
||||
state
|
||||
.db
|
||||
.set_global_proxy_url(url_opt)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 2. 热更新全局 HTTP 客户端
|
||||
http_client::update_proxy(url_opt)?;
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Configuration updated: {}",
|
||||
url_opt.unwrap_or("direct connection")
|
||||
url_opt
|
||||
.map(http_client::mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -87,7 +89,7 @@ pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
log::debug!(
|
||||
"[GlobalProxy] Test successful: {} -> {} ({}ms)",
|
||||
url,
|
||||
http_client::mask_url(&url),
|
||||
resp.status(),
|
||||
latency
|
||||
);
|
||||
@@ -99,7 +101,12 @@ pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
|
||||
}
|
||||
Err(e) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
log::debug!("[GlobalProxy] Test failed: {url} -> {e} ({latency}ms)");
|
||||
log::debug!(
|
||||
"[GlobalProxy] Test failed: {} -> {} ({}ms)",
|
||||
http_client::mask_url(&url),
|
||||
e,
|
||||
latency
|
||||
);
|
||||
Ok(ProxyTestResult {
|
||||
success: false,
|
||||
latency_ms: latency,
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::{
|
||||
ProxyError,
|
||||
};
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use reqwest::{Client, Response};
|
||||
use reqwest::Response;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -80,8 +80,6 @@ pub struct ForwardError {
|
||||
}
|
||||
|
||||
pub struct RequestForwarder {
|
||||
client: Option<Client>,
|
||||
client_init_error: Option<String>,
|
||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||
router: Arc<ProviderRouter>,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
@@ -92,13 +90,15 @@ pub struct RequestForwarder {
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||
current_provider_id_at_start: String,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl RequestForwarder {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
router: Arc<ProviderRouter>,
|
||||
_non_streaming_timeout: u64,
|
||||
non_streaming_timeout: u64,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
failover_manager: Arc<FailoverSwitchManager>,
|
||||
@@ -107,19 +107,14 @@ impl RequestForwarder {
|
||||
_streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
) -> Self {
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
// 如果全局客户端未初始化,会自动创建默认客户端
|
||||
let client = super::http_client::get();
|
||||
|
||||
Self {
|
||||
client: Some(client),
|
||||
client_init_error: None,
|
||||
router,
|
||||
status,
|
||||
current_providers,
|
||||
failover_manager,
|
||||
app_handle,
|
||||
current_provider_id_at_start,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,15 +378,9 @@ impl RequestForwarder {
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||
|
||||
// 构建请求
|
||||
let client = self.client.as_ref().ok_or_else(|| {
|
||||
ProxyError::ForwardFailed(
|
||||
self.client_init_error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "HTTP client is not initialized".to_string()),
|
||||
)
|
||||
})?;
|
||||
let mut request = client.post(&url);
|
||||
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
|
||||
let client = super::http_client::get();
|
||||
let mut request = client.post(&url).timeout(self.non_streaming_timeout);
|
||||
|
||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||
for (key, value) in headers {
|
||||
|
||||
@@ -30,7 +30,9 @@ pub fn init(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Initialized: {}",
|
||||
effective_url.unwrap_or("direct connection")
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -65,7 +67,9 @@ pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Updated: {}",
|
||||
effective_url.unwrap_or("direct connection")
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -115,6 +119,19 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
|
||||
// 有代理地址则使用代理,否则直连
|
||||
if let Some(url) = proxy_url {
|
||||
// 先验证 URL 格式和 scheme
|
||||
let parsed = url::Url::parse(url)
|
||||
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
|
||||
|
||||
let scheme = parsed.scheme();
|
||||
if !["http", "https", "socks5", "socks5h"].contains(&scheme) {
|
||||
return Err(format!(
|
||||
"Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h",
|
||||
scheme,
|
||||
mask_url(url)
|
||||
));
|
||||
}
|
||||
|
||||
let proxy = reqwest::Proxy::all(url)
|
||||
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
|
||||
builder = builder.proxy(proxy);
|
||||
@@ -130,7 +147,7 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
}
|
||||
|
||||
/// 隐藏 URL 中的敏感信息(用于日志)
|
||||
fn mask_url(url: &str) -> String {
|
||||
pub fn mask_url(url: &str) -> String {
|
||||
if let Ok(parsed) = url::Url::parse(url) {
|
||||
// 隐藏用户名和密码
|
||||
format!(
|
||||
@@ -189,7 +206,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_client_invalid_url() {
|
||||
let result = build_client(Some("not-a-valid-url"));
|
||||
assert!(result.is_err());
|
||||
// reqwest::Proxy::all 对某些无效 URL 不会立即报错
|
||||
// 使用明确无效的 scheme 来触发错误
|
||||
let result = build_client(Some("invalid-scheme://127.0.0.1:7890"));
|
||||
assert!(result.is_err(), "Should reject invalid proxy scheme");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,17 +65,21 @@ impl SpeedtestService {
|
||||
}
|
||||
|
||||
let timeout = Self::sanitize_timeout(timeout_secs);
|
||||
let client = Self::build_client(timeout)?;
|
||||
let (client, request_timeout) = Self::build_client(timeout)?;
|
||||
|
||||
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
||||
let _ = client.get(parsed_url.clone()).send().await;
|
||||
let _ = client
|
||||
.get(parsed_url.clone())
|
||||
.timeout(request_timeout)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
// 第二次请求开始计时,并将其作为结果返回。
|
||||
let start = Instant::now();
|
||||
let latency = match client.get(parsed_url).send().await {
|
||||
let latency = match client.get(parsed_url).timeout(request_timeout).send().await {
|
||||
Ok(resp) => EndpointLatency {
|
||||
url: trimmed,
|
||||
latency: Some(start.elapsed().as_millis()),
|
||||
@@ -112,11 +116,11 @@ impl SpeedtestService {
|
||||
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn build_client(_timeout_secs: u64) -> Result<Client, AppError> {
|
||||
fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
// 注意:speedtest 使用全局客户端的代理配置,如果需要单独的超时设置
|
||||
// 可以考虑在请求级别设置超时
|
||||
Ok(crate::proxy::http_client::get())
|
||||
// 返回 timeout Duration 供请求级别使用
|
||||
let timeout = std::time::Duration::from_secs(timeout_secs);
|
||||
Ok((crate::proxy::http_client::get(), timeout))
|
||||
}
|
||||
|
||||
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
||||
|
||||
@@ -138,18 +138,34 @@ impl StreamCheckService {
|
||||
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||
|
||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||
|
||||
let result = match app_type {
|
||||
AppType::Claude => {
|
||||
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
Self::check_claude_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
&auth,
|
||||
&model_to_test,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AppType::Codex => {
|
||||
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test, request_timeout)
|
||||
.await
|
||||
}
|
||||
AppType::Gemini => {
|
||||
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
Self::check_gemini_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
&auth,
|
||||
&model_to_test,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
@@ -190,6 +206,7 @@ impl StreamCheckService {
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = if base.ends_with("/v1") {
|
||||
@@ -210,6 +227,7 @@ impl StreamCheckService {
|
||||
.header("x-api-key", &auth.api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -240,6 +258,7 @@ impl StreamCheckService {
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = if base.ends_with("/v1") {
|
||||
@@ -272,6 +291,7 @@ impl StreamCheckService {
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -301,6 +321,7 @@ impl StreamCheckService {
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = format!("{base}/v1/chat/completions");
|
||||
@@ -317,6 +338,7 @@ impl StreamCheckService {
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -212,9 +212,10 @@ struct RequestConfig {
|
||||
}
|
||||
|
||||
/// 发送 HTTP 请求
|
||||
async fn send_http_request(config: &RequestConfig, _timeout_secs: u64) -> Result<String, AppError> {
|
||||
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
let request_timeout = std::time::Duration::from_secs(timeout_secs);
|
||||
|
||||
// 严格校验 HTTP 方法,非法值不回退为 GET
|
||||
let method: reqwest::Method = config.method.parse().map_err(|_| {
|
||||
@@ -225,7 +226,9 @@ async fn send_http_request(config: &RequestConfig, _timeout_secs: u64) -> Result
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut req = client.request(method.clone(), &config.url);
|
||||
let mut req = client
|
||||
.request(method.clone(), &config.url)
|
||||
.timeout(request_timeout);
|
||||
|
||||
// 添加请求头
|
||||
for (k, v) in &config.headers {
|
||||
|
||||
Reference in New Issue
Block a user