fix(postgres): 支持旧版 TLS 兼容模式

This commit is contained in:
zipg authored and t8y2 committed 2026-08-21 10:52:27 +08:00
1 parent 12da615daa
commit ab0ff28fe4
16 files changed
+537 -20

No files matched your search

+3
View File
@@ -39,6 +39,8 @@ uuid = { version = "1", features = ["v4", "serde"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
deadpool-postgres = { version = "0.14", features = ["rt_tokio_1"] }
tokio-postgres-rustls = "0.13"
postgres-openssl = "0.5"
openssl = { version = "0.10", features = ["vendored"] }
webpki-roots = "0.26"
rusqlite = { version = "0.32", features = ["bundled", "load_extension", "backup", "functions", "column_decltype"] }
mysql_async = { version = "0.37", default-features = false, features = ["default-rustls", "client_ed25519", "chrono", "rust_decimal"] }
@@ -91,3 +93,4 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32
[dev-dependencies]
mysql_common = { git = "https://github.com/t8y2/rust_mysql_common.git", rev = "47740d85a8277167a5717afac785d37b46a36296", default-features = false }
tokio = { version = "1", features = ["test-util"] }
tokio-openssl = "0.6"
+340 -18
View File
@@ -1,6 +1,7 @@
use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime};
use deadpool_postgres::{ManagerConfig, Pool, PoolError, RecyclingMethod, Runtime};
use futures::{SinkExt, StreamExt};
use openssl::ssl::{SslConnector, SslFiletype, SslMethod, SslVerifyMode};
use percent_encoding::percent_decode_str;
use rust_decimal::Decimal;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
@@ -2117,17 +2118,27 @@ async fn connect_postgres_pool_attempt(
// executor's ReconnectAndRetry path (see pool_error_action / do_execute
// in query.rs).
let mgr_config = ManagerConfig { recycling_method: RecyclingMethod::Fast };
let tls_config = postgres_tls_config(
&pg_config,
&postgres_url.ssl_files,
postgres_url.accepts_invalid_certs,
postgres_url.verifies_hostname,
)?;
let mgr = deadpool_postgres::Manager::from_connect(
pg_config.clone(),
NoticeCapturingConnect { tls: tokio_postgres_rustls::MakeRustlsConnect::new(tls_config) },
mgr_config,
);
let mgr = if postgres_url.legacy_tls {
log::info!("PostgreSQL legacy TLS compatibility enabled");
let tls = postgres_openssl_connector(
&postgres_url.ssl_files,
postgres_url.accepts_invalid_certs,
postgres_url.verifies_hostname,
)?;
deadpool_postgres::Manager::from_connect(pg_config.clone(), NoticeCapturingConnect { tls }, mgr_config)
} else {
let tls_config = postgres_tls_config(
&pg_config,
&postgres_url.ssl_files,
postgres_url.accepts_invalid_certs,
postgres_url.verifies_hostname,
)?;
deadpool_postgres::Manager::from_connect(
pg_config.clone(),
NoticeCapturingConnect { tls: tokio_postgres_rustls::MakeRustlsConnect::new(tls_config) },
mgr_config,
)
};
let pool = Pool::builder(mgr)
.max_size(10)
.runtime(Runtime::Tokio1)
@@ -2264,6 +2275,7 @@ pub struct PostgresCancelContext {
pub accepts_invalid_certs: bool,
pub verifies_hostname: bool,
pub ssl_mode: SslMode,
pub legacy_tls: bool,
}
/// Build a TLS cancel context from the connection URL.
@@ -2279,6 +2291,7 @@ pub fn build_postgres_cancel_context(url: &str) -> Option<PostgresCancelContext>
accepts_invalid_certs: postgres_url.accepts_invalid_certs,
verifies_hostname: postgres_url.verifies_hostname,
ssl_mode: pg_config.get_ssl_mode(),
legacy_tls: postgres_url.legacy_tls,
})
}
@@ -2293,12 +2306,19 @@ fn make_rustls_connect_from_context(
Ok(tokio_postgres_rustls::MakeRustlsConnect::new(tls_config))
}
fn make_openssl_connect_from_context(
ctx: &PostgresCancelContext,
) -> Result<postgres_openssl::MakeTlsConnector, String> {
postgres_openssl_connector(&ctx.ssl_files, ctx.accepts_invalid_certs, ctx.verifies_hostname)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PostgresConnectionUrl {
url: String,
ssl_files: PostgresSslFiles,
accepts_invalid_certs: bool,
verifies_hostname: bool,
legacy_tls: bool,
}
/// Inject TCP keepalive parameters into the PostgreSQL URL (only when the user has not explicitly specified them).
@@ -2331,6 +2351,7 @@ fn postgres_connection_url(url: &str) -> Result<PostgresConnectionUrl, String> {
ssl_files: PostgresSslFiles::default(),
accepts_invalid_certs: postgres_sslmode_accepts_invalid_certs(pg_config.get_ssl_mode()),
verifies_hostname: false,
legacy_tls: false,
});
};
@@ -2341,6 +2362,7 @@ fn postgres_connection_url(url: &str) -> Result<PostgresConnectionUrl, String> {
let mut kept_params = Vec::new();
let mut accepts_invalid_certs = true;
let mut verifies_hostname = false;
let mut legacy_tls = false;
for param in query_string.split('&') {
if param.is_empty() {
@@ -2352,7 +2374,9 @@ fn postgres_connection_url(url: &str) -> Result<PostgresConnectionUrl, String> {
continue;
};
if key.eq_ignore_ascii_case("sslcert")
if key.eq_ignore_ascii_case("legacy_tls") {
legacy_tls = matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on");
} else if key.eq_ignore_ascii_case("sslcert")
|| key.eq_ignore_ascii_case("sslkey")
|| key.eq_ignore_ascii_case("sslrootcert")
{
@@ -2414,7 +2438,49 @@ fn postgres_connection_url(url: &str) -> Result<PostgresConnectionUrl, String> {
sanitized_url.push_str(fragment);
}
Ok(PostgresConnectionUrl { url: sanitized_url, ssl_files, accepts_invalid_certs, verifies_hostname })
Ok(PostgresConnectionUrl { url: sanitized_url, ssl_files, accepts_invalid_certs, verifies_hostname, legacy_tls })
}
fn postgres_openssl_connector(
ssl_files: &PostgresSslFiles,
accepts_invalid_certs: bool,
verifies_hostname: bool,
) -> Result<postgres_openssl::MakeTlsConnector, String> {
let mut builder = SslConnector::builder(SslMethod::tls_client())
.map_err(|e| format!("Failed to initialize PostgreSQL legacy TLS: {e}"))?;
if accepts_invalid_certs {
builder.set_verify(SslVerifyMode::NONE);
} else {
builder.set_verify(SslVerifyMode::PEER);
if let Some(path) = ssl_files.sslrootcert.as_deref() {
builder
.set_ca_file(path)
.map_err(|e| format!("sslrootcert: failed to load CA certificate from {path}: {e}"))?;
}
}
match (ssl_files.sslcert.as_deref(), ssl_files.sslkey.as_deref()) {
(Some(cert_path), Some(key_path)) => {
builder
.set_certificate_chain_file(cert_path)
.map_err(|e| format!("sslcert: failed to load certificate from {cert_path}: {e}"))?;
builder
.set_private_key_file(key_path, SslFiletype::PEM)
.map_err(|e| format!("sslkey: failed to load private key from {key_path}: {e}"))?;
builder.check_private_key().map_err(|e| format!("sslkey: private key does not match sslcert: {e}"))?;
}
(Some(_), None) => return Err("PostgreSQL sslcert requires sslkey".to_string()),
(None, Some(_)) => return Err("PostgreSQL sslkey requires sslcert".to_string()),
(None, None) => {}
}
let mut connector = postgres_openssl::MakeTlsConnector::new(builder.build());
connector.set_callback(move |config, _| {
config.set_verify_hostname(verifies_hostname);
Ok(())
});
Ok(connector)
}
fn postgres_tls_config(
@@ -6337,8 +6403,25 @@ async fn cancel_postgres_query(
) {
let cancel_timeout = postgres_cancel_attempt_timeout(cancel_timeout, cancel_context);
if let Some(ctx) = cancel_context {
match make_rustls_connect_from_context(ctx) {
Ok(tls) => match tokio::time::timeout(cancel_timeout, pg_cancel_token.cancel_query(tls)).await {
let tls_result = if ctx.legacy_tls {
match make_openssl_connect_from_context(ctx) {
Ok(tls) => Some(tokio::time::timeout(cancel_timeout, pg_cancel_token.cancel_query(tls)).await),
Err(err) => {
log::warn!("Failed to build legacy TLS connector for cancel: {err}; falling back to NoTls cancel");
None
}
}
} else {
match make_rustls_connect_from_context(ctx) {
Ok(tls) => Some(tokio::time::timeout(cancel_timeout, pg_cancel_token.cancel_query(tls)).await),
Err(err) => {
log::warn!("Failed to build TLS connector for cancel: {err}; falling back to NoTls cancel");
None
}
}
};
if let Some(result) = tls_result {
match result {
Ok(Ok(())) => return,
Ok(Err(err)) => {
log::warn!("Failed to send PostgreSQL TLS cancel request: {err}");
@@ -6352,9 +6435,6 @@ async fn cancel_postgres_query(
return;
}
}
},
Err(err) => {
log::warn!("Failed to build TLS connector for cancel: {err}; falling back to NoTls cancel");
}
}
}
@@ -7231,7 +7311,16 @@ pub async fn copy_in(pool: &Pool, sql: &str, data: &[u8]) -> Result<(), String>
#[cfg(test)]
mod tests {
use super::*;
use openssl::asn1::Asn1Time;
use openssl::bn::{BigNum, MsbOption};
use openssl::hash::MessageDigest;
use openssl::pkey::PKey;
use openssl::rsa::Rsa;
use openssl::ssl::{Ssl, SslAcceptor, SslMethod, SslVersion};
use openssl::x509::extension::{BasicConstraints, KeyUsage, SubjectAlternativeName};
use openssl::x509::{X509NameBuilder, X509};
use std::cell::Cell;
use std::pin::Pin;
use std::process::Command;
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -7283,6 +7372,125 @@ mod tests {
plain_socket.write_all(&postgres_error_response("identity probe unavailable")).await.unwrap();
}
fn tls12_rsa_certificate_acceptor(cipher_list: &str) -> (SslAcceptor, Vec<u8>) {
let key = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap();
let mut name = X509NameBuilder::new().unwrap();
name.append_entry_by_text("CN", "localhost").unwrap();
let name = name.build();
let mut serial = BigNum::new().unwrap();
serial.rand(64, MsbOption::MAYBE_ZERO, false).unwrap();
let serial = serial.to_asn1_integer().unwrap();
let mut certificate = X509::builder().unwrap();
certificate.set_version(2).unwrap();
certificate.set_serial_number(&serial).unwrap();
certificate.set_subject_name(&name).unwrap();
certificate.set_issuer_name(&name).unwrap();
certificate.set_pubkey(&key).unwrap();
certificate.set_not_before(Asn1Time::days_from_now(0).unwrap().as_ref()).unwrap();
certificate.set_not_after(Asn1Time::days_from_now(1).unwrap().as_ref()).unwrap();
certificate.append_extension(BasicConstraints::new().critical().ca().build().unwrap()).unwrap();
certificate
.append_extension(KeyUsage::new().digital_signature().key_encipherment().key_cert_sign().build().unwrap())
.unwrap();
let subject_alt_name =
SubjectAlternativeName::new().dns("localhost").build(&certificate.x509v3_context(None, None)).unwrap();
certificate.append_extension(subject_alt_name).unwrap();
certificate.sign(&key, MessageDigest::sha256()).unwrap();
let certificate = certificate.build();
let certificate_pem = certificate.to_pem().unwrap();
let mut acceptor = SslAcceptor::mozilla_intermediate(SslMethod::tls_server()).unwrap();
acceptor.set_min_proto_version(Some(SslVersion::TLS1_2)).unwrap();
acceptor.set_max_proto_version(Some(SslVersion::TLS1_2)).unwrap();
acceptor.set_cipher_list(cipher_list).unwrap();
acceptor.set_private_key(&key).unwrap();
acceptor.set_certificate(&certificate).unwrap();
acceptor.check_private_key().unwrap();
(acceptor.build(), certificate_pem)
}
async fn accept_tls12_with_acceptor(
mut socket: tokio::net::TcpStream,
acceptor: &SslAcceptor,
) -> Result<tokio_openssl::SslStream<tokio::net::TcpStream>, openssl::ssl::Error> {
let mut ssl_request = [0_u8; 8];
socket.read_exact(&mut ssl_request).await.unwrap();
assert_eq!(ssl_request, [0, 0, 0, 8, 4, 210, 22, 47]);
socket.write_all(b"S").await.unwrap();
let ssl = Ssl::new(acceptor.context()).unwrap();
let mut tls_socket = tokio_openssl::SslStream::new(ssl, socket).unwrap();
Pin::new(&mut tls_socket).accept().await?;
Ok(tls_socket)
}
async fn accept_tls12(
socket: tokio::net::TcpStream,
cipher_list: &str,
) -> Result<tokio_openssl::SslStream<tokio::net::TcpStream>, openssl::ssl::Error> {
let (acceptor, _) = tls12_rsa_certificate_acceptor(cipher_list);
accept_tls12_with_acceptor(socket, &acceptor).await
}
async fn authenticate_tls12_postgres(tls_socket: &mut tokio_openssl::SslStream<tokio::net::TcpStream>) {
let startup_len = tls_socket.read_u32().await.unwrap();
assert!(startup_len >= 8);
let mut startup = vec![0_u8; startup_len as usize - 4];
tls_socket.read_exact(&mut startup).await.unwrap();
assert_eq!(&startup[..4], &[0, 3, 0, 0]);
tls_socket
.write_all(&[
b'R', 0, 0, 0, 8, 0, 0, 0, 0, // AuthenticationOk
b'K', 0, 0, 0, 12, 0, 0, 0, 1, 0, 0, 0, 2, // BackendKeyData
b'Z', 0, 0, 0, 5, b'I', // ReadyForQuery
])
.await
.unwrap();
let mut request = [0_u8; 1024];
let read = tls_socket.read(&mut request).await.unwrap();
assert!(read > 0, "client should issue the best-effort identity query");
tls_socket.write_all(&postgres_error_response("identity probe unavailable")).await.unwrap();
}
async fn serve_tls12_postgres(listener: TcpListener, cipher_list: &'static str, expect_handshake: bool) {
let (socket, _) = listener.accept().await.unwrap();
let handshake = accept_tls12(socket, cipher_list).await;
if !expect_handshake {
assert!(handshake.is_err(), "rustls unexpectedly negotiated a static RSA cipher suite");
return;
}
let mut tls_socket = handshake.unwrap();
authenticate_tls12_postgres(&mut tls_socket).await;
}
async fn serve_tls12_postgres_with_acceptor(listener: TcpListener, acceptor: SslAcceptor, expect_handshake: bool) {
let (socket, _) = listener.accept().await.unwrap();
let handshake = accept_tls12_with_acceptor(socket, &acceptor).await;
if !expect_handshake {
assert!(handshake.is_err(), "TLS handshake unexpectedly succeeded");
return;
}
let mut tls_socket = handshake.unwrap();
authenticate_tls12_postgres(&mut tls_socket).await;
}
async fn serve_static_rsa_postgres_with_cancel(listener: TcpListener) {
let (main_socket, _) = listener.accept().await.unwrap();
let mut main_tls = accept_tls12(main_socket, "AES128-GCM-SHA256").await.unwrap();
authenticate_tls12_postgres(&mut main_tls).await;
let (cancel_socket, _) = listener.accept().await.unwrap();
let mut cancel_tls = accept_tls12(cancel_socket, "AES128-GCM-SHA256").await.unwrap();
let mut cancel_request = [0_u8; 16];
cancel_tls.read_exact(&mut cancel_request).await.unwrap();
assert_eq!(u32::from_be_bytes(cancel_request[0..4].try_into().unwrap()), 16);
assert_eq!(u32::from_be_bytes(cancel_request[4..8].try_into().unwrap()), 80_877_102);
assert_eq!(u32::from_be_bytes(cancel_request[8..12].try_into().unwrap()), 1);
assert_eq!(u32::from_be_bytes(cancel_request[12..16].try_into().unwrap()), 2);
}
#[tokio::test]
async fn postgres_prefer_retries_plaintext_after_tls_handshake_failure() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -7295,6 +7503,104 @@ mod tests {
server.await.unwrap();
}
#[tokio::test]
async fn postgres_default_tls_rejects_static_rsa_key_exchange() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(serve_tls12_postgres(listener, "AES128-GCM-SHA256", false));
let url = format!("postgres://postgres@127.0.0.1:{port}/postgres?sslmode=require");
let error = connect_with_optional_local_timezone(&url, Duration::from_secs(2), None)
.await
.expect_err("rustls must not negotiate static RSA key exchange");
assert!(error.to_ascii_lowercase().contains("tls"), "{error}");
server.await.unwrap();
}
#[tokio::test]
async fn postgres_legacy_tls_connects_with_static_rsa_key_exchange() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(serve_tls12_postgres(listener, "AES128-GCM-SHA256", true));
let url = format!("postgres://postgres@127.0.0.1:{port}/postgres?sslmode=require&legacy_tls=true");
let result = connect_with_optional_local_timezone(&url, Duration::from_secs(2), None).await;
assert!(result.is_ok(), "legacy TLS should negotiate static RSA: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn postgres_legacy_tls_verify_ca_uses_configured_root_without_hostname_check() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (acceptor, certificate_pem) = tls12_rsa_certificate_acceptor("AES128-GCM-SHA256");
let root_dir = tempfile::tempdir().unwrap();
let root_path = root_dir.path().join("root.pem");
std::fs::write(&root_path, certificate_pem).unwrap();
let root_path_text = root_path.to_string_lossy();
let encoded_root =
percent_encoding::utf8_percent_encode(root_path_text.as_ref(), percent_encoding::NON_ALPHANUMERIC);
let server = tokio::spawn(serve_tls12_postgres_with_acceptor(listener, acceptor, true));
let url = format!(
"postgres://postgres@127.0.0.1:{port}/postgres?sslmode=verify-ca&legacy_tls=true&sslrootcert={encoded_root}"
);
let result = connect_with_optional_local_timezone(&url, Duration::from_secs(2), None).await;
assert!(result.is_ok(), "verify-ca should trust the configured root without checking the host: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn postgres_legacy_tls_verify_full_rejects_hostname_mismatch() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (acceptor, certificate_pem) = tls12_rsa_certificate_acceptor("AES128-GCM-SHA256");
let root_dir = tempfile::tempdir().unwrap();
let root_path = root_dir.path().join("root.pem");
std::fs::write(&root_path, certificate_pem).unwrap();
let root_path_text = root_path.to_string_lossy();
let encoded_root =
percent_encoding::utf8_percent_encode(root_path_text.as_ref(), percent_encoding::NON_ALPHANUMERIC);
let server = tokio::spawn(serve_tls12_postgres_with_acceptor(listener, acceptor, false));
let url = format!(
"postgres://postgres@127.0.0.1:{port}/postgres?sslmode=verify-full&legacy_tls=true&sslrootcert={encoded_root}"
);
let error = connect_with_optional_local_timezone(&url, Duration::from_secs(2), None)
.await
.expect_err("verify-full must reject a certificate for another host");
assert!(error.to_ascii_lowercase().contains("certificate"), "{error}");
server.await.unwrap();
}
#[tokio::test]
async fn postgres_default_tls_still_connects_with_ecdhe_rsa() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(serve_tls12_postgres(listener, "ECDHE-RSA-AES128-GCM-SHA256", true));
let url = format!("postgres://postgres@127.0.0.1:{port}/postgres?sslmode=require");
let result = connect_with_optional_local_timezone(&url, Duration::from_secs(2), None).await;
assert!(result.is_ok(), "default TLS should keep negotiating ECDHE-RSA: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn postgres_legacy_tls_cancel_uses_static_rsa_connector() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(serve_static_rsa_postgres_with_cancel(listener));
let url = format!("postgres://postgres@127.0.0.1:{port}/postgres?sslmode=require&legacy_tls=true");
let pool = connect_with_optional_local_timezone(&url, Duration::from_secs(2), None).await.unwrap();
let client = pool.get().await.unwrap();
let context = build_postgres_cancel_context(&url).unwrap();
assert!(context.legacy_tls);
cancel_postgres_query(client.cancel_token(), Some(&context), Duration::from_secs(2)).await;
server.await.unwrap();
}
fn pg_array_binary(element_oid: u32, elements: &[Option<Vec<u8>>]) -> Vec<u8> {
let mut raw = Vec::new();
raw.extend_from_slice(&1_i32.to_be_bytes());
@@ -9038,6 +9344,21 @@ mod tests {
assert!(!parsed.verifies_hostname);
}
#[test]
fn postgres_connection_url_extracts_legacy_tls_before_driver_parse() {
let parsed =
postgres_connection_url("postgres://localhost/db?sslmode=require&legacy_tls=true&application_name=dbx")
.unwrap();
assert_eq!(parsed.url, "postgres://localhost/db?sslmode=require&application_name=dbx");
assert!(parsed.legacy_tls);
tokio_postgres::Config::from_str(&parsed.url).unwrap();
let parsed = postgres_connection_url("postgres://localhost/db?legacy_tls=false").unwrap();
assert_eq!(parsed.url, "postgres://localhost/db");
assert!(!parsed.legacy_tls);
}
#[test]
fn postgres_connection_url_normalizes_channel_binding_require_to_prefer() {
let parsed =
@@ -9187,6 +9508,7 @@ mod tests {
accepts_invalid_certs: true,
verifies_hostname: false,
ssl_mode: SslMode::Require,
legacy_tls: false,
})
),
Duration::from_secs(5)