1use 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
22pub struct TlsSetup {
24 enforcer: TlsEnforcer,
26
27 config: Option<TlsServerConfig>,
29
30 db_config: Option<DatabaseTlsConfig>,
32}
33
34impl TlsSetup {
35 pub fn new(
43 tls_config: Option<TlsServerConfig>,
44 db_tls_config: Option<DatabaseTlsConfig>,
45 ) -> Result<Self> {
46 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 fn create_enforcer(config: &TlsServerConfig) -> Result<TlsEnforcer> {
66 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 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 #[must_use]
97 pub const fn enforcer(&self) -> &TlsEnforcer {
98 &self.enforcer
99 }
100
101 #[must_use]
103 pub const fn config(&self) -> &Option<TlsServerConfig> {
104 &self.config
105 }
106
107 #[must_use]
109 pub const fn db_config(&self) -> &Option<DatabaseTlsConfig> {
110 &self.db_config
111 }
112
113 #[must_use]
115 pub fn is_tls_enabled(&self) -> bool {
116 self.config.as_ref().is_some_and(|c| c.enabled)
117 }
118
119 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 pub fn apply_postgres_tls(&self, db_url: &str) -> String {
187 let mut url = db_url.to_string();
188
189 let ssl_mode = self.postgres_ssl_mode();
191 if !ssl_mode.is_empty() && ssl_mode != "prefer" {
192 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 pub fn apply_redis_tls(&self, redis_url: &str) -> String {
205 if self.redis_ssl_enabled() {
206 redis_url.replace("redis://", "rediss://")
208 } else {
209 redis_url.to_string()
210 }
211 }
212
213 pub fn apply_clickhouse_tls(&self, ch_url: &str) -> String {
215 if self.clickhouse_https_enabled() {
216 ch_url.replace("http://", "https://")
218 } else {
219 ch_url.to_string()
220 }
221 }
222
223 pub fn apply_elasticsearch_tls(&self, es_url: &str) -> String {
225 if self.elasticsearch_https_enabled() {
226 es_url.replace("http://", "https://")
228 } else {
229 es_url.to_string()
230 }
231 }
232
233 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(_) => {}, 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 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(_) => {}, None => break,
282 }
283 }
284
285 Err(ServerError::ConfigError("No private key found in key file".to_string()))
286 }
287
288 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)] #![allow(clippy::cast_precision_loss)] #![allow(clippy::cast_sign_loss)] #![allow(clippy::cast_possible_truncation)] #![allow(clippy::cast_possible_wrap)] #![allow(clippy::missing_panics_doc)] #![allow(clippy::missing_errors_doc)] #![allow(missing_docs)] #![allow(clippy::items_after_statements)] 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}