Rust in Everything
基于 pingora-core 0.9.0

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 在服务线程(线程名就是服务名)里,进程没有退出,最后是 runq 4 秒后杀掉的。
  • 不只是 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,这几行没变。

📝NOTE
冷知识:「Plumb errors through rustls implementation」之后,build 还是 panic

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。

💬 关联讨论

发起讨论
加载中...