Skip to content

feat(postgres): Support direct SSL connections to Postgres 17+ servers #3879

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/sqlx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,17 @@ jobs:
# but `PgLTree` should just fall back to text format
RUSTFLAGS: --cfg postgres_${{ matrix.postgres }}_client_ssl

- if: matrix.tls != 'none' && matrix.postgres == '17'
run: >
cargo test
--no-default-features
--features any,postgres,macros,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
env:
DATABASE_URL: postgres://postgres@localhost:5432/sqlx?sslnegotiation=direct&sslmode=require&sslkey=.%2Ftests%2Fkeys%2Fclient.key&sslcert=.%2Ftests%2Fcerts%2Fclient.crt
# FIXME: needed to disable `ltree` tests in Postgres 9.6
# but `PgLTree` should just fall back to text format
RUSTFLAGS: --cfg postgres_${{ matrix.postgres }}_client_ssl

mysql:
name: MySQL
runs-on: ubuntu-24.04
Expand Down
2 changes: 1 addition & 1 deletion sqlx-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async-std = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }

# TLS
native-tls = { version = "0.2.10", optional = true }
native-tls = { version = "0.2.10", features = ["alpn"], optional = true }

rustls = { version = "0.23.15", default-features = false, features = ["std", "tls12"], optional = true }
webpki-roots = { version = "0.26", optional = true }
Expand Down
1 change: 1 addition & 0 deletions sqlx-core/src/net/tls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub struct TlsConfig<'a> {
pub root_cert_path: Option<&'a CertificateInput>,
pub client_cert_path: Option<&'a CertificateInput>,
pub client_key_path: Option<&'a CertificateInput>,
pub alpn_protocols: Option<Vec<&'a str>>,
}

pub async fn handshake<S, Ws>(
Expand Down
4 changes: 4 additions & 0 deletions sqlx-core/src/net/tls/tls_native_tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ pub async fn handshake<S: Socket>(
builder.add_root_certificate(native_tls::Certificate::from_pem(&data).map_err(Error::tls)?);
}

if let Some(protocols) = config.alpn_protocols {
builder.request_alpns(&protocols);
}

// authentication using user's key-file and its associated certificate
if let (Some(cert_path), Some(key_path)) = (config.client_cert_path, config.client_key_path) {
let cert_path = cert_path.data().await?;
Expand Down
11 changes: 10 additions & 1 deletion sqlx-core/src/net/tls/tls_rustls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ where
}
};

