Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/user-manual/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 19 additions & 7 deletions packages/catcher-core/src/types/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ pub enum ProxyMode {
/// 手动指定代理(现有行为)。
#[default]
Manual,
/// 强制直连,禁用显式代理、环境代理和 reqwest 自动系统代理。
Direct,
/// 自动从 OS 系统代理检测。
System,
}
Expand All @@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth: Option<ProxyAuth>,
Expand All @@ -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);
};
Expand Down Expand Up @@ -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);
}
}
62 changes: 61 additions & 1 deletion packages/catcher-dns/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// 从操作系统读取系统代理配置。
///
Expand Down Expand Up @@ -54,6 +54,36 @@ pub fn detect_system_proxy() -> Option<ProxyConfig> {
None
}

/// 重新检测系统代理,并在未检测到可用的固定代理时返回显式 Direct 配置。
///
/// `reqwest::ClientBuilder` 在未配置 proxy 时会自动读取环境/系统代理,因此不能用
/// `None` 表示 System 模式的直连回退。这里始终返回一个有效配置,确保调用方重建
/// client 后仍保持严格直连语义。
pub fn detect_system_proxy_or_direct(user_no_proxy: Vec<String>) -> ProxyConfig {
system_proxy_or_direct(detect_system_proxy(), user_no_proxy)
}

fn system_proxy_or_direct(
detected_proxy: Option<ProxyConfig>,
user_no_proxy: Vec<String>,
) -> 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::*;
Expand All @@ -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()]
);
}
}
26 changes: 12 additions & 14 deletions packages/catcher-http/src/transport/http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 请求,带重试中间件 + 熔断器 + 取消 + 自适应超时
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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())
Expand Down
115 changes: 115 additions & 0 deletions packages/catcher-http/tests/direct_proxy_test.rs
Original file line number Diff line number Diff line change
@@ -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<dyn Error>>
{
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<dyn Error>> {
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(())
}
10 changes: 10 additions & 0 deletions packages/catcher-napi-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,20 @@ interface DnsConfig {
nameservers?: string[]
host_mapping?: Record<string, string>
}

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 |
Expand Down
11 changes: 10 additions & 1 deletion packages/catcher-napi-http/ts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -137,6 +141,11 @@ export interface ManualProxyConfig {
no_proxy?: string[]
}

/** 强制直连,不读取任何代理配置。 */
export interface DirectProxyConfig {
mode: 'direct'
}

/** 系统代理:自动从 OS 检测(需构建时启用 `system-proxy` feature)。 */
export interface SystemProxyConfig {
mode: 'system'
Expand Down
11 changes: 11 additions & 0 deletions packages/catcher-napi-ws/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ interface WsClientConfig {
heartbeat?: HeartbeatConfig
race_count?: number // default: 1
dns?: DnsConfig
proxy?: ProxyConfig
msgpack?: boolean // default: false
}

Expand All @@ -124,10 +125,20 @@ interface DnsConfig {
nameservers?: string[]
host_mapping?: Record<string, string>
}

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 |
Expand Down
Loading
Loading