代理
主线位置:连接主线的岔路口。
ConnectorService::call拿到目标 URI 后,先按顺序问每个代理规则「拦不拦」。没人拦,走连接器讲的直连;有人拦,按代理的协议分三条路:SOCKS 握手、HTTPS 目标走 CONNECT 隧道、HTTP 目标直接连代理并改用绝对 URI。建好的连接照常交回连接池。
flowchart TB
call["ConnectorService call"] --> loop{"逐个 Matcher intercept"}
loop -- 都不拦 --> direct["直连 is_proxy=false"]
loop -- 拦下 --> via["connect_via_proxy"]
via --> sk{"代理 scheme"}
sk -- socks --> socks["connect_socks"]
sk -- http 或 https --> dst{"目标 scheme"}
dst -- https --> tun["Tunnel 发 CONNECT"]
tun --> tls["隧道里做 TLS"]
dst -- http --> fwd["连代理 is_proxy=true"]
fwd --> abs["hyper 写绝对 URI"]
本页的源码在三个文件里:proxy.rs(948 行,其中 184 行是用 /* */ 注释掉的旧代码,测试从 809 行开始)定义公开的 Proxy / NoProxy 和内部的 Matcher;connect.rs 里的 connect_via_proxy 和 mod socks 负责连;async_impl/client.rs 负责把代理装进 Client、给 HTTP 请求补认证头。匹配规则、环境变量和 macOS 系统设置的读取、CONNECT 隧道和 SOCKS 握手,都在 hyper-util 0.1.21 的 client::proxy 与 client::legacy::connect::proxy 里,hyper 卷锁的也是 0.1.21。
实验平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5(默认 feature 加 socks 等),release 构建。所有请求都打到本机:一个 HTTP 源站、一个 HTTPS 源站(rcgen 自签证书,ALPN 给 h2 和 http/1.1)、一个自己写的约 60 行的日志代理(收到 CONNECT 就开隧道,收到绝对 URI 就原样转给源站,并把收到的请求头记下来),以及一个最小的 SOCKS5 服务器。实验 crate 在 exp-reqwest/connb-lab。这台 Mac 开着系统代理(Clash 的 127.0.0.1:7890),shell 里也设了代理环境变量,scutil --proxy 的输出(节选):
<dictionary> {
ExceptionsList : <array> {
0 : captive.apple.com
1 : localhost
...
}
ExcludeSimpleHostnames : 1
...
HTTPEnable : 1
HTTPPort : 7890
HTTPProxy : 127.0.0.1
HTTPSEnable : 1
HTTPSPort : 7890
HTTPSProxy : 127.0.0.1
...
}
下面每个客户端要么显式 .proxy(...)(这会关掉系统代理),要么 .no_proxy();讲系统代理的那一节只打印配置,或者把 HTTP_PROXY 指向本机的日志代理,没有请求发到 7890。
从 Proxy 到 Matcher
Proxy 是给用户的构建器,三个字段:
pub struct Proxy {
extra: Extra,
intercept: Intercept,
no_proxy: Option<NoProxy>,
}
intercept 是 Intercept 枚举:All(Url)、Http(Url)、Https(Url)、Custom(Custom),对应四个构造函数 Proxy::http、https、all、custom。注意名字说的是目标的 scheme,不是代理自己的:Proxy::http("https://p") 的意思是「http:// 的请求交给 https://p 这台代理」。extra 存另外配的认证头和自定义头,后面讲。
代理地址经 IntoProxy::into_proxy 变成 Url:解析失败、且原因是缺 scheme 时补一个 http:// 再试;socks4、socks4a、socks5、socks5h 没写端口时补 1080。
ClientBuilder::build 时,每个 Proxy 经 into_matcher 变成内部的 Matcher。前三种直接交给 hyper-util 的 matcher::Matcher 构建器,只差调用的方法:
Intercept::Http(url) => {
// ...
Matcher_::Util(
matcher::Matcher::builder()
.http(String::from(url))
.no(no_proxy.as_ref().map(|n| n.inner.as_ref()).unwrap_or(""))
.build(),
)
}
hyper-util 的 Matcher 只有三个字段:http: Option<Intercept>、https: Option<Intercept>、no: NoProxy。intercept 先查 no,再按目标 scheme 取 http 或 https;.all(url) 在 build 里填进两个都空着的位置。所以 reqwest 的 Proxy::all 和「http、https 各填一遍」是同一个东西。Custom 不进 hyper-util,留在 reqwest 的 Matcher_::Custom 里。
装进 Client 的是 ClientBuilder::proxy:
pub fn proxy(mut self, proxy: Proxy) -> ClientBuilder {
self.config.proxies.push(proxy.into_matcher());
self.config.auto_sys_proxy = false;
self
}
两件事:转换成 Matcher 推进列表;把 auto_sys_proxy 关掉。也就是说,只要配了一个显式代理,系统代理就整个不用了,而不是排在后面兜底。build 里只有 auto_sys_proxy 还为真时才在列表末尾追加 ProxyMatcher::system()。
连接器拿到的是这个列表。ConnectorService::call 按顺序问,第一个说「拦」的就是它:
for prox in self.proxies.iter() {
if let Some(intercepted) = prox.intercept(&dst) {
return Box::pin(with_timeout(
self.clone().connect_via_proxy(dst, intercepted),
timeout,
));
}
}
Box::pin(with_timeout(
self.clone().connect_with_maybe_proxy(dst, false),
timeout,
))
连接超时 with_timeout 包住的是整个过程,连代理、发 CONNECT、隧道里的 TLS 握手都算在 connect_timeout 里。(在这之前还有一个分支:配了 Unix 域套接字或 Windows 命名管道时直接走本地传输,代理全跳过。)
实测几种拦截方式。源站 http://127.0.0.1:端口/a 和 https://localhost:端口/b,源站把自己收到的请求行和版本写进响应体,日志代理打印它收到的请求头:
let c = builder().proxy(Proxy::http(&purl).unwrap()).build().unwrap();
go(&c, &http_url, &plog).await;
go(&c, &https_url, &plog).await;
== A. Proxy::http
http://127.0.0.1:64987/a -> 200 OK HTTP/1.1 tls_info=false body="conn#1 HTTP/1.1 GET http://127.0.0.1:64987/a []"
proxy saw: GET http://127.0.0.1:64987/a HTTP/1.1 | accept: */* | accept-encoding: gzip | host: 127.0.0.1:64987
https://localhost:64992/b -> 200 OK HTTP/2.0 tls_info=true body="conn#1 HTTP/2.0 GET https://localhost:64992/b []"
== B. Proxy::https
http://127.0.0.1:64987/a -> 200 OK HTTP/1.1 tls_info=false body="conn#2 HTTP/1.1 GET /a []"
https://localhost:64992/b -> 200 OK HTTP/2.0 tls_info=false body="conn#2 HTTP/2.0 GET https://localhost:64992/b []"
proxy saw: CONNECT localhost:64992 HTTP/1.1 | Host: localhost:64992
...
== H. two proxies: Proxy::http(P1) then Proxy::all(P2=auth proxy)
http://127.0.0.1:64987/a -> 200 OK HTTP/1.1 tls_info=false body="conn#9 HTTP/1.1 GET http://127.0.0.1:64987/a []"
proxy saw: GET http://127.0.0.1:64987/a HTTP/1.1 | accept: */* | accept-encoding: gzip | host: 127.0.0.1:64987
https://localhost:64992/b -> 200 OK HTTP/2.0 tls_info=false body="conn#8 HTTP/2.0 GET https://localhost:64992/b []"
proxy saw: CONNECT localhost:64992 HTTP/1.1 | Host: localhost:64992 | Proxy-Authorization: Basic YWxpY2U6czNjcmV0
- A:
Proxy::http只拦http://。HTTPS 请求没有经过代理(代理没有日志),直连源站。 - B:反过来,HTTP 直连,源站收到的是普通的
GET /a;HTTPS 走 CONNECT。 - H:两个代理按添加顺序问。HTTP 请求被第一个
Proxy::http拦走,HTTPS 请求它不拦,落到第二个Proxy::all。Proxy文档里提醒的就是反过来的情况:先加一个Proxy::all,后面的规则永远轮不到。 - 源站响应体里 HTTP 请求的请求行是
GET http://127.0.0.1:64987/a,这是日志代理原样转发的结果;真正的代理通常会改写成GET /a。HTTPS 请求的 URI 是完整的https://localhost:64992/b,因为它走的是 HTTP/2,hyper 服务端从:scheme和:authority伪头拼出了完整 URI。 tls_info=false那几行后面专门讲。
NoProxy 规则
NoProxy 里只存一个 String。NoProxy::from_string 什么也不解析,原样保存,注释写着「lazy parsed, to not make the type public in hyper-util」,所以它的返回值虽然是 Option,在 0.13.5 里永远是 Some。真正的解析在 into_matcher 调 .no(...) 之后,由 hyper-util 的 NoProxy::from_string 做:
- 按逗号切开,去掉两边空白;
- 能解析成
IpNet(10.0.0.0/8)或IpAddr(127.0.0.1、::1)的进 IP 列表,按地址或网段匹配; - 其余进域名列表。
DomainMatcher::contains不分大小写,example.com和.example.com都匹配example.com本身及其所有子域名,notexample.com不匹配; *匹配一切,这是唯一的通配符。*.example.com这种写法不是通配,会被当成一个字面域名。
匹配的对象是 URI 里的 host 字符串,不做 DNS。IP 规则只对写成 IP 的 URL 生效,域名规则只对写成域名的 URL 生效:
== E. Proxy::all + NoProxy("localhost")
http://127.0.0.1:64987/a -> 200 OK HTTP/1.1 tls_info=false body="conn#4 HTTP/1.1 GET http://127.0.0.1:64987/a []"
proxy saw: GET http://127.0.0.1:64987/a HTTP/1.1 | accept: */* | accept-encoding: gzip | host: 127.0.0.1:64987
https://localhost:64992/b -> 200 OK HTTP/2.0 tls_info=true body="conn#5 HTTP/2.0 GET https://localhost:64992/b []"
== E2. NoProxy("127.0.0.0/8")
http://127.0.0.1:64987/a -> 200 OK HTTP/1.1 tls_info=false body="conn#5 HTTP/1.1 GET /a []"
localhost 放过了 https://localhost,却拦不住 http://127.0.0.1;换成 127.0.0.0/8 才直连。
NoProxy::from_env 读 NO_PROXY,没有再读 no_proxy;两个都没设返回 None,设了空串返回 Some(NoProxy::default())。它只在你手动配 Proxy 时有用:系统代理那条 Matcher 自己会读 NO_PROXY,不需要它。
Proxy::custom 不认 NoProxy
Proxy::custom(f).no_proxy(...) 能编译,文档也没说不行,但在 0.13.5 里不起作用。into_matcher 把它存进了 Custom:
Intercept::Custom(mut custom) => {
maybe_has_http_auth = true; // never know
maybe_has_http_custom_headers = true;
custom.no_proxy = no_proxy;
Matcher_::Custom(custom)
}
而 Custom::call 从头到尾没有读 self.no_proxy。用 NoProxy::from_string("*")(应当放过一切)实测,请求照样进了代理:
== F2. Proxy::custom + no_proxy("*")
http://127.0.0.1:64987/a -> 200 OK HTTP/1.1 tls_info=false body="conn#7 HTTP/1.1 GET http://127.0.0.1:64987/a []"
proxy saw: GET http://127.0.0.1:64987/a HTTP/1.1 | accept: */* | accept-encoding: gzip | host: 127.0.0.1:64987
这是 #2681(4ec1fe5,「refactor: use hyper-util's proxy::Matcher」,首次发布在 v0.12.16)留下的。在那之前,Proxy::intercept 先算 in_no_proxy,五种拦截方式(含 Custom)都先看它;改用 hyper-util 的 Matcher 以后,前三种的排除规则交给了 hyper-util,Custom 这一支没有接上。要排除,只能写在闭包里,返回 None。
Proxy::custom 的闭包
Custom 存一个 Arc<dyn Fn(&Url) -> Option<crate::Result<Url>>>。Custom::call 先把目标 URI 拼回一个 Url,只拼 scheme、host、port:
let url = format!(
"{}://{}{}{}",
uri.scheme()?,
uri.host()?,
uri.port().map_or("", |_| ":"),
uri.port().map_or(String::new(), |p| p.to_string())
)
.parse()
.expect("should be valid Url");
闭包看不到路径和查询串,没法按路径选代理。连接器调 intercept 时手里本来就只有 scheme://host:port(连接池按它建连接),这个限制来自连接层,不是疏忽。闭包返回代理地址后,每次调用都现建一个 Matcher::builder().all(target) 再 intercept 一次,借 hyper-util 解析代理 URL 里的用户名密码。
GET http://127.0.0.1:端口/a?q=1,Proxy::custom 的闭包会被调用几次?let c = builder()
.proxy(Proxy::custom(move |url| {
println!(" custom closure got: {url}");
if url.scheme() == "http" { Some(purl2.clone()) } else { None }
}))
.build()
.unwrap();
go(&c, &format!("{http_url}?q=1"), &plog).await;
go(&c, &https_url, &plog).await;
同一个客户端接着发一次 https://localhost:端口/b,又是几次?平台:M2 Max、macOS 27,reqwest 0.13.5。
系统代理
默认 feature 里有 system-proxy,它只做一件事:打开 hyper-util 的 client-proxy-system(Cargo.toml 里 system-proxy = ["hyper-util/client-proxy-system"])。ClientBuilder::new 里 auto_sys_proxy: true,build 时追加 Matcher::system:
pub(crate) fn system() -> Self {
Self {
inner: Matcher_::Util(matcher::Matcher::from_system()),
extra: Extra {
auth: None,
misc: None,
},
// maybe env vars have auth!
maybe_has_http_auth: true,
maybe_has_http_custom_headers: true,
}
}
Matcher::from_system 先读环境变量(ALL_PROXY、HTTPS_PROXY、HTTP_PROXY、NO_PROXY,大写优先,再试小写),再在 macOS / Windows 上读系统设置。macOS 上它只取 HTTP、HTTPS 两组的开关、主机、端口六个键,不读 ExceptionsList,也不读 ExcludeSimpleHostnames;排除规则只有 NO_PROXY 一个来源。这些细节、以及「系统例外列表里明写的 localhost 也被交给代理」的实测,hyper 卷的本站的出站客户端一页已经讲完,这里不重复,只补 reqwest 这一侧的几件事。不开 system-proxy 时,from_system 仍然存在,退化成只读环境变量。
Client 的 Debug 输出会打印代理列表(hyper-util 的 Intercept 打印时故意不带认证信息),拿它看 build 的结果,不用发请求。程序在 connb-lab/src/bin/sysproxy.rs,U 是去掉 8 个代理环境变量的 env -u ... 参数:
== shell env
Client::new(): proxies: [Matcher { http: Intercept { uri: http://127.0.0.1:7890/ }, https: Intercept { uri: http://127.0.0.1:7890/ }, no: NoProxy { ips: IpMatcher([Address(127.0.0.1), Network(192.168.64.0/24), Address(::1)]), domains: DomainMatcher(["localhost", ".local", ".xmpbinfo.cn"]) } }]
.proxy(Proxy::http(..)): proxies: [Matcher { http: Intercept { uri: http://127.0.0.1:1/ } }]
.no_proxy(): (no proxies field)
== no proxy env vars
Client::new(): proxies: [Matcher { http: Intercept { uri: http://127.0.0.1:7890/ }, https: Intercept { uri: http://127.0.0.1:7890/ } }]
== only ALL_PROXY=http://127.0.0.1:1
Client::new(): proxies: [Matcher { http: Intercept { uri: http://127.0.0.1:7890/ }, https: Intercept { uri: http://127.0.0.1:7890/ } }]
== HTTP_PROXY=http://127.0.0.1:3 + REQUEST_METHOD=GET
Client::new(): proxies: [Matcher]
== rebuild after set_var (no env vars)
before set_var: proxies: [Matcher { http: Intercept { uri: http://127.0.0.1:7890/ }, https: Intercept { uri: http://127.0.0.1:7890/ } }]
after set_var : proxies: [Matcher { http: Intercept { uri: http://127.0.0.1:2/ }, https: Intercept { uri: http://127.0.0.1:7890/ } }]
(== 标题是运行时 echo 的;「no proxy env vars」连跑 3 次,输出相同。)逐行看:
.proxy(...)之后列表里只剩显式那一条,系统代理没了;.no_proxy()之后列表为空,Debug干脆不打印这个字段。- 去掉环境变量,系统设置里的
127.0.0.1:7890补了进来,no是空的。 - 只设
ALL_PROXY时,它被系统设置盖掉了。mac::with_system只看builder.http.is_empty()/builder.https.is_empty(),不看builder.all;ALL_PROXY只进了all,于是http、https两个位置被系统设置填上,build时parse_env_uri(&self.http).or_else(|| all.clone())先用了http。from_system的文档说「checks the same environment variables asfrom_env(), and if not set, checks the system configuration」,ALL_PROXY明明设了,结果却是系统设置优先。reqwest CHANGELOG v0.12.6 那条「HTTP(S)_PROXYvalues take precedence overALL_PROXY」说的是环境变量之间的顺序,没覆盖这个情况。 - 设了
REQUEST_METHOD,整个Matcher变空,连系统设置也不用了。Builder::from_env把它当作「在 CGI 脚本里」(is_cgi),build直接返回空Matcher:CGI 把请求头映射成HTTP_*环境变量,客户端发一个Proxy:请求头就能伪造出HTTP_PROXY,所以不能信。reqwest 很早就有这条规则(CHANGELOG 里「Fix checkingHTTP_PROXYenvironment variable if it the environment is from a CGI script」),现在由 hyper-util 执行,范围更大:连HTTPS_PROXY和系统设置也一并不用。 - 最后一组在同一个进程里:先
Client::new(),再std::env::set_var("HTTP_PROXY", ...),再Client::new(),第二个客户端看到了新值。环境变量和系统设置是在每次build时读的。
#2442「refactor: remove internal proxy sys cache」(3ad6e02,首次发布在 v0.12.9)。之前 proxy.rs 里有一个 static SYS_PROXIES: Lazy<Arc<SystemProxyMap>>,Proxy::system() 默认克隆它,环境变量和系统设置只在第一次用到时读一次;测试为了能改环境变量,要开一个内部 feature __internal_proxy_sys_no_cache。PR 描述:「Now it will check every time a Client is built.」这个 feature 随之删掉。
现在 Matcher::system() 每次都调 from_system(),上面「rebuild after set_var」那一组就是它的结果。代价是每个 build 都要读一遍环境变量,macOS 上还要建一次 SCDynamicStore,这也是「不要每个请求新建 Client」的又一个理由。
127.0.0.1 请求被交给谁
把 HTTP_PROXY 指向本机日志代理,NO_PROXY 分别不设、设成 127.0.0.1、设成 localhost,再用 Client::new() 和 .no_proxy() 各访问一次 127.0.0.1 和 localhost 上的源站(connb-lab/src/bin/sys127.rs,单线程运行时,set_var 时没有别的线程在读环境变量):
NO_PROXY=None
Client::new() http://127.0.0.1:62336/ -> conn#1 HTTP/1.1 GET http://127.0.0.1:62336/ []
proxy saw: GET http://127.0.0.1:62336/ HTTP/1.1 | accept: */* | accept-encoding: gzip | host: 127.0.0.1:62336
Client::new() http://localhost:62336/ -> conn#2 HTTP/1.1 GET http://localhost:62336/ []
proxy saw: GET http://localhost:62336/ HTTP/1.1 | accept: */* | accept-encoding: gzip | host: localhost:62336
builder().no_proxy() http://127.0.0.1:62336/ -> conn#3 HTTP/1.1 GET / []
builder().no_proxy() http://localhost:62336/ -> conn#4 HTTP/1.1 GET / []
NO_PROXY=Some("127.0.0.1")
Client::new() http://127.0.0.1:62346/ -> conn#1 HTTP/1.1 GET / []
Client::new() http://localhost:62346/ -> conn#2 HTTP/1.1 GET http://localhost:62346/ []
proxy saw: GET http://localhost:62346/ HTTP/1.1 | accept: */* | accept-encoding: gzip | host: localhost:62346
builder().no_proxy() http://127.0.0.1:62346/ -> conn#3 HTTP/1.1 GET / []
builder().no_proxy() http://localhost:62346/ -> conn#4 HTTP/1.1 GET / []
NO_PROXY=Some("localhost")
Client::new() http://127.0.0.1:62355/ -> conn#1 HTTP/1.1 GET http://127.0.0.1:62355/ []
proxy saw: GET http://127.0.0.1:62355/ HTTP/1.1 | accept: */* | accept-encoding: gzip | host: 127.0.0.1:62355
Client::new() http://localhost:62355/ -> conn#2 HTTP/1.1 GET / []
builder().no_proxy() http://127.0.0.1:62355/ -> conn#3 HTTP/1.1 GET / []
builder().no_proxy() http://localhost:62355/ -> conn#4 HTTP/1.1 GET / []
回环地址没有任何特殊待遇:只要有代理配置、NO_PROXY 又没写这个 host 的原样字符串,请求就交给代理。这里的日志代理老老实实转发了,所以请求成功;代理要是把它丢掉(Clash 一类工具对回环地址的处理各不相同),得到的就是 hyper 卷本站的出站客户端里那个 connection closed before message completed。.no_proxy() 在三种环境下都直连。
问: ClientBuilder::no_proxy() 和 Proxy::no_proxy(...) 名字一样,是一回事吗?
答: 不是。ClientBuilder::no_proxy 清空整个代理列表并关掉系统代理,这个 Client 从此一律直连。Proxy::no_proxy 是给一个代理配排除列表,参数是 Option<NoProxy>,其余请求照样走这个代理。想「保留系统代理、只放过本机」,两个都不对:要么设环境变量 NO_PROXY,要么自己 Proxy::all(..).no_proxy(..) 写一条,但写了显式代理,系统代理就不再读了。
HTTP 目标走绝对 URI,HTTPS 目标走 CONNECT
拦下以后进 connect_via_proxy。SOCKS 先分出去(下一节),剩下的按目标的 scheme 分。默认的 rustls 分支:
Inner::RustlsTls {
http,
tls,
tls_proxy,
} => {
if dst.scheme() == Some(&Scheme::HTTPS) {
// ...
let http = http.clone();
let inner = hyper_rustls::HttpsConnector::from((http, tls_proxy.clone()));
// TODO: we could cache constructing this
let mut tunnel =
hyper_util::client::legacy::connect::proxy::Tunnel::new(proxy_dst, inner);
if let Some(auth) = auth {
tunnel = tunnel.with_auth(auth);
}
if let Some(custom_headers) = misc {
tunnel = tunnel.with_headers(custom_headers.clone());
}
if let Some(ua) = self.user_agent {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, ua);
tunnel = tunnel.with_headers(headers);
}
// We don't wrap this again in an HttpsConnector since that uses Maybe,
// and we know this is definitely HTTPS.
let tunneled = tunnel.call(dst.clone()).await?;
// ...
let io = RustlsConnector::from(tls.clone())
.connect(server_name, TokioIo::new(tunneled))
.await?;
return Ok(Conn {
inner: self.verbose.wrap(RustlsTlsConn {
inner: TokioIo::new(io),
}),
is_proxy: false,
tls_info: false,
});
}
}
// ...
}
self.connect_with_maybe_proxy(proxy_dst, true).await
HTTP 目标跳过整个 if,落到最后一行:把代理地址当作目标,走普通的直连流程(代理地址是 https:// 时这里会和代理做 TLS),只是 is_proxy 传了 true。它最后进 Conn 的 connected(),变成 hyper-util 的 Connected::proxy(true)。Conn 上的注释写得很直白:is_proxy 的意思是「plain text HTTP proxy」,它决定 hyper 写 origin-form(GET /just/a/path)还是 absolute-form(GET http://foo.bar/and/a/path)。legacy Client 怎么根据它改写请求,见 hyper 卷的 legacy Client。实验 A 里代理收到的 GET http://127.0.0.1:64987/a HTTP/1.1 就是这一支。
HTTPS 目标用 hyper-util 的 Tunnel:它包着一个连接器,call(dst) 时先连 proxy_dst,再在这条连接上执行 tunnel:
let mut buf = format!(
"\
CONNECT {host}:{port} HTTP/1.1\r\n\
Host: {host}:{port}\r\n\
"
)
.into_bytes();
后面接 Proxy-Authorization 或额外的头,再读代理的响应:200 就把这条连接原样还回去,407 是 ProxyAuthRequired,其他状态码是 TunnelUnsuccessful,响应头超过 8192 字节是 ProxyHeadersTooLong。目标 URI 没写端口时用 443。隧道建好,reqwest 自己在上面用 tls(不是 tls_proxy)跟源站握手,返回的 Conn 里 is_proxy: false:对 hyper 来说,这就是一条普通的到源站的 TLS 连接,请求写 origin-form,HTTP/2 也照常可用。实验 B 里 HTTPS 请求走 CONNECT 之后是 HTTP/2.0。
tls 和 tls_proxy 的区别在 new_rustls_tls 里:
let (tls, tls_proxy) = if proxies.is_empty() {
let tls = Arc::new(tls);
(tls.clone(), tls)
} else {
let mut tls_proxy = tls.clone();
tls_proxy.alpn_protocols.clear();
(Arc::new(tls), Arc::new(tls_proxy))
};
tls_proxy 只用在「HTTPS 代理 + HTTPS 目标」时和代理之间那一层 TLS 上,ALPN 清空,代理就不会选 h2:CONNECT 是一个 HTTP/1.1 请求,Tunnel 也只会写 HTTP/1.1 字节。注意 proxies.is_empty() 判断的是显式代理加系统代理的总列表,默认配置下系统代理那条 Matcher 总在,这里几乎总是走 else,多克隆一份 ClientConfig。
#466「fix proxy missing alpn」(a27db28,首次发布在 v0.9.12,修 #459),提交说明只有一行:「disable alpn for proxy connection」。之前 rustls 分支只有一份 ClientConfig,和代理的 TLS 也带着 ALPN;代理选了 h2,后面却要按 HTTP/1.1 发 CONNECT。这次改动做了两件事:拷一份清空 ALPN 的配置给代理那一跳用;在隧道里的 TLS 握手完成后检查协商结果,是 h2 就告诉 hyper(当时的写法是 connected.negotiated_h2())。
tls / tls_proxy 这一对一直沿用到 0.13.5,只是从元组变成了 Inner::RustlsTls 的具名字段。
两个实测里的细节:
tls_info(true) 经过隧道就拿不到 TlsInfo。 上面 rustls 分支返回的 Conn 写死了 tls_info: false,native-tls 的隧道分支同样如此。实验 A 里直连的 HTTPS 是 tls_info=true,B、C、H 里走 CONNECT 的都是 false;.no_proxy() 直连对照组是 true。依赖 TlsInfo 取对端证书的代码,一旦机器上有 HTTPS_PROXY,会静默拿到 None。
HTTP 目标 + HTTPS 代理时,和代理之间会协商 HTTP/2。 这一支走 connect_with_maybe_proxy,用的是带 ALPN 的 tls,不是 tls_proxy。用 HTTPS 源站扮演代理(它会自己回应带绝对 URI 的请求):
== J. HTTPS proxy (TLS to the proxy; the TLS origin plays the proxy) for an http:// URL
http://127.0.0.1:64987/a -> 200 OK HTTP/2.0 tls_info=true body="conn#9 HTTP/2.0 GET http://127.0.0.1:64987/a []"
代理选了 h2,reqwest 就用 HTTP/2 发 GET http://127.0.0.1:64987/a。这里 tls_info=true,但这份 TlsInfo 是代理的证书,不是源站的:源站是明文 HTTP。
代理认证与自定义头
认证信息有三个来源:
- 代理 URL 里的
user:pass@,由 hyper-util 的parse_env_uri拆出来,百分号解码后编码成Basic(SOCKS 代理则保留原始的用户名密码)。环境变量里的代理也走这里。 Proxy::basic_auth:对All/Http/Https,它调url_auth把用户名密码写进代理 URL,等于上一种;对Custom写进extra.auth。Proxy::custom_http_auth:整个头的值,写进extra.auth。Intercepted::basic_auth先看extra.auth,没有才用 URL 里的。
送出去的位置由目标 scheme 决定。HTTPS 目标:连接器把它交给 Tunnel::with_auth,只出现在 CONNECT 请求里,源站看不到。HTTP 目标:连接器那一支(connect_with_maybe_proxy)不管认证,由发请求的一侧补。Client::proxy_auth 在 execute_request 里、请求进 hyper 之前调用:
// Only set the header here if the destination scheme is 'http',
// since otherwise, the header will be included in the CONNECT tunnel
// request instead.
if dst.scheme() != Some(&Scheme::HTTP) {
return;
}
if headers.contains_key(PROXY_AUTHORIZATION) {
return;
}
for proxy in self.inner.proxies.iter() {
if let Some(proxy) = proxy.intercept(dst) {
if let Some(header) = proxy.http_non_tunnel_basic_auth() {
headers.insert(PROXY_AUTHORIZATION, header);
}
// Use only the first matching proxy, as the connector does.
break;
}
}
前面还有一道 proxies_maybe_http_auth 的快速判断:build 时算好「有没有哪个代理可能带 HTTP 认证」(Matcher::maybe_has_http_auth),全都没有就不必每个请求再 intercept 一遍。Custom 和系统代理无从预知,一律算「可能有」。用户自己在请求上设了 Proxy-Authorization 时不覆盖。
实测,日志代理要求 Basic YWxpY2U6czNjcmV0(alice:s3cret),不对就回 407:
== C. Proxy::all + basic_auth (proxy requires it)
http://127.0.0.1:64987/a -> 200 OK HTTP/1.1 tls_info=false body="conn#3 HTTP/1.1 GET http://127.0.0.1:64987/a [proxy-authorization=Basic YWxpY2U6czNjcmV0]"
proxy saw: GET http://127.0.0.1:64987/a HTTP/1.1 | accept: */* | proxy-authorization: Basic YWxpY2U6czNjcmV0 | accept-encoding: gzip | host: 127.0.0.1:64987
https://localhost:64992/b -> 200 OK HTTP/2.0 tls_info=false body="conn#3 HTTP/2.0 GET https://localhost:64992/b []"
proxy saw: CONNECT localhost:64992 HTTP/1.1 | Host: localhost:64992 | Proxy-Authorization: Basic YWxpY2U6czNjcmV0
== D. Proxy::all, wrong password
http://127.0.0.1:64987/a -> 407 Proxy Authentication Required HTTP/1.1 tls_info=false body=""
proxy saw: GET http://127.0.0.1:64987/a HTTP/1.1 | accept: */* | proxy-authorization: Basic YWxpY2U6d3Jvbmc= | accept-encoding: gzip | host: 127.0.0.1:64987
https://localhost:64992/b -> error: error sending request for url (https://localhost:64992/b) <- client error (Connect) <- tunnel error: proxy authorization required
proxy saw: CONNECT localhost:64992 HTTP/1.1 | Host: localhost:64992 | Proxy-Authorization: Basic YWxpY2U6d3Jvbmc=
== D2. credentials in the proxy URL
https://localhost:64992/b -> 200 OK HTTP/2.0 tls_info=false body="conn#4 HTTP/2.0 GET https://localhost:64992/b []"
proxy saw: CONNECT localhost:64992 HTTP/1.1 | Host: localhost:64992 | Proxy-Authorization: Basic YWxpY2U6czNjcmV0
- HTTP 请求的
Proxy-Authorization是请求头的一部分。日志代理原样转发,源站也收到了;正规的代理会去掉它,但这说明它和普通请求头走的是同一条路。HTTPS 请求的源站没收到。 - 认证失败的表现不一样:HTTP 目标拿到一个
Ok的 407 响应(407 是代理回的,reqwest 当普通响应交给你,不调error_for_status就当成功了);HTTPS 目标是连接错误,错误链底部是tunnel error: proxy authorization required,is_connect()为真。 - D2:写在 URL 里的
alice:s3cret@和basic_auth结果一样。
break#3098「fix: proxy could use wrong credentials if many matched」(4d3fe12,2026-09-07,首次发布就在 v0.13.5,关 #3097)。改之前的循环是:
for proxy in self.inner.proxies.iter() {
if let Some(header) = proxy.http_non_tunnel_basic_auth(dst) {
headers.insert(PROXY_AUTHORIZATION, header);
break;
}
}
它找的是「第一个有认证信息的匹配代理」,而连接器用的是「第一个匹配的代理」。两者不一致时,比如先加一个不带认证的 Proxy::http(A),再加一个带认证的 Proxy::http(B).basic_auth(..),连接去了 A,B 的用户名密码却写进了发给 A 的请求头。改成先 intercept、匹配上就 break 以后,两边选的是同一个代理;proxy_custom_headers 同样改了。CHANGELOG v0.13.5 的说法是「Fix sending wrong proxy-auth if multiple proxies intercept a given URL」。本站用的 0.12.28 里还是旧循环,但本站没有配多个显式代理,不受影响。
Proxy::headers 存进 extra.misc,走法和认证一样:HTTP 目标由 Client::proxy_custom_headers insert 进请求头,HTTPS 目标交给 Tunnel::with_headers 写进 CONNECT。ClientBuilder::user_agent 设的 UA 也会被写进 CONNECT。两者都设了 User-Agent 会怎样:
== G. Proxy::headers + client user_agent
http://127.0.0.1:64987/a -> 200 OK HTTP/1.1 tls_info=false body="conn#8 HTTP/1.1 GET http://127.0.0.1:64987/a [user-agent=ua-from-proxy-headers, x-proxy-secret=42]"
proxy saw: GET http://127.0.0.1:64987/a HTTP/1.1 | accept: */* | user-agent: ua-from-proxy-headers | x-proxy-secret: 42 | accept-encoding: gzip | host: 127.0.0.1:64987
https://localhost:64992/b -> 200 OK HTTP/2.0 tls_info=false body="conn#7 HTTP/2.0 GET https://localhost:64992/b [user-agent=ua-from-client]"
proxy saw: CONNECT localhost:64992 HTTP/1.1 | Host: localhost:64992 | user-agent: ua-from-client | x-proxy-secret: 42
- HTTP 目标:代理头
insert在默认头之后,覆盖了客户端的 UA,x-proxy-secret也成了普通请求头,转发到了源站。给代理的私密头,在 HTTP 目标上和请求一起走。 - HTTPS 目标:CONNECT 里是客户端的 UA。rustls 分支先
with_headers(custom)、后with_headers(ua),Tunnel::with_headers用HeaderMap::extend合并,后来的覆盖先来的。native-tls 分支的顺序相反(先 UA 后自定义头),旁边的注释写着「Note that custom headers may override the user agent header」,在 rustls 分支上不成立。两个后端行为不一致,native-tls 这一侧本页没有实测。
SOCKS
socks feature 在 0.13.5 是空的(socks = []):协议实现在 hyper-util 的 SocksV4 / SocksV5 里,reqwest 一直打开着 hyper-util 的 client-proxy,feature 只控制 reqwest 这边的代码编不编进来。connect_via_proxy 一看代理 scheme 是 socks4 / socks4a / socks5 / socks5h,就转进 connect_socks:
let dns = match proxy.uri().scheme_str() {
Some("socks4") | Some("socks5") => socks::DnsResolve::Local,
Some("socks4a") | Some("socks5h") => socks::DnsResolve::Proxy,
_ => {
unreachable!("connect_socks is only called for socks proxies");
}
};
带 a / h 的让代理解析域名,不带的在本地解析,再把 IP 交给代理。socks::connect 里本地解析这一步是:
if let DnsResolve::Local = dns_mode {
let maybe_new_target = resolver
.http_resolve(&dst)
.await
.map_err(SocksProxyError::SocksLocalResolve)?
.next();
只取解析结果的第一个地址,不像直连那样逐个尝试(happy eyeballs 见 DNS)。拿到 TCP 流以后,HTTPS 目标再用带 ALPN 的 tls 握手。用户名密码来自代理 URL,只有 SOCKS5 用得上(方法 0x02)。
本机的 SOCKS5 服务器只实现 CONNECT 和 0x00 / 0x02 两种方法,记录握手内容;源站只监听 127.0.0.1,请求用 localhost(connb-lab/src/bin/socks.rs):
== socks5://127.0.0.1:62422
http://localhost:62420/a -> error: reqwest::Error { kind: Request, url: "http://localhost:62420/a", source: hyper_util::client::legacy::Error(Connect, SocksConnect(Io(Custom { kind: UnexpectedEof, error: "unexpected eof" }))) }
socks saw: greeting ver=5 methods=[0]; CONNECT ipv6 ::1 port 62420
https://localhost:62421/b -> error: reqwest::Error { kind: Request, url: "https://localhost:62421/b", source: hyper_util::client::legacy::Error(Connect, SocksConnect(Io(Custom { kind: UnexpectedEof, error: "unexpected eof" }))) }
socks saw: greeting ver=5 methods=[0]; CONNECT ipv6 ::1 port 62421
== socks5h://127.0.0.1:62422
http://localhost:62420/a -> HTTP/1.1 tls_info=false conn#1 HTTP/1.1 GET /a []
socks saw: greeting ver=5 methods=[0]; CONNECT domain localhost port 62420
https://localhost:62421/b -> HTTP/2.0 tls_info=false conn#1 HTTP/2.0 GET https://localhost:62421/b []
socks saw: greeting ver=5 methods=[0]; CONNECT domain localhost port 62421
== socks5h://alice:s3cret@127.0.0.1:62422
http://localhost:62420/a -> HTTP/1.1 tls_info=false conn#2 HTTP/1.1 GET /a []
socks saw: greeting ver=5 methods=[2]; auth alice:s3cret; CONNECT domain localhost port 62420
https://localhost:62421/b -> HTTP/2.0 tls_info=false conn#2 HTTP/2.0 GET https://localhost:62421/b []
socks saw: greeting ver=5 methods=[2]; auth alice:s3cret; CONNECT domain localhost port 62421
Proxy(All(Url { scheme: "socks5h", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("example.invalid")), port: Some(1080), path: "", query: None, fragment: None }), None)
最后一行是 Proxy::all("socks5h://example.invalid") 的 Debug 输出。
socks5://:本地解析localhost,第一个结果是::1,代理被要求连[::1]:端口,源站不在那里。我的 SOCKS 服务器连不上就直接关连接,所以 reqwest 看到的是unexpected eof;正规的服务器会回一个失败码,错误文字不同,但结果一样。直连同一个localhostURL 没问题,因为HttpConnector会接着试127.0.0.1。socks5h://:域名原样交给代理,由代理解析,成功。HTTPS 目标在 SOCKS 通道上协商出了 HTTP/2。- 带用户名密码时,问候里只报了方法 2(用户名/密码),没有报 0(无认证)。
- 走 SOCKS 的 HTTP 请求,源站收到 origin-form 的
GET /a:SOCKS 是纯 TCP 通道,Conn的is_proxy是false。 tls_info=false:rustls 的 SOCKS 分支同样写死了tls_info: false(native-tls 的 SOCKS 分支用的是self.tls_info)。- 没写端口的 SOCKS 代理补了 1080。改用 hyper-util 的
Matcher后这一步一度丢了,#2771(f186803,v0.12.23)在into_proxy里补回来。
SOCKS4 / 4a 没有实测:本机的服务器只实现了 SOCKS5。
SOCKS 握手本身(状态机、send_optimistically、错误类型)在 hyper 卷的 代理连接器 里讲。那一页实测的一个限制 reqwest 也会碰到:SOCKS5 的用户名、密码和交给代理的主机名合计超过 249 字节时,握手以 SOCKS error: failed serialize request 失败。
动脑筋答案
== F. Proxy::custom (closure sees the URL)
custom closure got: http://127.0.0.1:64987/
custom closure got: http://127.0.0.1:64987/
custom closure got: http://127.0.0.1:64987/
http://127.0.0.1:64987/a?q=1 -> 200 OK HTTP/1.1 tls_info=false body="conn#6 HTTP/1.1 GET http://127.0.0.1:64987/a?q=1 []"
proxy saw: GET http://127.0.0.1:64987/a?q=1 HTTP/1.1 | accept: */* | accept-encoding: gzip | host: 127.0.0.1:64987
custom closure got: https://localhost:64992/
https://localhost:64992/b -> 200 OK HTTP/2.0 tls_info=true body="conn#6 HTTP/2.0 GET https://localhost:64992/b []"
HTTP 请求 3 次,HTTPS 请求 1 次。
Custom的maybe_has_http_auth和maybe_has_http_custom_headers都被设成true(「never know」),所以execute_request里的proxy_auth和proxy_custom_headers各intercept一次;新建连接时ConnectorService::call再intercept一次。三次都是同一个闭包。- HTTPS 请求时,
proxy_auth和proxy_custom_headers在检查 scheme 那一行就返回了,只剩连接器那一次。闭包返回None,请求直连,tls_info=true。 - 闭包每次看到的都是
http://127.0.0.1:64987/:路径/a和查询串q=1都不在,Custom::call只拼了 scheme、host、port。 - 如果连接是从池里复用的,连接器那一次不会发生。闭包要便宜,而且要对同一个 host 给出同样的答案:auth 头按前两次的答案加,连接按第三次的答案建。
小结
Proxy::http/https/all按目标 scheme 拦截,转换成 hyper-util 的Matcher;多个代理按添加顺序,第一个匹配的生效。- 显式
.proxy(..)会关掉系统代理;.no_proxy()清空一切。系统代理每次build时重读,macOS 上ALL_PROXY会被系统设置盖掉,REQUEST_METHOD存在时全部不用。 NoProxy按 host 字符串匹配:localhost不覆盖127.0.0.1。Proxy::custom上配的NoProxy不起作用(自 0.12.16 起)。- HTTP 目标:连代理、
is_proxy=true、绝对 URI,认证头和代理自定义头进请求头。HTTPS 目标:Tunnel发 CONNECT,认证头只在 CONNECT 里,隧道里用带 ALPN 的 TLS,可以是 HTTP/2。 - 认证失败:HTTP 目标是
Ok(407),HTTPS 目标是连接错误tunnel error: proxy authorization required。 - 经 CONNECT 或 SOCKS(rustls)的连接拿不到
TlsInfo;HTTPS 代理 + HTTP 目标时TlsInfo是代理的。 socks5本地解析只用第一个地址,socks5h交给代理解析。