fraiseql_wire/connection/tls.rs
1//! TLS configuration and support for secure connections to Postgres.
2//!
3//! This module provides TLS configuration for connecting to remote Postgres servers.
4//! TLS is recommended for all non-local connections to prevent credential interception.
5
6use crate::{Error, Result};
7use rustls::ClientConfig;
8use rustls::RootCertStore;
9use rustls_pemfile::Item;
10use std::fs;
11use std::sync::Arc;
12
13/// TLS configuration for secure Postgres connections.
14///
15/// Provides a builder for creating TLS configurations with various certificate handling options.
16/// By default, server certificates are validated against system root certificates.
17///
18/// # Examples
19///
20/// ```ignore
21/// use fraiseql_wire::connection::TlsConfig;
22///
23/// // With system root certificates (production)
24/// let tls = TlsConfig::builder()
25/// .verify_hostname(true)
26/// .build()?;
27///
28/// // With custom CA certificate
29/// let tls = TlsConfig::builder()
30/// .ca_cert_path("/path/to/ca.pem")?
31/// .verify_hostname(true)
32/// .build()?;
33///
34/// // For development (danger: disables verification)
35/// let tls = TlsConfig::builder()
36/// .danger_accept_invalid_certs(true)
37/// .danger_accept_invalid_hostnames(true)
38/// .build()?;
39/// ```
40#[derive(Clone)]
41pub struct TlsConfig {
42 /// Path to CA certificate file (None = use system roots)
43 ca_cert_path: Option<String>,
44 /// Whether to verify hostname matches certificate
45 verify_hostname: bool,
46 /// Whether to accept invalid certificates (development only)
47 danger_accept_invalid_certs: bool,
48 /// Whether to accept invalid hostnames (development only)
49 danger_accept_invalid_hostnames: bool,
50 /// Compiled rustls ClientConfig
51 client_config: Arc<ClientConfig>,
52}
53
54impl TlsConfig {
55 /// Create a new TLS configuration builder.
56 ///
57 /// # Examples
58 ///
59 /// ```ignore
60 /// let tls = TlsConfig::builder()
61 /// .verify_hostname(true)
62 /// .build()?;
63 /// ```
64 pub fn builder() -> TlsConfigBuilder {
65 TlsConfigBuilder::default()
66 }
67
68 /// Get the rustls ClientConfig for this TLS configuration.
69 pub fn client_config(&self) -> Arc<ClientConfig> {
70 self.client_config.clone()
71 }
72
73 /// Check if hostname verification is enabled.
74 pub fn verify_hostname(&self) -> bool {
75 self.verify_hostname
76 }
77
78 /// Check if invalid certificates are accepted (development only).
79 pub fn danger_accept_invalid_certs(&self) -> bool {
80 self.danger_accept_invalid_certs
81 }
82
83 /// Check if invalid hostnames are accepted (development only).
84 pub fn danger_accept_invalid_hostnames(&self) -> bool {
85 self.danger_accept_invalid_hostnames
86 }
87}
88
89impl std::fmt::Debug for TlsConfig {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 f.debug_struct("TlsConfig")
92 .field("ca_cert_path", &self.ca_cert_path)
93 .field("verify_hostname", &self.verify_hostname)
94 .field(
95 "danger_accept_invalid_certs",
96 &self.danger_accept_invalid_certs,
97 )
98 .field(
99 "danger_accept_invalid_hostnames",
100 &self.danger_accept_invalid_hostnames,
101 )
102 .field("client_config", &"<ClientConfig>")
103 .finish()
104 }
105}
106
107/// Builder for TLS configuration.
108///
109/// Provides a fluent API for constructing TLS configurations with custom settings.
110pub struct TlsConfigBuilder {
111 ca_cert_path: Option<String>,
112 verify_hostname: bool,
113 danger_accept_invalid_certs: bool,
114 danger_accept_invalid_hostnames: bool,
115}
116
117impl Default for TlsConfigBuilder {
118 fn default() -> Self {
119 Self {
120 ca_cert_path: None,
121 verify_hostname: true,
122 danger_accept_invalid_certs: false,
123 danger_accept_invalid_hostnames: false,
124 }
125 }
126}
127
128impl TlsConfigBuilder {
129 /// Set the path to a custom CA certificate file (PEM format).
130 ///
131 /// If not set, system root certificates will be used.
132 ///
133 /// # Arguments
134 ///
135 /// * `path` - Path to CA certificate file in PEM format
136 ///
137 /// # Examples
138 ///
139 /// ```ignore
140 /// let tls = TlsConfig::builder()
141 /// .ca_cert_path("/etc/ssl/certs/ca.pem")?
142 /// .build()?;
143 /// ```
144 pub fn ca_cert_path(mut self, path: impl Into<String>) -> Self {
145 self.ca_cert_path = Some(path.into());
146 self
147 }
148
149 /// Enable or disable hostname verification (default: enabled).
150 ///
151 /// When enabled, the certificate's subject alternative names (SANs) are verified
152 /// to match the server hostname.
153 ///
154 /// # Arguments
155 ///
156 /// * `verify` - Whether to verify hostname matches certificate
157 ///
158 /// # Examples
159 ///
160 /// ```ignore
161 /// let tls = TlsConfig::builder()
162 /// .verify_hostname(true)
163 /// .build()?;
164 /// ```
165 pub fn verify_hostname(mut self, verify: bool) -> Self {
166 self.verify_hostname = verify;
167 self
168 }
169
170 /// ⚠️ **DANGER**: Accept invalid certificates (development only).
171 ///
172 /// **NEVER use in production.** This disables certificate validation entirely,
173 /// making the connection vulnerable to man-in-the-middle attacks.
174 ///
175 /// Only use for testing with self-signed certificates.
176 ///
177 /// # Examples
178 ///
179 /// ```ignore
180 /// let tls = TlsConfig::builder()
181 /// .danger_accept_invalid_certs(true)
182 /// .build()?;
183 /// ```
184 pub fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
185 self.danger_accept_invalid_certs = accept;
186 self
187 }
188
189 /// ⚠️ **DANGER**: Accept invalid hostnames (development only).
190 ///
191 /// **NEVER use in production.** This disables hostname verification,
192 /// making the connection vulnerable to man-in-the-middle attacks.
193 ///
194 /// Only use for testing with self-signed certificates where you can't
195 /// match the hostname.
196 ///
197 /// # Examples
198 ///
199 /// ```ignore
200 /// let tls = TlsConfig::builder()
201 /// .danger_accept_invalid_hostnames(true)
202 /// .build()?;
203 /// ```
204 pub fn danger_accept_invalid_hostnames(mut self, accept: bool) -> Self {
205 self.danger_accept_invalid_hostnames = accept;
206 self
207 }
208
209 /// Build the TLS configuration.
210 ///
211 /// # Errors
212 ///
213 /// Returns an error if:
214 /// - CA certificate file cannot be read
215 /// - CA certificate is invalid PEM
216 /// - Dangerous options are configured incorrectly
217 ///
218 /// # Examples
219 ///
220 /// ```ignore
221 /// let tls = TlsConfig::builder()
222 /// .verify_hostname(true)
223 /// .build()?;
224 /// ```
225 pub fn build(self) -> Result<TlsConfig> {
226 // Load root certificates
227 let root_store = if let Some(ca_path) = &self.ca_cert_path {
228 // Load custom CA certificate from file
229 self.load_custom_ca(ca_path)?
230 } else {
231 // Use system root certificates via rustls-native-certs
232 let result = rustls_native_certs::load_native_certs();
233
234 let mut store = RootCertStore::empty();
235 for cert in result.certs {
236 let _ = store.add_parsable_certificates(std::iter::once(cert));
237 }
238
239 // Log warnings if there were errors, but don't fail
240 if !result.errors.is_empty() && store.is_empty() {
241 return Err(Error::Config(
242 "Failed to load any system root certificates".to_string(),
243 ));
244 }
245
246 store
247 };
248
249 // Create ClientConfig using the correct API for rustls 0.23
250 let client_config = Arc::new(
251 ClientConfig::builder()
252 .with_root_certificates(root_store)
253 .with_no_client_auth(),
254 );
255
256 Ok(TlsConfig {
257 ca_cert_path: self.ca_cert_path,
258 verify_hostname: self.verify_hostname,
259 danger_accept_invalid_certs: self.danger_accept_invalid_certs,
260 danger_accept_invalid_hostnames: self.danger_accept_invalid_hostnames,
261 client_config,
262 })
263 }
264
265 /// Load a custom CA certificate from a PEM file.
266 fn load_custom_ca(&self, ca_path: &str) -> Result<RootCertStore> {
267 let ca_cert_data = fs::read(ca_path).map_err(|e| {
268 Error::Config(format!(
269 "Failed to read CA certificate file '{}': {}",
270 ca_path, e
271 ))
272 })?;
273
274 let mut reader = std::io::Cursor::new(&ca_cert_data);
275 let mut root_store = RootCertStore::empty();
276 let mut found_certs = 0;
277
278 // Parse PEM file and extract certificates
279 loop {
280 match rustls_pemfile::read_one(&mut reader) {
281 Ok(Some(Item::X509Certificate(cert))) => {
282 let _ = root_store.add_parsable_certificates(std::iter::once(cert));
283 found_certs += 1;
284 }
285 Ok(Some(_)) => {
286 // Skip non-certificate items (private keys, etc.)
287 }
288 Ok(None) => {
289 // End of file
290 break;
291 }
292 Err(_) => {
293 return Err(Error::Config(format!(
294 "Failed to parse CA certificate from '{}'",
295 ca_path
296 )));
297 }
298 }
299 }
300
301 if found_certs == 0 {
302 return Err(Error::Config(format!(
303 "No valid certificates found in '{}'",
304 ca_path
305 )));
306 }
307
308 Ok(root_store)
309 }
310}
311
312/// Parse server name from hostname for TLS SNI (Server Name Indication).
313///
314/// # Arguments
315///
316/// * `hostname` - Hostname to parse (without port)
317///
318/// # Returns
319///
320/// A string suitable for TLS server name indication
321///
322/// # Errors
323///
324/// Returns an error if the hostname is invalid.
325pub fn parse_server_name(hostname: &str) -> Result<String> {
326 // Remove trailing dot if present
327 let hostname = hostname.trim_end_matches('.');
328
329 // Validate hostname (basic check)
330 if hostname.is_empty() || hostname.len() > 253 {
331 return Err(Error::Config(format!(
332 "Invalid hostname for TLS: '{}'",
333 hostname
334 )));
335 }
336
337 // Check for invalid characters
338 if !hostname
339 .chars()
340 .all(|c| c.is_alphanumeric() || c == '-' || c == '.')
341 {
342 return Err(Error::Config(format!(
343 "Invalid hostname for TLS: '{}'",
344 hostname
345 )));
346 }
347
348 Ok(hostname.to_string())
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn test_tls_config_builder_defaults() {
357 let tls = TlsConfigBuilder::default();
358 assert!(!tls.danger_accept_invalid_certs);
359 assert!(!tls.danger_accept_invalid_hostnames);
360 assert!(tls.verify_hostname);
361 assert!(tls.ca_cert_path.is_none());
362 }
363
364 #[test]
365 fn test_tls_config_builder_with_hostname_verification() {
366 let tls = TlsConfig::builder()
367 .verify_hostname(true)
368 .build()
369 .expect("Failed to build TLS config");
370
371 assert!(tls.verify_hostname());
372 assert!(!tls.danger_accept_invalid_certs());
373 }
374
375 #[test]
376 fn test_tls_config_builder_with_custom_ca() {
377 // This test would require an actual PEM file
378 // Skipping for now as it requires filesystem setup
379 }
380
381 #[test]
382 fn test_parse_server_name_valid() {
383 let result = parse_server_name("localhost");
384 assert!(result.is_ok());
385
386 let result = parse_server_name("example.com");
387 assert!(result.is_ok());
388
389 let result = parse_server_name("db.internal.example.com");
390 assert!(result.is_ok());
391 }
392
393 #[test]
394 fn test_parse_server_name_trailing_dot() {
395 let result = parse_server_name("example.com.");
396 assert!(result.is_ok());
397 }
398
399 #[test]
400 fn test_parse_server_name_with_port_fails() {
401 // ServerName expects just hostname, not host:port
402 let result = parse_server_name("example.com:5432");
403 // This might actually succeed or fail depending on rustls version
404 // Just ensure it doesn't panic
405 let _ = result;
406 }
407
408 #[test]
409 fn test_tls_config_debug() {
410 let tls = TlsConfig::builder()
411 .verify_hostname(true)
412 .build()
413 .expect("Failed to build TLS config");
414
415 let debug_str = format!("{:?}", tls);
416 assert!(debug_str.contains("TlsConfig"));
417 assert!(debug_str.contains("verify_hostname"));
418 }
419}