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:
YoVinchen
2026-01-12 11:34:31 +08:00
parent fdd539759e
commit c2adb1af46
6 changed files with 87 additions and 43 deletions
+14 -7
View File
@@ -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,
+8 -19
View File
@@ -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 {
+24 -5
View File
@@ -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");
}
}
+11 -7
View File
@@ -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 {
+25 -3
View File
@@ -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
+5 -2
View File
@@ -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 {