Skip to main content

camel_component_api/
tls_source.rs

1//! Shared [`ServerTlsSource`] — builds a validated [`rustls::ServerConfig`] from
2//! PEM cert/key files, optionally with mTLS client CA verification.
3//!
4//! This deduplicates ~40 lines of PEM parsing that each of gRPC, HTTP, and WS
5//! components implement separately. The individual components still own ALPN
6//! configuration — `build_server_config` intentionally leaves ALPN empty so the
7//! caller can set it after construction.
8
9use std::path::PathBuf;
10use std::sync::{Arc, Mutex};
11
12use async_trait::async_trait;
13use camel_api::CamelError;
14use rustls::ServerConfig;
15
16/// File-based TLS source for rustls server-side configuration.
17///
18/// Reads PEM-encoded cert+key from the filesystem. When `client_ca_path` is
19/// set, the server requires (and verifies) a client certificate (mTLS).
20#[derive(Debug, Clone)]
21pub struct ServerTlsSource {
22    /// Path to the PEM-encoded server certificate chain.
23    pub cert_path: PathBuf,
24    /// Path to the PEM-encoded private key for the server certificate.
25    pub key_path: PathBuf,
26    /// Optional path to a PEM-encoded CA certificate for client verification.
27    /// When set, mTLS is enabled (clients must present a cert signed by this CA).
28    pub client_ca_path: Option<PathBuf>,
29}
30
31impl ServerTlsSource {
32    /// Build a [`rustls::ServerConfig`] from the PEM files.
33    ///
34    /// Returns `EndpointCreationFailed` on any I/O, PEM parse, or rustls
35    /// configuration error.
36    ///
37    /// ALPN protocols are intentionally NOT set — callers add their own
38    /// (e.g., `b"h2"` for gRPC) after construction.
39    pub fn build_server_config(&self) -> Result<ServerConfig, CamelError> {
40        // Read cert and key files as raw bytes.
41        let cert_pem = std::fs::read(&self.cert_path).map_err(|e| {
42            CamelError::EndpointCreationFailed(format!(
43                "TLS cert file '{}': {e}",
44                self.cert_path.display()
45            ))
46        })?;
47        let key_pem = std::fs::read(&self.key_path).map_err(|e| {
48            CamelError::EndpointCreationFailed(format!(
49                "TLS key file '{}': {e}",
50                self.key_path.display()
51            ))
52        })?;
53
54        // Parse PEM certs.
55        let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_slice())
56            .collect::<Result<Vec<_>, _>>()
57            .map_err(|e| {
58                CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}"))
59            })?;
60
61        // Parse PEM private key.
62        let key = rustls_pemfile::private_key(&mut key_pem.as_slice())
63            .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
64            .ok_or_else(|| {
65                CamelError::EndpointCreationFailed("TLS: no private key found in key PEM".into())
66            })?;
67
68        // Explicit ring provider — rustls 0.23 requires this (the default
69        // `ServerConfig::builder()` panics at runtime without a process-default).
70        let provider = Arc::new(rustls::crypto::ring::default_provider());
71
72        // Build the protocol-version-safe builder.
73        let builder = rustls::ServerConfig::builder_with_provider(Arc::clone(&provider))
74            .with_safe_default_protocol_versions()
75            .map_err(|e| {
76                CamelError::EndpointCreationFailed(format!("rustls protocol versions: {e}"))
77            })?;
78
79        // Branch: mTLS (client cert verification) or plain server auth.
80        let config = match &self.client_ca_path {
81            Some(ca_path) => {
82                let ca_pem = std::fs::read(ca_path).map_err(|e| {
83                    CamelError::EndpointCreationFailed(format!(
84                        "TLS client CA file '{}': {e}",
85                        ca_path.display()
86                    ))
87                })?;
88                let ca_certs: Vec<_> = rustls_pemfile::certs(&mut ca_pem.as_slice())
89                    .collect::<Result<Vec<_>, _>>()
90                    .map_err(|e| {
91                        CamelError::EndpointCreationFailed(format!("invalid client CA PEM: {e}"))
92                    })?;
93                let mut roots = rustls::RootCertStore::empty();
94                for cert in ca_certs {
95                    roots.add(cert).map_err(|e| {
96                        CamelError::EndpointCreationFailed(format!("client CA root add: {e}"))
97                    })?;
98                }
99                let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(
100                    Arc::new(roots),
101                    Arc::clone(&provider),
102                )
103                .build()
104                .map_err(|e| {
105                    CamelError::EndpointCreationFailed(format!("mTLS client cert verifier: {e}"))
106                })?;
107                builder
108                    .with_client_cert_verifier(verifier)
109                    .with_single_cert(certs, key)
110                    .map_err(|e| {
111                        CamelError::EndpointCreationFailed(format!("rustls ServerConfig: {e}"))
112                    })?
113            }
114            None => builder
115                .with_no_client_auth()
116                .with_single_cert(certs, key)
117                .map_err(|e| {
118                    CamelError::EndpointCreationFailed(format!("rustls ServerConfig: {e}"))
119                })?,
120        };
121
122        Ok(config)
123    }
124}
125
126// ---------------------------------------------------------------------------
127// TlsReloadHandler + TlsReloadRegistry
128// ---------------------------------------------------------------------------
129
130/// Implemented by each TLS-terminating component.
131/// Registered with [`TlsReloadRegistry::global()`] when a server first spawns.
132#[async_trait]
133pub trait TlsReloadHandler: Send + Sync {
134    /// Returns true if this handler owns the (scheme, host, port).
135    fn matches(&self, scheme: &str, host: &str, port: u16) -> bool;
136    /// Re-read cert files and swap. Returns Err if cert invalid; old cert stays.
137    async fn reload(&self) -> Result<(), CamelError>;
138}
139
140/// Process-global singleton registry of TLS reload handlers.
141///
142/// The global is reachable through two handles that wrap the same
143/// allocation: [`TlsReloadRegistry::global()`] returns a `&'static`
144/// reference and [`TlsReloadRegistry::global_arc()`] returns an
145/// [`Arc`] clone — a registration made through either handle is
146/// observable through the other. Isolated instances for tests come
147/// from [`Default`], which never touches the process global.
148///
149/// Each TLS-terminating component registers its handler when it first
150/// spawns, and unregisters on release/eviction.
151#[derive(Default)]
152pub struct TlsReloadRegistry {
153    handlers: Mutex<Vec<Arc<dyn TlsReloadHandler>>>,
154}
155
156impl TlsReloadRegistry {
157    /// Returns a reference to the process-global singleton.
158    pub fn global() -> &'static TlsReloadRegistry {
159        Self::backing().as_ref()
160    }
161
162    /// Returns an [`Arc`] handle to the process-global singleton.
163    ///
164    /// The `Arc` wraps the same allocation as [`global`](Self::global).
165    pub fn global_arc() -> Arc<TlsReloadRegistry> {
166        Self::backing().clone()
167    }
168
169    /// Single backing allocation behind both [`global`](Self::global)
170    /// and [`global_arc`](Self::global_arc). The `&'static Arc` derefs
171    /// to a `&'static TlsReloadRegistry`, so `global()`'s signature
172    /// and instance identity are unchanged for all callers.
173    fn backing() -> &'static Arc<TlsReloadRegistry> {
174        static BACKING: std::sync::OnceLock<Arc<TlsReloadRegistry>> = std::sync::OnceLock::new();
175        BACKING.get_or_init(|| Arc::new(TlsReloadRegistry::default()))
176    }
177
178    /// Register a handler so it can be found later via [`find`](Self::find).
179    pub fn register(&self, handler: Arc<dyn TlsReloadHandler>) {
180        let mut guard = self
181            .handlers
182            .lock()
183            .expect("TlsReloadRegistry lock poisoned"); // allow-unwrap
184        guard.push(handler);
185    }
186
187    /// Find the handler that matches (scheme, host, port), if any.
188    pub fn find(&self, scheme: &str, host: &str, port: u16) -> Option<Arc<dyn TlsReloadHandler>> {
189        let guard = self
190            .handlers
191            .lock()
192            .expect("TlsReloadRegistry lock poisoned"); // allow-unwrap
193        guard
194            .iter()
195            .find(|h| h.matches(scheme, host, port))
196            .cloned()
197    }
198
199    /// Remove handlers matching (scheme, host, port). Called on server release/eviction.
200    pub fn unregister(&self, scheme: &str, host: &str, port: u16) {
201        let mut guard = self
202            .handlers
203            .lock()
204            .expect("TlsReloadRegistry lock poisoned"); // allow-unwrap
205        guard.retain(|h| !h.matches(scheme, host, port));
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    /// Helper: write a PEM string to a temp file and return the path.
214    fn write_pem(pem: &str, name: &str) -> PathBuf {
215        crate::test_support::tls::write_pem_tmp(name, pem)
216    }
217
218    #[test]
219    fn build_server_config_valid_cert() {
220        let (ca_pem, cert_pem, key_pem) = crate::test_support::tls::gen_server_cert();
221        let _ca = write_pem(&ca_pem, "ca.pem");
222        let cert = write_pem(&cert_pem, "cert.pem");
223        let key = write_pem(&key_pem, "key.pem");
224
225        let source = ServerTlsSource {
226            cert_path: cert,
227            key_path: key,
228            client_ca_path: None,
229        };
230
231        let config = source.build_server_config().expect("valid cert+key");
232        assert!(config.alpn_protocols.is_empty(), "ALPN should be empty");
233    }
234
235    #[test]
236    fn build_server_config_mtls() {
237        let (ca_pem, cert_pem, key_pem) = crate::test_support::tls::gen_server_cert();
238        let ca = write_pem(&ca_pem, "mtls-ca.pem");
239        let cert = write_pem(&cert_pem, "cert.pem");
240        let key = write_pem(&key_pem, "key.pem");
241
242        let source = ServerTlsSource {
243            cert_path: cert,
244            key_path: key,
245            client_ca_path: Some(ca),
246        };
247
248        let config = source.build_server_config().expect("mTLS should work");
249        assert!(config.alpn_protocols.is_empty());
250        // mTLS is enabled — verifier should require client cert.
251        // We can't easily assert the verifier type, but we can verify
252        // the config uses a client-cert verifier by checking it was
253        // built without errors.
254    }
255
256    #[test]
257    fn build_server_config_mtls_bad_ca_rejected() {
258        let (_, cert_pem, key_pem) = crate::test_support::tls::gen_server_cert();
259        let cert = write_pem(&cert_pem, "cert.pem");
260        let key = write_pem(&key_pem, "key.pem");
261
262        // Write garbage as the CA PEM.
263        let bad_ca = write_pem("not-a-valid-ca-certificate\n", "bad-ca.pem");
264
265        let source = ServerTlsSource {
266            cert_path: cert,
267            key_path: key,
268            client_ca_path: Some(bad_ca),
269        };
270
271        let err = source.build_server_config().unwrap_err();
272        assert!(
273            matches!(&err, CamelError::EndpointCreationFailed(_)),
274            "expected EndpointCreationFailed, got {err:?}"
275        );
276    }
277
278    #[test]
279    fn build_server_config_missing_file() {
280        let source = ServerTlsSource {
281            cert_path: PathBuf::from("/nonexistent/cert.pem"),
282            key_path: PathBuf::from("/nonexistent/key.pem"),
283            client_ca_path: None,
284        };
285
286        let err = source.build_server_config().unwrap_err();
287        assert!(
288            matches!(&err, CamelError::EndpointCreationFailed(_)),
289            "expected EndpointCreationFailed, got {err:?}"
290        );
291    }
292
293    #[test]
294    fn build_server_config_malformed_pem() {
295        let cert = write_pem("garbage-content\n", "bad-cert.pem");
296        let key = write_pem("also-garbage\n", "bad-key.pem");
297
298        let source = ServerTlsSource {
299            cert_path: cert,
300            key_path: key,
301            client_ca_path: None,
302        };
303
304        let err = source.build_server_config().unwrap_err();
305        assert!(
306            matches!(&err, CamelError::EndpointCreationFailed(_)),
307            "expected EndpointCreationFailed, got {err:?}"
308        );
309    }
310}
311
312#[cfg(test)]
313mod registry_tests {
314    use super::*;
315
316    struct FakeHandler {
317        scheme: String,
318        host: String,
319        port: u16,
320    }
321
322    #[async_trait::async_trait]
323    impl TlsReloadHandler for FakeHandler {
324        fn matches(&self, scheme: &str, host: &str, port: u16) -> bool {
325            self.scheme == scheme && self.host == host && self.port == port
326        }
327        async fn reload(&self) -> Result<(), CamelError> {
328            Ok(())
329        }
330    }
331
332    #[test]
333    fn registry_find_returns_matching_handler() {
334        let reg = TlsReloadRegistry::default();
335        reg.register(Arc::new(FakeHandler {
336            scheme: "grpcs".into(),
337            host: "0.0.0.0".into(),
338            port: 9090,
339        }));
340        assert!(reg.find("grpcs", "0.0.0.0", 9090).is_some());
341        assert!(reg.find("https", "0.0.0.0", 9090).is_none());
342    }
343
344    #[test]
345    fn registry_find_returns_none_when_empty() {
346        let reg = TlsReloadRegistry::default();
347        assert!(reg.find("grpcs", "0.0.0.0", 9090).is_none());
348    }
349
350    #[test]
351    fn registry_unregister_removes_handler() {
352        let reg = TlsReloadRegistry::default();
353        reg.register(Arc::new(FakeHandler {
354            scheme: "grpcs".into(),
355            host: "0.0.0.0".into(),
356            port: 9090,
357        }));
358        assert!(reg.find("grpcs", "0.0.0.0", 9090).is_some());
359        reg.unregister("grpcs", "0.0.0.0", 9090);
360        assert!(reg.find("grpcs", "0.0.0.0", 9090).is_none());
361    }
362
363    #[test]
364    fn global_and_global_arc_share_instance() {
365        const SCHEME: &str = "tlsseam-shared";
366        const HOST: &str = "shared.invalid";
367        const PORT: u16 = 64401;
368
369        let arc = TlsReloadRegistry::global_arc();
370        assert!(std::ptr::eq(TlsReloadRegistry::global(), arc.as_ref()));
371
372        arc.register(Arc::new(FakeHandler {
373            scheme: SCHEME.into(),
374            host: HOST.into(),
375            port: PORT,
376        }));
377        assert!(
378            TlsReloadRegistry::global()
379                .find(SCHEME, HOST, PORT)
380                .is_some(),
381            "registration via global_arc must be visible through global()"
382        );
383        TlsReloadRegistry::global().unregister(SCHEME, HOST, PORT);
384        assert!(
385            TlsReloadRegistry::global()
386                .find(SCHEME, HOST, PORT)
387                .is_none(),
388            "cleanup must leave no residue for sibling tests"
389        );
390    }
391
392    #[test]
393    fn default_instance_is_isolated_from_global() {
394        const SCHEME: &str = "tlsseam-isolated";
395        const HOST: &str = "isolated.invalid";
396        const PORT: u16 = 64402;
397
398        let global = TlsReloadRegistry::global();
399        global.register(Arc::new(FakeHandler {
400            scheme: SCHEME.into(),
401            host: HOST.into(),
402            port: PORT,
403        }));
404        let fresh = TlsReloadRegistry::default();
405        assert!(
406            fresh.find(SCHEME, HOST, PORT).is_none(),
407            "Default instance must not see global registrations"
408        );
409        // Cleanup so sibling tests see no residue.
410        global.unregister(SCHEME, HOST, PORT);
411        assert!(global.find(SCHEME, HOST, PORT).is_none());
412    }
413}