依赖开了 native-tls,默认后端就换了
主线位置:连接主线的 TLS 一步,决定却在
ClientBuilder::build里:config.tls的默认值由编译进来的 feature 决定。后端怎么选、两个后端各自怎么配,见 TLS 配置。本页讲这条规则在依赖图里怎么咬人。
flowchart TB app["应用 reqwest 默认"] --> uni["feature 并集"] dep["某个依赖 native-tls"] --> uni uni --> both["rustls 加 native-tls"] both --> def["TlsBackend default"] def --> nt["NativeTls"] nt --> s1["PEM 证书报错"] nt --> s2["TLS 1.3 下限失败"] nt --> s3["TlsInfo 无版本"] fix["tls_backend_rustls"] --> rs["Rustls"]
实验 crate:exp-reqwest/pitb-native(应用)和 exp-reqwest/pitb-dep(替身依赖)。M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,release 构建,客户端一律 .no_proxy()。服务器是 testsrv 的 TLS 模式(rustls 服务端,ALPN h2、http/1.1),客户端用 tls_certs_only 只信它的测试 CA。
现象
应用自己写的是 reqwest = { version = "=0.13.5", features = ["json"] },按 0.13 的默认,后端是 rustls。pitb-dep 是一个替身,代表某个 SDK,它的 Cargo.toml 里只有一行:
reqwest = { version = "=0.13.5", features = ["native-tls"] }
应用的代码一个字不改,同一个程序编两次:不链接 pitb-dep,和链接 pitb-dep(--features dep;应用代码从不调用它)。程序建四个客户端:什么都不配;加一个 PEM 格式的客户端证书;设 TLS 1.3 下限;显式 tls_backend_rustls() 再设 TLS 1.3 下限:
go("plain builder", base(), &url).await;
go("identity(from_pem)", base().identity(Identity::from_pem(pem.as_bytes()).unwrap()), &url).await;
go("tls_version_min(1.3)", base().tls_version_min(tls::Version::TLS_1_3), &url).await;
go("tls_backend_rustls + 1.3", base().tls_backend_rustls().tls_version_min(tls::Version::TLS_1_3), &url).await;
不链接 pitb-dep:
pitb-dep linked: false
plain builder: 200 OK HTTP/2.0, TlsInfo version Some(Version(Tls1_3))
identity(from_pem): 200 OK HTTP/2.0, TlsInfo version Some(Version(Tls1_3))
tls_version_min(1.3): 200 OK HTTP/2.0, TlsInfo version Some(Version(Tls1_3))
tls_backend_rustls + 1.3: 200 OK HTTP/2.0, TlsInfo version Some(Version(Tls1_3))
链接 pitb-dep:
pitb-dep linked: true
plain builder: 200 OK HTTP/2.0, TlsInfo version None
identity(from_pem): build: builder error <- incompatible TLS identity type
tls_version_min(1.3): send: error sending request for url (https://localhost:50116/) <- client error (Connect) <- illegal parameter
innermost: Error { code: -9830, message: "illegal parameter" }
tls_backend_rustls + 1.3: 200 OK HTTP/2.0, TlsInfo version Some(Version(Tls1_3))
- 请求照样成功,但换了实现。 第一行仍是
200 OK,TlsInfo的版本却成了None:走的是 native-tls(macOS 上是 Security.framework),这个后端从不报告版本。它在这台机器上只谈 TLS 1.2,ClientHello 的样子见 TLS 配置。 - PEM 客户端证书在
build()失败,报builder error,source是incompatible TLS identity type。Identity::from_pem是给 rustls 用的格式,native-tls 不认。 - TLS 1.3 下限在连接时失败,
source是illegal parameter,最底层是 Security.framework 的错误码 -9830。 - 第四个客户端显式选了 rustls,两次构建结果一样。
依赖只是被链接进来,从头到尾没有被调用过。
原因
Cargo 对同一个包的 feature 取并集。依赖图里只有一份 reqwest 0.13.5,应用要了默认的 default-tls,pitb-dep 要了 native-tls,编出来的 reqwest 两个后端都有。Cargo.toml 里这两个 feature 的定义见 default-tls 和 native-tls。
两个都在的时候,默认用谁由 TlsBackend::default 决定:
impl Default for TlsBackend {
fn default() -> TlsBackend {
#[cfg(any(
all(feature = "__rustls", not(feature = "__native-tls")),
feature = "http3"
))]
{
TlsBackend::Rustls
}
#[cfg(all(feature = "__native-tls", not(feature = "http3")))]
{
TlsBackend::NativeTls
}
}
}
只要 __native-tls 编进来(且没开 http3),默认就是 NativeTls,default-tls 开没开都一样。这样安排的来历见 TLS 配置 里关于 #2752 的冷知识。
三个症状各有出处:
build()里TlsBackend::NativeTls分支调Identity::add_to_native_tls,它只接受 PKCS#12 和 PKCS#8 两种形态,碰到from_pem产生的ClientCert::Pem就返回crate::error::builder("incompatible TLS identity type")。- 同一个分支把
tls_version_min翻译成native_tls::Protocol::Tlsv13交给 native-tls。tls_version_min的文档写着:「On Apple platforms, a value oftls::Version::TLS_1_3may cause requests to fail (with error -9830) with thenative-tlsbackend due to lack of TLS 1.3 support.」上面的code: -9830就是它。 TlsInfo::version的文档:「Thenative-tlsbackend never reports a version.」
修法
在你建的每个 ClientBuilder 上显式选后端:
let client = reqwest::Client::builder()
.tls_backend_rustls()
// ...
.build()?;
tls_backend_rustls 只是把 config.tls 设成 TlsBackend::Rustls,上面第四行就是它:链接了 pitb-dep 也拿到 TLS 1.3。0.12 里的旧名字 use_rustls_tls() 在 0.13.5 还在,文档注释写着「Deprecated」,实现就是转调 tls_backend_rustls()。tls.rs 的模块文档给的也是这个建议:「If you wish to ensure your Client uses a specific backend, call the appropriate builder methods」。
这只管你自己建的客户端。依赖内部用 Client::new() 建的客户端,同样拿到 native-tls 默认值,你改不了它;要让整个进程都不用 native-tls,只能让那个依赖不打开 reqwest 的 native-tls(看它有没有关掉这个 feature 的开关)。
不想要 native-tls 带来的 OpenSSL / Security.framework 依赖时也是同一个办法:找到开它的依赖。
怎么检查
reqwest 最终开了哪些 feature。 方括号里有 __native-tls,没配置的客户端就是 native-tls:
$ cargo tree --offline --color never -i reqwest -f '{p} [{f}]' --depth 0 --features dep
reqwest v0.13.5 [__native-tls,__native-tls-alpn,__rustls,__rustls-aws-lc-rs,__tls,blocking,charset,cookies,default,default-tls,form,gzip,hickory-dns,http2,json,multipart,native-tls,query,rustls,socks,stream,system-proxy]
$ cargo tree --offline --color never -i reqwest -f '{p} [{f}]' --depth 0
reqwest v0.13.5 [__rustls,__rustls-aws-lc-rs,__tls,blocking,charset,cookies,default,default-tls,form,gzip,hickory-dns,http2,json,multipart,query,rustls,socks,stream,system-proxy]
(blocking、cookies 这些来自实验用的 testsrv,同样是并集的结果。)
是谁开的。 反查 feature 的来路:
$ cargo tree --offline --color never --features dep -e features -i reqwest
...
├── reqwest feature "__native-tls"
│ └── reqwest feature "native-tls"
│ └── pitb-dep v0.1.0 (.../pitb-dep)
│ └── pitb-dep feature "default"
│ └── pitb-native v0.1.0 (.../pitb-native)
...
运行时。 打开 tls_info(true),HTTPS 响应上的 TlsInfo::version() 是 None,就是 native-tls 握的手。
本站锁的 0.12.28 也是两个后端都编进来,实测默认走 native-tls,升级 0.13 以后这条规则不变,见 本站的 reqwest。
小结
- 依赖树里任何一个 crate 打开 reqwest 的
native-tls,TlsBackend::default()就是NativeTls,default-tls开着也没用;应用代码不用调用那个依赖。 - 症状不一定是失败:请求照样 200,但
TlsInfo没有版本、macOS 上只谈 TLS 1.2;PEM 客户端证书报incompatible TLS identity type;TLS 1.3 下限报 -9830。 - 修法:自己建的每个 builder 都调
tls_backend_rustls();要整个进程不用 native-tls,得让那个依赖关掉它。 - 检查:
cargo tree -i reqwest -f '{p} [{f}]' --depth 0看有没有__native-tls,-e features -i reqwest找来源。