From efad0c0f91ba1389535f5d6facd5d6bdd43f05fb Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 19 Jan 2026 22:28:55 +0800 Subject: [PATCH] fix(proxy): prevent proxy recursion when system proxy points to localhost Detect if HTTP_PROXY, HTTPS_PROXY, or ALL_PROXY environment variables point to loopback addresses (localhost, 127.0.0.1), and bypass system proxy in such cases to avoid infinite request loops. --- src-tauri/src/mcp/opencode.rs | 18 ++- src-tauri/src/opencode_config.rs | 5 +- src-tauri/src/proxy/http_client.rs | 111 +++++++++++++++-- src-tauri/src/services/provider/live.rs | 19 +-- src-tauri/src/services/skill.rs | 28 ++++- src/App.tsx | 21 +++- src/components/common/FullScreenPanel.tsx | 4 +- .../providers/AddProviderDialog.tsx | 8 +- src/components/providers/ProviderActions.tsx | 3 +- .../providers/forms/BasicFormFields.tsx | 5 +- .../providers/forms/OpenCodeFormFields.tsx | 29 +++-- .../providers/forms/ProviderForm.tsx | 116 +++++++++++++----- src/lib/query/mutations.ts | 2 +- src/types.ts | 1 - 14 files changed, 272 insertions(+), 98 deletions(-) diff --git a/src-tauri/src/mcp/opencode.rs b/src-tauri/src/mcp/opencode.rs index 0b5873fbb..61257dfcd 100644 --- a/src-tauri/src/mcp/opencode.rs +++ b/src-tauri/src/mcp/opencode.rs @@ -97,8 +97,7 @@ pub fn convert_to_opencode_format(spec: &Value) -> Result { } _ => { return Err(AppError::McpValidation(format!( - "Unknown MCP type: {}", - typ + "Unknown MCP type: {typ}" ))); } } @@ -171,8 +170,7 @@ pub fn convert_from_opencode_format(spec: &Value) -> Result { } _ => { return Err(AppError::McpValidation(format!( - "Unknown OpenCode MCP type: {}", - typ + "Unknown OpenCode MCP type: {typ}" ))); } } @@ -230,16 +228,16 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result s, Err(e) => { - log::warn!("Skip invalid OpenCode MCP server '{}': {}", id, e); - errors.push(format!("{}: {}", id, e)); + log::warn!("Skip invalid OpenCode MCP server '{id}': {e}"); + errors.push(format!("{id}: {e}")); continue; } }; // Validate the converted spec if let Err(e) = validate_server_spec(&unified_spec) { - log::warn!("Skip invalid MCP server '{}' after conversion: {}", id, e); - errors.push(format!("{}: {}", id, e)); + log::warn!("Skip invalid MCP server '{id}' after conversion: {e}"); + errors.push(format!("{id}: {e}")); continue; } @@ -248,7 +246,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result Result Result<(), AppError> { // 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况) write_json_file(&path, config)?; - log::debug!("OpenCode config written to {:?}", path); + log::debug!("OpenCode config written to {path:?}"); Ok(()) } @@ -165,7 +165,7 @@ pub fn get_typed_providers() -> Result, result.insert(id, config); } Err(e) => { - log::warn!("Failed to parse provider '{}': {}", id, e); + log::warn!("Failed to parse provider '{id}': {e}"); // Skip invalid providers but continue } } @@ -219,4 +219,3 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> { write_opencode_config(&config) } - diff --git a/src-tauri/src/proxy/http_client.rs b/src-tauri/src/proxy/http_client.rs index cb771b9c3..0489e9083 100644 --- a/src-tauri/src/proxy/http_client.rs +++ b/src-tauri/src/proxy/http_client.rs @@ -6,6 +6,8 @@ use crate::provider::ProviderProxyConfig; use once_cell::sync::OnceCell; use reqwest::Client; +use std::env; +use std::net::IpAddr; use std::sync::RwLock; use std::time::Duration; @@ -163,16 +165,8 @@ pub fn get() -> Client { .and_then(|lock| lock.read().ok()) .map(|c| c.clone()) .unwrap_or_else(|| { - // 如果还没初始化,创建一个默认客户端(配置与 build_client 一致) - // 不调用 no_proxy(),让 reqwest 自动检测系统代理 log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback"); - Client::builder() - .timeout(Duration::from_secs(600)) - .connect_timeout(Duration::from_secs(30)) - .pool_max_idle_per_host(10) - .tcp_keepalive(Duration::from_secs(60)) - .build() - .unwrap_or_default() + build_client(None).unwrap_or_default() }) } @@ -220,9 +214,16 @@ fn build_client(proxy_url: Option<&str>) -> Result { builder = builder.proxy(proxy); log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url)); } else { - // 未设置全局代理时,不调用 no_proxy(),让 reqwest 自动检测系统代理 - // reqwest 会自动读取 HTTP_PROXY、HTTPS_PROXY 等环境变量 - log::debug!("[GlobalProxy] Following system proxy (no explicit proxy configured)"); + // 未设置全局代理时,让 reqwest 自动检测系统代理(环境变量) + // 若系统代理指向本机,禁用系统代理避免自环 + if system_proxy_points_to_loopback() { + builder = builder.no_proxy(); + log::warn!( + "[GlobalProxy] System proxy points to localhost, bypassing to avoid recursion" + ); + } else { + log::debug!("[GlobalProxy] Following system proxy (no explicit proxy configured)"); + } } builder @@ -230,6 +231,50 @@ fn build_client(proxy_url: Option<&str>) -> Result { .map_err(|e| format!("Failed to build HTTP client: {e}")) } +fn system_proxy_points_to_loopback() -> bool { + const KEYS: [&str; 6] = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ]; + + KEYS.iter() + .filter_map(|key| env::var(key).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .any(|value| proxy_points_to_loopback(&value)) +} + +fn proxy_points_to_loopback(value: &str) -> bool { + fn host_is_loopback(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + host.parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) + } + + if let Ok(parsed) = url::Url::parse(value) { + if let Some(host) = parsed.host_str() { + return host_is_loopback(host); + } + return false; + } + + let with_scheme = format!("http://{value}"); + if let Ok(parsed) = url::Url::parse(&with_scheme) { + if let Some(host) = parsed.host_str() { + return host_is_loopback(host); + } + } + + false +} + /// 隐藏 URL 中的敏感信息(用于日志) pub fn mask_url(url: &str) -> String { if let Ok(parsed) = url::Url::parse(url) { @@ -346,6 +391,12 @@ pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client { #[cfg(test)] mod tests { use super::*; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } #[test] fn test_mask_url() { @@ -394,4 +445,40 @@ mod tests { let result = build_client(Some("invalid-scheme://127.0.0.1:7890")); assert!(result.is_err(), "Should reject invalid proxy scheme"); } + + #[test] + fn test_proxy_points_to_loopback() { + assert!(proxy_points_to_loopback("http://127.0.0.1:7890")); + assert!(proxy_points_to_loopback("socks5://localhost:1080")); + assert!(proxy_points_to_loopback("127.0.0.1:7890")); + assert!(!proxy_points_to_loopback("http://192.168.1.10:7890")); + } + + #[test] + fn test_system_proxy_points_to_loopback() { + let _guard = env_lock().lock().unwrap(); + + let keys = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ]; + + for key in &keys { + std::env::remove_var(key); + } + + std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890"); + assert!(system_proxy_points_to_loopback()); + + std::env::set_var("HTTP_PROXY", "http://10.0.0.2:7890"); + assert!(!system_proxy_points_to_loopback()); + + for key in &keys { + std::env::remove_var(key); + } + } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 9585d3fb3..4a16961ac 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -501,17 +501,13 @@ pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<() // Check if OpenCode config directory exists if !opencode_config::get_opencode_dir().exists() { log::debug!( - "OpenCode config directory doesn't exist, skipping removal of '{}'", - provider_id + "OpenCode config directory doesn't exist, skipping removal of '{provider_id}'" ); return Ok(()); } opencode_config::remove_provider(provider_id)?; - log::info!( - "OpenCode provider '{}' removed from live config", - provider_id - ); + log::info!("OpenCode provider '{provider_id}' removed from live config"); Ok(()) } @@ -535,10 +531,7 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result Result v, Err(e) => { - log::warn!("Failed to serialize OpenCode provider '{}': {}", id, e); + log::warn!("Failed to serialize OpenCode provider '{id}': {e}"); continue; } }; @@ -561,12 +554,12 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result = HashMap::new(); - for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + ] { let app_dir = match Self::get_app_skills_dir(&app) { Ok(d) => d, Err(_) => continue, @@ -464,7 +474,12 @@ impl SkillService { let mut source_path: Option = None; let mut found_in: Vec = Vec::new(); - for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + ] { if let Ok(app_dir) = Self::get_app_skills_dir(&app) { let skill_path = app_dir.join(&dir_name); if skill_path.exists() { @@ -985,7 +1000,12 @@ pub fn migrate_skills_to_ssot(db: &Arc) -> Result { let mut discovered: HashMap = HashMap::new(); // 扫描各应用目录 - for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + ] { let app_dir = match SkillService::get_app_skills_dir(&app) { Ok(d) => d, Err(_) => continue, diff --git a/src/App.tsx b/src/App.tsx index c0baf0ed6..e12b3c0b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -355,7 +355,10 @@ function App() { }; // Generate a unique provider key for OpenCode duplication - const generateUniqueOpencodeKey = (originalKey: string, existingKeys: string[]): string => { + const generateUniqueOpencodeKey = ( + originalKey: string, + existingKeys: string[], + ): string => { const baseKey = `${originalKey}-copy`; if (!existingKeys.includes(baseKey)) { @@ -376,7 +379,9 @@ function App() { const newSortIndex = provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined; - const duplicatedProvider: Omit & { providerKey?: string } = { + const duplicatedProvider: Omit & { + providerKey?: string; + } = { name: `${provider.name} copy`, settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝 websiteUrl: provider.websiteUrl, @@ -392,7 +397,10 @@ function App() { // OpenCode: generate unique provider key (used as ID) if (activeApp === "opencode") { const existingKeys = Object.keys(providers); - duplicatedProvider.providerKey = generateUniqueOpencodeKey(provider.id, existingKeys); + duplicatedProvider.providerKey = generateUniqueOpencodeKey( + provider.id, + existingKeys, + ); } // 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1 @@ -501,7 +509,12 @@ function App() { /> ); case "skillsDiscovery": - return ; + return ( + + ); case "mcp": return ( = ({ {/* Content */}
-
- {children} -
+
{children}
{/* Footer */} diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index c3ff9e578..14b7ec086 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -24,7 +24,9 @@ interface AddProviderDialogProps { open: boolean; onOpenChange: (open: boolean) => void; appId: AppId; - onSubmit: (provider: Omit & { providerKey?: string }) => Promise | void; + onSubmit: ( + provider: Omit & { providerKey?: string }, + ) => Promise | void; } export function AddProviderDialog({ @@ -186,7 +188,9 @@ export function AddProviderDialog({ } } else if (appId === "opencode") { // OpenCode uses options.baseURL - const options = parsedConfig.options as Record | undefined; + const options = parsedConfig.options as + | Record + | undefined; if (options?.baseURL) { addUrl(options.baseURL); } diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index aa5b75187..6b4057a8f 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -64,7 +64,8 @@ export function ProviderActions({ const isOpenCodeMode = appId === "opencode"; // 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移) - const isFailoverMode = !isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover; + const isFailoverMode = + !isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover; // 处理主按钮点击 const handleMainButtonClick = () => { diff --git a/src/components/providers/forms/BasicFormFields.tsx b/src/components/providers/forms/BasicFormFields.tsx index 0bdaf5b18..1c8d090e3 100644 --- a/src/components/providers/forms/BasicFormFields.tsx +++ b/src/components/providers/forms/BasicFormFields.tsx @@ -29,7 +29,10 @@ interface BasicFormFieldsProps { beforeNameSlot?: ReactNode; } -export function BasicFormFields({ form, beforeNameSlot }: BasicFormFieldsProps) { +export function BasicFormFields({ + form, + beforeNameSlot, +}: BasicFormFieldsProps) { const { t } = useTranslation(); const [iconDialogOpen, setIconDialogOpen] = useState(false); diff --git a/src/components/providers/forms/OpenCodeFormFields.tsx b/src/components/providers/forms/OpenCodeFormFields.tsx index b6fafc049..49f08e25b 100644 --- a/src/components/providers/forms/OpenCodeFormFields.tsx +++ b/src/components/providers/forms/OpenCodeFormFields.tsx @@ -265,7 +265,7 @@ export function OpenCodeFormFields({ const handleModelOptionKeyChange = ( modelKey: string, oldKey: string, - newKey: string + newKey: string, ) => { if (!newKey.trim() || oldKey === newKey) return; const model = models[modelKey]; @@ -283,7 +283,7 @@ export function OpenCodeFormFields({ const handleModelOptionValueChange = ( modelKey: string, optionKey: string, - value: string + value: string, ) => { const model = models[modelKey]; let parsedValue: unknown; @@ -443,7 +443,9 @@ export function OpenCodeFormFields({ /> handleExtraOptionValueChange(key, e.target.value)} + onChange={(e) => + handleExtraOptionValueChange(key, e.target.value) + } placeholder={t("opencode.extraOptionValuePlaceholder", { defaultValue: "600000", })} @@ -521,7 +523,7 @@ export function OpenCodeFormFields({ @@ -575,17 +577,24 @@ export function OpenCodeFormFields({ <> {Object.entries(model.options || {}).map( ([optKey, optValue]) => ( -
+
- handleModelOptionKeyChange(key, optKey, newKey) + handleModelOptionKeyChange( + key, + optKey, + newKey, + ) } placeholder={t( "opencode.modelOptionKeyPlaceholder", { defaultValue: "provider", - } + }, )} /> @@ -621,7 +630,7 @@ export function OpenCodeFormFields({
- ) + ), )}
) : undefined } diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 73d75c854..0aa84356d 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -12,7 +12,7 @@ export const useAddProviderMutation = (appId: AppId) => { return useMutation({ mutationFn: async ( - providerInput: Omit & { providerKey?: string } + providerInput: Omit & { providerKey?: string }, ) => { let id: string; diff --git a/src/types.ts b/src/types.ts index 2cfd216bb..9fd73774a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -330,4 +330,3 @@ export interface OpenCodeMcpServerSpec { // 通用字段 enabled?: boolean; } -