cloudflare / cloudflare/pingora
Support dynamic certificate selection (SNI) in rustls backend by accepting ServerConfig
- Dominant language
- Rust
- Stars
- 27.4k
- Forks
- 1.7k
- Avg merge
- 6h 22m
- Merged PRs (30d)
- 3
Description
Hello,
#### Description
Currently, the `rustls` backend in `pingora-core` (specifically in `listeners/tls/rustls/mod.rs`) is architecturally limited to serving certificates via static file paths (`cert_path` and `key_path`).
While the `openssl` backend allows for dynamic SNI certificate selection through callbacks (e.g., `set_servername_callback`), the `rustls` implementation forces a static `ServerConfig` creation at build time using `with_single_cert`. This prevents users from implementing dynamic certificate resolution (e.g., loading from a database, memory cache, or custom resolver) for multi-tenant proxy services.
#### Proposed Solution
I propose exposing an API in `TlsSettings` that allows injecting a pre-built `Arc` or providing a mechanism to inject a custom `ResolvesServerCert` trait implementation.
By allowing the injection of a custom `ServerConfig` (which already supports the `with_cert_resolver` method in `rustls`), Pingora users could easily implement dynamic SNI, bringing `rustls` support to parity with the `openssl` backend capabilities.
**Example of the desired API:**
```rust
let cert_resolver = Arc::new(MyCustomSniResolver::new());
let server_config = ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(cert_resolver);
let mut tls_settings = TlsSettings::with_server_config(Arc::new(server_config));
tls_settings.enable_h2();
proxy_service.add_tls_with_settings(&address, Some(tcp_options), tls_settings);
```
#### Impact
This change would:
1. **Unblock multi-tenant architectures** using **rustls** that require SNI.
2. **Improve flexibility** for users who manage certificates in memory (e.g., via HashMaps or databases) rather than on the filesystem.
3. **Parity with openssl** backend features: Aligning the capabilities of both TLS backends, allowing users to choose **rustls** for its memory safety without sacrificing core proxy functionality.
#### Context
I am currently working on a proxy solution that requires serving hundreds of certificates dynamically. The current limitation forces a choice between using the openssl backend (which is less ideal for our pure-Rust stack) or manually patching the pingora-core source code to allow custom ServerConfig injection.
__________
#### Suggestion
```rust
use std::sync::Arc;
use crate::listeners::TlsAcceptCallbacks;
use crate::protocols::tls::{TlsStream, server::handshake, server::handshake_with_callback};
use log::debug;
use pingora_error::ErrorType::InternalError;
use pingora_error::{Error, OrErr, Result};
use pingora_rustls::ClientCertVerifier;
use pingora_rustls::ServerConfig;
use pingora_rustls::load_certs_and_key_files;
use pingora_rustls::{TlsAcceptor as RusTlsAcceptor, version};
use crate::protocols::{ALPN, IO};
/// The TLS settings of a listening endpoint
pub struct TlsSettings {
alpn_protocols: Option>>,
// Optional paths to allow custom_config usage
cert_path: Option,
key_path: Option,
client_cert_verifier: Option>,
// ADDITION: Allows injecting a pre-built ServerConfig (e.g., for dynamic SNI)
custom_config: Option>,
}
pub struct Acceptor {
pub acceptor: RusTlsAcceptor,
callbacks: Option,
}
impl TlsSettings {
/// ADDITION: Creates TlsSettings from an existing rustls ServerConfig
pub fn with_server_config(config: Arc) -> Self {
TlsSettings {
alpn_protocols: None,
cert_path: None,
key_path: None,
client_cert_verifier: None,
custom_config: Some(config),
}
}
/// Create a Rustls acceptor based on the current setting for certificates,
/// keys, and protocols.
pub fn build(self) -> Acceptor {
// ADDITION: Use custom configuration if provided
let config = if let Some(custom) = self.custom_config {
let mut config_clone = (*custom).clone();
// Apply ALPN configured via the enable_h2() method
if let Some(alpn_protocols) = self.alpn_protocols {
config_clone.alpn_protocols = alpn_protocols;
}
Arc::new(config_clone)
} else {
// ORIGINAL PINGORA 0.8 BEHAVIOR
let cert_path = self.cert_path.as_ref().expect("cert_path missing");
let key_path = self.key_path.as_ref().expect("key_path missing");
let Ok(Some((certs, key))) = load_certs_and_key_files(cert_path, key_path) else {
panic!(
"Failed to load provided certificates \"{}\" or key \"{}\".",
cert_path, key_path
)
};
let builder =
ServerConfig::builder_with_protocol_versions(&[&version::TLS12, &version::TLS13]);
let builder = if let Some(verifier) = self.client_cert_verifier {
builder.with_client_cert_verifier(verifier)
} else {
builder.with_no_client_auth()
};
let mut config = builder
.with_single_cert(certs, key)
.explain_err(InternalError, |e| {
format!("Failed to create server listener config: {e}")
})
.unwrap();
if let Some(alpn_protocols) = self.alpn_protocols {
config.alpn_protocols = alpn_protocols;
}
Arc::new(config)
};
Acceptor {
acceptor: RusTlsAcceptor::from(config),
callbacks: None,
}
}
/// Enable HTTP/2 support for this endpoint, which is default off.
/// This effectively sets the ALPN to prefer HTTP/2 with HTTP/1.1 allowed
pub fn enable_h2(&mut self) {
self.set_alpn(ALPN::H2H1);
}
pub fn set_alpn(&mut self, alpn: ALPN) {
self.alpn_protocols = Some(alpn.to_wire_protocols());
}
/// Configure mTLS by providing a rustls client certificate verifier.
pub fn set_client_cert_verifier(&mut self, verifier: Arc) {
self.client_cert_verifier = Some(verifier);
}
pub fn intermediate(cert_path: &str, key_path: &str) -> Result
where
Self: Sized,
{
Ok(TlsSettings {
alpn_protocols: None,
cert_path: Some(cert_path.to_string()),
key_path: Some(key_path.to_string()),
client_cert_verifier: None,
custom_config: None, // Initialize our new field
})
}
pub fn with_callbacks() -> Result
where
Self: Sized,
{
// TODO: verify if/how callback in handshake can be done using Rustls
Error::e_explain(
InternalError,
"Certificate callbacks are not supported with feature \"rustls\".",
)
}
}
impl Acceptor {
pub async fn tls_handshake(&self, stream: S) -> Result> {
debug!("new tls session");
// TODO: be able to offload this handshake in a thread pool
if let Some(cb) = self.callbacks.as_ref() {
handshake_with_callback(self, stream, cb).await
} else {
handshake(self, stream).await
}
}
}
```
Thanks..
Contributor guide
Assessment
This issue has not been assessed yet.