cloudflare / cloudflare/pingora

Support dynamic certificate selection (SNI) in rustls backend by accepting ServerConfig

Open
#832 2 comments 2 reactions 0 assignees View on GitHub
enhancement
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.