Rust in Everything
基于 pingora-core 0.9.0

TLS

主线位置:请求主线里 accept 和 HTTP 之间的那一步。HTTPS 监听器上,UninitializedStream::handshake 先 set_buffer,再跑可选的 TLS 前回调,然后 Acceptor::tls_handshake 把 l4::Stream 包成 TLS 流;TLS 流同样实现 IO,HTTP 层看到的还是 Box<dyn IO>,ALPN 的结果决定下一步是 HTTP/1 还是 h2。

flowchart TB
  feat["Cargo feature"] --> ossl["openssl 系"]
  feat --> rus["rustls"]
  feat --> s2n["s2n"]
  feat --> none["都不开"]
  ossl --> od["openssl_derived"]
  od --> m1["boringssl_openssl"]
  rus --> m2["rustls 模块"]
  s2n --> m3["s2n 模块"]
  none --> m4["noop_tls"]
  m1 --> api["同名的类型"]
  m2 --> api
  m3 --> api
  m4 --> api
  api --> use["上层写 tls::X"]

TLS 代码分三处:protocols/tls/(TLS 流、握手、摘要,共 2449 行)、listeners/tls/(服务端的 TlsSettings 和 Acceptor,867 行)、connectors/tls/(客户端,1178 行,见 L4 连接)。每一处下面都有 boringssl_openssl/、rustls/、s2n/ 三个子目录,protocols/tls/ 还多一个 noop_tls/。本页只讲 openssl 和 rustls 两个后端:boringssl 和 openssl 共用 boringssl_openssl 那份代码,s2n 本机没有依赖缓存。

实验在 exp-pingora/proto-main(openssl feature)和 exp-pingora/proto-rustls(rustls feature),两边是同一份 main.rs,M2 Max、macOS 27、rustc 1.99.0,debug,tokio 1.53.2。证书用 rcgen 0.13 的 generate_simple_self_signed 生成,SAN 是 localhost 和 127.0.0.1,密钥是 ECDSA P-256。HTTPS 监听在 127.0.0.1:18307:TlsSettings::intermediate(cert, key) 加 enable_h2()。openssl 后端用的是 openssl-sys 自己编译、静态链接的 OpenSSL 3.6.3(二进制的 otool -L 里没有任何 ssl 动态库);rustls 是 0.23.46。客户端是 OpenSSL 4.1.0 的 openssl s_client 和 curl 8.7.1(LibreSSL 3.3.6)。

四个后端,一套名字

pingora 没有给 TLS 定义一个 trait。每个后端在各自的模块里写一组同名的类型和函数,listeners/tls/mod.rs 整个文件就是三段 cfg:

#[cfg(feature = "openssl_derived")]
mod boringssl_openssl;

#[cfg(feature = "openssl_derived")]
pub use boringssl_openssl::*;

#[cfg(feature = "rustls")]
mod rustls;

#[cfg(feature = "rustls")]
pub use rustls::*;

#[cfg(feature = "s2n")]
mod s2n;

#[cfg(feature = "s2n")]
pub use s2n::*;

protocols/tls/mod.rs 一样,外加一段 #[cfg(not(feature = "any_tls"))] pub use noop_tls::*;。监听器那边,listeners/mod.rs 在没有 TLS 时把 tls 指向 noop_tls 里的 listeners 子模块,然后统一写 use tls::{Acceptor, TlsSettings};。上层代码(listeners/mod.rs、connectors/mod.rs、upstreams/peer.rs)只写 tls::TlsSettings、tls::Acceptor、TlsRef 这些名字,编译时由 feature 决定它们指向哪个后端。pingora_core::tls 本身则是后端 crate 的改名(lib.rs 里的 pub use pingora_openssl as tls;),用户代码可以通过它直接摸到 openssl 或 rustls 的类型。

