Skip to main content

dynamo_runtime/
tls_utils.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared TLS utilities for the Dynamo runtime.
5//!
6//! Provides helpers for loading PEM certificates and building rustls
7//! `ServerConfig` / `ClientConfig` objects for transport-layer security.
8
9use std::{
10    fmt,
11    path::{Path, PathBuf},
12    sync::{Arc, Mutex},
13    time::Duration,
14};
15
16use anyhow::{Context, Result};
17use arc_swap::ArcSwap;
18use rustls::server::{ClientHello, ResolvesServerCert};
19use rustls::sign::CertifiedKey;
20use rustls::{ClientConfig, RootCertStore, ServerConfig, SignatureScheme};
21use rustls_pemfile::{certs, private_key};
22
23/// TLS handshake timeout, configurable via `DYN_TCP_TLS_HANDSHAKE_TIMEOUT_SECS` (default: 3s).
24pub fn handshake_timeout() -> std::time::Duration {
25    use crate::config::environment_names::tcp_response_stream::tls as env;
26    let secs = std::env::var(env::DYN_TCP_TLS_HANDSHAKE_TIMEOUT_SECS)
27        .ok()
28        .and_then(|v| v.parse::<u64>().ok())
29        .unwrap_or(3);
30    std::time::Duration::from_secs(secs)
31}
32
33/// Build a rustls `ServerConfig` from PEM certificate and key files.
34///
35/// The certificate is served through a `ReloadingCertifiedKey`, so a rotated
36/// cert/key on disk (in-place rewrite or an atomic symlink swap) is picked up
37/// automatically on the next handshake without restarting the process. The
38/// initial load is validated eagerly: an invalid cert/key path
39/// fails here rather than starting a server that cannot serve TLS.
40///
41/// When `client_ca_cert_path` is `Some`, the server requires clients to present
42/// a certificate signed by that CA (mutual TLS); an unauthenticated client is
43/// rejected at the handshake. When `None`, client certificates are not
44/// requested.
45pub fn server_tls_config(
46    cert_path: &Path,
47    key_path: &Path,
48    client_ca_cert_path: Option<&Path>,
49) -> Result<ServerConfig> {
50    let resolver = Arc::new(ReloadingCertifiedKey::new(cert_path, key_path)?);
51
52    let provider = Arc::new(rustls::crypto::ring::default_provider());
53    let builder = ServerConfig::builder_with_provider(provider.clone())
54        .with_safe_default_protocol_versions()
55        .context("configuring TLS protocol versions")?;
56
57    let config = if let Some(ca_path) = client_ca_cert_path {
58        let ca_pem = std::fs::read(ca_path)
59            .with_context(|| format!("reading client CA cert: {}", ca_path.display()))?;
60        let ca_certs = certs(&mut ca_pem.as_slice())
61            .collect::<Result<Vec<_>, _>>()
62            .context("parsing client CA certificate PEM")?;
63        let mut client_roots = RootCertStore::empty();
64        for cert in ca_certs {
65            client_roots
66                .add(cert)
67                .context("adding client CA certificate to root store")?;
68        }
69        if client_roots.is_empty() {
70            anyhow::bail!(
71                "client CA certificate store is empty after parsing {}; \
72                 ensure the file contains at least one valid PEM certificate",
73                ca_path.display()
74            );
75        }
76        let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(
77            Arc::new(client_roots),
78            provider,
79        )
80        .build()
81        .context("building client certificate verifier")?;
82        builder
83            .with_client_cert_verifier(verifier)
84            .with_cert_resolver(resolver)
85    } else {
86        builder.with_no_client_auth().with_cert_resolver(resolver)
87    };
88
89    Ok(config)
90}
91
92/// Build a server `ServerConfig` for a TCP plane from optional cert/key/client-CA
93/// paths, with the validation and misconfiguration diagnostics shared by the
94/// request-plane and response-stream servers. `plane` labels the log lines
95/// (e.g. `"TCP request plane"` / `"TCP server"`).
96///
97/// Returns `Ok(None)` for the plaintext case and fails closed on partial or
98/// invalid configuration (cert without key, a client CA without a server
99/// cert/key). When a client CA is supplied, the resulting config enforces mTLS.
100pub fn server_tls_acceptor_config(
101    plane: &str,
102    cert: Option<&Path>,
103    key: Option<&Path>,
104    client_ca: Option<&Path>,
105) -> Result<Option<ServerConfig>> {
106    use crate::config::environment_names::tcp_response_stream::tls as env;
107    match (cert, key) {
108        (Some(cert), Some(key)) => {
109            let config = server_tls_config(cert, key, client_ca)
110                .with_context(|| format!("building {plane} TLS config from cert/key/client CA"))?;
111            if client_ca.is_some() {
112                tracing::info!(
113                    plane,
114                    "TLS enabled with mutual authentication (client certificates required)"
115                );
116                // Every component also dials peers as a client. Enforcing client
117                // certs here while presenting no identity of our own means our
118                // outbound handshakes to other mTLS peers would fail.
119                let has_client_identity = std::env::var(env::DYN_TCP_TLS_CLIENT_CERT_PATH).is_ok()
120                    && std::env::var(env::DYN_TCP_TLS_CLIENT_KEY_PATH).is_ok();
121                if !has_client_identity {
122                    tracing::warn!(
123                        plane,
124                        client_ca_var = env::DYN_TCP_TLS_CLIENT_CA_CERT_PATH,
125                        client_cert_var = env::DYN_TCP_TLS_CLIENT_CERT_PATH,
126                        client_key_var = env::DYN_TCP_TLS_CLIENT_KEY_PATH,
127                        "server enforces client certificates but no client identity is configured; outbound connections to peers that also enforce mTLS will fail the handshake",
128                    );
129                }
130            } else {
131                tracing::info!(plane, "TLS enabled");
132            }
133            // Applies to both TLS and mTLS: if the client side has no way to
134            // verify this server, peers dialing it fail the handshake with an
135            // opaque error.
136            let client_trust_set = std::env::var(env::DYN_TCP_TLS_CA_CERT_PATH).is_ok()
137                || crate::config::env_is_truthy(env::DYN_TCP_TLS_INSECURE);
138            if !client_trust_set {
139                tracing::warn!(
140                    plane,
141                    ca_var = env::DYN_TCP_TLS_CA_CERT_PATH,
142                    insecure_var = env::DYN_TCP_TLS_INSECURE,
143                    "server has TLS enabled but no client trust is configured; peers cannot verify this server",
144                );
145            }
146            Ok(Some(config))
147        }
148        (Some(_), None) | (None, Some(_)) => anyhow::bail!(
149            "both {} and {} must be set to enable {plane} TLS",
150            env::DYN_TCP_TLS_CERT_PATH,
151            env::DYN_TCP_TLS_KEY_PATH,
152        ),
153        (None, None) if client_ca.is_some() => anyhow::bail!(
154            "{} requires {} and {} to also be set",
155            env::DYN_TCP_TLS_CLIENT_CA_CERT_PATH,
156            env::DYN_TCP_TLS_CERT_PATH,
157            env::DYN_TCP_TLS_KEY_PATH,
158        ),
159        (None, None) => {
160            let client_trust_set = std::env::var(env::DYN_TCP_TLS_CA_CERT_PATH).is_ok()
161                || crate::config::env_is_truthy(env::DYN_TCP_TLS_INSECURE);
162            if client_trust_set {
163                tracing::warn!(
164                    plane,
165                    cert_var = env::DYN_TCP_TLS_CERT_PATH,
166                    key_var = env::DYN_TCP_TLS_KEY_PATH,
167                    "server is running in plaintext but client TLS env vars are set; set the server cert/key to enable TLS, or unset the client vars",
168                );
169            }
170            Ok(None)
171        }
172    }
173}
174
175/// Build a rustls `ClientConfig` for outbound TLS connections.
176///
177/// - `ca_cert_path`: trust this CA for verifying the server certificate.
178///   When `None`, the root store is empty — supply a CA cert or use `insecure`.
179/// - `insecure`: skip certificate verification entirely. **Dev/test only.**
180/// - `client_cert_path` + `client_key_path`: when both are `Some`, the client
181///   presents this certificate to the server (mutual TLS). The identity is
182///   served through a `ReloadingCertifiedKey`, so a rotated client cert/key on
183///   disk is picked up without a process restart. Both must be set together.
184pub fn client_tls_config(
185    ca_cert_path: Option<&Path>,
186    insecure: bool,
187    client_cert_path: Option<&Path>,
188    client_key_path: Option<&Path>,
189) -> Result<ClientConfig> {
190    if client_cert_path.is_some() != client_key_path.is_some() {
191        anyhow::bail!("client cert and key paths must both be set or both be unset");
192    }
193
194    let provider = Arc::new(rustls::crypto::ring::default_provider());
195
196    if insecure {
197        tracing::info!("TLS: certificate verification disabled (insecure mode)");
198        let builder = ClientConfig::builder_with_provider(provider)
199            .with_safe_default_protocol_versions()
200            .context("configuring TLS protocol versions")?
201            .dangerous()
202            .with_custom_certificate_verifier(Arc::new(NoVerifier));
203        let config = match (client_cert_path, client_key_path) {
204            (Some(cp), Some(kp)) => {
205                builder.with_client_cert_resolver(Arc::new(ReloadingCertifiedKey::new(cp, kp)?))
206            }
207            _ => builder.with_no_client_auth(),
208        };
209        return Ok(config);
210    }
211
212    let mut root_store = RootCertStore::empty();
213    if let Some(ca_path) = ca_cert_path {
214        let ca_pem = std::fs::read(ca_path)
215            .with_context(|| format!("reading CA cert: {}", ca_path.display()))?;
216        let ca_certs = certs(&mut ca_pem.as_slice())
217            .collect::<Result<Vec<_>, _>>()
218            .context("parsing CA certificate PEM")?;
219        for cert in ca_certs {
220            root_store
221                .add(cert)
222                .context("adding CA certificate to root store")?;
223        }
224        if root_store.is_empty() {
225            anyhow::bail!(
226                "CA certificate store is empty after parsing {}; \
227                 ensure the file contains at least one valid PEM certificate",
228                ca_path.display()
229            );
230        }
231    }
232    // When no CA cert is provided, the root store is empty — the caller must
233    // supply a CA cert or use `insecure = true`. This is intentional: in
234    // cluster deployments, certs are issued by an internal CA and system roots
235    // are not relevant.
236
237    let builder = ClientConfig::builder_with_provider(provider)
238        .with_safe_default_protocol_versions()
239        .context("configuring TLS protocol versions")?
240        .with_root_certificates(root_store);
241    let config = match (client_cert_path, client_key_path) {
242        (Some(cp), Some(kp)) => {
243            builder.with_client_cert_resolver(Arc::new(ReloadingCertifiedKey::new(cp, kp)?))
244        }
245        _ => builder.with_no_client_auth(),
246    };
247
248    Ok(config)
249}
250
251/// Load a leaf certificate chain + private key from PEM bytes into a rustls
252/// [`CertifiedKey`], validating that the certificate and key match.
253fn load_certified_key(cert_pem: &[u8], key_pem: &[u8]) -> Result<CertifiedKey> {
254    let mut cert_reader = cert_pem;
255    let cert_chain = certs(&mut cert_reader)
256        .collect::<Result<Vec<_>, _>>()
257        .context("parsing certificate PEM")?;
258    if cert_chain.is_empty() {
259        anyhow::bail!("no certificates found in PEM");
260    }
261
262    let mut key_reader = key_pem;
263    let key = private_key(&mut key_reader)
264        .context("parsing private key PEM")?
265        .context("no private key found in PEM")?;
266    let signing_key =
267        rustls::crypto::ring::sign::any_supported_type(&key).context("loading TLS private key")?;
268
269    let certified_key = CertifiedKey::new(cert_chain, signing_key);
270    certified_key
271        .keys_match()
272        .context("TLS certificate and private key do not match")?;
273    Ok(certified_key)
274}
275
276/// Content fingerprint of a loaded identity. Change is detected by hashing the
277/// file *contents* (blake3) rather than mtime, so atomic symlink swaps (where a
278/// mounted directory of certs is rotated by relinking) are handled reliably.
279#[derive(Debug, Eq, PartialEq)]
280struct IdentityFingerprint {
281    content_hash: [u8; 32],
282}
283
284impl IdentityFingerprint {
285    fn from_loaded(cert_pem: &[u8], key_pem: &[u8]) -> Self {
286        let mut hasher = blake3::Hasher::new();
287        hasher.update(cert_pem);
288        hasher.update(&[0]); // domain separator between cert and key
289        hasher.update(key_pem);
290        Self {
291            content_hash: *hasher.finalize().as_bytes(),
292        }
293    }
294}
295
296struct LoadedIdentity {
297    fingerprint: IdentityFingerprint,
298    certified_key: Arc<CertifiedKey>,
299}
300
301/// Shared reloadable identity. The current identity lives in an [`ArcSwap`]
302/// read lock-free on the handshake path; a background thread owns the filesystem
303/// reads and swaps in a new identity when the on-disk contents change.
304struct ReloadingState {
305    cert_path: PathBuf,
306    key_path: PathBuf,
307    current: ArcSwap<CertifiedKey>,
308    /// Fingerprint of the last successfully loaded identity. Only the background
309    /// reloader (and tests) touch this, so it never contends with handshakes.
310    fingerprint: Mutex<IdentityFingerprint>,
311}
312
313impl ReloadingState {
314    fn load(cert_path: &Path, key_path: &Path) -> Result<LoadedIdentity> {
315        let cert_pem = std::fs::read(cert_path)
316            .with_context(|| format!("reading cert: {}", cert_path.display()))?;
317        let key_pem = std::fs::read(key_path)
318            .with_context(|| format!("reading key: {}", key_path.display()))?;
319        let certified_key = load_certified_key(&cert_pem, &key_pem)?;
320        let fingerprint = IdentityFingerprint::from_loaded(&cert_pem, &key_pem);
321        Ok(LoadedIdentity {
322            fingerprint,
323            certified_key: Arc::new(certified_key),
324        })
325    }
326
327    /// Re-read the identity from disk and swap it in if the contents changed.
328    /// Runs off the handshake path (background thread / tests). A failed read
329    /// leaves the last valid identity in place and propagates the error so the
330    /// caller can back off.
331    fn refresh(&self) -> Result<()> {
332        let reloaded = Self::load(&self.cert_path, &self.key_path)?;
333        let mut fingerprint = self
334            .fingerprint
335            .lock()
336            .unwrap_or_else(|poisoned| poisoned.into_inner());
337        if *fingerprint != reloaded.fingerprint {
338            let cert_count = reloaded.certified_key.cert.len();
339            self.current.store(reloaded.certified_key);
340            *fingerprint = reloaded.fingerprint;
341            tracing::info!(
342                cert_path = %self.cert_path.display(),
343                cert_count,
344                "Reloaded rotated TLS certificate and key from disk"
345            );
346        }
347        Ok(())
348    }
349
350    /// Spawn a background thread that periodically refreshes the identity. It
351    /// holds only a `Weak` reference, so it exits once the resolver is dropped.
352    /// A plain OS thread (not a Tokio task) keeps the filesystem reads off every
353    /// async runtime worker and avoids depending on a runtime being present when
354    /// the resolver is built.
355    fn spawn_reloader(state: &Arc<Self>) {
356        let weak = Arc::downgrade(state);
357        let spawned = std::thread::Builder::new()
358            .name("tls-cert-reloader".to_string())
359            .spawn(move || {
360                let mut interval = ReloadingCertifiedKey::RELOAD_CHECK_INTERVAL;
361                let mut consecutive_failures: u32 = 0;
362                loop {
363                    std::thread::sleep(interval);
364                    let Some(state) = weak.upgrade() else {
365                        break; // resolver dropped; stop reloading
366                    };
367                    match state.refresh() {
368                        Ok(()) => {
369                            if consecutive_failures > 0 {
370                                tracing::info!(
371                                    cert_path = %state.cert_path.display(),
372                                    failed_attempts = consecutive_failures,
373                                    "Recovered: reloaded TLS certificate after earlier failures"
374                                );
375                            }
376                            consecutive_failures = 0;
377                            interval = ReloadingCertifiedKey::RELOAD_CHECK_INTERVAL;
378                        }
379                        Err(error) => {
380                            consecutive_failures = consecutive_failures.saturating_add(1);
381                            // Exponential backoff from FAILURE_RETRY_INTERVAL, capped
382                            // at the normal check interval, so a persistently broken
383                            // file doesn't hammer the filesystem or the logs.
384                            let backoff = 2u32.saturating_pow((consecutive_failures - 1).min(16));
385                            interval = (ReloadingCertifiedKey::FAILURE_RETRY_INTERVAL * backoff)
386                                .min(ReloadingCertifiedKey::RELOAD_CHECK_INTERVAL);
387                            // Warn once when the failure begins; drop to debug while
388                            // it persists so a permanently broken file isn't logged
389                            // on every retry.
390                            if consecutive_failures == 1 {
391                                tracing::warn!(
392                                    cert_path = %state.cert_path.display(),
393                                    error = %format!("{error:#}"),
394                                    "Failed to reload rotated TLS certificate; keeping the last valid identity"
395                                );
396                            } else {
397                                tracing::debug!(
398                                    cert_path = %state.cert_path.display(),
399                                    error = %format!("{error:#}"),
400                                    attempt = consecutive_failures,
401                                    "TLS certificate reload still failing; retrying with backoff"
402                                );
403                            }
404                        }
405                    }
406                }
407            });
408        if let Err(error) = spawned {
409            tracing::warn!(
410                error = %error,
411                "Failed to spawn TLS certificate reloader thread; certificate hot-reload is disabled for this identity"
412            );
413        }
414    }
415}
416
417/// A rustls certificate resolver whose served identity is refreshed from disk by
418/// a background thread, so a rotated cert/key (in-place rewrite or atomic
419/// symlink swap) is picked up without a process restart.
420///
421/// `resolve()` only reads the current identity from an [`ArcSwap`] — it performs
422/// no filesystem I/O and never blocks, so it is safe to call from the
423/// `tokio-rustls` handshake poll. A failed reload keeps the last valid identity.
424///
425/// The same type serves as both a [`ResolvesServerCert`] (server leaf cert) and
426/// a [`rustls::client::ResolvesClientCert`] (mTLS client identity).
427pub(crate) struct ReloadingCertifiedKey {
428    state: Arc<ReloadingState>,
429}
430
431impl fmt::Debug for ReloadingCertifiedKey {
432    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
433        f.debug_struct("ReloadingCertifiedKey")
434            .field("cert_path", &self.state.cert_path)
435            .field("key_path", &self.state.key_path)
436            .finish_non_exhaustive()
437    }
438}
439
440impl ReloadingCertifiedKey {
441    const RELOAD_CHECK_INTERVAL: Duration = Duration::from_secs(30);
442    const FAILURE_RETRY_INTERVAL: Duration = Duration::from_secs(1);
443
444    fn new(cert_path: &Path, key_path: &Path) -> Result<Self> {
445        let loaded = ReloadingState::load(cert_path, key_path)?;
446        let state = Arc::new(ReloadingState {
447            cert_path: cert_path.to_path_buf(),
448            key_path: key_path.to_path_buf(),
449            current: ArcSwap::from(loaded.certified_key),
450            fingerprint: Mutex::new(loaded.fingerprint),
451        });
452        ReloadingState::spawn_reloader(&state);
453        Ok(Self { state })
454    }
455
456    fn resolve_key(&self) -> Arc<CertifiedKey> {
457        self.state.current.load_full()
458    }
459
460    /// Test-only: perform one synchronous reload cycle (what the background
461    /// thread does on each tick) so tests can drive rotation deterministically.
462    #[cfg(test)]
463    fn reload_now(&self) -> Result<()> {
464        self.state.refresh()
465    }
466}
467
468impl ResolvesServerCert for ReloadingCertifiedKey {
469    fn resolve(&self, _client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
470        Some(self.resolve_key())
471    }
472}
473
474impl rustls::client::ResolvesClientCert for ReloadingCertifiedKey {
475    fn resolve(
476        &self,
477        _root_hint_subjects: &[&[u8]],
478        _sigschemes: &[SignatureScheme],
479    ) -> Option<Arc<CertifiedKey>> {
480        Some(self.resolve_key())
481    }
482
483    fn has_certs(&self) -> bool {
484        true
485    }
486}
487
488/// Certificate verifier that accepts any certificate.
489/// **Only for development/testing. Never use in production.**
490#[derive(Debug)]
491struct NoVerifier;
492
493impl rustls::client::danger::ServerCertVerifier for NoVerifier {
494    fn verify_server_cert(
495        &self,
496        _end_entity: &rustls::pki_types::CertificateDer<'_>,
497        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
498        _server_name: &rustls::pki_types::ServerName<'_>,
499        _ocsp_response: &[u8],
500        _now: rustls::pki_types::UnixTime,
501    ) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
502        Ok(rustls::client::danger::ServerCertVerified::assertion())
503    }
504
505    fn verify_tls12_signature(
506        &self,
507        _message: &[u8],
508        _cert: &rustls::pki_types::CertificateDer<'_>,
509        _dss: &rustls::DigitallySignedStruct,
510    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
511        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
512    }
513
514    fn verify_tls13_signature(
515        &self,
516        _message: &[u8],
517        _cert: &rustls::pki_types::CertificateDer<'_>,
518        _dss: &rustls::DigitallySignedStruct,
519    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
520        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
521    }
522
523    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
524        rustls::crypto::ring::default_provider()
525            .signature_verification_algorithms
526            .supported_schemes()
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use std::io::Write;
534    use tempfile::NamedTempFile;
535
536    fn make_cert_files() -> (NamedTempFile, NamedTempFile) {
537        let key_pair = rcgen::KeyPair::generate().unwrap();
538        let cert = rcgen::CertificateParams::new(vec!["localhost".to_string()])
539            .unwrap()
540            .self_signed(&key_pair)
541            .unwrap();
542        let mut cert_file = NamedTempFile::new().unwrap();
543        cert_file.write_all(cert.pem().as_bytes()).unwrap();
544        let mut key_file = NamedTempFile::new().unwrap();
545        key_file
546            .write_all(key_pair.serialize_pem().as_bytes())
547            .unwrap();
548        (cert_file, key_file)
549    }
550
551    #[test]
552    fn server_config_roundtrip() {
553        let (cert, key) = make_cert_files();
554        server_tls_config(cert.path(), key.path(), None).unwrap();
555    }
556
557    #[test]
558    fn server_config_with_mtls() {
559        // A client CA turns on client-certificate verification (mTLS).
560        let (cert, key) = make_cert_files();
561        server_tls_config(cert.path(), key.path(), Some(cert.path())).unwrap();
562    }
563
564    #[test]
565    fn server_config_mtls_empty_client_ca_errors() {
566        let (cert, key) = make_cert_files();
567        let empty = NamedTempFile::new().unwrap();
568        assert!(
569            server_tls_config(cert.path(), key.path(), Some(empty.path()))
570                .unwrap_err()
571                .to_string()
572                .contains("client CA certificate store is empty")
573        );
574    }
575
576    #[test]
577    fn server_config_bad_paths() {
578        let missing = std::path::Path::new("/nonexistent/x.pem");
579        assert!(
580            server_tls_config(missing, missing, None)
581                .unwrap_err()
582                .to_string()
583                .contains("reading cert")
584        );
585        let (cert, _) = make_cert_files();
586        assert!(
587            server_tls_config(cert.path(), missing, None)
588                .unwrap_err()
589                .to_string()
590                .contains("reading key")
591        );
592    }
593
594    fn make_cert_pem() -> (String, String) {
595        let key_pair = rcgen::KeyPair::generate().unwrap();
596        let cert = rcgen::CertificateParams::new(vec!["localhost".to_string()])
597            .unwrap()
598            .self_signed(&key_pair)
599            .unwrap();
600        (cert.pem(), key_pair.serialize_pem())
601    }
602
603    #[test]
604    fn certified_key_reloads_rotated_files() {
605        let (cert1, key1) = make_cert_pem();
606        let cert_file = NamedTempFile::new().unwrap();
607        let key_file = NamedTempFile::new().unwrap();
608        std::fs::write(cert_file.path(), &cert1).unwrap();
609        std::fs::write(key_file.path(), &key1).unwrap();
610
611        let resolver = ReloadingCertifiedKey::new(cert_file.path(), key_file.path()).unwrap();
612        let before = resolver.resolve_key().cert[0].clone();
613
614        // Rotate the file contents in place and force a re-check.
615        let (cert2, key2) = make_cert_pem();
616        std::fs::write(cert_file.path(), &cert2).unwrap();
617        std::fs::write(key_file.path(), &key2).unwrap();
618        resolver.reload_now().unwrap();
619
620        let after = resolver.resolve_key().cert[0].clone();
621        assert_ne!(
622            before, after,
623            "resolver should serve the rotated certificate after the contents change"
624        );
625    }
626
627    #[test]
628    fn certified_key_reloads_symlinked_generation() {
629        use std::os::unix::fs::symlink;
630
631        // Mimic a symlink-based cert rotation: the mounted paths are symlinks
632        // into a per-generation directory, rotated by an atomic rename over the
633        // link.
634        let dir = tempfile::tempdir().unwrap();
635        let (c1, k1) = make_cert_pem();
636        let gen1 = dir.path().join("gen1");
637        std::fs::create_dir(&gen1).unwrap();
638        std::fs::write(gen1.join("tls.crt"), &c1).unwrap();
639        std::fs::write(gen1.join("tls.key"), &k1).unwrap();
640
641        let cert_link = dir.path().join("tls.crt");
642        let key_link = dir.path().join("tls.key");
643        symlink(gen1.join("tls.crt"), &cert_link).unwrap();
644        symlink(gen1.join("tls.key"), &key_link).unwrap();
645
646        let resolver = ReloadingCertifiedKey::new(&cert_link, &key_link).unwrap();
647        let before = resolver.resolve_key().cert[0].clone();
648
649        let (c2, k2) = make_cert_pem();
650        let gen2 = dir.path().join("gen2");
651        std::fs::create_dir(&gen2).unwrap();
652        std::fs::write(gen2.join("tls.crt"), &c2).unwrap();
653        std::fs::write(gen2.join("tls.key"), &k2).unwrap();
654        // Atomic symlink swap: create new links then rename over the live ones.
655        let cert_tmp = dir.path().join("tls.crt.tmp");
656        let key_tmp = dir.path().join("tls.key.tmp");
657        symlink(gen2.join("tls.crt"), &cert_tmp).unwrap();
658        symlink(gen2.join("tls.key"), &key_tmp).unwrap();
659        std::fs::rename(&cert_tmp, &cert_link).unwrap();
660        std::fs::rename(&key_tmp, &key_link).unwrap();
661        resolver.reload_now().unwrap();
662
663        let after = resolver.resolve_key().cert[0].clone();
664        assert_ne!(
665            before, after,
666            "resolver should follow the swapped symlink to the new generation"
667        );
668    }
669
670    #[test]
671    fn certified_key_keeps_previous_on_corrupt_reload() {
672        let (c1, k1) = make_cert_pem();
673        let cert_file = NamedTempFile::new().unwrap();
674        let key_file = NamedTempFile::new().unwrap();
675        std::fs::write(cert_file.path(), &c1).unwrap();
676        std::fs::write(key_file.path(), &k1).unwrap();
677        let resolver = ReloadingCertifiedKey::new(cert_file.path(), key_file.path()).unwrap();
678        let before = resolver.resolve_key().cert[0].clone();
679
680        // Simulate a partial write mid-rotation.
681        std::fs::write(cert_file.path(), b"not a valid pem").unwrap();
682        let reload_result = resolver.reload_now();
683
684        assert!(
685            reload_result.is_err(),
686            "a corrupt reload should surface an error to the caller"
687        );
688        let after = resolver.resolve_key().cert[0].clone();
689        assert_eq!(
690            before, after,
691            "a failed reload must keep serving the previously loaded certificate"
692        );
693    }
694
695    #[test]
696    fn client_config_insecure() {
697        client_tls_config(None, true, None, None).unwrap();
698    }
699
700    #[test]
701    fn client_config_with_ca() {
702        let (cert, _) = make_cert_files();
703        client_tls_config(Some(cert.path()), false, None, None).unwrap();
704    }
705
706    #[test]
707    fn client_config_with_mtls() {
708        // A client cert/key pair is presented as the client identity (mTLS).
709        let (cert, key) = make_cert_files();
710        client_tls_config(
711            Some(cert.path()),
712            false,
713            Some(cert.path()),
714            Some(key.path()),
715        )
716        .unwrap();
717    }
718
719    #[test]
720    fn client_config_mtls_insecure() {
721        // Client identity is also honored in insecure (no server verification) mode.
722        let (cert, key) = make_cert_files();
723        client_tls_config(None, true, Some(cert.path()), Some(key.path())).unwrap();
724    }
725
726    #[test]
727    fn client_config_partial_mtls_errors() {
728        // Cert without key (or vice versa) is rejected.
729        let (cert, _) = make_cert_files();
730        assert!(client_tls_config(Some(cert.path()), false, Some(cert.path()), None).is_err());
731    }
732
733    #[test]
734    fn client_config_empty_ca_errors() {
735        let empty = NamedTempFile::new().unwrap();
736        assert!(
737            client_tls_config(Some(empty.path()), false, None, None)
738                .unwrap_err()
739                .to_string()
740                .contains("CA certificate store is empty")
741        );
742    }
743
744    #[test]
745    fn client_config_missing_ca_errors() {
746        assert!(
747            client_tls_config(
748                Some(std::path::Path::new("/nonexistent/ca.pem")),
749                false,
750                None,
751                None
752            )
753            .unwrap_err()
754            .to_string()
755            .contains("reading CA cert")
756        );
757    }
758}