diff --git a/docs/user-manual/migration.md b/docs/user-manual/migration.md index 206a3b6..259cc37 100644 --- a/docs/user-manual/migration.md +++ b/docs/user-manual/migration.md @@ -30,6 +30,11 @@ const client = new HttpClient({ `networkChanged()` 时会自动重新检测系统代理,地址变化后重建连接池。详见 [`proxy.rs`](../../packages/catcher-dns/src/proxy.rs) 注释了解平台支持情况。 +> `System` 模式不执行 PAC/WPAD 脚本。Electron 等宿主应先用平台 API 按目标 URL +> 解析 PAC,再把结果作为 Manual 代理传入。需要严格绕过显式代理、环境代理和 +> reqwest 自动系统代理时,使用 `proxy: { mode: 'direct' }`;仅省略 `proxy` 不等价于 +> 强制直连。 + --- ## 版本升级:0.3.12 → 0.3.13 diff --git a/packages/catcher-core/src/types/network.rs b/packages/catcher-core/src/types/network.rs index f25be08..560f2f2 100644 --- a/packages/catcher-core/src/types/network.rs +++ b/packages/catcher-core/src/types/network.rs @@ -97,6 +97,8 @@ pub enum ProxyMode { /// 手动指定代理(现有行为)。 #[default] Manual, + /// 强制直连,禁用显式代理、环境代理和 reqwest 自动系统代理。 + Direct, /// 自动从 OS 系统代理检测。 System, } @@ -108,7 +110,7 @@ pub struct ProxyConfig { #[serde(default)] pub mode: ProxyMode, /// 代理地址,例如 `http://host:port`、`https://host:port`、`socks5://host:port`、`socks5h://host:port`。 - /// Manual 模式必填,System 模式忽略。 + /// Manual 模式必填,Direct / System 模式忽略。 pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub auth: Option, @@ -125,13 +127,12 @@ impl ProxyConfig { /// /// # Panics /// - /// 当 `url` 为 `None` 时 panic。调用方应在调用前确保 proxy 已解析 - /// (System 模式在 `detect_system_proxy()` 后 url 一定为 Some)。 + /// 当 `url` 为 `None` 时 panic。仅 Manual 模式或已解析为 Manual 的 System + /// 模式可以调用此方法。 pub fn transport_url(&self) -> Cow<'_, str> { - let url = self - .url - .as_deref() - .expect("transport_url: url is None; System proxy must be resolved via detect_system_proxy() before building the client"); + let url = self.url.as_deref().expect( + "transport_url: url is None; only resolved manual proxies have a transport URL", + ); let Some((scheme, rest)) = url.split_once("://") else { return Cow::Borrowed(url); }; @@ -199,4 +200,15 @@ mod tests { assert_eq!(parsed.mode, ProxyMode::System); assert!(parsed.url.is_none()); } + + #[test] + fn direct_mode_json_roundtrip() { + let config: ProxyConfig = serde_json::from_str(r#"{"mode":"direct"}"#).unwrap(); + assert_eq!(config.mode, ProxyMode::Direct); + assert!(config.url.is_none()); + + let json = serde_json::to_string(&config).unwrap(); + let parsed: ProxyConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.mode, ProxyMode::Direct); + } } diff --git a/packages/catcher-dns/src/proxy.rs b/packages/catcher-dns/src/proxy.rs index a93cc44..cbd1c6a 100644 --- a/packages/catcher-dns/src/proxy.rs +++ b/packages/catcher-dns/src/proxy.rs @@ -12,9 +12,9 @@ //! - **Linux**: 不读取 GNOME gsettings,仅读取环境变量和 /etc/sysconfig/proxy。 //! - **PAC/WPAD**: 不支持,需要 JS 引擎执行 PAC 脚本。 +use catcher_core::types::network::ProxyConfig; #[cfg(feature = "system-proxy")] use catcher_core::types::network::ProxyMode; -use catcher_core::types::network::ProxyConfig; /// 从操作系统读取系统代理配置。 /// @@ -54,6 +54,36 @@ pub fn detect_system_proxy() -> Option { None } +/// 重新检测系统代理,并在未检测到可用的固定代理时返回显式 Direct 配置。 +/// +/// `reqwest::ClientBuilder` 在未配置 proxy 时会自动读取环境/系统代理,因此不能用 +/// `None` 表示 System 模式的直连回退。这里始终返回一个有效配置,确保调用方重建 +/// client 后仍保持严格直连语义。 +pub fn detect_system_proxy_or_direct(user_no_proxy: Vec) -> ProxyConfig { + system_proxy_or_direct(detect_system_proxy(), user_no_proxy) +} + +fn system_proxy_or_direct( + detected_proxy: Option, + user_no_proxy: Vec, +) -> ProxyConfig { + if let Some(mut proxy) = detected_proxy { + for entry in user_no_proxy { + if !proxy.no_proxy.contains(&entry) { + proxy.no_proxy.push(entry); + } + } + proxy + } else { + ProxyConfig { + mode: catcher_core::types::network::ProxyMode::Direct, + url: None, + auth: None, + no_proxy: user_no_proxy, + } + } +} + #[cfg(all(test, feature = "system-proxy"))] mod tests { use super::*; @@ -80,4 +110,34 @@ mod tests { assert_eq!(proxy.mode, ProxyMode::Manual); } } + + #[test] + fn unresolved_system_proxy_becomes_explicit_direct() { + let proxy = system_proxy_or_direct(None, vec!["localhost".into()]); + + assert_eq!(proxy.mode, ProxyMode::Direct); + assert!(proxy.url.is_none()); + assert_eq!(proxy.no_proxy, vec!["localhost"]); + } + + #[test] + fn detected_system_proxy_merges_user_no_proxy() { + let detected = ProxyConfig { + mode: ProxyMode::Manual, + url: Some("http://127.0.0.1:7890".into()), + auth: None, + no_proxy: vec!["localhost".into()], + }; + + let proxy = system_proxy_or_direct( + Some(detected), + vec!["localhost".into(), "internal.example".into()], + ); + + assert_eq!(proxy.mode, ProxyMode::Manual); + assert_eq!( + proxy.no_proxy, + vec!["localhost".to_string(), "internal.example".to_string()] + ); + } } diff --git a/packages/catcher-http/src/transport/http_client.rs b/packages/catcher-http/src/transport/http_client.rs index 742cbf5..3535396 100644 --- a/packages/catcher-http/src/transport/http_client.rs +++ b/packages/catcher-http/src/transport/http_client.rs @@ -14,8 +14,8 @@ use crate::resilience::timeout::AdaptiveTimeout; use crate::transport::retry_middleware::MetricsRetryMiddleware; use crate::transport::tls::build_tls_config; use crate::types::http::*; -use catcher_core::types::resilience::CbState; use catcher_core::types::network::ProxyMode; +use catcher_core::types::resilience::CbState; use catcher_core::CatcherError; /// HTTP 传输层 — 真实收发 HTTP 请求,带重试中间件 + 熔断器 + 取消 + 自适应超时 @@ -173,15 +173,11 @@ impl HttpTransport { .as_ref() .map(|p| p.no_proxy.clone()) .unwrap_or_default(); - new_config.proxy = catcher_dns::proxy::detect_system_proxy(); - if let Some(ref mut p) = new_config.proxy { - // 合并:OS whitelist + 用户额外配置的去重 - for entry in user_no_proxy { - if !p.no_proxy.contains(&entry) { - p.no_proxy.push(entry); - } - } - } + // 检测不到固定代理时必须保留显式 Direct,不能设为 None;None 会让 + // reqwest 重新启用自动环境/系统代理,破坏 System 的直连回退语义。 + new_config.proxy = Some(catcher_dns::proxy::detect_system_proxy_or_direct( + user_no_proxy, + )); Arc::new(new_config) } else { self.config.clone() @@ -248,10 +244,12 @@ fn build_middleware_client( // G4: Proxy configuration if let Some(ref proxy_config) = config.proxy { - // System 模式且尚未解析(url=None)时跳过:networkChanged() 会重新检测后再重建。 - // 首次构建时若无系统代理,退化直连。 - if proxy_config.mode == ProxyMode::System && proxy_config.url.is_none() { - // 跳过 — 无系统代理可用,直连 + if proxy_config.mode == ProxyMode::Direct + || (proxy_config.mode == ProxyMode::System && proxy_config.url.is_none()) + { + // Direct 必须显式关闭 reqwest 自动环境代理。System 尚未解析到固定代理时也 + // 按文档语义退化为真正直连,避免残留 HTTP_PROXY/HTTPS_PROXY 被意外使用。 + reqwest_builder = reqwest_builder.no_proxy(); } else { let proxy_url = proxy_config.transport_url(); let mut proxy = reqwest::Proxy::all(proxy_url.as_ref()) diff --git a/packages/catcher-http/tests/direct_proxy_test.rs b/packages/catcher-http/tests/direct_proxy_test.rs new file mode 100644 index 0000000..833ee98 --- /dev/null +++ b/packages/catcher-http/tests/direct_proxy_test.rs @@ -0,0 +1,115 @@ +use std::error::Error; + +use catcher_core::types::network::{ProxyConfig, ProxyMode}; +use catcher_http::types::http::{HttpClientConfig, HttpMethod, HttpRequest}; +use catcher_http::HttpTransport; +use wiremock::matchers::method; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn install_unreachable_proxy_env() { + // SAFETY: 此集成测试单独运行在自己的测试进程中,不会与其他测试线程竞争环境变量。 + unsafe { + for name in [ + "http_proxy", + "https_proxy", + "all_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + ] { + std::env::set_var(name, "http://127.0.0.1:9"); + } + std::env::remove_var("no_proxy"); + std::env::remove_var("NO_PROXY"); + } +} + +#[tokio::test] +async fn direct_mode_bypasses_proxy_environment_after_network_change() -> Result<(), Box> +{ + install_unreachable_proxy_env(); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_string("direct")) + .mount(&server) + .await; + + let transport = HttpTransport::new(HttpClientConfig { + proxy: Some(ProxyConfig { + mode: ProxyMode::Direct, + url: None, + auth: None, + no_proxy: Vec::new(), + }), + connect_timeout_ms: 1_000, + response_timeout_ms: 2_000, + max_concurrency: 0, + ..Default::default() + })?; + + let response = transport + .execute(HttpRequest { + method: HttpMethod::GET, + url: format!("{}/before-network-change", server.uri()), + timeout_ms: Some(2_000), + ..Default::default() + }) + .await?; + assert_eq!(response.status, 200); + assert_eq!(response.body, b"direct"); + + transport.network_changed()?; + let response = transport + .execute(HttpRequest { + method: HttpMethod::GET, + url: format!("{}/after-network-change", server.uri()), + timeout_ms: Some(2_000), + ..Default::default() + }) + .await?; + assert_eq!(response.status, 200); + assert_eq!(response.body, b"direct"); + Ok(()) +} + +/// 未启用 system-proxy feature 时,System 检测必然无结果。networkChanged() 仍须 +/// 显式回退 Direct,不能让 reqwest 重新读取不可达的代理环境变量。 +#[cfg(not(feature = "system-proxy"))] +#[tokio::test] +async fn unresolved_system_mode_stays_direct_after_network_change() -> Result<(), Box> { + install_unreachable_proxy_env(); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_string("system-fallback-direct")) + .mount(&server) + .await; + + let transport = HttpTransport::new(HttpClientConfig { + proxy: Some(ProxyConfig { + mode: ProxyMode::System, + url: None, + auth: None, + no_proxy: Vec::new(), + }), + connect_timeout_ms: 1_000, + response_timeout_ms: 2_000, + max_concurrency: 0, + ..Default::default() + })?; + + transport.network_changed()?; + let response = transport + .execute(HttpRequest { + method: HttpMethod::GET, + url: format!("{}/after-network-change", server.uri()), + timeout_ms: Some(2_000), + ..Default::default() + }) + .await?; + + assert_eq!(response.status, 200); + assert_eq!(response.body, b"system-fallback-direct"); + Ok(()) +} diff --git a/packages/catcher-napi-http/README.md b/packages/catcher-napi-http/README.md index 80f6e9e..1d4a409 100644 --- a/packages/catcher-napi-http/README.md +++ b/packages/catcher-napi-http/README.md @@ -137,10 +137,20 @@ interface DnsConfig { nameservers?: string[] host_mapping?: Record } + +type ProxyConfig = + | { mode?: 'manual'; url: string; auth?: ProxyAuth; no_proxy?: string[] } + | { mode: 'direct' } + | { mode: 'system'; no_proxy?: string[] } ``` When `msgpack` is enabled, JSON request bodies are encoded as MessagePack and MessagePack responses are decoded back to JSON bytes when the response content type contains `msgpack`. +`mode: 'direct'` calls reqwest's `ClientBuilder::no_proxy()` and therefore ignores +explicit, environment, and automatic system proxies. `mode: 'system'` detects fixed +system proxies after `networkChanged()`; PAC/WPAD scripts must be resolved by the host +application and passed as a manual proxy because Catcher does not embed a PAC engine. + ### Methods | Method | Signature | diff --git a/packages/catcher-napi-http/ts/types.ts b/packages/catcher-napi-http/ts/types.ts index 2e79148..3a66d14 100644 --- a/packages/catcher-napi-http/ts/types.ts +++ b/packages/catcher-napi-http/ts/types.ts @@ -121,11 +121,15 @@ export interface ProxyAuth { * 代理配置 — 对应 Rust ProxyConfig(判别联合,按 `mode` 区分)。 * * - Manual(默认,省略 `mode` 或 `mode: 'manual'`):必须提供 `url`。 + * - Direct(`mode: 'direct'`):强制直连,禁用显式代理、环境代理和自动系统代理。 * - System(`mode: 'system'`):自动从 OS 读取系统代理(需构建时启用 `system-proxy` * feature),`url` 可省略。注意:System 模式仅在调用 `networkChanged()` 后才会 * 探测并应用系统代理,首次构建时走直连。 */ -export type ProxyConfig = ManualProxyConfig | SystemProxyConfig +export type ProxyConfig = + | ManualProxyConfig + | DirectProxyConfig + | SystemProxyConfig /** 手动代理(默认)。`url` 必填。 */ export interface ManualProxyConfig { @@ -137,6 +141,11 @@ export interface ManualProxyConfig { no_proxy?: string[] } +/** 强制直连,不读取任何代理配置。 */ +export interface DirectProxyConfig { + mode: 'direct' +} + /** 系统代理:自动从 OS 检测(需构建时启用 `system-proxy` feature)。 */ export interface SystemProxyConfig { mode: 'system' diff --git a/packages/catcher-napi-ws/README.md b/packages/catcher-napi-ws/README.md index 2d59333..a10e626 100644 --- a/packages/catcher-napi-ws/README.md +++ b/packages/catcher-napi-ws/README.md @@ -111,6 +111,7 @@ interface WsClientConfig { heartbeat?: HeartbeatConfig race_count?: number // default: 1 dns?: DnsConfig + proxy?: ProxyConfig msgpack?: boolean // default: false } @@ -124,10 +125,20 @@ interface DnsConfig { nameservers?: string[] host_mapping?: Record } + +type ProxyConfig = + | { mode?: 'manual'; url: string; auth?: ProxyAuth; no_proxy?: string[] } + | { mode: 'direct' } + | { mode: 'system'; no_proxy?: string[] } ``` When `msgpack` is enabled, JSON text messages are sent as MessagePack binary frames. Incoming MessagePack binary frames are decoded and delivered as JSON text. +`mode: 'direct'` calls reqwest's `ClientBuilder::no_proxy()` and therefore ignores +explicit, environment, and automatic system proxies. `mode: 'system'` detects fixed +system proxies after `networkChanged()`; PAC/WPAD scripts must be resolved by the host +application and passed as a manual proxy because Catcher does not embed a PAC engine. + ### Methods | Method | Signature | diff --git a/packages/catcher-napi-ws/ts/types.ts b/packages/catcher-napi-ws/ts/types.ts index f81a256..04a6c57 100644 --- a/packages/catcher-napi-ws/ts/types.ts +++ b/packages/catcher-napi-ws/ts/types.ts @@ -85,11 +85,15 @@ export interface ProxyAuth { * 代理配置 — 对应 Rust ProxyConfig(判别联合,按 `mode` 区分)。 * * - Manual(默认,省略 `mode` 或 `mode: 'manual'`):必须提供 `url`。 + * - Direct(`mode: 'direct'`):强制直连,禁用显式代理、环境代理和自动系统代理。 * - System(`mode: 'system'`):自动从 OS 读取系统代理(需构建时启用 `system-proxy` * feature),`url` 可省略。注意:System 模式仅在调用 `networkChanged()` 后才会 * 探测并应用系统代理,首次构建时走直连。 */ -export type ProxyConfig = ManualProxyConfig | SystemProxyConfig +export type ProxyConfig = + | ManualProxyConfig + | DirectProxyConfig + | SystemProxyConfig /** 手动代理(默认)。`url` 必填。 */ export interface ManualProxyConfig { @@ -101,6 +105,11 @@ export interface ManualProxyConfig { no_proxy?: string[] } +/** 强制直连,不读取任何代理配置。 */ +export interface DirectProxyConfig { + mode: 'direct' +} + /** 系统代理:自动从 OS 检测(需构建时启用 `system-proxy` feature)。 */ export interface SystemProxyConfig { mode: 'system' diff --git a/packages/catcher-ws/src/transport/ws_client.rs b/packages/catcher-ws/src/transport/ws_client.rs index 72de9f9..8f1cae1 100644 --- a/packages/catcher-ws/src/transport/ws_client.rs +++ b/packages/catcher-ws/src/transport/ws_client.rs @@ -448,11 +448,13 @@ pub(crate) fn build_reqwest_client( } if let Some(ref proxy_config) = config.proxy { - // System 模式且尚未解析(url=None)时跳过 - if proxy_config.mode == catcher_core::types::network::ProxyMode::System - && proxy_config.url.is_none() + if proxy_config.mode == catcher_core::types::network::ProxyMode::Direct + || (proxy_config.mode == catcher_core::types::network::ProxyMode::System + && proxy_config.url.is_none()) { - // 跳过 — 无系统代理可用,直连 + // Direct 必须显式关闭 reqwest 自动环境代理。System 尚未解析到固定代理时也 + // 按文档语义退化为真正直连,避免残留 HTTP_PROXY/HTTPS_PROXY 被意外使用。 + builder = builder.no_proxy(); } else { let proxy_url = proxy_config.transport_url(); let mut proxy = reqwest::Proxy::all(proxy_url.as_ref()) @@ -599,14 +601,11 @@ fn build_reqwest_client_for_network_change( .as_ref() .map(|p| p.no_proxy.clone()) .unwrap_or_default(); - c.proxy = catcher_dns::proxy::detect_system_proxy(); - if let Some(ref mut p) = c.proxy { - for entry in user_no_proxy { - if !p.no_proxy.contains(&entry) { - p.no_proxy.push(entry); - } - } - } + // 检测不到固定代理时必须保留显式 Direct,不能设为 None;None 会让 + // reqwest 重新启用自动环境/系统代理,破坏 System 的直连回退语义。 + c.proxy = Some(catcher_dns::proxy::detect_system_proxy_or_direct( + user_no_proxy, + )); owned_config = c; effective_config = &owned_config; } else { diff --git a/packages/catcher-ws/tests/direct_proxy_test.rs b/packages/catcher-ws/tests/direct_proxy_test.rs new file mode 100644 index 0000000..3857a4d --- /dev/null +++ b/packages/catcher-ws/tests/direct_proxy_test.rs @@ -0,0 +1,118 @@ +use std::error::Error; +use std::time::Duration; + +use catcher_core::types::network::ProxyMode; +use catcher_ws::{ProxyConfig, WsClientConfig, WsEvent, WsTransport}; +use futures_util::StreamExt; +use tokio::time::timeout; + +fn install_unreachable_proxy_env() { + // SAFETY: 此集成测试单独运行在自己的测试进程中,不会与其他测试线程竞争环境变量。 + unsafe { + for name in [ + "http_proxy", + "https_proxy", + "all_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + ] { + std::env::set_var(name, "http://127.0.0.1:9"); + } + std::env::remove_var("no_proxy"); + std::env::remove_var("NO_PROXY"); + } +} + +#[tokio::test] +async fn direct_mode_bypasses_automatic_proxy_environment() -> Result<(), Box> { + install_unreachable_proxy_env(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + let _ = timeout(Duration::from_secs(2), ws.next()).await; + }); + + let config = WsClientConfig { + urls: vec![format!("ws://127.0.0.1:{port}")], + proxy: Some(ProxyConfig { + mode: ProxyMode::Direct, + url: None, + auth: None, + no_proxy: Vec::new(), + }), + handshake_timeout_ms: 2_000, + ..Default::default() + }; + + let (handle, mut events) = WsTransport::connect(&config).await?; + let event = timeout(Duration::from_secs(2), events.recv()) + .await? + .ok_or("WebSocket event channel closed")?; + assert!(matches!(event, WsEvent::Connected { .. })); + + let _ = handle.close(1000, "direct mode test"); + let _ = server.await; + Ok(()) +} + +/// 未启用 system-proxy feature 时,System 检测必然无结果。networkChanged() 重连 +/// 仍须显式回退 Direct,不能让 reqwest 重新读取不可达的代理环境变量。 +#[cfg(not(feature = "system-proxy"))] +#[tokio::test] +async fn unresolved_system_mode_stays_direct_after_network_change() -> Result<(), Box> { + install_unreachable_proxy_env(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + let server = tokio::spawn(async move { + let mut connections = Vec::new(); + for _ in 0..2 { + let (stream, _) = listener.accept().await.unwrap(); + connections.push(tokio::spawn(async move { + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + let _ = timeout(Duration::from_secs(2), ws.next()).await; + })); + } + for connection in connections { + let _ = connection.await; + } + }); + + let config = WsClientConfig { + urls: vec![format!("ws://127.0.0.1:{port}")], + proxy: Some(ProxyConfig { + mode: ProxyMode::System, + url: None, + auth: None, + no_proxy: Vec::new(), + }), + handshake_timeout_ms: 2_000, + ..Default::default() + }; + + let (handle, mut events) = WsTransport::connect(&config).await?; + let event = timeout(Duration::from_secs(2), events.recv()) + .await? + .ok_or("WebSocket event channel closed")?; + assert!(matches!(event, WsEvent::Connected { .. })); + + handle.network_changed()?; + timeout(Duration::from_secs(3), async { + loop { + match events.recv().await { + Some(WsEvent::Connected { .. }) => break, + Some(_) => continue, + None => panic!("WebSocket event channel closed before reconnect"), + } + } + }) + .await?; + + let _ = handle.close(1000, "system fallback direct test"); + let _ = server.await; + Ok(()) +}