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}