Skip to main content

prax_postgres/
tls.rs

1//! TLS connector construction for the connection pool, via rustls.
2//!
3//! Enabled by the `tls` cargo feature (on by default). Certificates are
4//! verified against the Mozilla root store ([`webpki_roots`]) — chain and
5//! hostname — for every TLS-capable [`crate::config::SslMode`]. This is
6//! deliberately stricter than libpq, whose `sslmode=require` performs no
7//! certificate verification; there is no encrypt-without-verify mode.
8//!
9//! A server whose CA is deliberately not publicly trusted therefore cannot be
10//! verified out of the box. Amazon RDS is the common case: its `rds-ca-*`
11//! authorities are Amazon-operated and absent from the Mozilla store, so with
12//! `rds.force_ssl` on there is no working combination — the TLS-requiring
13//! modes cannot verify the chain and the plaintext modes are refused by the
14//! server. Point [`crate::config::PgConfig::ssl_root_cert`] (the libpq
15//! `sslrootcert` URL parameter) at the provider's CA bundle for those.
16
17use std::path::Path;
18use std::sync::Arc;
19
20use postgres_rustls::MakeTlsConnector;
21use rustls::pki_types::CertificateDer;
22use rustls::pki_types::pem::PemObject;
23use rustls::{ClientConfig, RootCertStore};
24
25use crate::error::{PgError, PgResult};
26
27/// Build the TLS connector handed to deadpool's `Manager`.
28///
29/// The same connector serves every TLS mode: `Prefer` (tokio-postgres falls
30/// back to plaintext only when the *server* declines TLS), and
31/// `Require`/`VerifyCa`/`VerifyFull` (driver-level `SslMode::Require`, so a
32/// server that refuses TLS fails the connection). Verification is always
33/// webpki chain + hostname — see the module docs.
34///
35/// This is the workspace's shared rustls connector, exposed so downstream
36/// tooling (e.g. `prax-cli`'s introspector) can reuse the same certificate
37/// verification behavior. Available only when the `tls` cargo feature is
38/// enabled (on by default).
39pub fn make_tls_connector() -> MakeTlsConnector {
40    connector_with_roots(webpki_root_store())
41}
42
43/// Build the TLS connector, optionally verifying against a PEM bundle instead
44/// of the Mozilla root store.
45///
46/// `None` is identical to [`make_tls_connector`]. `Some(path)` *replaces* the
47/// webpki roots with the certificates in that file rather than adding to them,
48/// matching libpq's `sslrootcert`: a pool addresses one server, so "trust
49/// exactly this bundle" is the stricter and more predictable reading. It also
50/// means a typo in the path cannot silently fall back to public trust.
51///
52/// Errors if the file cannot be read, contains no certificates, or contains
53/// one rustls rejects — all of which would otherwise surface much later as an
54/// opaque handshake failure.
55pub fn make_tls_connector_with_root_cert(root_cert: Option<&Path>) -> PgResult<MakeTlsConnector> {
56    let roots = match root_cert {
57        None => webpki_root_store(),
58        Some(path) => {
59            let certs: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(path)
60                .map_err(|e| {
61                    PgError::config(format!(
62                        "sslrootcert: cannot read certificates from {}: {e}",
63                        path.display()
64                    ))
65                })?
66                .collect::<Result<_, _>>()
67                .map_err(|e| {
68                    PgError::config(format!(
69                        "sslrootcert: invalid certificate in {}: {e}",
70                        path.display()
71                    ))
72                })?;
73
74            if certs.is_empty() {
75                return Err(PgError::config(format!(
76                    "sslrootcert: no certificates found in {}",
77                    path.display()
78                )));
79            }
80
81            let mut roots = RootCertStore::empty();
82            for cert in certs {
83                roots.add(cert).map_err(|e| {
84                    PgError::config(format!(
85                        "sslrootcert: rustls rejected a certificate in {}: {e}",
86                        path.display()
87                    ))
88                })?;
89            }
90            roots
91        }
92    };
93
94    Ok(connector_with_roots(roots))
95}
96
97fn webpki_root_store() -> RootCertStore {
98    let mut roots = RootCertStore::empty();
99    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
100    roots
101}
102
103fn connector_with_roots(roots: RootCertStore) -> MakeTlsConnector {
104    // Select aws-lc-rs explicitly: workspace feature unification can enable
105    // both rustls providers (aws-lc-rs via sqlx, ring via other crates), in
106    // which case rustls cannot auto-determine a process-level default.
107    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
108    let client_config = ClientConfig::builder_with_provider(provider)
109        .with_safe_default_protocol_versions()
110        .expect("safe default protocol versions are available")
111        .with_root_certificates(roots)
112        .with_no_client_auth();
113
114    MakeTlsConnector::new(tokio_rustls::TlsConnector::from(Arc::new(client_config)))
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::io::Write;
121    use tokio_postgres::tls::MakeTlsConnect;
122
123    /// A self-signed certificate, PEM-encoded. Only ever parsed, never used to
124    /// establish a connection, so it needs to be well-formed rather than valid
125    /// for any particular name.
126    const TEST_CA_PEM: &str = include_str!("../tests/data/test-ca.pem");
127
128    fn write_temp(contents: &str, name: &str) -> std::path::PathBuf {
129        let path = std::env::temp_dir().join(format!("prax-{}-{}.pem", std::process::id(), name));
130        let mut f = std::fs::File::create(&path).expect("create temp pem");
131        f.write_all(contents.as_bytes()).expect("write temp pem");
132        path
133    }
134
135    #[test]
136    fn connector_is_constructible_and_cloneable() {
137        // deadpool requires MakeTlsConnect + Clone + Sync + Send + 'static;
138        // construction also proves the rustls provider and root store load.
139        let connector = make_tls_connector();
140        let mut cloned = connector.clone();
141        // A syntactically invalid domain must fail in make_tls_connect
142        // (DNS name parsing) without any network I/O.
143        assert!(
144            <MakeTlsConnector as MakeTlsConnect<tokio::net::TcpStream>>::make_tls_connect(
145                &mut cloned,
146                "invalid..domain"
147            )
148            .is_err()
149        );
150    }
151
152    #[test]
153    fn none_root_cert_matches_the_default_connector() {
154        // The no-argument form must stay exactly the webpki path.
155        assert!(make_tls_connector_with_root_cert(None).is_ok());
156    }
157
158    #[test]
159    fn loads_a_pem_bundle() {
160        let path = write_temp(TEST_CA_PEM, "valid");
161        let loaded = make_tls_connector_with_root_cert(Some(&path)).is_ok();
162        let _ = std::fs::remove_file(&path);
163        assert!(loaded, "a well-formed PEM bundle must load");
164    }
165
166    #[test]
167    fn missing_file_names_the_path() {
168        // Failing at pool construction with the path in the message is the
169        // whole point: the alternative is an opaque handshake error later.
170        // `MakeTlsConnector` is not Debug, so unwrap the error by hand rather
171        // than through expect_err.
172        let msg = match make_tls_connector_with_root_cert(Some(std::path::Path::new(
173            "/nonexistent/prax-no-such-ca.pem",
174        ))) {
175            Ok(_) => panic!("a missing bundle must fail"),
176            Err(e) => e.to_string(),
177        };
178        assert!(msg.contains("sslrootcert"), "message was: {msg}");
179        assert!(msg.contains("prax-no-such-ca.pem"), "message was: {msg}");
180    }
181
182    #[test]
183    fn empty_bundle_is_rejected() {
184        // An empty file parses to zero certificates; accepting it would build
185        // a connector that trusts nothing and fails every handshake.
186        let path = write_temp("# no certificates here\n", "empty");
187        let result = make_tls_connector_with_root_cert(Some(&path));
188        let _ = std::fs::remove_file(&path);
189        let msg = match result {
190            Ok(_) => panic!("an empty bundle must fail"),
191            Err(e) => e.to_string(),
192        };
193        assert!(msg.contains("no certificates found"), "message was: {msg}");
194    }
195}