Skip to main content

krabka_security/
reload.rs

1//! Hot-reloadable TLS server config.
2//!
3//! This module wraps a [`rustls::ServerConfig`] in an [`arc_swap::ArcSwap`], so
4//! the broker can swap cert/key/client-CA without a listener restart and
5//! without breaking in-flight connections. A new TLS handshake picks up the
6//! latest config. An already-established TLS session continues to use the
7//! `ServerConfig` it negotiated against.
8//!
9//! This works with the existing [`crate::TlsConfig`]. The path-and-options
10//! struct stays as the source of truth, and
11//! [`DynamicServerConfig::reload_from`] re-reads the files into a fresh
12//! `Arc<ServerConfig>` and atomically swaps it in.
13
14use std::sync::Arc;
15
16use arc_swap::ArcSwap;
17
18use crate::tls::{TlsConfig, TlsError};
19
20/// Atomically swappable wrapper around a [`rustls::ServerConfig`].
21///
22/// A clone costs one `Arc` bump. A read is lock-free. The only expensive
23/// operation is [`reload_from`], which re-parses cert files.
24///
25/// [`reload_from`]: Self::reload_from
26#[derive(Debug)]
27pub struct DynamicServerConfig {
28    inner: ArcSwap<rustls::ServerConfig>,
29}
30
31impl DynamicServerConfig {
32    /// Build a fresh `DynamicServerConfig` from a [`TlsConfig`].
33    ///
34    /// This function reads the cert, the key, and the optional client-CA paths
35    /// immediately.
36    ///
37    /// # Errors
38    ///
39    /// Propagates the underlying [`TlsError`] from
40    /// [`TlsConfig::build_server_config`].
41    // Initial dynamic-TLS-config construction. skip_all keeps cert/key material
42    // out of span fields. `err` surfaces build failures (Debug).
43    #[tracing::instrument(level = "info", skip_all, err)]
44    pub fn from_tls_config(cfg: &TlsConfig) -> Result<Arc<Self>, TlsError> {
45        let server_config = cfg.build_server_config()?;
46        Ok(Arc::new(Self {
47            inner: ArcSwap::new(server_config),
48        }))
49    }
50
51    /// Snapshot the current `ServerConfig`.
52    ///
53    /// The returned `Arc` is independent of later
54    /// [`reload_from`](Self::reload_from) calls. A concurrent reload does not
55    /// change the handshakes that already run against this snapshot.
56    #[must_use]
57    pub fn current(&self) -> Arc<rustls::ServerConfig> {
58        // `ArcSwap::load_full` clones the inner Arc (one atomic bump);
59        // we then unwrap the outer `Guard` into a plain `Arc`.
60        self.inner.load_full()
61    }
62
63    /// Re-read the cert, the key, and the optional client-CA from disk, then
64    /// swap the new `ServerConfig` in atomically.
65    ///
66    /// On error this function keeps the previous config in place and returns
67    /// the error to the caller.
68    ///
69    /// A reload has no semantic effect when the inputs have not changed. But
70    /// rustls exposes no content-equality hash on `ServerConfig`, so this
71    /// function rebuilds unconditionally. A caller that wants to skip identical
72    /// reloads should diff the mtimes upstream.
73    ///
74    /// # Errors
75    ///
76    /// Propagates the underlying [`TlsError`] from
77    /// [`TlsConfig::build_server_config`].
78    // TLS cert/key hot-reload + atomic swap. skip_all keeps cert/key material
79    // out of span fields. `err` surfaces rebuild failures (Debug); on error the
80    // previous config stays in place.
81    #[tracing::instrument(level = "info", skip_all, err)]
82    pub fn reload_from(&self, cfg: &TlsConfig) -> Result<(), TlsError> {
83        let new = cfg.build_server_config()?;
84        self.inner.store(new);
85        Ok(())
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use std::{
92        fs::{self, File},
93        io::Write,
94        path::{Path, PathBuf},
95    };
96
97    use rustls::crypto::ring::default_provider;
98
99    use super::*;
100    use crate::tls::ClientAuthMode;
101
102    fn install_provider() {
103        let _ = default_provider().install_default();
104    }
105
106    fn write_pair(dir: &Path, cert_pem: &str, key_pem: &str) -> (PathBuf, PathBuf) {
107        let cp = dir.join("cert.pem");
108        let kp = dir.join("key.pem");
109        File::create(&cp)
110            .unwrap()
111            .write_all(cert_pem.as_bytes())
112            .unwrap();
113        File::create(&kp)
114            .unwrap()
115            .write_all(key_pem.as_bytes())
116            .unwrap();
117        (cp, kp)
118    }
119
120    /// The `current()` snapshot is stable across a later `reload_from`. An
121    /// existing handshake that captured the old `Arc<ServerConfig>` continues
122    /// to see the old certs.
123    #[test]
124    fn snapshot_is_stable_across_reload() {
125        install_provider();
126        let dir = tempfile::tempdir().unwrap();
127        let (cp, kp) = write_pair(
128            dir.path(),
129            include_str!("../tests/fixtures/dev_cert.pem"),
130            include_str!("../tests/fixtures/dev_key.pem"),
131        );
132        let cfg = TlsConfig {
133            cert_chain_path: cp.clone(),
134            private_key_path: kp.clone(),
135            trust_roots_path: None,
136            client_ca_path: None,
137            client_auth: ClientAuthMode::Disabled,
138        };
139        let dynamic = DynamicServerConfig::from_tls_config(&cfg).unwrap();
140        let snap_before = dynamic.current();
141
142        // Overwrite the cert files with the alt fixture and reload.
143        fs::write(&cp, include_str!("../tests/fixtures/dev_cert_alt.pem")).unwrap();
144        fs::write(&kp, include_str!("../tests/fixtures/dev_key_alt.pem")).unwrap();
145        dynamic.reload_from(&cfg).expect("reload must succeed");
146        let snap_after = dynamic.current();
147
148        // The two snapshots must be distinct `Arc`s — `current()`
149        // returns the latest, so `after != before` is the post-swap
150        // invariant. Use raw pointer comparison: `ServerConfig` is not
151        // `PartialEq`, but `Arc::ptr_eq` is exactly what we want.
152        assert2::assert!(!Arc::ptr_eq(&snap_before, &snap_after));
153    }
154
155    /// A reload error leaves the prior config in place. Valid filesystem inputs
156    /// make a "rebuild fails" path hard to produce, so this test points the
157    /// second reload at a nonexistent cert path. The current snapshot must
158    /// remain the originally-loaded config.
159    #[test]
160    fn reload_error_does_not_swap() {
161        install_provider();
162        let dir = tempfile::tempdir().unwrap();
163        let (cp, kp) = write_pair(
164            dir.path(),
165            include_str!("../tests/fixtures/dev_cert.pem"),
166            include_str!("../tests/fixtures/dev_key.pem"),
167        );
168        let cfg = TlsConfig {
169            cert_chain_path: cp,
170            private_key_path: kp,
171            trust_roots_path: None,
172            client_ca_path: None,
173            client_auth: ClientAuthMode::Disabled,
174        };
175        let dynamic = DynamicServerConfig::from_tls_config(&cfg).unwrap();
176        let snap_before = dynamic.current();
177
178        let bogus = TlsConfig {
179            cert_chain_path: dir.path().join("missing.pem"),
180            private_key_path: dir.path().join("missing.key"),
181            trust_roots_path: None,
182            client_ca_path: None,
183            client_auth: ClientAuthMode::Disabled,
184        };
185        let err = dynamic.reload_from(&bogus).unwrap_err();
186        // Don't pattern-match on the variant — `NoCerts` vs
187        // `NoPrivateKey` depends on read order. The point is that
188        // `reload_from` errored.
189        let _ = err;
190
191        let snap_after = dynamic.current();
192        assert2::assert!(Arc::ptr_eq(&snap_before, &snap_after));
193    }
194}