From 74df10851c78ebd54af277b852522d2853979ec5 Mon Sep 17 00:00:00 2001 From: Andy Paine Date: Thu, 9 Jul 2026 19:25:25 +0100 Subject: [PATCH] feat(tls): present client certificate to upstream servers for mTLS PgDog could validate an upstream Postgres server's certificate (`tls_verify` + `tls_server_ca_certificate`) but had no way to present its own client certificate on that connection, so it could not satisfy servers that require mutual TLS (e.g. Cloud SQL with "require trusted client certificate", or any Postgres using `clientcert=verify-full`). Add two settings, mirroring the existing `tls_server_ca_certificate` naming for the upstream leg: - tls_server_certificate: client cert PgDog presents to the server - tls_server_private_key: its private key The certificate is loaded as a full chain (leaf + intermediates) and attached to the client config across every `tls_verify` mode. Setting only one of the two is rejected at load time so misconfigurations surface on reload rather than at connection time. Closes #890 Co-Authored-By: Claude Opus 4.8 --- .schema/pgdog.schema.json | 18 +++- example.pgdog.toml | 6 ++ pgdog-config/src/core.rs | 38 ++++++++ pgdog-config/src/general.rs | 22 +++++ pgdog/src/backend/server.rs | 2 + pgdog/src/net/tls.rs | 188 ++++++++++++++++++++++++++++++------ 6 files changed, 244 insertions(+), 30 deletions(-) diff --git a/.schema/pgdog.schema.json b/.schema/pgdog.schema.json index d689adbb4..77089841c 100644 --- a/.schema/pgdog.schema.json +++ b/.schema/pgdog.schema.json @@ -114,6 +114,8 @@ "tls_client_required": false, "tls_private_key": null, "tls_server_ca_certificate": null, + "tls_server_certificate": null, + "tls_server_private_key": null, "tls_verify": "prefer", "two_phase_commit": false, "two_phase_commit_auto": null, @@ -189,7 +191,7 @@ "$ref": "#/$defs/Plugin" } }, - "query_parser": { + "query_parsers": { "description": "Query parser levels per-database.", "type": "array", "default": [], @@ -1181,6 +1183,20 @@ "null" ] }, + "tls_server_certificate": { + "description": "Path to the TLS certificate PgDog presents to upstream Postgres servers,\nenabling mutual TLS (client authentication) on the server connection.\n\nhttps://docs.pgdog.dev/configuration/pgdog.toml/general/#tls_server_certificate", + "type": [ + "string", + "null" + ] + }, + "tls_server_private_key": { + "description": "Path to the TLS private key for the certificate configured in\n`tls_server_certificate`.\n\nhttps://docs.pgdog.dev/configuration/pgdog.toml/general/#tls_server_private_key", + "type": [ + "string", + "null" + ] + }, "tls_verify": { "description": "How to handle TLS connections to Postgres servers.\n\n_Default:_ `prefer`\n\nhttps://docs.pgdog.dev/configuration/pgdog.toml/general/#tls_verify", "$ref": "#/$defs/TlsVerifyMode", diff --git a/example.pgdog.toml b/example.pgdog.toml index a3c427d9e..a4dcf3d18 100644 --- a/example.pgdog.toml +++ b/example.pgdog.toml @@ -124,6 +124,12 @@ tls_verify = "disabled" # Path to PEM-encoded certificate bundle to use for Postgres server # certificate validation. # tls_server_ca_certificate = "relative/or/absolute/path/to/certificate.pem" +# Path to PEM-encoded TLS certificate PgDog presents to Postgres servers for +# mutual TLS (client authentication on the server connection). Requires +# tls_server_private_key to also be set. +# tls_server_certificate = "relative/or/absolute/path/to/certificate.pem" +# Path to the PEM-encoded private key for tls_server_certificate. +# tls_server_private_key = "relative/or/absolute/path/to/private_key.pem" # How long to wait for active connections to finish transactions # when shutting down PgDog. # diff --git a/pgdog-config/src/core.rs b/pgdog-config/src/core.rs index 1c4e683ef..75a58626d 100644 --- a/pgdog-config/src/core.rs +++ b/pgdog-config/src/core.rs @@ -744,6 +744,44 @@ column = "tenant_id" assert_eq!(config.multi_tenant.unwrap().column, "tenant_id"); } + #[test] + fn test_upstream_client_cert_config_parses() { + let source = r#" +[general] +tls_verify = "verify_full" +tls_server_ca_certificate = "/certs/ca.pem" +tls_server_certificate = "/certs/client.pem" +tls_server_private_key = "/certs/client.key" + +[[databases]] +name = "production" +host = "127.0.0.1" +database_name = "postgres" +"#; + + let config: Config = toml::from_str(source).unwrap(); + assert_eq!(config.general.tls_verify, TlsVerifyMode::VerifyFull); + assert_eq!( + config.general.tls_server_certificate.as_deref(), + Some(std::path::Path::new("/certs/client.pem")) + ); + assert_eq!( + config.general.tls_server_private_key.as_deref(), + Some(std::path::Path::new("/certs/client.key")) + ); + + // Both keys are optional: omitting them leaves the fields unset. + let without = r#" +[[databases]] +name = "production" +host = "127.0.0.1" +database_name = "postgres" +"#; + let config: Config = toml::from_str(without).unwrap(); + assert!(config.general.tls_server_certificate.is_none()); + assert!(config.general.tls_server_private_key.is_none()); + } + #[test] fn test_prepared_statements_disabled_in_session_mode() { let mut config = ConfigAndUsers::default(); diff --git a/pgdog-config/src/general.rs b/pgdog-config/src/general.rs index ee0c8d307..07a2305cb 100644 --- a/pgdog-config/src/general.rs +++ b/pgdog-config/src/general.rs @@ -252,6 +252,18 @@ pub struct General { /// https://docs.pgdog.dev/configuration/pgdog.toml/general/#tls_server_ca_certificate pub tls_server_ca_certificate: Option, + /// Path to the TLS certificate PgDog presents to upstream Postgres servers, + /// enabling mutual TLS (client authentication) on the server connection. + /// + /// https://docs.pgdog.dev/configuration/pgdog.toml/general/#tls_server_certificate + pub tls_server_certificate: Option, + + /// Path to the TLS private key for the certificate configured in + /// `tls_server_certificate`. + /// + /// https://docs.pgdog.dev/configuration/pgdog.toml/general/#tls_server_private_key + pub tls_server_private_key: Option, + /// Path to a certificate bundle used to validate the client certificate on TLS connection creation. /// /// https://docs.pgdog.dev/configuration/pgdog.toml/general/#tls_client_ca_certificate @@ -834,6 +846,8 @@ impl Default for General { tls_client_required: bool::default(), tls_verify: Self::default_tls_verify(), tls_server_ca_certificate: Self::tls_server_ca_certificate(), + tls_server_certificate: Self::tls_server_certificate(), + tls_server_private_key: Self::tls_server_private_key(), tls_client_ca_certificate: Self::tls_client_ca_certificate(), shutdown_timeout: Self::default_shutdown_timeout(), shutdown_termination_timeout: Self::default_shutdown_termination_timeout(), @@ -1227,6 +1241,14 @@ impl General { Self::env_option_string("PGDOG_TLS_SERVER_CA_CERTIFICATE").map(PathBuf::from) } + fn tls_server_certificate() -> Option { + Self::env_option_string("PGDOG_TLS_SERVER_CERTIFICATE").map(PathBuf::from) + } + + fn tls_server_private_key() -> Option { + Self::env_option_string("PGDOG_TLS_SERVER_PRIVATE_KEY").map(PathBuf::from) + } + fn tls_client_ca_certificate() -> Option { Self::env_option_string("PGDOG_TLS_CLIENT_CA_CERTIFICATE").map(PathBuf::from) } diff --git a/pgdog/src/backend/server.rs b/pgdog/src/backend/server.rs index 3bf9d603f..a778bbb5f 100644 --- a/pgdog/src/backend/server.rs +++ b/pgdog/src/backend/server.rs @@ -188,6 +188,8 @@ impl Server { let connector = connector_with_verify_mode( tls_mode, config.config.general.tls_server_ca_certificate.as_ref(), + config.config.general.tls_server_certificate.as_ref(), + config.config.general.tls_server_private_key.as_ref(), )?; let plain = stream.take()?; diff --git a/pgdog/src/net/tls.rs b/pgdog/src/net/tls.rs index 90f695e76..99ee15262 100644 --- a/pgdog/src/net/tls.rs +++ b/pgdog/src/net/tls.rs @@ -34,13 +34,22 @@ static CONNECTOR: ArcSwapOption = ArcSwapOption::const_empt struct ConnectorConfigKey { mode: TlsVerifyMode, ca_path: Option, + client_cert_path: Option, + client_key_path: Option, } impl ConnectorConfigKey { - fn new(mode: TlsVerifyMode, ca_path: Option<&PathBuf>) -> Self { + fn new( + mode: TlsVerifyMode, + ca_path: Option<&PathBuf>, + client_cert_path: Option<&PathBuf>, + client_key_path: Option<&PathBuf>, + ) -> Self { Self { mode, ca_path: ca_path.cloned(), + client_cert_path: client_cert_path.cloned(), + client_key_path: client_key_path.cloned(), } } } @@ -113,9 +122,12 @@ pub(crate) fn identity_from_certs(certs: &[CertificateDer<'_>]) -> Option Result { let config = config(); + let general = &config.config.general; connector_with_verify_mode( - config.config.general.tls_verify, - config.config.general.tls_server_ca_certificate.as_ref(), + general.tls_verify, + general.tls_server_ca_certificate.as_ref(), + general.tls_server_certificate.as_ref(), + general.tls_server_private_key.as_ref(), ) } @@ -134,10 +146,13 @@ pub fn reload() -> Result<(), Error> { let config = config(); let general = &config.config.general; - // Always validate upstream TLS settings so we surface CA issues early. + // Always validate upstream TLS settings so we surface CA and client + // certificate issues early. let _ = connector_with_verify_mode( general.tls_verify, general.tls_server_ca_certificate.as_ref(), + general.tls_server_certificate.as_ref(), + general.tls_server_private_key.as_ref(), )?; let tls_paths = general.tls(); @@ -287,22 +302,40 @@ fn build_connector(config_key: &ConnectorConfigKey) -> Result, rustls::RootCertStore::empty() }; + // Optional client certificate PgDog presents to the upstream server (mTLS). + let client_auth = match ( + config_key.client_cert_path.as_deref(), + config_key.client_key_path.as_deref(), + ) { + (Some(cert), Some(key)) => Some((cert, key)), + (None, None) => None, + _ => { + return Err(invalid_data( + "tls_server_certificate and tls_server_private_key must both be set to present a client certificate to upstream servers", + )); + } + }; + let config = match config_key.mode { - TlsVerifyMode::Disabled => ClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth(), + TlsVerifyMode::Disabled => build_client_config( + ClientConfig::builder().with_root_certificates(roots), + client_auth, + )?, TlsVerifyMode::Prefer => { let verifier = AllowAllVerifier; - ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(verifier)) - .with_no_client_auth() + build_client_config( + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)), + client_auth, + )? } TlsVerifyMode::VerifyCa => { let verifier = NoHostnameVerifier::new(roots.clone()); - let mut config = ClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth(); + let mut config = build_client_config( + ClientConfig::builder().with_root_certificates(roots), + client_auth, + )?; config .dangerous() @@ -310,9 +343,10 @@ fn build_connector(config_key: &ConnectorConfigKey) -> Result, config } - TlsVerifyMode::VerifyFull => ClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth(), + TlsVerifyMode::VerifyFull => build_client_config( + ClientConfig::builder().with_root_certificates(roots), + client_auth, + )?, }; increment_connector_build_count(); @@ -320,6 +354,24 @@ fn build_connector(config_key: &ConnectorConfigKey) -> Result, Ok(Arc::new(config)) } +/// Build the client TLS config, attaching the client certificate PgDog presents +/// to upstream servers for mTLS when one is configured. +fn build_client_config( + builder: rustls::ConfigBuilder, + client_auth: Option<(&Path, &Path)>, +) -> Result { + match client_auth { + // Load the leaf certificate and key the same way `build_acceptor` + // loads PgDog's own server certificate. + Some((cert_path, key_path)) => { + let cert = CertificateDer::from_pem_file(cert_path)?; + let key = PrivateKeyDer::from_pem_file(key_path)?; + Ok(builder.with_client_auth_cert(vec![cert], key)?) + } + None => Ok(builder.with_no_client_auth()), + } +} + #[cfg_attr(not(test), allow(dead_code))] #[doc(hidden)] pub fn test_acceptor_build_count() -> usize { @@ -403,8 +455,10 @@ impl ServerCertVerifier for AllowAllVerifier { pub fn connector_with_verify_mode( mode: TlsVerifyMode, ca_cert_path: Option<&PathBuf>, + client_cert_path: Option<&PathBuf>, + client_key_path: Option<&PathBuf>, ) -> Result { - let config_key = ConnectorConfigKey::new(mode, ca_cert_path); + let config_key = ConnectorConfigKey::new(mode, ca_cert_path, client_cert_path, client_key_path); if let Some(entry) = CONNECTOR.load_full() && entry.key == config_key @@ -584,9 +638,9 @@ mod tests { async fn test_connector_with_verify_mode() { crate::logger(); - let prefer = connector_with_verify_mode(TlsVerifyMode::Prefer, None); - let certificate = connector_with_verify_mode(TlsVerifyMode::VerifyCa, None); - let full = connector_with_verify_mode(TlsVerifyMode::VerifyFull, None); + let prefer = connector_with_verify_mode(TlsVerifyMode::Prefer, None, None, None); + let certificate = connector_with_verify_mode(TlsVerifyMode::VerifyCa, None, None, None); + let full = connector_with_verify_mode(TlsVerifyMode::VerifyFull, None, None, None); // All should succeed assert!(prefer.is_ok()); @@ -613,14 +667,24 @@ mod tests { crate::config::set(cfg).unwrap(); - let _first = super::connector_with_verify_mode(TlsVerifyMode::VerifyFull, Some(&ca_path)) - .expect("first connector builds"); + let _first = super::connector_with_verify_mode( + TlsVerifyMode::VerifyFull, + Some(&ca_path), + None, + None, + ) + .expect("first connector builds"); let first_cache = super::CONNECTOR .load_full() .expect("connector cached after first build"); - let _second = super::connector_with_verify_mode(TlsVerifyMode::VerifyFull, Some(&ca_path)) - .expect("second connector reuses cache"); + let _second = super::connector_with_verify_mode( + TlsVerifyMode::VerifyFull, + Some(&ca_path), + None, + None, + ) + .expect("second connector reuses cache"); let second_cache = super::CONNECTOR .load_full() .expect("connector cached after second build"); @@ -659,8 +723,13 @@ mod tests { "reload retains client config" ); - let _third = super::connector_with_verify_mode(TlsVerifyMode::VerifyFull, Some(&ca_path)) - .expect("third connector still reuses cache"); + let _third = super::connector_with_verify_mode( + TlsVerifyMode::VerifyFull, + Some(&ca_path), + None, + None, + ) + .expect("third connector still reuses cache"); let third_cache = super::CONNECTOR .load_full() .expect("connector cached after third build"); @@ -685,7 +754,8 @@ mod tests { crate::logger(); let bad_ca_path = PathBuf::from("/tmp/test_ca.pem"); - let result = connector_with_verify_mode(TlsVerifyMode::VerifyFull, Some(&bad_ca_path)); + let result = + connector_with_verify_mode(TlsVerifyMode::VerifyFull, Some(&bad_ca_path), None, None); // This should fail because the file doesn't exist assert!(result.is_err(), "Should fail with non-existent cert file"); @@ -701,11 +771,71 @@ mod tests { // check that the file exists assert!(good_ca_path.exists(), "Test CA file should exist"); - let result = connector_with_verify_mode(TlsVerifyMode::VerifyFull, Some(&good_ca_path)); + let result = + connector_with_verify_mode(TlsVerifyMode::VerifyFull, Some(&good_ca_path), None, None); assert!(result.is_ok(), "Should succeed with valid cert file"); } + #[tokio::test] + async fn connector_requires_both_client_cert_and_key() { + crate::logger(); + super::test_reset_connector(); + + let cert = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/tls/cert.pem"); + let key = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/tls/key.pem"); + + // Certificate without a key is a misconfiguration and must be rejected. + let cert_only = + connector_with_verify_mode(TlsVerifyMode::VerifyFull, None, Some(&cert), None); + assert!( + cert_only.is_err(), + "client certificate without a private key should fail" + ); + + // ...and so is a key without a certificate. + let key_only = + connector_with_verify_mode(TlsVerifyMode::VerifyFull, None, None, Some(&key)); + assert!( + key_only.is_err(), + "client private key without a certificate should fail" + ); + + super::test_reset_connector(); + } + + #[tokio::test] + async fn client_cert_is_part_of_connector_cache_key() { + crate::logger(); + super::test_reset_connector(); + + let cert = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/tls/cert.pem"); + let key = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/tls/key.pem"); + + // First build without a client cert. + connector_with_verify_mode(TlsVerifyMode::Prefer, None, None, None).unwrap(); + assert_eq!(super::test_connector_build_count(), 1); + + // Adding a client cert must rebuild rather than serve the cached + // no-client-auth connector. + connector_with_verify_mode(TlsVerifyMode::Prefer, None, Some(&cert), Some(&key)).unwrap(); + assert_eq!( + super::test_connector_build_count(), + 2, + "adding a client certificate rebuilds the connector" + ); + + // Same inputs reuse the cache. + connector_with_verify_mode(TlsVerifyMode::Prefer, None, Some(&cert), Some(&key)).unwrap(); + assert_eq!( + super::test_connector_build_count(), + 2, + "identical client cert reuses the cached connector" + ); + + super::test_reset_connector(); + } + #[test] fn identity_from_test_cert() { let pem = include_str!("../../tests/tls/cert.pem");