1use 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
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 fn enforcer(&self) -> &TlsEnforcer {
98 &self.enforcer
99 }
100
101 #[must_use]
103 pub fn config(&self) -> &Option<TlsServerConfig> {
104 &self.config
105 }
106
107 #[must_use]
109 pub 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
150 .as_ref()
151 .map(|c| c.postgres_ssl_mode.as_str())
152 .unwrap_or("prefer")
153 }
154
155 #[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 #[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 #[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 #[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 #[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 pub fn apply_postgres_tls(&self, db_url: &str) -> String {
190 let mut url = db_url.to_string();
191
192 let ssl_mode = self.postgres_ssl_mode();
194 if !ssl_mode.is_empty() && ssl_mode != "prefer" {
195 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 pub fn apply_redis_tls(&self, redis_url: &str) -> String {
208 if self.redis_ssl_enabled() {
209 redis_url.replace("redis://", "rediss://")
211 } else {
212 redis_url.to_string()
213 }
214 }
215
216 pub fn apply_clickhouse_tls(&self, ch_url: &str) -> String {
218 if self.clickhouse_https_enabled() {
219 ch_url.replace("http://", "https://")
221 } else {
222 ch_url.to_string()
223 }
224 }
225
226 pub fn apply_elasticsearch_tls(&self, es_url: &str) -> String {
228 if self.elasticsearch_https_enabled() {
229 es_url.replace("http://", "https://")
231 } else {
232 es_url.to_string()
233 }
234 }
235
236 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(_) => {}, 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 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(_) => {}, None => break,
285 }
286 }
287
288 Err(ServerError::ConfigError("No private key found in key file".to_string()))
289 }
290
291 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}