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::{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 fn enforcer(&self) -> &TlsEnforcer {
98        &self.enforcer
99    }
100
101    /// Get the server TLS configuration.
102    #[must_use]
103    pub fn config(&self) -> &Option<TlsServerConfig> {
104        &self.config
105    }
106
107    /// Get the database TLS configuration.
108    #[must_use]
109    pub 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
150            .as_ref()
151            .map(|c| c.postgres_ssl_mode.as_str())
152            .unwrap_or("prefer")
153    }
154
155    /// Check if Redis TLS is enabled.
156    #[must_use]
157    pub fn redis_ssl_enabled(&self) -> bool {
158        self.db_config.as_ref().is_some_and(|c| c.redis_ssl)
159    }
160
161    /// Check if ClickHouse HTTPS is enabled.
162    #[must_use]
163    pub fn clickhouse_https_enabled(&self) -> bool {
164        self.db_config.as_ref().is_some_and(|c| c.clickhouse_https)
165    }
166
167    /// Check if Elasticsearch HTTPS is enabled.
168    #[must_use]
169    pub fn elasticsearch_https_enabled(&self) -> bool {
170        self.db_config.as_ref().is_some_and(|c| c.elasticsearch_https)
171    }
172
173    /// Check if certificate verification is enabled for databases.
174    #[must_use]
175    pub fn verify_certificates(&self) -> bool {
176        self.db_config.as_ref().map_or(true, |c| c.verify_certificates)
177    }
178
179    /// Get the CA bundle path for verifying database certificates.
180    #[must_use]
181    pub fn ca_bundle_path(&self) -> Option<&Path> {
182        self.db_config
183            .as_ref()
184            .and_then(|c| c.ca_bundle_path.as_ref())
185            .map(|p| p.as_path())
186    }
187
188    /// Get database URL with TLS applied (for PostgreSQL).
189    pub fn apply_postgres_tls(&self, db_url: &str) -> String {
190        let mut url = db_url.to_string();
191
192        // Parse SSL mode into URL parameter
193        let ssl_mode = self.postgres_ssl_mode();
194        if !ssl_mode.is_empty() && ssl_mode != "prefer" {
195            // Add or update sslmode parameter
196            if url.contains("?") {
197                url.push_str(&format!("&sslmode={}", ssl_mode));
198            } else {
199                url.push_str(&format!("?sslmode={}", ssl_mode));
200            }
201        }
202
203        url
204    }
205
206    /// Get Redis URL with TLS applied.
207    pub fn apply_redis_tls(&self, redis_url: &str) -> String {
208        if self.redis_ssl_enabled() {
209            // Replace redis:// with rediss://
210            redis_url.replace("redis://", "rediss://")
211        } else {
212            redis_url.to_string()
213        }
214    }
215
216    /// Get ClickHouse URL with TLS applied.
217    pub fn apply_clickhouse_tls(&self, ch_url: &str) -> String {
218        if self.clickhouse_https_enabled() {
219            // Replace http:// with https://
220            ch_url.replace("http://", "https://")
221        } else {
222            ch_url.to_string()
223        }
224    }
225
226    /// Get Elasticsearch URL with TLS applied.
227    pub fn apply_elasticsearch_tls(&self, es_url: &str) -> String {
228        if self.elasticsearch_https_enabled() {
229            // Replace http:// with https://
230            es_url.replace("http://", "https://")
231        } else {
232            es_url.to_string()
233        }
234    }
235
236    /// Load certificates from PEM file.
237    fn load_certificates(path: &Path) -> Result<Vec<CertificateDer<'static>>> {
238        let cert_file = std::fs::File::open(path).map_err(|e| {
239            ServerError::ConfigError(format!(
240                "Failed to open certificate file {}: {}",
241                path.display(),
242                e
243            ))
244        })?;
245
246        let mut reader = std::io::BufReader::new(cert_file);
247        let mut certificates = Vec::new();
248
249        loop {
250            match rustls_pemfile::read_one(&mut reader).map_err(|e| {
251                ServerError::ConfigError(format!("Failed to parse certificate: {}", e))
252            })? {
253                Some(Item::X509Certificate(cert)) => certificates.push(cert),
254                Some(_) => {}, // Skip other items
255                None => break,
256            }
257        }
258
259        if certificates.is_empty() {
260            return Err(ServerError::ConfigError(
261                "No certificates found in certificate file".to_string(),
262            ));
263        }
264
265        Ok(certificates)
266    }
267
268    /// Load private key from PEM file.
269    fn load_private_key(path: &Path) -> Result<rustls::pki_types::PrivateKeyDer<'static>> {
270        let key_file = std::fs::File::open(path).map_err(|e| {
271            ServerError::ConfigError(format!("Failed to open key file {}: {}", path.display(), e))
272        })?;
273
274        let mut reader = std::io::BufReader::new(key_file);
275
276        loop {
277            match rustls_pemfile::read_one(&mut reader).map_err(|e| {
278                ServerError::ConfigError(format!("Failed to parse private key: {}", e))
279            })? {
280                Some(Item::Pkcs8Key(key)) => return Ok(key.into()),
281                Some(Item::Pkcs1Key(key)) => return Ok(key.into()),
282                Some(Item::Sec1Key(key)) => return Ok(key.into()),
283                Some(_) => {}, // Skip other items
284                None => break,
285            }
286        }
287
288        Err(ServerError::ConfigError("No private key found in key file".to_string()))
289    }
290
291    /// Create a rustls ServerConfig for TLS.
292    ///
293    /// # Errors
294    ///
295    /// Returns error if:
296    /// - Certificate or key files cannot be read
297    /// - Certificate or key format is invalid
298    pub fn create_rustls_config(&self) -> Result<Arc<ServerConfig>> {
299        let (cert_path, key_path) = match self.config.as_ref() {
300            Some(c) if c.enabled => (&c.cert_path, &c.key_path),
301            _ => return Err(ServerError::ConfigError("TLS not enabled".to_string())),
302        };
303
304        info!(
305            cert_path = %cert_path.display(),
306            key_path = %key_path.display(),
307            "Loading TLS certificates"
308        );
309
310        let certs = Self::load_certificates(cert_path)?;
311        let key = Self::load_private_key(key_path)?;
312
313        let server_config = ServerConfig::builder()
314            .with_no_client_auth()
315            .with_single_cert(certs, key)
316            .map_err(|e| ServerError::ConfigError(format!("Failed to build TLS config: {}", e)))?;
317
318        Ok(Arc::new(server_config))
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use std::path::PathBuf;
325
326    use super::*;
327
328    #[test]
329    fn test_tls_setup_disabled() {
330        let setup = TlsSetup::new(None, None).expect("should create setup");
331
332        assert!(!setup.is_tls_enabled());
333        assert!(!setup.is_mtls_required());
334        assert!(setup.cert_path().is_none());
335        assert!(setup.key_path().is_none());
336    }
337
338    #[test]
339    fn test_database_tls_defaults() {
340        let setup = TlsSetup::new(None, None).expect("should create setup");
341
342        assert_eq!(setup.postgres_ssl_mode(), "prefer");
343        assert!(!setup.redis_ssl_enabled());
344        assert!(!setup.clickhouse_https_enabled());
345        assert!(!setup.elasticsearch_https_enabled());
346        assert!(setup.verify_certificates());
347    }
348
349    #[test]
350    fn test_postgres_url_tls_application() {
351        let db_config = DatabaseTlsConfig {
352            postgres_ssl_mode:   "require".to_string(),
353            redis_ssl:           false,
354            clickhouse_https:    false,
355            elasticsearch_https: false,
356            verify_certificates: true,
357            ca_bundle_path:      None,
358        };
359
360        let setup = TlsSetup::new(None, Some(db_config)).expect("should create setup");
361
362        let url = "postgresql://localhost/fraiseql";
363        let tls_url = setup.apply_postgres_tls(url);
364
365        assert!(tls_url.contains("sslmode=require"));
366    }
367
368    #[test]
369    fn test_redis_url_tls_application() {
370        let db_config = DatabaseTlsConfig {
371            postgres_ssl_mode:   "prefer".to_string(),
372            redis_ssl:           true,
373            clickhouse_https:    false,
374            elasticsearch_https: false,
375            verify_certificates: true,
376            ca_bundle_path:      None,
377        };
378
379        let setup = TlsSetup::new(None, Some(db_config)).expect("should create setup");
380
381        let url = "redis://localhost:6379";
382        let tls_url = setup.apply_redis_tls(url);
383
384        assert_eq!(tls_url, "rediss://localhost:6379");
385    }
386
387    #[test]
388    fn test_clickhouse_url_tls_application() {
389        let db_config = DatabaseTlsConfig {
390            postgres_ssl_mode:   "prefer".to_string(),
391            redis_ssl:           false,
392            clickhouse_https:    true,
393            elasticsearch_https: false,
394            verify_certificates: true,
395            ca_bundle_path:      None,
396        };
397
398        let setup = TlsSetup::new(None, Some(db_config)).expect("should create setup");
399
400        let url = "http://localhost:8123";
401        let tls_url = setup.apply_clickhouse_tls(url);
402
403        assert_eq!(tls_url, "https://localhost:8123");
404    }
405
406    #[test]
407    fn test_elasticsearch_url_tls_application() {
408        let db_config = DatabaseTlsConfig {
409            postgres_ssl_mode:   "prefer".to_string(),
410            redis_ssl:           false,
411            clickhouse_https:    false,
412            elasticsearch_https: true,
413            verify_certificates: true,
414            ca_bundle_path:      None,
415        };
416
417        let setup = TlsSetup::new(None, Some(db_config)).expect("should create setup");
418
419        let url = "http://localhost:9200";
420        let tls_url = setup.apply_elasticsearch_tls(url);
421
422        assert_eq!(tls_url, "https://localhost:9200");
423    }
424
425    #[test]
426    fn test_all_database_tls_enabled() {
427        let db_config = DatabaseTlsConfig {
428            postgres_ssl_mode:   "require".to_string(),
429            redis_ssl:           true,
430            clickhouse_https:    true,
431            elasticsearch_https: true,
432            verify_certificates: true,
433            ca_bundle_path:      Some(PathBuf::from("/etc/ssl/certs/ca-bundle.crt")),
434        };
435
436        let setup = TlsSetup::new(None, Some(db_config)).expect("should create setup");
437
438        assert_eq!(setup.postgres_ssl_mode(), "require");
439        assert!(setup.redis_ssl_enabled());
440        assert!(setup.clickhouse_https_enabled());
441        assert!(setup.elasticsearch_https_enabled());
442        assert!(setup.verify_certificates());
443        assert!(setup.ca_bundle_path().is_some());
444    }
445
446    #[test]
447    fn test_postgres_url_with_existing_params() {
448        let db_config = DatabaseTlsConfig {
449            postgres_ssl_mode:   "require".to_string(),
450            redis_ssl:           false,
451            clickhouse_https:    false,
452            elasticsearch_https: false,
453            verify_certificates: true,
454            ca_bundle_path:      None,
455        };
456
457        let setup = TlsSetup::new(None, Some(db_config)).expect("should create setup");
458
459        let url = "postgresql://localhost/fraiseql?application_name=fraiseql";
460        let tls_url = setup.apply_postgres_tls(url);
461
462        assert!(tls_url.contains("application_name=fraiseql"));
463        assert!(tls_url.contains("sslmode=require"));
464    }
465
466    #[test]
467    fn test_database_tls_config_getters() {
468        let db_config = DatabaseTlsConfig {
469            postgres_ssl_mode:   "verify-full".to_string(),
470            redis_ssl:           true,
471            clickhouse_https:    true,
472            elasticsearch_https: false,
473            verify_certificates: true,
474            ca_bundle_path:      Some(PathBuf::from("/etc/ssl/certs/ca.pem")),
475        };
476
477        let setup = TlsSetup::new(None, Some(db_config)).expect("should create setup");
478
479        assert!(setup.db_config().is_some());
480        assert_eq!(setup.postgres_ssl_mode(), "verify-full");
481        assert!(setup.redis_ssl_enabled());
482        assert!(setup.clickhouse_https_enabled());
483        assert!(!setup.elasticsearch_https_enabled());
484        assert_eq!(setup.ca_bundle_path(), Some(Path::new("/etc/ssl/certs/ca.pem")));
485    }
486
487    #[test]
488    fn test_create_rustls_config_without_tls_enabled() {
489        let setup = TlsSetup::new(None, None).expect("should create setup");
490
491        let result = setup.create_rustls_config();
492        assert!(result.is_err());
493        assert!(result.unwrap_err().to_string().contains("TLS not enabled"));
494    }
495
496    #[test]
497    fn test_create_rustls_config_with_missing_cert() {
498        let tls_config = TlsServerConfig {
499            enabled:             true,
500            cert_path:           PathBuf::from("/nonexistent/cert.pem"),
501            key_path:            PathBuf::from("/nonexistent/key.pem"),
502            require_client_cert: false,
503            client_ca_path:      None,
504            min_version:         "1.2".to_string(),
505        };
506
507        let setup = TlsSetup::new(Some(tls_config), None).expect("should create setup");
508
509        let result = setup.create_rustls_config();
510        assert!(result.is_err());
511        assert!(result.unwrap_err().to_string().contains("Failed to open"));
512    }
513}