Failed to load provided certificates(rustls)
主线位置:请求主线里 accept 之后的 TLS 握手要用的
Acceptor,是在服务主线「起监听」时由TlsSettings::build建的。openssl 和 rustls 两个后端的intermediate做的事完全不同,见 TLS。本页讲 rustls 上证书配错时进程的样子和怎么让它提前失败。
flowchart TB
main["main"] --> im["intermediate 记路径"]
im --> ok["返回 Ok"]
ok --> run["run_forever"]
run --> st["start_service"]
st --> bl["Listeners build"]
bl --> tb["TlsSettings build"]
tb --> ld{"读证书和私钥"}
ld -->|成功| lis["监听"]
ld -->|失败| pan["panic 服务线程"]
pan --> none["本服务端口全无"]
实验 crate exp-pingora/pita-rustls(pingora 开 rustls 而不是 openssl,其余同模板):一个代理服务,明文 127.0.0.1:19270 和 HTTPS 127.0.0.1:19271 加在同一个服务上,证书目录从命令行传;第二个参数 check 打开本页的改法。目录 certs-nokey/ 里只有 cert.pem,certs/ 里证书和私钥都有。M2 Max、macOS 27、rustc 1.99.0,debug,tokio 1.53.2。
现象
let mut tls = pingora_core::listeners::tls::TlsSettings::intermediate(&cert, &key).unwrap();
tls.enable_h2();
proxy.add_tls_with_settings("127.0.0.1:19271", None, tls);
用 certs-nokey 启动,1.5 秒后请求两个端口:
started pid=52533
thread 'Pingora HTTP Proxy Service' (5914503) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-core-0.9.0/src/listeners/tls/rustls/mod.rs:80:17:
Failed to load provided certificates "certs-nokey/cert.pem" or key "certs-nokey/key.pem".
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ curl http://127.0.0.1:19270/a -> http=000 exit=7
$ curl https://127.0.0.1:19271/b -> http=000 exit=7
process alive: yes
[runq: killed after 4s]
intermediate(...).unwrap()没有失败,main打出了started:私钥文件不存在这件事,main里根本不知道。- panic 在服务线程(线程名就是服务名)里,进程没有退出,最后是
runq4 秒后杀掉的。 - 不只是 HTTPS 端口:同一个服务上的明文端口 19270 也连不上(curl 7)。
Listeners::build按顺序建这个服务的所有端点,每个端点先bind再TlsSettings::build;panic 时已经绑上的 socket 随任务一起被 drop,一个都没留下。放在另一个服务里的端口不受影响,TLS 页的实验就是那样,明文端口照常返回 200。 - 换成 openssl 后端,同样的代码在
intermediate里就返回Err,unwrap让main当场 panic、进程退出;这个坑只属于 rustls。
出处
rustls 的 TlsSettings::intermediate 只把两个路径存进结构体。真正读文件在 build 里:
/// _NOTE_ This function will panic if there is an error in loading
/// certificate files or constructing the builder
///
/// Todo: Return a result instead of panicking XD
pub fn build(self) -> Acceptor {
// ...
let Ok(Some((certs, key))) = load_certs_and_key_files(&self.cert_path, &self.key_path)
else {
panic!(
"Failed to load provided certificates \"{}\" or key \"{}\".",
self.cert_path, self.key_path
)
};
build 由 TransportStackBuilder::build 调用,那是 start_service 里 listeners.build(..).await.expect("Failed to build listeners") 的一部分,跑在服务运行时上。let ... else 把 load_certs_and_key_files 返回的具体错误(文件不存在、PEM 里没有私钥)丢掉了,panic 的文字只说「certificates ... or key」,不说是哪一个、为什么。证书和私钥不匹配的话,后面 with_single_cert(...).unwrap() 也会 panic,文字是 Failed to create server listener config。main 上到 84f21ee,这几行没变。
rustls 后端随 0.4.0 第一次发布(354a6ee,「Add preliminary rustls support」)。同一天的 d445e74(「Plumb errors through rustls implementation」,没有 PR 编号,也是首次进入 0.4.0)把加载函数从返回 Option 的 load_certs_key_file 换成返回 Result<Option<..>> 的 load_certs_and_key_files,让错误能往上传。但在 build 这一头,它把原来的 unwrap_or_else(|| panic!(..)) 改写成了上面的 let Ok(Some(..)) = .. else { panic!(..) },错误到这里又变回了 panic,同时加上了那行「Todo: Return a result instead of panicking XD」。从 0.4.0 到 0.9.0,这行 TODO 一直在。
修法
build 的签名是 -> Acceptor,改不了它的行为,就在 main 里、交给 pingora 之前,用同一个加载函数自己读一遍(pita-rustls <dir> check):
if std::env::args().nth(2).as_deref() == Some("check") {
// the same loader TlsSettings::build uses, called while we can still exit cleanly
match pingora_core::tls::load_certs_and_key_files(&cert, &key) {
Ok(Some(_)) => {}
Ok(None) => {
eprintln!("no usable certificate or private key in {cert} / {key}");
std::process::exit(1);
}
Err(e) => {
eprintln!("cannot load {cert} / {key}: {e}");
std::process::exit(1);
}
}
}
rustls 后端下 pingora_core::tls 就是 pingora_rustls,这是 build 里调的那个函数。
== certs-nokey check
cannot load certs-nokey/cert.pem / certs-nokey/key.pem: FileReadError context: Failed to load file cause: No such file or directory (os error 2)
exit=1
== certs check
started pid=52553
$ curl https://127.0.0.1:19271/b -> http=200 exit=0
$ curl http://127.0.0.1:19270/a -> http=200 exit=0
证书有问题时进程在启动时以 1 退出,错误原因也比 panic 的那句话具体;证书没问题时照常启动。它只覆盖「读不到、解析不出」这一类;证书和私钥不匹配要到 build 里的 with_single_cert 才发现,这个预检拦不住。
另一个办法是自己建好 rustls 的 ServerConfig:0.9.0 加了 Acceptor::from_server_config,构造失败的错误全在自己手里。但它产出的是 Acceptor,监听器的 add_tls_with_settings 收的是 TlsSettings,要用它就得自己处理握手,代价比上面的预检大得多。
怎么确认没中招
- 证书配置会变的部署(证书路径来自环境变量、证书由别的进程续期),启动后用
openssl s_client或curl -k真的握一次手,不要只看进程。 - 日志里搜
Failed to load provided certificates和Failed to create server listener config。 - 用 rustls 后端时,把 HTTPS 端口和明文端口放在不同的服务里,至少证书出错时明文端口(比如 80 的跳转)还活着。
小结
- rustls 的
intermediate只记路径、总是Ok;证书在start_service建监听时才读,读不到就在服务线程里 panic(rustls/mod.rs:80),进程不退出。 - 同一个服务上的所有端口一起没了,包括明文端口;openssl 后端没有这个问题,它在
intermediate里就返回Err。 - 在
main里先调pingora_core::tls::load_certs_and_key_files试读,失败就退出;build那行「Todo: Return a result instead of panicking XD」从 0.4.0 留到了 0.9.0。