cloudflare/pingora

Support loading TLS certificates from in-memory strings

Opened this issue · 1 comments

What is the problem your feature solves, or the need it fulfills?

Pingora currently requires TLS certificates to be loaded from file paths, which limits flexibility in environments where certificates are obtained dynamically (e.g., from a database, remote API, or secret manager). This restriction makes it difficult to run Pingora in containerized or ephemeral environments where filesystem access is limited or where certificates are short-lived and rotated frequently.

Describe the solution you'd like

Allow pingora::listeners::TlsListener to accept in-memory certificate and key strings (e.g., PEM format), in addition to file paths. Ideally, this would involve exposing an API to construct TlsConfig directly from a rustls::ServerConfig or helper methods to load from &str. This would let developers securely and dynamically inject TLS configuration at runtime.

Describe alternatives you've considered

Currently, the only workaround is to manually create a TlsConfig using rustls and pass it into TlsListener, but this is not documented and may break with future changes to Pingora’s internal APIs. Writing temporary files to disk to satisfy the path requirement is also possible, but introduces unnecessary complexity and security risks.

Additional context

Many modern frameworks and reverse proxies (e.g., hyper, actix, warp) support in-memory TLS loading. Supporting this in Pingora would make it easier to integrate into cloud-native deployments and dynamic certificate management systems such as Let's Encrypt or SPIRE.

@SecAegis isnt there is tempfile crate out there for some reason?

[mem] -> [tempfile] -> [load the cert, without needing the actual file/folder]

what kind of security risk may occure, it would be nice it it's include the use cases and risk examples.

---edit---

oh actually you can implement the dynamic certificate using boringssl example
this is my implementation

let mut dynamic_cert = boringssl_openssl::DynamicCert::new();
let mut tls_settings = TlsSettings::with_callbacks(dynamic_cert).unwrap();
my_gateway_service.add_tls_with_settings(&gw.addr_listen, None, tls_settings);

you can change the let cert_bytes = std::fs::read(cert)?; into your needs.
you can also implement caching like my code below. or you can use lazy_static or RwLock to load the cert string from memory.

i think this is sufficient for your needs

mod boringssl_openssl {
    use async_trait::async_trait;
    use pingora::tls::pkey::{PKey, Private};
    use pingora::tls::ssl::{NameType, SslRef};
    use pingora::tls::x509::X509;
    use std::collections::HashMap;
    use std::sync::{Arc, Mutex};

    pub(super) struct DynamicCert {
        certs: Vec<(Option<String>, X509, PKey<Private>)>,
        // Thread-safe cache for hostname lookups
        cache: Mutex<HashMap<String, (Arc<X509>, Arc<PKey<Private>>)>>,
        // Maximum number of entries to prevent unbounded growth
        max_cache_size: usize,
    }

    impl DynamicCert {
        pub(super) fn new() -> Box<Self> {
            Box::new(DynamicCert {
                certs: Vec::new(),
                cache: Mutex::new(HashMap::new()),
                max_cache_size: 1000, // Default size, can be adjusted based on expected traffic patterns
            })
        }

        // Optional: Allow configuring the cache size
        #[allow(dead_code)]
        pub(super) fn with_cache_size(mut self, max_size: usize) -> Self {
            self.max_cache_size = max_size;
            self
        }

        pub(super) fn add_cert(
            &mut self,
            domain: String,
            cert: &str,
            key: &str,
        ) -> Result<(), Box<dyn std::error::Error>> {
            let cert_bytes = std::fs::read(cert)?;
            let cert = X509::from_pem(&cert_bytes)?;

            let key_bytes = std::fs::read(key)?;
            let key = PKey::private_key_from_pem(&key_bytes)?;

            self.certs.push((Some(domain), cert, key));
            Ok(())
        }

        fn domain_matches(pattern: &str, domain: &str) -> bool {
            // Existing implementation
            if pattern.starts_with("*.") {
                let suffix = &pattern[1..];
                domain.ends_with(suffix)
                    && domain[..domain.len() - suffix.len()].matches('.').count() == 0
            } else {
                pattern == domain
            }
        }

        // Find certificate for a hostname and cache the result
        fn find_cert_for_hostname(
            &self,
            hostname: &str,
        ) -> Option<(Arc<X509>, Arc<PKey<Private>>)> {
            // First check the cache
            {
                let cache = self.cache.lock().unwrap();
                if let Some(cached) = cache.get(hostname) {
                    return Some(cached.clone());
                }
            }

            // Not in cache, search for it
            let result = self.find_matching_cert(hostname);

            // If found, add to cache
            if let Some((cert, key)) = &result {
                let mut cache = self.cache.lock().unwrap();

                // Simple cache size management
                if cache.len() >= self.max_cache_size {
                    // Remove some entries if we're at capacity
                    // A more sophisticated approach would use LRU policy
                    if cache.len() > 10 {
                        // Remove approximately 10% of entries
                        let to_remove = (cache.len() / 10).max(1);
                        for _ in 0..to_remove {
                            if let Some(key) = cache.keys().next().cloned() {
                                cache.remove(&key);
                            }
                        }
                    } else {
                        cache.clear(); // Small cache, just clear it
                    }
                }

                cache.insert(hostname.to_string(), (cert.clone(), key.clone()));
            }

            result
        }

        // Search for matching certificate (exact or wildcard)
        fn find_matching_cert(&self, hostname: &str) -> Option<(Arc<X509>, Arc<PKey<Private>>)> {
            // First try exact matches
            for (domain, cert, key) in &self.certs {
                if let Some(domain_str) = domain {
                    if domain_str == hostname {
                        return Some((Arc::new(cert.clone()), Arc::new(key.clone())));
                    }
                }
            }

            // Then try wildcard matches
            for (domain, cert, key) in &self.certs {
                if let Some(domain_str) = domain {
                    if Self::domain_matches(domain_str, hostname) {
                        return Some((Arc::new(cert.clone()), Arc::new(key.clone())));
                    }
                }
            }

            // No match found, return the default if available
            if !self.certs.is_empty() {
                let (_, default_cert, default_key) = &self.certs[0];
                return Some((
                    Arc::new(default_cert.clone()),
                    Arc::new(default_key.clone()),
                ));
            }

            None
        }
    }

    #[async_trait]
    impl pingora::listeners::TlsAccept for DynamicCert {
        async fn certificate_callback(&self, ssl: &mut SslRef) {
            use pingora::tls::ext;

            if self.certs.is_empty() {
                panic!("No certificates configured for TLS!");
            }

            if let Some(server_name) = ssl.servername(NameType::HOST_NAME) {
                // Use the cache to efficiently look up certificates
                if let Some((cert, key)) = self.find_cert_for_hostname(server_name) {
                    ext::ssl_use_certificate(ssl, &cert).unwrap();
                    ext::ssl_use_private_key(ssl, &key).unwrap();
                    return;
                }
            }

            // No SNI or no matching certificate found, use default (index 0)
            let (_, default_cert, default_key) = &self.certs[0];
            ext::ssl_use_certificate(ssl, default_cert).unwrap();
            ext::ssl_use_private_key(ssl, default_key).unwrap();
        }
    }
}