
1. UnknownIssuer 之后我做的第一件事检查的不是代码是证书链前几天对接公司内部一个数据平台对方运维只丢过来一个 HTTPS 地址说“证书是内网自己签的你们客户端处理一下”。我用 reqwest rustls 的组合写了不到三十行请求代码一跑就崩核心错误一句话error sending request for url (...): invalid peer certificate: UnknownIssuer更早之前用 Python requests 的时候我也遇过类似问题通常加上verifyFalse就完事儿了。但这次是 Rust 项目而且是公司生产环境的客户端我肯定不能关证书校验。于是我先静下来理了一下这个错误到底是谁报的证书链在哪一步断的以及“自签名证书”这四个字在 TLS 校验体系里到底意味着什么。先说一个很容易踩的认知误区。很多人觉得“自签名证书校验不过”是代码层面的问题于是翻来覆去看 reqwest 的 ClientBuilder 配置。但大部分情况下根因是证书链不完整或者客户端根本不知道该用哪张证书去验证服务端。你要做的第一件事绝对不是改代码而是把服务端的证书链完整拉下来看看到底缺了什么。这也是我决定写这篇文章的原因reqwest rustls 自签名证书这个组合表面上是一行add_root_certificate的事但实际踩进去会发现TLS 后端的信任源差异、证书文件格式、多证书链解析、甚至 SNI 和服务端主机名匹配任何一环没对上都会报一模一样的 UnknownIssuer。这篇文章把我的排查过程和最终方案完整讲一遍同时把 rustls 这个后端的一些隐蔽行为也讲透适合正在用 Rust 写 HTTP 客户端、又被内网自签名证书坑过的人阅读。先说结论90% 的 UnknownIssuer 不是 rustls 的问题也不是 reqwest 的 bug而是你还没有告诉客户端“该信任哪张根证书”。2. rustls 到底从哪里读根证书native-tls 与 rustls 的信任源差异2.1 reqwest 的默认 TLS 后端其实是 native-tls如果你只是往 Cargo.toml 里加了reqwest 0.12没有手动关掉默认 features那么你用的 TLS 后端是native-tls。在 Linux 上通常是对接 OpenSSL在 Windows 上是 Schannel在 macOS 上则是 Security Framework。这几个后端的共同特点是它们都读系统证书库。也就是说当你在内网 CA 里安装了一个自签名根证书并且把它加入了系统信任库时用默认的 reqwest 去请求内网 HTTPS 接口往往是能跑通的因为 native-tls 检测到了系统里多出来的那根根证书。但换成 rustls 之后情况就变了。rustls 默认不读系统证书库它用的是自己的信任根集合要么是 webpki-roots内置 Mozilla 根证书库要么是 native-roots通过rustls-native-certs读取系统证书库。这取决于你启用了哪个 feature。很多人的第一个坑就来自这里代码没变只是把 TLS 后端从 native-tls 换成了 rustls结果内网接口直接校验失败。这不是 rustls 不会校验自签名证书而是它根本没有把你系统里的根证书当回事。2.2 reqwest 的 features 是怎么控制信任根的在 reqwest 0.12 里和 rustls 相关的 features 主要有这几个Feature作用rustls-tls启用 rustls 后端使用 rustls 默认的根证书设置rustls-tls-webpki-roots使用 webpki-roots 内置的 Mozilla 根证书库rustls-tls-native-roots使用 rustls-native-certs 从系统读取根证书rustls-tls-manual-roots不自动加载任何根证书全部由你手动配置其中rustls-tls是一个便捷组合 feature在 0.11 时代它等价于rustls-tls-webpki-roots在 0.12 里它的默认行为也基本和 webpki-roots 对齐。Webpki-roots 是一个被打进二进制里的静态根证书集合它包含了 Mozilla 根证书库里的公网根证书但绝不包含你们公司的私有 CA 根证书。所以当你的 Cargo.toml 是这样时reqwest { version 0.12, default-features false, features [rustls-tls] }你得到了两个明确行为TLS 握手完全由 rustls 负责OpenSSL 那套系统库不参与客户端只信任 webpki-roots 里的公网根证书不信任你机器上安装的任何自定义 CA如果你的服务端证书是某台内部设备自己生成的或者由公司内网 CA 签发那 rustls 在看到服务端证书时往上找不到对应的信任根立刻就会抛 UnknownIssuer。2.3 这里有一个非常容易混淆的点自签名证书 vs 内部 CA 签发很多人说“自签名证书”其实说的是两种不同场景。第一种是服务端证书本身是自签名的也就是说这张证书既是叶子证书、又是根证书没有上级签发者。比如一些嵌入式设备、医疗仪器、边缘网关出厂时直接内置了一个自签名的身份证书你没法换只能去适配它。第二种是公司内部搭建了一套私有 CA用这个 CA 去签发各服务的叶子证书。这种情况下服务端证书不是自签名的它信任链的顶端是一张内部根证书。客户端要做的是信任这个内部根证书。这两种场景的解决办法不太一样。内部 CA 签发的证书客户端只要信任那根内部 CA 根证书之后的证书链校验就顺理成章地全部通过。而真正自签名的叶子证书你在实践中通常不会去走“把叶子证书加进信任根”这条路而是会用它对应的证书指纹去做固定校验也就是人们常说的 Certificate Pinning。后面我会分别给出方案。3. 让 reqwest 信任自签名根证书add_root_certificate 的完整实操3.1 最简单、也最推荐的方式手动加载根证书 PEM 文件如果你的服务器证书由内部 CA 签发那么根 CA 的 PEM 文件一般是能从运维那里拿到的。拿到之后把它加进 reqwest 的 Client 即可。我的 Cargo.toml 关键依赖是这样配的[dependencies] reqwest { version 0.12, default-features false, features [rustls-tls, json] } tokio { version 1, features [rt-multi-thread, macros] }这里要特别注意default-features false。如果不关掉默认 featuresreqwest 会同时引入 native-tls你不一定能意识到实际生效的是哪个 TLS 后端。关了之后明确走 rustls行为可预期调试也更容易。然后是核心代码use reqwest::Certificate; use std::fs::File; use std::io::Read; #[tokio::main] async fn main() - Result(), Boxdyn std::error::Error { // 读取内部 CA 的 PEM 根证书 let mut pem Vec::new(); File::open(internal-root-ca.pem)?.read_to_end(mut pem)?; // 将 PEM 内容转换为 reqwest 的 Certificate let ca Certificate::from_pem(pem)?; // 构建客户端时追加信任根 let client reqwest::Client::builder() .add_root_certificate(ca) .build()?; let resp client .get(https://internal-api.example.internal/health) .send() .await?; println!(status: {}, resp.status()); Ok(()) }这段代码跑通之后UnknownIssuer立刻消失。原因很简单add_root_certificate把 internal-root-ca.pem 里那张根证书加入了 rustls 的信任根集合服务端下来的证书链向上追溯时能找到信任根校验就通过了。3.2 记住加的是“根证书”不是“服务端证书”这个点太重要值得单独讲。我在刚踩坑时犯过一个错误手里只有服务端自己的证书没有内部 CA 根证书于是直接调add_root_certificate把服务端证书加了进去结果依然报 UnknownIssuer。原因在于 TLS 证书链校验的逻辑。当客户端连接到服务器服务端会把叶子证书以及为了补齐链路所需的中间证书一起发给客户端。客户端拿到后需要借助本地信任根来构建一条从叶子到根的可信路径。如果你加进去的是叶子证书本身rustls 不会认为它是信任锚点而是会继续向上寻找签发者找不到就失败。换句话说信任锚点必须是根证书而不是叶子证书。如果拿不到根证书可以反向推导拿到服务端的叶子证书看它的Issuer字段里写的是谁然后找运维要签发这张证书的根 CA。实在找不到就只能走后面的证书指纹校验方案。3.3 多个 PEM 证书、中间证书链和重复信任根的处理有人会问如果根证书文件里包含多张证书比如根证书和中间证书都写在同一个 PEM 文件里能不能直接Certificate::from_pem实际测试下来reqwest::Certificate::from_pem主要用于解析单个 PEM 块。如果你把根证书和中间证书拼在同一个文件里最好用rustls-pemfile逐个解析并向 Builder 逐个添加这样最稳。例如use reqwest::Certificate; use rustls_pemfile::certs; use std::fs::File; use std::io::BufReader; let mut reader BufReader::new(File::open(chain.pem)?); let mut builder reqwest::Client::builder(); for cert in certs(mut reader) { let der cert?; builder builder.add_root_certificate(Certificate::from_der(der)?); } let client builder.build()?;注意cert?这里的实际类型会随 rustls-pemfile 版本变化2.x 的 API 返回的是包含CertificateDer的迭代器。如果你的项目里版本较新以你引入版本的官方文档为准。不过我还是建议如果服务端证书链里有中间证书中间证书通常由服务端在握手时下发客户端一般只需要信任根 CA。如果服务端下发的证书链配置不完整你才需要考虑把中间证书也放入客户端信任集合。3.4 追加信任根之后公网网站的证书还认吗add_root_certificate是追加不是覆盖。也就是说即使你往里添加了内部根证书reqwest 依然会用 rustls 自带的根证书库去校验公网 HTTPS 站点。这一点和 rustls-tls 默认使用 webpki-roots 的行为是一致的。但如果你为了省事启用了rustls-tls-native-roots又额外用add_root_certificate添加了内部根证书那么两个信任源都会生效。有一个实际场景我得提醒一下如果你的程序既访问内网自签名服务又访问公网 API推荐只使用rustls-tls也就是 webpki-roots并显式追加内部根证书不要依赖系统证书库。因为系统证书库会随机器环境变化程序行为不够稳定。把信任根集合完全控制在代码里才是可复现、可测试的做法。4. 不想架设内部 CA用 rustls 的 ServerCertVerifier 做证书指纹锁定4.1 什么时候必须走到这套方案有一些设备场景你拿不到根证书甚至服务端证书就是设备出厂自签的一张叶子证书上面写的主机名也可能和 IP 对不上。比如下面三种情况我实际都遇到过嵌入式的工业网关Web 管理页面用的是设备自己生成的证书每次重置可能还会生成新证书一个临时测试环境开发随手用openssl req -x509生成了一张证书证书的 Common Name 和 SAN 都不对对接第三方平台对方只允许你把证书指纹抄到配置文件里不接受你安装他们的根证书在这些情况下把自签名证书“加入信任根”的方案走不通因为自签名证书本身就不是合格的信任锚点。你需要换思路手动校验服务端证书的 SHA-256 指纹和本地预置的指纹比对一致才能通过 TLS 握手。这就是ServerCertVerifier的用武之地。4.2 实现思路不要关校验而是替换校验逻辑刚接触这个场景的人第一反应可能是let client reqwest::Client::builder() .danger_accept_invalid_certs(true) .build()?;我必须非常明确地说这条路在生产环境绝对不能走。danger_accept_invalid_certs(true)会同时关闭证书链校验和主机名校验等于把 TLS 的安全边界完全拆除。任何处于同一网络的人都可以用一张伪造证书对你的连接进行中间人攻击而客户端不会有任何察觉。certificate pinning 的正确做法是保留 TLS 握手的完整流程只把“验证证书链”这一环节替换成“比对证书指纹”。以 rustls 0.22 的 API 为例你会实现一个ServerCertVerifiertraituse rustls::client::{ServerCertVerified, ServerCertVerifier}; use rustls::{Certificate, Error as RustlsError, ServerName}; use std::time::SystemTime; #[derive(Debug)] struct FingerprintVerifier { expected_sha256: Vecu8, } impl ServerCertVerifier for FingerprintVerifier { fn verify_server_cert( self, end_entity: Certificate, _intermediates: [Certificate], _server_name: ServerName, _scts: mut dyn IteratorItem [u8], _ocsp_response: [u8], _now: SystemTime, ) - ResultServerCertVerified, RustlsError { use ring::digest::{digest, SHA256}; let actual digest(SHA256, end_entity.0); if actual.as_ref() self.expected_sha256.as_slice() { Ok(ServerCertVerified::assertion()) } else { Err(RustlsError::General( certificate fingerprint mismatch.to_string(), )) } } }这段代码的意图很直白取服务器发来的叶子证书的 DER 编码计算 SHA-256和本地预先保存的指纹比对。一致就告诉 rustls“证书校验通过”不一致就让握手失败。rustls 不同版本的 trait 方法签名有差异上面代码以 0.22 为基准0.23 以后叶子证书类型变成了CertificateDer参数列表也有调整。当你动手写的时候直接以你 Cargo.lock 里锁定的 rustls 源码为准。拿到这个 verifier 之后还需要构造一个被预配置了 verifier 的 TLS 连接器交给 reqwest 使用use std::sync::Arc; let config rustls::ClientConfig::builder() .with_custom_certificate_verifier(Arc::new(FingerprintVerifier { expected_sha256, })) .with_no_client_auth(); let tls_connector reqwest::tls::TlsConnector::from(Arc::new(config)); let client reqwest::Client::builder() .use_preconfigured_tls(tls_connector) .build()?;4.3 证书指纹从哪里拿这一步不用写代码。先用 openssl 把服务端证书拉下来再用任意的哈希工具计算指纹openssl s_client -connect 192.168.100.55:443 -servername 192.168.100.55 /dev/null 2/dev/null \ | openssl x509 -outform DER -out server.der openssl dgst -sha256 -hex server.der第一句里的-servername参数对应的是客户端 TLS 握手中的 SNI 扩展。就算服务端证书完全匹配不上主机名SNI 也会在握手阶段发送别省略很多设备必须要看到 SNI 才回复证书。得到指印后把它写进配置let expected_sha256 hex::decode(你的64位十六进制指纹)?;之后每次请求时客户端会对服务端下发的叶子证书做同样的 SHA-256两端比对一致才通过。4.4 指纹方案的两个局限指纹校验虽然绕过了内部 CA 的问题但它有两个天然的局限。第一证书过期后指纹会变。设备换了新证书你的预置指纹也要跟着更新。如果设备数量很多这会成为运维负担所以指纹方案更适合少量、高信任、网络封闭的场景。第二指纹方案不能防止“合法但恶意”的证书。如果对方服务端不是自签名证书而是由一个你并不信任的公开 CA 签发的指纹比对就失去了意义。因此指纹固定方案只建议用在自签名证书场景不要把它当成通用的证书校验替代品。5. 排错过程实录RUST_LOG 和 openssl 帮我把问题定位到一行配置5.1 先开启 rustls 和 reqwest 的完整日志遇到证书问题第一件事就是打开 TLS 层的日志。reqwest用的是tracing体系配合env_logger或tracing-subscriber都行。我个人习惯直接用RUST_LOG环境变量RUST_LOGreqwesttrace,rustlstrace cargo run开启 trace 后rustls 会打印出它验证证书链的全过程。典型输出会包含类似certificate verifier: now... certificate verifier: Subject: ... certificate verifier: Issuer: ... certificate verifier: Chain is not valid: UnknownIssuer看到Issuer字段非常关键。它能告诉你服务端下发给客户端的证书签发者是谁。如果签发者是你们内部 CA那么问题就明确指向客户端缺少这个 CA 的信任根如果签发者写的是一个你已经添加、但仍然报 UnknownIssuer 的证书那就要怀疑 PEM 文件有没有解析正确。5.2 用 openssl s_client 模拟一次完整握手当纯日志还不够直观时我会直接在命令行用 openssl 模拟一次 TLS 握手。这个操作不依赖任何 Rust 代码能快速判断问题到底出在服务端还是客户端。openssl s_client -connect internal-api.example.internal:443 \ -showcerts -servername internal-api.example.internal /dev/null看三段输出Certificate chain部分会列出服务端发下来的所有证书。如果只有一张说明中间证书没有下发完整如果有两张及以上说明服务端证书链配置基本正常。Verify return code如果输出unable to get local issuer certificate说明当前机器的信任库里没有对应根证书。subject和issuer两个字段帮助判断证书之间的签发关系。用-showcerts输出的内容还可以直接把整条证书链保存下来openssl s_client -connect internal-api.example.internal:443 \ -showcerts -servername internal-api.example.internal /dev/null 2/dev/null \ | awk /BEGIN CERTIFICATE/,/END CERTIFICATE/ chain.pem5.3 错误信息对照表我把这类场景最常见的几个错误讯息整理了一下方便你排查时对号入座错误信息含义解决方向UnknownIssuer找不到可信任的签发者把正确的根 CA 证书加入客户端信任根NotValidForName证书不适用于当前访问的主机名检查 URL 里的 host 是否和证书 SAN 匹配CertExpired证书已过期更新服务端证书或系统时间InvalidCertificateSignature证书签名校验失败可能是证书文件损坏或信任根不匹配unable to get local issuer certificateopenssl 系统库缺少信任根仅影响 native-tls/openssl 调试rustls 不读系统库注意NotValidForName这个报错很容易被误认为证书链问题。如果你的服务端证书完全没有 SAN 扩展或者 SAN 里的名字和访问的 host 不一致即使信任根已经加入rustls 也会直接拒绝握手。这种情况下错误不是 UnknownIssuer而是 NotValidForName别排查错了方向。5.4 一个真实的排错过程复现我之前处理过一个比较典型的案例。同事写了一个小工具去访问一台现场设备的 HTTPS 接口设备证书是自签的。他往客户端代码里加了这个设备的证书文件请求依然报 UnknownIssuer。他以为是自己代码写错了我去看了一下立刻发现他加的是设备证书的 PEM而设备证书的 Issuer 字段指向一张他并不存在的根证书。这正好回到文章前面说的设备证书本身就缺了根你把叶子加进信任根当然无效。处理方式是我用上面的 openssl 命令拉回证书链查看证书的 Issuer 字段发现签发者名称是一个随机字符串明显不是商用 CA。由于设备不允许上传 CA最终我们采用了证书指纹方案在客户端配置文件的server_cert_sha256字段里写死了设备的指纹并用自定义 verifier 完成校验。那次排查大概花了一个小时但真正定位只用了五分钟。剩下五十五分钟都是在确认“指纹方案会不会带来后续换证书的麻烦”。所以我也建议你在动手前先想清楚是去要根证书建立长期信任还是做短期指纹固定这两者的后续维护成本完全不同。6. 一些值得写进团队文档的实践细节如果你所在团队经常要对接内网 HTTPS 服务下面几条经验建议直接沉淀到项目文档里。第一rustls 的信任根集合应该由代码控制而不是依赖运行机器的系统证书库。系统证书库在不同环境、不同 Docker 镜像里的差异很大今天开发机上能跑明天 CI 容器里就挂了。把根证书随代码仓库管理构建、测试、产线环境的行为才能保持一致。第二不要把 PEM 根证书直接硬编码在源码里。编译期 include_bytes! 虽然可行但根证书要更新时就得重新编译。更合理的做法是把证书放到配置文件或环境指定的目录里程序启动时读取。如果你担心运行时读文件失败可以在启动阶段做一次校验解析失败立即 panic避免带着错误的 TLS 配置跑进生产。第三指纹校验方案里一旦比对失败别只打一条错误日志就完事。建议把两边的指纹都打印出来预期指纹和服务端实际指纹。这样证书轮换时排障人员能一眼看出是新指纹替换了旧指纹还是连接到了错误的服务器。第四写测试时不要对着真实证书写死依赖。用 rustls 官方提供的测试证书或在测试环境里动态生成一个临时 CA而且测试用例里至少覆盖两种情况信任根匹配时握手成功信任根不匹配时握手失败。这样以后升级 reqwest 或 rustls 版本时CI 能第一时间帮你发现证书行为是否被改变。最后再分享一个我亲身经历过的小技巧。内网服务经常有多个域名或 IP 对应同一张证书的情况而证书里的 SAN 往往只写了其中一个名字。这时候优先通过服务器上配置的完整证书链修复 SAN不要试图在客户端绕过主机名校验。因为一旦允许跳过主机名校验你就把这个 HTTPS 连接降级成了加密管道但无法确认连接对象是不是目标服务器安全性大打折扣。正确做法是请服务端把需要的域名和 IP 全部加到证书 SAN 里重新签发一张证书一劳永逸。