Skip to main content

fraiseql_server/
tls.rs

1//! TLS/SSL server configuration and enforcement.
2//!
3//! This module handles:
4//! - Loading and validating TLS certificates and keys
5//! - Building TLS acceptance profiles for servers
6//! - Configuring mTLS (client certificate requirements)
7//! - Database connection TLS settings
8//! - Per-connection TLS enforcement using the `TlsEnforcer`
9
10use std::{fmt::Write as _, path::Path, sync::Arc};
11
12use fraiseql_core::security::{TlsConfig, TlsEnforcer, TlsVersion};
13use rustls::{ServerConfig, pki_types::CertificateDer};
14use rustls_pemfile::Item;
15use tracing::info;
16
17use crate::{
18    Result, ServerError,
19    server_config::{DatabaseTlsConfig, TlsServerConfig},
20};
21
22/// TLS server setup and enforcement.
23pub struct TlsSetup {
24    /// TLS enforcer for validating connections.
25    enforcer: TlsEnforcer,
26
27    /// Server TLS configuration.
28    config: Option<TlsServerConfig>,
29
30    /// Database TLS configuration.
31    db_config: Option<DatabaseTlsConfig>,
32}
33
34impl TlsSetup {
35    /// Create new TLS setup from server configuration.
36    ///
37    /// # Errors
38    ///
39    /// Returns error if:
40    /// - TLS is enabled but certificate/key files cannot be read
41    /// - TLS configuration is invalid
42    pub fn new(
43        tls_config: Option<TlsServerConfig>,
44        db_tls_config: Option<DatabaseTlsConfig>,
45    ) -> Result<Self> {
46        // Create the enforcer based on configuration
47        let enforcer = if let Some(ref tls) = tls_config {
48            if tls.enabled {
49                Self::create_enforcer(tls)?
50            } else {
51                TlsEnforcer::permissive()
52            }
53        } else {
54            TlsEnforcer::permissive()
55        };
56
57        Ok(Self {
58            enforcer,
59            config: tls_config,
60            db_config: db_tls_config,
61        })
62    }
63
64    /// Create a TLS enforcer from configuration.
65    fn create_enforcer(config: &TlsServerConfig) -> Result<TlsEnforcer> {
66        // Parse minimum TLS version
67        let min_version = match config.min_version.as_str() {
68            "1.2" => TlsVersion::V1_2,
69            "1.3" => TlsVersion::V1_3,
70            other => {
71                return Err(ServerError::ConfigError(format!(
72                    "Invalid TLS minimum version: {}",
73                    other
74                )));
75            },
76        };
77
78        // Create TLS configuration
79        let tls_config = TlsConfig {
80            tls_required: true,
81            mtls_required: config.require_client_cert,
82            min_version,
83        };
84
85        info!(
86            tls_enabled = true,
87            require_mtls = config.require_client_cert,
88            min_version = %min_version,
89            "TLS configuration loaded"
90        );
91
92        Ok(TlsEnforcer::from_config(tls_config))
93    }
94
95    /// Get the TLS enforcer.
96    #[must_use]
97    pub const fn enforcer(&self) -> &TlsEnforcer {
98        &self.enforcer
99    }
100
101    /// Get the server TLS configuration.
102    #[must_use]
103    pub const fn config(&self) -> &Option<TlsServerConfig> {
104        &self.config
105    }
106
107    /// Get the database TLS configuration.
108    #[must_use]
109    pub const fn db_config(&self) -> &Option<DatabaseTlsConfig> {
110        &self.db_config
111    }
112
113    /// Check if TLS is enabled for server.
114    #[must_use]
115    pub fn is_tls_enabled(&self) -> bool {
116        self.config.as_ref().is_some_and(|c| c.enabled)
117    }
118
119    /// Check if mTLS is required.
120    #[must_use]
121    pub fn is_mtls_required(&self) -> bool {
122        self.config.as_ref().is_some_and(|c| c.enabled && c.require_client_cert)
123    }
124
125    /// Get the certificate path.
126    #[must_use]
127    pub fn cert_path(&self) -> Option<&Path> {
128        self.config.as_ref().map(|c| c.cert_path.as_path())
129    }
130
131    /// Get the key path.
132    #[must_use]
133    pub fn key_path(&self) -> Option<&Path> {
134        self.config.as_ref().map(|c| c.key_path.as_path())
135    }
136
137    /// Get the client CA path (for mTLS).
138    #[must_use]
139    pub fn client_ca_path(&self) -> Option<&Path> {
140        self.config
141            .as_ref()
142            .and_then(|c| c.client_ca_path.as_ref())
143            .map(|p| p.as_path())
144    }
145
146    /// Get PostgreSQL SSL mode for database connections.
147    #[must_use]
148    pub fn postgres_ssl_mode(&self) -> &str {
149        self.db_config.as_ref().map_or("prefer", |c| c.postgres_ssl_mode.as_str())
150    }
151
152    /// Check if Redis TLS is enabled.
153    #[must_use]
154    pub fn redis_ssl_enabled(&self) -> bool {
155        self.db_config.as_ref().is_some_and(|c| c.redis_ssl)
156    }
157
158    /// Check if `ClickHouse` HTTPS is enabled.
159    #[must_use]
160    pub fn clickhouse_https_enabled(&self) -> bool {
161        self.db_config.as_ref().is_some_and(|c| c.clickhouse_https)
162    }
163
164    /// Check if Elasticsearch HTTPS is enabled.
165    #[must_use]
166    pub fn elasticsearch_https_enabled(&self) -> bool {
167        self.db_config.as_ref().is_some_and(|c| c.elasticsearch_https)
168    }
169
170    /// Check if certificate verification is enabled for databases.
171    #[must_use]
172    pub fn verify_certificates(&self) -> bool {
173        self.db_config.as_ref().is_none_or(|c| c.verify_certificates)
174    }
175
176    /// Get the CA bundle path for verifying database certificates.
177    #[must_use]
178    pub fn ca_bundle_path(&self) -> Option<&Path> {
179        self.db_config
180            .as_ref()
181            .and_then(|c| c.ca_bundle_path.as_ref())
182            .map(|p| p.as_path())
183    }
184
185    /// Get database URL with TLS applied (for PostgreSQL).
186    pub fn apply_postgres_tls(&self, db_url: &str) -> String {
187        let mut url = db_url.to_string();
188
189        // Parse SSL mode into URL parameter
190        let ssl_mode = self.postgres_ssl_mode();
191        if !ssl_mode.is_empty() && ssl_mode != "prefer" {
192            // Add or update sslmode parameter
193            if url.contains('?') {
194                let _ = write!(url, "&sslmode={ssl_mode}");
195            } else {
196                let _ = write!(url, "?sslmode={ssl_mode}");
197            }
198        }
199
200        url
201    }
202
203    /// Get Redis URL with TLS applied.
204    pub fn apply_redis_tls(&self, redis_url: &str) -> String {
205        if self.redis_ssl_enabled() {
206            // Replace redis:// with rediss://
207            redis_url.replace("redis://", "rediss://")
208        } else {
209            redis_url.to_string()
210        }
211    }
212
213    /// Get `ClickHouse` URL with TLS applied.
214    pub fn apply_clickhouse_tls(&self, ch_url: &str) -> String {
215        if self.clickhouse_https_enabled() {
216            // Replace http:// with https://
217            ch_url.replace("http://", "https://")
218        } else {
219            ch_url.to_string()
220        }
221    }
222
223    /// Get Elasticsearch URL with TLS applied.
224    pub fn apply_elasticsearch_tls(&self, es_url: &str) -> String {
225        if self.elasticsearch_https_enabled() {
226            // Replace http:// with https://
227            es_url.replace("http://", "https://")
228        } else {
229            es_url.to_string()
230        }
231    }
232
233    /// Load certificates from PEM file.
234    fn load_certificates(path: &Path) -> Result<Vec<CertificateDer<'static>>> {
235        let cert_file = std::fs::File::open(path).map_err(|e| {
236            ServerError::ConfigError(format!(
237                "Failed to open certificate file {}: {}",
238                path.display(),
239                e
240            ))
241        })?;
242
243        let mut reader = std::io::BufReader::new(cert_file);
244        let mut certificates = Vec::new();
245
246        loop {
247            match rustls_pemfile::read_one(&mut reader).map_err(|e| {
248                ServerError::ConfigError(format!("Failed to parse certificate: {}", e))
249            })? {
250                Some(Item::X509Certificate(cert)) => certificates.push(cert),
251                Some(_) => {}, // Skip other items
252                None => break,
253            }
254        }
255
256        if certificates.is_empty() {
257            return Err(ServerError::ConfigError(
258                "No certificates found in certificate file".to_string(),
259            ));
260        }
261
262        Ok(certificates)
263    }
264
265    /// Load private key from PEM file.
266    fn load_private_key(path: &Path) -> Result<rustls::pki_types::PrivateKeyDer<'static>> {
267        let key_file = std::fs::File::open(path).map_err(|e| {
268            ServerError::ConfigError(format!("Failed to open key file {}: {}", path.display(), e))
269        })?;
270
271        let mut reader = std::io::BufReader::new(key_file);
272
273        loop {
274            match rustls_pemfile::read_one(&mut reader).map_err(|e| {
275                ServerError::ConfigError(format!("Failed to parse private key: {}", e))
276            })? {
277                Some(Item::Pkcs8Key(key)) => return Ok(key.into()),
278                Some(Item::Pkcs1Key(key)) => return Ok(key.into()),
279                Some(Item::Sec1Key(key)) => return Ok(key.into()),
280                Some(_) => {}, // Skip other items
281                None => break,
282            }
283        }
284
285        Err(ServerError::ConfigError("No private key found in key file".to_string()))
286    }
287
288    /// Create a rustls `ServerConfig` for TLS.
289    ///
290    /// # Errors
291    ///
292    /// Returns error if:
293    /// - Certificate or key files cannot be read
294    /// - Certificate or key format is invalid
295    pub fn create_rustls_config(&self) -> Result<Arc<ServerConfig>> {
296        let (cert_path, key_path) = match self.config.as_ref() {
297            Some(c) if c.enabled => (&c.cert_path, &c.key_path),
298            _ => return Err(ServerError::ConfigError("TLS not enabled".to_string())),
299        };
300
301        info!(
302            cert_path = %cert_path.display(),
303            key_path = %key_path.display(),
304            "Loading TLS certificates"
305        );
306
307        let certs = Self::load_certificates(cert_path)?;
308        let key = Self::load_private_key(key_path)?;
309
310        let server_config = ServerConfig::builder()
311            .with_no_client_auth()
312            .with_single_cert(certs, key)
313            .map_err(|e| ServerError::ConfigError(format!("Failed to build TLS config: {}", e)))?;
314
315        Ok(Arc::new(server_config))
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    #![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
322    #![allow(clippy::cast_precision_loss)] // Reason: test metrics reporting
323    #![allow(clippy::cast_sign_loss)] // Reason: test data uses small positive integers
324    #![allow(clippy::cast_possible_truncation)] // Reason: test data values are bounded
325    #![allow(clippy::cast_possible_wrap)] // Reason: test data values are bounded
326    #![allow(clippy::missing_panics_doc)] // Reason: test helpers
327    #![allow(clippy::missing_errors_doc)] // Reason: test helpers
328    #![allow(missing_docs)] // Reason: test code
329    #![allow(clippy::items_after_statements)] // Reason: test helpers defined near use site
330
331    use std::path::PathBuf;
332
333    use super::*;
334
335    #[test]
336    fn test_tls_setup_disabled() {
337        let setup = TlsSetup::new(None, None)
338            .expect("TlsSetup::new is infallible when cert and db_config are None");
339
340        assert!(!setup.is_tls_enabled());
341        assert!(!setup.is_mtls_required());
342        assert!(setup.cert_path().is_none());
343        assert!(setup.key_path().is_none());
344    }
345
346    #[test]
347    fn test_database_tls_defaults() {
348        let setup = TlsSetup::new(None, None)
349            .expect("TlsSetup::new is infallible when cert and db_config are None");
350
351        assert_eq!(setup.postgres_ssl_mode(), "prefer");
352        assert!(!setup.redis_ssl_enabled());
353        assert!(!setup.clickhouse_https_enabled());
354        assert!(!setup.elasticsearch_https_enabled());
355        assert!(setup.verify_certificates());
356    }
357
358    #[test]
359    fn test_postgres_url_tls_application() {
360        let db_config = DatabaseTlsConfig {
361            postgres_ssl_mode:   "require".to_string(),
362            redis_ssl:           false,
363            clickhouse_https:    false,
364            elasticsearch_https: false,
365            verify_certificates: true,
366            ca_bundle_path:      None,
367        };
368
369        let setup = TlsSetup::new(None, Some(db_config))
370            .expect("TlsSetup::new is infallible when cert and db_config are None");
371
372        let url = "postgresql://localhost/fraiseql";
373        let tls_url = setup.apply_postgres_tls(url);
374
375        assert!(tls_url.contains("sslmode=require"));
376    }
377
378    #[test]
379    fn test_redis_url_tls_application() {
380        let db_config = DatabaseTlsConfig {
381            postgres_ssl_mode:   "prefer".to_string(),
382            redis_ssl:           true,
383            clickhouse_https:    false,
384            elasticsearch_https: false,
385            verify_certificates: true,
386            ca_bundle_path:      None,
387        };
388
389        let setup = TlsSetup::new(None, Some(db_config))
390            .expect("TlsSetup::new is infallible when cert and db_config are None");
391
392        let url = "redis://localhost:6379";
393        let tls_url = setup.apply_redis_tls(url);
394
395        assert_eq!(tls_url, "rediss://localhost:6379");
396    }
397
398    #[test]
399    fn test_clickhouse_url_tls_application() {
400        let db_config = DatabaseTlsConfig {
401            postgres_ssl_mode:   "prefer".to_string(),
402            redis_ssl:           false,
403            clickhouse_https:    true,
404            elasticsearch_https: false,
405            verify_certificates: true,
406            ca_bundle_path:      None,
407        };
408
409        let setup = TlsSetup::new(None, Some(db_config))
410            .expect("TlsSetup::new is infallible when cert and db_config are None");
411
412        let url = "http://localhost:8123";
413        let tls_url = setup.apply_clickhouse_tls(url);
414
415        assert_eq!(tls_url, "https://localhost:8123");
416    }
417
418    #[test]
419    fn test_elasticsearch_url_tls_application() {
420        let db_config = DatabaseTlsConfig {
421            postgres_ssl_mode:   "prefer".to_string(),
422            redis_ssl:           false,
423            clickhouse_https:    false,
424            elasticsearch_https: true,
425            verify_certificates: true,
426            ca_bundle_path:      None,
427        };
428
429        let setup = TlsSetup::new(None, Some(db_config))
430            .expect("TlsSetup::new is infallible when cert and db_config are None");
431
432        let url = "http://localhost:9200";
433        let tls_url = setup.apply_elasticsearch_tls(url);
434
435        assert_eq!(tls_url, "https://localhost:9200");
436    }
437
438    #[test]
439    fn test_all_database_tls_enabled() {
440        let db_config = DatabaseTlsConfig {
441            postgres_ssl_mode:   "require".to_string(),
442            redis_ssl:           true,
443            clickhouse_https:    true,
444            elasticsearch_https: true,
445            verify_certificates: true,
446            ca_bundle_path:      Some(PathBuf::from("/etc/ssl/certs/ca-bundle.crt")),
447        };
448
449        let setup = TlsSetup::new(None, Some(db_config))
450            .expect("TlsSetup::new is infallible when cert and db_config are None");
451
452        assert_eq!(setup.postgres_ssl_mode(), "require");
453        assert!(setup.redis_ssl_enabled());
454        assert!(setup.clickhouse_https_enabled());
455        assert!(setup.elasticsearch_https_enabled());
456        assert!(setup.verify_certificates());
457        assert!(
458            setup.ca_bundle_path().is_some(),
459            "ca_bundle_path should be propagated from DatabaseTlsConfig"
460        );
461    }
462
463    #[test]
464    fn test_postgres_url_with_existing_params() {
465        let db_config = DatabaseTlsConfig {
466            postgres_ssl_mode:   "require".to_string(),
467            redis_ssl:           false,
468            clickhouse_https:    false,
469            elasticsearch_https: false,
470            verify_certificates: true,
471            ca_bundle_path:      None,
472        };
473
474        let setup = TlsSetup::new(None, Some(db_config))
475            .expect("TlsSetup::new is infallible when cert and db_config are None");
476
477        let url = "postgresql://localhost/fraiseql?application_name=fraiseql";
478        let tls_url = setup.apply_postgres_tls(url);
479
480        assert!(tls_url.contains("application_name=fraiseql"));
481        assert!(tls_url.contains("sslmode=require"));
482    }
483
484    #[test]
485    fn test_database_tls_config_getters() {
486        let db_config = DatabaseTlsConfig {
487            postgres_ssl_mode:   "verify-full".to_string(),
488            redis_ssl:           true,
489            clickhouse_https:    true,
490            elasticsearch_https: false,
491            verify_certificates: true,
492            ca_bundle_path:      Some(PathBuf::from("/etc/ssl/certs/ca.pem")),
493        };
494
495        let setup = TlsSetup::new(None, Some(db_config))
496            .expect("TlsSetup::new is infallible when cert and db_config are None");
497
498        assert!(
499            setup.db_config().is_some(),
500            "db_config should be present when constructed with a DatabaseTlsConfig"
501        );
502        assert_eq!(setup.postgres_ssl_mode(), "verify-full");
503        assert!(setup.redis_ssl_enabled());
504        assert!(setup.clickhouse_https_enabled());
505        assert!(!setup.elasticsearch_https_enabled());
506        assert_eq!(setup.ca_bundle_path(), Some(Path::new("/etc/ssl/certs/ca.pem")));
507    }
508
509    #[test]
510    fn test_create_rustls_config_without_tls_enabled() {
511        let setup = TlsSetup::new(None, None)
512            .expect("TlsSetup::new is infallible when cert and db_config are None");
513
514        let result = setup.create_rustls_config();
515        assert!(result.is_err(), "expected Err when TLS not enabled, got: {result:?}");
516        assert!(result.unwrap_err().to_string().contains("TLS not enabled"));
517    }
518
519    #[test]
520    fn test_create_rustls_config_with_missing_cert() {
521        let tls_config = TlsServerConfig {
522            enabled:             true,
523            cert_path:           PathBuf::from("/nonexistent/cert.pem"),
524            key_path:            PathBuf::from("/nonexistent/key.pem"),
525            require_client_cert: false,
526            client_ca_path:      None,
527            min_version:         "1.2".to_string(),
528        };
529
530        let setup = TlsSetup::new(Some(tls_config), None)
531            .expect("TlsSetup::new succeeds with enabled=true when min_version is valid; cert reading happens later in create_rustls_config");
532
533        let result = setup.create_rustls_config();
534        assert!(result.is_err(), "expected Err for missing cert file, got: {result:?}");
535        assert!(result.unwrap_err().to_string().contains("Failed to open"));
536    }
537}