Skip to main content

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}