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");