Rust in Everything
基于 reqwest 0.13.5

代理

主线位置:连接主线的岔路口。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 里的用户名密码。

❗IMPORTANT
动脑筋:一个新连接上的一次 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 as from_env(), and if not set, checks the system configuration」,ALL_PROXY 明明设了,结果却是系统设置优先。reqwest CHANGELOG v0.12.6 那条「HTTP(S)_PROXY values take precedence over ALL_PROXY」说的是环境变量之间的顺序,没覆盖这个情况。
  • 设了 REQUEST_METHOD,整个 Matcher 变空,连系统设置也不用了。Builder::from_env 把它当作「在 CGI 脚本里」(is_cgi),build 直接返回空 Matcher:CGI 把请求头映射成 HTTP_* 环境变量,客户端发一个 Proxy: 请求头就能伪造出 HTTP_PROXY,所以不能信。reqwest 很早就有这条规则(CHANGELOG 里「Fix checking HTTP_PROXY environment 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 时读的。
📝NOTE
冷知识:系统代理以前一个进程只读一次

#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() 在三种环境下都直连。

📝NOTE
没有蠢问题

问: 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。

📝NOTE
冷知识:清空 ALPN 是为了修「走代理就连不上」

#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 结果一样。
📝NOTE
冷知识:v0.13.5 修的正是这个循环里的 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;正规的服务器会回一个失败码,错误文字不同,但结果一样。直连同一个 localhost URL 没问题,因为 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 交给代理解析。

💬 关联讨论

发起讨论
加载中...