这样做的好处是零开销:不用 trait object,也不用泛型参数一路传下去。代价有三个:

  • 两个后端不能同时开。 两个 pub use xxx::* 导出同名的东西,任何一处用到就是歧义错误;开两个、一个都不开会怎样,见怎么读这一卷。
  • 同一个名字,签名可以不一样。 编译器只检查当前那一个后端,没有任何东西保证四份实现长得一样。下面就有两个例子:TlsSettings 能不能当 SslAcceptorBuilder 用,intermediate 会不会读文件。
  • noop_tls 让没开 TLS 的程序也能编过。 它的 TlsSettings::intermediate 直接返回 Ok,tls_handshake 是 unimplemented!("No tls feature was specified"),第一个 TLS 连接进来才 panic。

TlsSettings::intermediate

本站网关就是用它建 HTTPS 监听的。两个后端的同名函数做的事完全不同。

openssl 的 intermediate:

    pub fn intermediate(cert_path: &str, key_path: &str) -> Result<Self> {
        let mut accept_builder = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()).or_err(
            TLS_CONF_ERR,
            "fail to create mozilla_intermediate_v5 Acceptor",
        )?;
        accept_builder
            .set_private_key_file(key_path, SslFiletype::PEM)
            .or_err_with(TLS_CONF_ERR, || format!("fail to read key file {key_path}"))?;
        accept_builder
            .set_certificate_chain_file(cert_path)
            .or_err_with(TLS_CONF_ERR, || {
                format!("fail to read cert file {cert_path}")
            })?;
        Ok(TlsSettings {
            accept_builder,
            callbacks: None,
            offload_threadpool: None,
        })
    }

mozilla_intermediate_v5 是 openssl crate 按 Mozilla「Intermediate」第 5 版配置建的 SslAcceptorBuilder:TLS 1.2 和 1.3,TLS 1.2 只留 ECDHE / DHE 加 AEAD 的套件。证书和私钥当场读,读不到就返回 Err。TlsSettings 还实现了 Deref / DerefMut 到 SslAcceptorBuilder,所以 tls.set_min_proto_version(...)、tls.set_cipher_list(...) 这些 openssl 的方法都能直接调。

rustls 的 intermediate 只把两个路径存进结构体:

    pub fn intermediate(cert_path: &str, key_path: &str) -> Result<Self>
    where
        Self: Sized,
    {
        Ok(TlsSettings {
            alpn_protocols: None,
            cert_path: cert_path.to_string(),
            key_path: key_path.to_string(),
            cert_resolver: None,
            client_cert_verifier: None,
            callbacks: None,
            offload_threadpool: None,
        })
    }

真正的配置在 build 里,服务启动、建监听器的时候才调:ServerConfig::builder_with_protocol_versions(&[&version::TLS12, &version::TLS13]),套件用 rustls 自己的默认列表,跟 Mozilla 的文档没有关系;证书加载失败直接 panic!,源码注释写着「Todo: Return a result instead of panicking XD」。rustls 的 TlsSettings 没有 Deref,能调的只有它自己定义的几个方法(set_alpn、set_cert_resolver、set_client_cert_verifier、两个设文件路径的方法);要完全自定义 ServerConfig,0.9.0 加了 Acceptor::from_server_config。

同一个 main.rs,证书目录里只放 cert.pem、不放 key.pem,两个后端启动:

== openssl
thread 'main' (5568932) panicked at src/main.rs:116:58:
called `Result::unwrap()` on an `Err` value: Error { etype: Custom("TLSConfigError"), esource: Unset, retry: Decided(false), cause: Some(ErrorStack([Error { code: 2147483650, library: "system library", function: "file_ctrl", file: "crypto/bio/bss_file.c", line: 299, data: "calling fopen(certs-nokey/
...
== rustls
thread 'Pingora HTTP Proxy Service' (5568956) panicked at .../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".
[runq: killed after 4s]

openssl 在 main 里就失败了(panic 是实验代码自己的 unwrap),进程退出。rustls 的 intermediate 返回 Ok,panic 发生在服务线程里,进程没有退出,直到 runq 4 秒后把它杀掉。在这段时间里另起一次:明文端口 18300 照常返回 200,18307 上 curl 连不上(exit 7)。也就是说,rustls 后端上证书配错了,进程看起来活着,只有 HTTPS 那个服务没起来。

握手

两个后端的 Acceptor::tls_handshake 结构相同(openssl 版在 listeners/tls/boringssl_openssl/mod.rs):设了握手卸载线程池(0.9.0 新加的 set_offload_threadpool)就按连接 id 选一个卸载运行时去 spawn 握手,否则就地做;有回调走 handshake_with_callback,没有走 handshake。回调是 TlsAccept 的两个方法:

  • certificate_callback:握手中途、要选证书时调用。openssl 的 handshake_with_callback 调 start_accept,它先 suspend_when_need_ssl_cert,握手停在要证书的地方就返回 false;这时调回调,再 resume_accept。trait 的文档注释写着「This is only supported for openssl and boringssl」。
  • handshake_complete_callback:握手完成后调用,返回值存进 SslDigest 的 extension,HTTP 层从会话的摘要里能取到。

ALPN 由 set_alpn 设。openssl 版 set_alpn 装一个选择回调,enable_h2 用的 prefer_h2 在客户端的列表里找 h2、http/1.1,都没有就返回 AlpnError::NOACK,注释说「unknown ALPN, just ignore it. Most clients will fallback to h1」。rustls 版 set_alpn 只是把协议列表写进 ServerConfig::alpn_protocols,没有回调,协商规则由 rustls 自己决定。这个差别在下面的实验里看得到。

📝NOTE
冷知识:rustls 后端的 TlsAccept 回调是 0.9.0 才有的

79771f5(「TlsAcceptCallbacks support for rustls backend」,PR #908,首次进入 0.9.0)之前,rustls 版的 TlsSettings 没有 with_callbacks,handshake_with_callback 也不存在。这个提交加了它们(7 个文件,新增 276 行)。但 rustls 没有「握手停在选证书那一步、交给调用方」的接口,所以只接上了一半:handshake_with_callback 里写着「NOTE: certificate_callback is not invoked for rustls. Dynamic cert selection should use a custom ResolvesServerCert instead.」,遇到这种情况只打一条 warn;handshake_complete_callback 照常调用。动态选证书在 rustls 上要用 set_cert_resolver。

实验:两个后端各握手一遍

openssl s_client -connect 127.0.0.1:18307 -servername localhost 加不同参数,取输出里的协议、套件、ALPN 和告警,代理日志里的握手错误放在最后一列:

参数openssl 后端rustls 后端
-tls1_3TLSv1.3,TLS_AES_256_GCM_SHA384同左
-tls1_2TLSv1.2,ECDHE-ECDSA-AES256-GCM-SHA384同左
-tls1_2 -cipher ECDHE-ECDSA-AES128-SHA(CBC 套件)告警 40(handshake failure);no shared cipher告警 40;NoCipherSuitesInCommon
-tls1_1 -cipher 'DEFAULT@SECLEVEL=0'告警 70(protocol version);unsupported protocol告警 40;SignatureAlgorithmsExtensionRequired
-alpn h2,http/1.1ALPN h2,TLSv1.3同左
-alpn foo握手成功,No ALPN negotiated告警 120(no application protocol);peer doesn't support any known protocol

curl 用 --http2 和 --http1.1 各发一次 https://localhost:18307/tls,两个后端都分别协商出 h2 和 HTTP/1.1,响应头里实验代码写进去的 SslDigest:

openssl:  tls=Some("TLSv1.3 TLS_CHACHA20_POLY1305_SHA256")
rustls:   tls=Some("TLSv1_3 TLS13_CHACHA20_POLY1305_SHA256")

对明文端口发一个 HTTP/1.1 请求到 18307:openssl 后端不回任何字节就关掉(日志 tls_validate_record_header:http request),rustls 后端回 7 个字节(一个 TLS 告警记录)再关(received corrupt message of type InvalidContentType)。

能用的组合两边一致:TLS 1.2 / 1.3,CBC 套件都不接受,h2 / HTTP/1.1 都能协商。差别在边缘:

  • 客户端只提议不认识的 ALPN 时,openssl 后端照样完成握手、走 HTTP/1;rustls 后端按 RFC 7301 回告警 120,连接失败。
  • SslDigest 的 version 和 cipher 是后端各自的字符串,TLSv1.3 和 TLSv1_3 不是同一个值。按这两个字段做日志统计或者路由的代码,换后端时要跟着改。
  • 握手失败时的错误文本完全不同,日志告警的匹配规则不能通用。

rustls 后端缺什么

pingora 的 Cargo.toml 把 rustls 标成「⚠️ Highly Experimental! ⚠️ Try it, but don't rely on it (yet)」。按 0.9.0 的源码,服务端一侧和 openssl 后端相比:

opensslrustls
intermediateMozilla Intermediate v5,当场读证书,失败返回 Errrustls 默认套件,只记路径,build 时失败 panic!
调底层库的接口TlsSettings Deref 到 SslAcceptorBuilder没有 Deref;只能整个换掉:Acceptor::from_server_config
certificate_callback支持,握手中途调用不调用,打 warn;改用 set_cert_resolver
ALPN 没有交集时不选 ALPN,继续握手告警 120,握手失败
客户端证书校验通过 Deref 用 openssl 的 set_verify 等set_client_cert_verifier

表里只比了服务端。连接器一侧(connectors/tls/)本页没有逐项对照;上游 TLS 握手在建连的哪一步、超时怎么算,见 L4 连接。

❗IMPORTANT
动脑筋:TlsSettings 都调了 enable_h2()。客户端只提议一个 pingora 不认识的 ALPN(openssl s_client -alpn foo),openssl 后端和 rustls 后端的握手各是什么结果?

动脑筋答案

openssl 后端:

New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
No ALPN negotiated
Protocol: TLSv1.3
Verification error: self-signed certificate
...

rustls 后端:

80FF2DEC01000000:error:0A000460:SSL routines:ssl3_read_bytes:tlsv1 alert no application protocol:ssl/record/rec_layer_s3.c:910:SSL alert number 120
...

代理日志(rustls):TLS accept() failed: TLSHandshakeFailure context: tls connect error: peer doesn't support any known protocol。

  • openssl:enable_h2 装的是 prefer_h2 回调,在客户端列表里找不到 h2 或 http/1.1 就返回 NOACK,OpenSSL 把它当作「不选 ALPN」,握手照常完成,之后按 HTTP/1 处理。Verification error 是客户端不信任自签名证书,s_client 只是报告,不中断。
  • rustls:set_alpn 把 ["h2", "http/1.1"] 写进 ServerConfig::alpn_protocols。服务端配了 ALPN 而客户端的列表和它没有交集,rustls 发 no_application_protocol 告警,握手失败。
  • 同一份 main.rs、同一个函数名,两个后端对同一个客户端给出相反的结果。

小结

  • TLS 后端靠 cfg 选模块、pub use xxx::* 导出同名的类型,没有 trait;零开销,但一次只能开一个后端,各后端同名函数的行为也不保证一致。
  • openssl 的 intermediate 是 Mozilla Intermediate v5,当场读证书;TlsSettings Deref 到 SslAcceptorBuilder,openssl 的配置全都能用。
  • rustls 的 intermediate 只记路径,证书问题推迟到服务启动时在服务线程里 panic!,进程不退出,HTTPS 服务悄悄不在。
  • 握手可以卸载到专门的运行时(0.9.0);certificate_callback 只有 openssl 系支持,rustls 用 ResolvesServerCert。
  • 实测两个后端在常规握手上一致;客户端只提议未知 ALPN 时 openssl 继续、rustls 失败;SslDigest 的版本和套件字符串因后端而异。

💬 关联讨论

发起讨论
加载中...