Skip to content

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

@rxdiscovery

Description

@rxdiscovery

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<ServerConfig> 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:

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

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<Vec<Vec<u8>>>,
    // Optional paths to allow custom_config usage
    cert_path: Option<String>,
    key_path: Option<String>,
    client_cert_verifier: Option<Arc<dyn ClientCertVerifier>>,
    // ADDITION: Allows injecting a pre-built ServerConfig (e.g., for dynamic SNI)
    custom_config: Option<Arc<ServerConfig>>,
}

pub struct Acceptor {
    pub acceptor: RusTlsAcceptor,
    callbacks: Option<TlsAcceptCallbacks>,
}

impl TlsSettings {
    /// ADDITION: Creates TlsSettings from an existing rustls ServerConfig
    pub fn with_server_config(config: Arc<ServerConfig>) -> 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<dyn ClientCertVerifier>) {
        self.client_cert_verifier = Some(verifier);
    }

    pub fn intermediate(cert_path: &str, key_path: &str) -> Result<Self>
    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<Self>
    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<S: IO>(&self, stream: S) -> Result<TlsStream<S>> {
        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..

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions