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 自己决定。这个差别在下面的实验里看得到。
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_3 | TLSv1.3,TLS_AES_256_GCM_SHA384 | 同左 |
-tls1_2 | TLSv1.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.1 | ALPN 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 后端相比:
| openssl | rustls | |
|---|---|---|
intermediate | Mozilla Intermediate v5,当场读证书,失败返回 Err | rustls 默认套件,只记路径,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 连接。
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,当场读证书;TlsSettingsDeref到SslAcceptorBuilder,openssl 的配置全都能用。 - rustls 的
intermediate只记路径,证书问题推迟到服务启动时在服务线程里panic!,进程不退出,HTTPS 服务悄悄不在。 - 握手可以卸载到专门的运行时(0.9.0);
certificate_callback只有 openssl 系支持,rustls 用ResolvesServerCert。 - 实测两个后端在常规握手上一致;客户端只提议未知 ALPN 时 openssl 继续、rustls 失败;
SslDigest的版本和套件字符串因后端而异。