let config = if tls_config.accept_invalid_certs {
let mut config = if tls_config.accept_invalid_certs {
if let Some(user_auth) = user_auth {
config
.dangerous()
Expand Down Expand Up @@ -183,6 +183,15 @@ where
}
};

if let Some(alpn_protocols) = tls_config.alpn_protocols {
let alpn_protocols: Vec<Vec<u8>> = alpn_protocols
.into_iter()
.map(|s| s.as_bytes().to_vec())
.collect();

config.alpn_protocols = alpn_protocols;
}

let host = ServerName::try_from(tls_config.hostname.to_owned()).map_err(Error::tls)?;

let mut socket = RustlsSocket {
Expand Down
1 change: 1 addition & 0 deletions sqlx-mysql/src/connection/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ pub(super) async fn maybe_upgrade<S: Socket>(
root_cert_path: options.ssl_ca.as_ref(),
client_cert_path: options.ssl_client_cert.as_ref(),
client_key_path: options.ssl_client_key.as_ref(),
alpn_protocols: None,
};

// Request TLS upgrade
Expand Down
67 changes: 46 additions & 21 deletions sqlx-postgres/src/connection/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::net::tls::{self, TlsConfig};
use crate::net::{Socket, SocketIntoBox, WithSocket};

use crate::message::SslRequest;
use crate::{PgConnectOptions, PgSslMode};
use crate::{PgConnectOptions, PgSslMode, PgSslNegotiation};

pub struct MaybeUpgradeTls<'a>(pub &'a PgConnectOptions);

Expand All @@ -19,28 +19,52 @@ async fn maybe_upgrade<S: Socket>(
mut socket: S,
options: &PgConnectOptions,
) -> Result<Box<dyn Socket>, Error> {
// https://www.postgresql.org/docs/12/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS
match options.ssl_mode {
// FIXME: Implement ALLOW
PgSslMode::Allow | PgSslMode::Disable => return Ok(Box::new(socket)),

PgSslMode::Prefer => {
if !tls::available() {
return Ok(Box::new(socket));
}

// try upgrade, but its okay if we fail
if !request_upgrade(&mut socket, options).await? {
return Ok(Box::new(socket));
// https://www.postgresql.org/docs/17/libpq-connect.html#LIBPQ-CONNECT-SSLNEGOTIATION
match options.ssl_negotiation {
PgSslNegotiation::Postgres => {
// https://www.postgresql.org/docs/12/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS
match options.ssl_mode {
// FIXME: Implement ALLOW
PgSslMode::Allow | PgSslMode::Disable => return Ok(Box::new(socket)),

PgSslMode::Prefer => {
if !tls::available() {
return Ok(Box::new(socket));
}

// try upgrade, but its okay if we fail
if !request_upgrade(&mut socket, options).await? {
return Ok(Box::new(socket));
}
}

PgSslMode::Require | PgSslMode::VerifyFull | PgSslMode::VerifyCa => {
tls::error_if_unavailable()?;

if !request_upgrade(&mut socket, options).await? {
// upgrade failed, die
return Err(Error::Tls("server does not support TLS".into()));
}
}
}
}

PgSslMode::Require | PgSslMode::VerifyFull | PgSslMode::VerifyCa => {
tls::error_if_unavailable()?;

if !request_upgrade(&mut socket, options).await? {
// upgrade failed, die
return Err(Error::Tls("server does not support TLS".into()));
PgSslNegotiation::Direct => {
// Direct TLS negotiation without PostgreSQL handshake
match options.ssl_mode {
PgSslMode::Disable | PgSslMode::Allow | PgSslMode::Prefer => {
return Err(Error::Tls(
format!(
"SSL mode {:?} is incompatible with direct SSL negotiation",
options.ssl_mode
)
.into(),
));
}

PgSslMode::Require | PgSslMode::VerifyFull | PgSslMode::VerifyCa => {
tls::error_if_unavailable()?;
// No need to request an upgrade. We go straight to TLS handshake.
}
}
}
}
Expand All @@ -58,6 +82,7 @@ async fn maybe_upgrade<S: Socket>(
root_cert_path: options.ssl_root_cert.as_ref(),
client_cert_path: options.ssl_client_cert.as_ref(),
client_key_path: options.ssl_client_key.as_ref(),
alpn_protocols: Some(vec!["postgresql"]),
};

tls::handshake(socket, config, SocketIntoBox).await
Expand Down
2 changes: 1 addition & 1 deletion sqlx-postgres/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ pub use database::Postgres;
pub use error::{PgDatabaseError, PgErrorPosition};
pub use listener::{PgListener, PgNotification};
pub use message::PgSeverity;
pub use options::{PgConnectOptions, PgSslMode};
pub use options::{PgConnectOptions, PgSslMode, PgSslNegotiation};
pub use query_result::PgQueryResult;
pub use row::PgRow;
pub use statement::PgStatement;
Expand Down
1 change: 1 addition & 0 deletions sqlx-postgres/src/options/doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ if a parameter is not passed in via URL, it is populated by reading
| `port` | `PGPORT` | `5432` |
| `dbname` | `PGDATABASE` | Unset; defaults to the username server-side. |
| `sslmode` | `PGSSLMODE` | `prefer`. See [`PgSslMode`] for details. |
| `sslnegotiation` | `PGSSLNEGOTIATION` | `postgres`. See [`PgSslNegotiation`] for details. |
| `sslrootcert` | `PGSSLROOTCERT` | Unset. See [Note: SSL](#note-ssl). |
| `sslcert` | `PGSSLCERT` | Unset. See [Note: SSL](#note-ssl). |
| `sslkey` | `PGSSLKEY` | Unset. See [Note: SSL](#note-ssl). |
Expand Down
40 changes: 40 additions & 0 deletions sqlx-postgres/src/options/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ use std::fmt::{Display, Write};
use std::path::{Path, PathBuf};

pub use ssl_mode::PgSslMode;
pub use ssl_negotiation::PgSslNegotiation;

use crate::{connection::LogSettings, net::tls::CertificateInput};

mod connect;
mod parse;
mod pgpass;
mod ssl_mode;
mod ssl_negotiation;

#[doc = include_str!("doc.md")]
#[derive(Debug, Clone)]
Expand All @@ -22,6 +24,7 @@ pub struct PgConnectOptions {
pub(crate) password: Option<String>,
pub(crate) database: Option<String>,
pub(crate) ssl_mode: PgSslMode,
pub(crate) ssl_negotiation: PgSslNegotiation,
pub(crate) ssl_root_cert: Option<CertificateInput>,
pub(crate) ssl_client_cert: Option<CertificateInput>,
pub(crate) ssl_client_key: Option<CertificateInput>,
Expand Down Expand Up @@ -85,6 +88,10 @@ impl PgConnectOptions {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_default(),
ssl_negotiation: var("PGSSLNEGOTIATION")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_default(),
statement_cache_capacity: 100,
application_name: var("PGAPPNAME").ok(),
extra_float_digits: Some("2".into()),
Expand Down Expand Up @@ -218,6 +225,26 @@ impl PgConnectOptions {
self
}

/// Sets the protocol with which the secure SSL TCP/IP connection will be negotiated with
/// the server.
///
/// By default, the protocol is [`Postgres`](PgSslNegotiation::Postgres), and the client will
/// first check whether the server supports SSL, and fallback to a non-SSL connection if not.
///
/// Ignored for Unix domain socket communication.
///
/// # Example
///
/// ```rust
/// # use sqlx_postgres::{PgSslNegotiation, PgConnectOptions};
/// let options = PgConnectOptions::new()
/// .ssl_negotiation(PgSslNegotiation::Postgres);
/// ```
pub fn ssl_negotiation(mut self, procedure: PgSslNegotiation) -> Self {
self.ssl_negotiation = procedure;
self
}

/// Sets the name of a file containing SSL certificate authority (CA) certificate(s).
/// If the file exists, the server's certificate will be verified to be signed by
/// one of these authorities.
Expand Down Expand Up @@ -542,6 +569,19 @@ impl PgConnectOptions {
self.ssl_mode
}

/// Get the SSL negotiation protocol.
///
/// # Example
///
/// ```rust
/// # use sqlx_postgres::{PgConnectOptions, PgSslNegotiation};
/// let options = PgConnectOptions::new();
/// assert!(matches!(options.get_ssl_negotiation(), PgSslNegotiation::Postgres));
/// ```
pub fn get_ssl_negotiation(&self) -> PgSslNegotiation {
self.ssl_negotiation
}

/// Get the application name.
///
/// # Example
Expand Down
33 changes: 30 additions & 3 deletions sqlx-postgres/src/options/parse.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::error::Error;
use crate::{PgConnectOptions, PgSslMode};
use crate::{PgConnectOptions, PgSslMode, PgSslNegotiation};
use sqlx_core::percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
use sqlx_core::Url;
use std::net::IpAddr;
Expand Down Expand Up @@ -53,6 +53,10 @@ impl PgConnectOptions {
options = options.ssl_mode(value.parse().map_err(Error::config)?);
}

"sslnegotiation" | "ssl-negotiation" => {
options = options.ssl_negotiation(value.parse().map_err(Error::config)?);
}

"sslrootcert" | "ssl-root-cert" | "ssl-ca" => {
options = options.ssl_root_cert(&*value);
}
Expand Down Expand Up @@ -146,6 +150,13 @@ impl PgConnectOptions {
};
url.query_pairs_mut().append_pair("sslmode", ssl_mode);

let ssl_negotiation = match self.ssl_negotiation {
PgSslNegotiation::Postgres => "postgres",
PgSslNegotiation::Direct => "direct",
};
url.query_pairs_mut()
.append_pair("sslnegotiation", ssl_negotiation);

if let Some(ssl_root_cert) = &self.ssl_root_cert {
url.query_pairs_mut()
.append_pair("sslrootcert", &ssl_root_cert.to_string());
Expand Down Expand Up @@ -266,6 +277,22 @@ fn it_parses_password_with_non_ascii_chars_correctly() {
assert_eq!(Some("p@ssw0rd".into()), opts.password);
}

#[test]
fn it_parses_sslmode_correctly_from_parameter() {
let url = "postgres://?sslmode=verify-full";
let opts = PgConnectOptions::from_str(url).unwrap();

assert!(matches!(opts.ssl_mode, PgSslMode::VerifyFull));
}

#[test]
fn it_parses_sslnegotiation_correctly_from_parameter() {
let url = "postgres://?sslnegotiation=direct";
let opts = PgConnectOptions::from_str(url).unwrap();

assert!(matches!(opts.ssl_negotiation, PgSslNegotiation::Direct));
}

#[test]
fn it_parses_socket_correctly_percent_encoded() {
let url = "postgres://%2Fvar%2Flib%2Fpostgres/database";
Expand Down Expand Up @@ -310,7 +337,7 @@ fn it_returns_the_parsed_url_when_socket() {

let mut expected_url = Url::parse(url).unwrap();
// PgConnectOptions defaults
let query_string = "sslmode=prefer&statement-cache-capacity=100";
let query_string = "sslmode=prefer&sslnegotiation=postgres&statement-cache-capacity=100";
let port = 5432;
expected_url.set_query(Some(query_string));
let _ = expected_url.set_port(Some(port));
Expand All @@ -325,7 +352,7 @@ fn it_returns_the_parsed_url_when_host() {

let mut expected_url = Url::parse(url).unwrap();
// PgConnectOptions defaults
let query_string = "sslmode=prefer&statement-cache-capacity=100";
let query_string = "sslmode=prefer&sslnegotiation=postgres&statement-cache-capacity=100";
expected_url.set_query(Some(query_string));

assert_eq!(expected_url, opts.build_url());
Expand Down
35 changes: 35 additions & 0 deletions sqlx-postgres/src/options/ssl_negotiation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
use crate::error::Error;
use std::str::FromStr;

/// Options for controlling the connection establishment procedure for PostgreSQL SSL connections.
///
/// It is used by the [`sslnegotiation`](super::PgConnectOptions::ssl_negotiation) method.
#[derive(Debug, Clone, Copy, Default)]
pub enum PgSslNegotiation {
/// The client first asks the server if SSL is supported.
///
/// This is the default if no other mode is specified.
#[default]
Postgres,

/// The client starts the standard SSL handshake directly after establishing the TCP/IP
/// connection.
Direct,
}

impl FromStr for PgSslNegotiation {
type Err = Error;

fn from_str(s: &str) -> Result<Self, Error> {
Ok(match &*s.to_ascii_lowercase() {
"postgres" => PgSslNegotiation::Postgres,
"direct" => PgSslNegotiation::Direct,

_ => {
return Err(Error::Configuration(
format!("unknown value {s:?} for `ssl_negotiation`").into(),
));
}
})
}
}