Skip to main content

apimock_server/
tls.rs

1//! TLS certificate loading and hot-reload support (RFC 020).
2//!
3//! # Two TLS setup modes
4//!
5//! | Mode | When used | Cert changes |
6//! |---|---|---|
7//! | `with_single_cert` | startup (static) | require restart |
8//! | `ReloadableCertResolver` | startup with hot-reload | soft reload via `reload_certs` |
9//!
10//! # Outcome C (RFC 020)
11//!
12//! - `TlsCertFile` / `TlsKeyFile` changes are `SoftReload` (no listener rebind).
13//! - `TlsEnabled` toggle is still `HardRestart` (changes the listener type).
14//! - In-progress TLS handshakes that started before a reload complete with
15//!   the old cert; new handshakes use the new cert atomically.
16
17use std::fmt;
18use std::path::PathBuf;
19use std::sync::{Arc, RwLock};
20
21use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
22use rustls::server::{ClientHello, ResolvesServerCert};
23use rustls::sign::CertifiedKey;
24use rustls::ServerConfig;
25
26use crate::error::{ServerError, ServerResult, TlsKind};
27
28// ── PEM loaders (unchanged from pre-5.11) ───────────────────────────────
29
30/// Load TLS/SSL certificates (leaf + any intermediates) from a PEM file.
31pub fn load_certs(file_path: &str) -> ServerResult<Vec<CertificateDer<'static>>> {
32    let path = PathBuf::from(file_path);
33    let iter = CertificateDer::pem_file_iter(file_path).map_err(|e| ServerError::TlsLoad {
34        kind: TlsKind::Certificate,
35        path: path.clone(),
36        reason: e.to_string(),
37    })?;
38
39    let mut certs = Vec::new();
40    for (idx, item) in iter.enumerate() {
41        let cert = item.map_err(|e| ServerError::TlsLoad {
42            kind: TlsKind::Certificate,
43            path: path.clone(),
44            reason: format!("failed to parse certificate #{}: {}", idx + 1, e),
45        })?;
46        certs.push(cert);
47    }
48
49    if certs.is_empty() {
50        return Err(ServerError::TlsLoad {
51            kind: TlsKind::Certificate,
52            path,
53            reason: "no certificates found in PEM file".to_owned(),
54        });
55    }
56
57    Ok(certs)
58}
59
60/// Load a TLS/SSL private key from a PEM file.
61pub fn load_private_key(file_path: &str) -> ServerResult<PrivateKeyDer<'static>> {
62    PrivateKeyDer::from_pem_file(file_path).map_err(|e| ServerError::TlsLoad {
63        kind: TlsKind::PrivateKey,
64        path: PathBuf::from(file_path),
65        reason: e.to_string(),
66    })
67}
68
69// ── CertifiedKey builder ─────────────────────────────────────────────────
70
71/// Error returned when `ReloadableCertResolver::reload_from_paths` fails.
72#[derive(Debug, Clone)]
73pub struct TlsReloadError {
74    pub reason: String,
75}
76
77impl fmt::Display for TlsReloadError {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        write!(f, "TLS cert reload failed: {}", self.reason)
80    }
81}
82
83impl std::error::Error for TlsReloadError {}
84
85/// Build a `CertifiedKey` from DER-encoded cert chain and private key.
86///
87/// Returns an error if the key cannot be parsed by the active crypto backend.
88fn make_certified_key(
89    certs: Vec<CertificateDer<'static>>,
90    key: PrivateKeyDer<'static>,
91) -> Result<CertifiedKey, TlsReloadError> {
92    let signing_key = rustls::crypto::ring::sign::any_supported_type(&key).map_err(|e| {
93        TlsReloadError {
94            reason: format!("unsupported private key type: {}", e),
95        }
96    })?;
97    Ok(CertifiedKey::new(certs, signing_key))
98}
99
100// ── ReloadableCertResolver ───────────────────────────────────────────────
101
102/// A [`ResolvesServerCert`] implementation that supports atomic in-place
103/// certificate rotation without restarting the listener (RFC 020).
104///
105/// # Usage
106///
107/// 1. Build with [`ReloadableCertResolver::new`] at server startup.
108/// 2. Pass `Arc::clone(&resolver)` to the server loop and keep one Arc in
109///    `ServerHandle::cert_reloader`.
110/// 3. Call [`reload_from_paths`] when the GUI applies a `TlsCertFile` or
111///    `TlsKeyFile` change.  The swap is atomic: in-progress handshakes
112///    complete with the old cert; new handshakes use the new cert.
113///
114/// [`reload_from_paths`]: ReloadableCertResolver::reload_from_paths
115#[derive(Debug)]
116pub struct ReloadableCertResolver {
117    inner: RwLock<Arc<CertifiedKey>>,
118}
119
120impl ReloadableCertResolver {
121    /// Create a new resolver from DER-encoded cert and key material.
122    pub fn new(
123        certs: Vec<CertificateDer<'static>>,
124        key: PrivateKeyDer<'static>,
125    ) -> Result<Self, TlsReloadError> {
126        let ck = make_certified_key(certs, key)?;
127        Ok(Self {
128            inner: RwLock::new(Arc::new(ck)),
129        })
130    }
131
132    /// Reload certificates from PEM files on disk.
133    ///
134    /// If loading or parsing fails, the old certificate remains active and
135    /// this method returns an error describing the failure.
136    pub fn reload_from_paths(
137        &self,
138        cert_path: &str,
139        key_path: &str,
140    ) -> Result<(), TlsReloadError> {
141        let certs = load_certs(cert_path).map_err(|e| TlsReloadError {
142            reason: e.to_string(),
143        })?;
144        let key = load_private_key(key_path).map_err(|e| TlsReloadError {
145            reason: e.to_string(),
146        })?;
147        let new_ck = make_certified_key(certs, key)?;
148
149        let mut guard = self.inner.write().map_err(|_| TlsReloadError {
150            reason: "cert RwLock poisoned".to_owned(),
151        })?;
152        *guard = Arc::new(new_ck);
153        log::info!("TLS certificate reloaded successfully from {}", cert_path);
154        Ok(())
155    }
156}
157
158impl ResolvesServerCert for ReloadableCertResolver {
159    fn resolve(&self, _client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
160        self.inner
161            .read()
162            .ok()
163            .map(|g| Arc::clone(&*g))
164    }
165}
166
167// ── ServerConfig builder helpers ──────────────────────────────────────────
168
169/// Build a static (non-reloadable) `ServerConfig` for the common case.
170///
171/// Used when the caller doesn't need hot-reload (e.g. integration test
172/// environments, or once TLS-toggle is still `HardRestart`).
173pub fn build_server_config_static(
174    certs: Vec<CertificateDer<'static>>,
175    key: PrivateKeyDer<'static>,
176) -> Result<ServerConfig, String> {
177    let mut config = ServerConfig::builder()
178        .with_no_client_auth()
179        .with_single_cert(certs, key)
180        .map_err(|e| format!("failed to build rustls ServerConfig: {}", e))?;
181    config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
182    Ok(config)
183}
184
185/// Build a `ServerConfig` backed by a [`ReloadableCertResolver`].
186///
187/// Returns the config and an `Arc` to the resolver so the caller can later
188/// call `reload_from_paths` without locking the entire config.
189pub fn build_server_config_reloadable(
190    certs: Vec<CertificateDer<'static>>,
191    key: PrivateKeyDer<'static>,
192) -> Result<(ServerConfig, Arc<ReloadableCertResolver>), TlsReloadError> {
193    let resolver = Arc::new(ReloadableCertResolver::new(certs, key)?);
194    let mut config = ServerConfig::builder()
195        .with_no_client_auth()
196        .with_cert_resolver(Arc::clone(&resolver) as Arc<dyn ResolvesServerCert>);
197    config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
198    Ok((config, resolver))
199}
200
201// ── Tests ─────────────────────────────────────────────────────────────────
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use std::io::Write;
207
208    fn write_pem_file(path: &str, content: &str) {
209        std::fs::write(path, content).unwrap();
210    }
211
212    // Minimal self-signed ECDSA P-256 cert + key, generated with:
213    //   openssl ecparam -genkey -name P-256 -noout -out key.pem
214    //   openssl req -new -x509 -key key.pem -out cert.pem -days 3650 -subj "/CN=test"
215    const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
216MIIBczCCARmgAwIBAgIUNNKjB+m5H6ZCjEPHNFEL5GYW3/UwCgYIKoZIzj0EAwIw\n\
217DzENMAsGA1UEAwwEdGVzdDAeFw0yNjA1MjIwMjQ0NTZaFw0zNjA1MTkwMjQ0NTZa\n\
218MA8xDTALBgNVBAMMBHRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQTo544\n\
219m3Yk+4kNlcFXR8RL5rtGVqrZohzvanN7oUiIYXzpofwYNBLqLg9AOZPeiX32aizX\n\
220wqEBuYMV4B6gBj1Ho1MwUTAdBgNVHQ4EFgQUxoL28LxPMcYmwNAvUCIaaZp02xAw\n\
221HwYDVR0jBBgwFoAUxoL28LxPMcYmwNAvUCIaaZp02xAwDwYDVR0TAQH/BAUwAwEB\n\
222/zAKBggqhkjOPQQDAgNIADBFAiEA2IO7sD+CIM4OWZkF0SMCmrnus/xQbNFBICXg\n\
223YNQ/K+oCIGlsqHA+PmxwUknuDDS5dQF26iNztRz2PY4diIfWxLNi\n\
224-----END CERTIFICATE-----\n";
225
226    const TEST_KEY_PEM: &str = "-----BEGIN EC PRIVATE KEY-----\n\
227MHcCAQEEIBK3C/2yAvhbvjxP7f5aCgVZN9udnXStns0xKk7LQ3RnoAoGCCqGSM49\n\
228AwEHoUQDQgAEE6OeOJt2JPuJDZXBV0fES+a7Rlaq2aIc72pze6FIiGF86aH8GDQS\n\
2296i4PQDmT3ol99mos18KhAbmDFeAeoAY9Rw==\n\
230-----END EC PRIVATE KEY-----\n";
231
232    #[test]
233    fn load_certs_returns_error_for_missing_file() {
234        let result = load_certs("/nonexistent/cert.pem");
235        assert!(result.is_err());
236    }
237
238    #[test]
239    fn load_private_key_returns_error_for_missing_file() {
240        let result = load_private_key("/nonexistent/key.pem");
241        assert!(result.is_err());
242    }
243
244    #[test]
245    fn reloadable_resolver_init_and_reload_bad_path_keeps_old_cert() {
246        let cert_path = "/tmp/apimock_test_cert.pem";
247        let key_path  = "/tmp/apimock_test_key.pem";
248        write_pem_file(cert_path, TEST_CERT_PEM);
249        write_pem_file(key_path,  TEST_KEY_PEM);
250
251        let certs = load_certs(cert_path).expect("load test cert");
252        let key   = load_private_key(key_path).expect("load test key");
253        let resolver = ReloadableCertResolver::new(certs, key)
254            .expect("build resolver");
255
256        // Reload from bad paths → error, resolver must not crash.
257        let result = resolver.reload_from_paths("/no/cert.pem", "/no/key.pem");
258        assert!(result.is_err(), "expected error for missing paths");
259
260        // The resolver's inner cert is still readable (lock not poisoned).
261        let guard = resolver.inner.read().unwrap();
262        drop(guard);
263    }
264
265    #[test]
266    fn reloadable_resolver_reload_from_same_files_succeeds() {
267        let cert_path = "/tmp/apimock_test_cert2.pem";
268        let key_path  = "/tmp/apimock_test_key2.pem";
269        write_pem_file(cert_path, TEST_CERT_PEM);
270        write_pem_file(key_path,  TEST_KEY_PEM);
271
272        let certs = load_certs(cert_path).expect("load test cert");
273        let key   = load_private_key(key_path).expect("load test key");
274        let resolver = ReloadableCertResolver::new(certs, key)
275            .expect("build resolver");
276
277        // Re-loading from the same valid files should succeed.
278        let result = resolver.reload_from_paths(cert_path, key_path);
279        assert!(result.is_ok(), "reload from same valid files must succeed: {:?}", result);
280    }
281
282    #[test]
283    fn build_server_config_reloadable_returns_resolver() {
284        let cert_path = "/tmp/apimock_test_cert3.pem";
285        let key_path  = "/tmp/apimock_test_key3.pem";
286        write_pem_file(cert_path, TEST_CERT_PEM);
287        write_pem_file(key_path,  TEST_KEY_PEM);
288
289        let certs = load_certs(cert_path).unwrap();
290        let key   = load_private_key(key_path).unwrap();
291        let result = build_server_config_reloadable(certs, key);
292        assert!(result.is_ok(), "build_server_config_reloadable failed: {:?}", result);
293        let (_config, resolver) = result.unwrap();
294        // Resolver should be usable after config is built.
295        let reload = resolver.reload_from_paths(cert_path, key_path);
296        assert!(reload.is_ok());
297    }
298}