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}