diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 2fce23a8..5484db12 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -758,8 +758,8 @@ jobs: schema-check > parity-evidence/tls-wrong-hostname.txt 2>&1; then exit 1 fi - grep -Fq storage_unavailable parity-evidence/tls-untrusted.txt - grep -Fq storage_unavailable parity-evidence/tls-wrong-hostname.txt + grep -Fq database_tls_connection_failed parity-evidence/tls-untrusted.txt + grep -Fq database_tls_connection_failed parity-evidence/tls-wrong-hostname.txt - name: Collect logs and clean owned resources if: always() run: | @@ -1299,8 +1299,8 @@ jobs: schema-check > parity-evidence/tls-wrong-hostname.txt 2>&1; then exit 1 fi - grep -Fq storage_unavailable parity-evidence/tls-untrusted.txt - grep -Fq storage_unavailable parity-evidence/tls-wrong-hostname.txt + grep -Fq database_tls_connection_failed parity-evidence/tls-untrusted.txt + grep -Fq database_tls_connection_failed parity-evidence/tls-wrong-hostname.txt - name: Collect logs and clean owned resources if: always() run: | diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 0600015c..e57434eb 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -229,6 +229,39 @@ support is not evidence of multi-node safety. Newest checkpoints first. Each entry is exact to the source, tree and runs it names, including unsuccessful observations. +October 20, database TLS settings (PHP Server 2.9.10, #545): + +- **Source.** Native resolves PostgreSQL, MySQL and MariaDB TLS with + `database_tls`, the rules of PHP's `DatabaseTlsConfiguration`: + - `DB_SSLMODE`/`DB_SSL_MODE` and `DB_SSLROOTCERT`/`DB_SSL_ROOT_CERT`, plus a + `DB_URL`'s `sslmode`/`sslrootcert`; + - MySQL's mode names (`required`, `verify_identity`); + - MySQL's legacy `MYSQL_ATTR_SSL_CA`. + + Refusals use PHP's messages, with reason `database_tls_configuration_invalid`: + - conflicting aliases; + - a URL that contradicts `DB_SSLMODE` or `DB_SSLROOTCERT`; + - unknown modes, and MySQL modes other than disable, require and + verify-full; + - verification without a readable CA bundle; + - a CA without a mode; + - required TLS over a Unix socket. +- **Behavior change.** Native used to ignore `DB_SSLMODE` and + `DB_SSLROOTCERT` when `DB_URL` was set. TLS now always comes from the + resolved settings, and TLS spellings that only SQLx reads are removed from + the URL first. +- **Startup.** Native refuses to start on an invalid configuration. When a + connection that required TLS fails, it refuses with + `database_tls_connection_failed`. PHP keeps serving and reports both as an + unavailable readiness check. Readiness carries PHP's `tls` snapshot when the + database is reachable, scoped `rust_to_configured_database_endpoint`. +- **Known difference.** With no configured mode, native MySQL uses TLS when + the server offers it. PHP connects without TLS options. Both report the mode + as unset. +- **Qualification.** `scripts/conformance/native-database-tls.sh` runs PHP's + TLS cases against the same fixtures, with the database side confirming the + transport. + October 20, lost workflow task claims: - **Found by.** The [multi-node runs](../benchmarks/server-port/multinode/results-2026-10-20.md). diff --git a/rust/src/database_tls.rs b/rust/src/database_tls.rs new file mode 100644 index 00000000..13c3a957 --- /dev/null +++ b/rust/src/database_tls.rs @@ -0,0 +1,743 @@ +//! Database TLS settings, resolved as published PHP Server resolves them +//! (`DatabaseTlsConfiguration` since 2.9.10), so one configuration means the +//! same thing to both servers: the same inputs, aliases, mode names and +//! refusals. The resolved settings then replace any TLS parameters in +//! `DB_URL`, so a URL spelling that only SQLx reads cannot change them. +use serde_json::{Value, json}; +use std::sync::OnceLock; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Driver { + Postgres, + /// MySQL and MariaDB. + MySql, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Disable, + Allow, + Prefer, + Require, + VerifyCa, + VerifyFull, +} + +impl Mode { + pub fn as_str(self) -> &'static str { + match self { + Self::Disable => "disable", + Self::Allow => "allow", + Self::Prefer => "prefer", + Self::Require => "require", + Self::VerifyCa => "verify-ca", + Self::VerifyFull => "verify-full", + } + } + + fn required(self) -> bool { + matches!(self, Self::Require | Self::VerifyCa | Self::VerifyFull) + } +} + +/// The resolved TLS mode and CA bundle for one connection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Settings { + pub driver: Driver, + pub mode: Option, + pub root_cert: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("{reason}: {message}")] +pub struct Refused { + pub reason: &'static str, + pub message: &'static str, +} + +const INVALID: &str = "database_tls_configuration_invalid"; +pub const CONNECTION_FAILED: Refused = Refused { + reason: "database_tls_connection_failed", + message: "The required database TLS connection could not be established. Check the endpoint, CA bundle, certificate hostname and credentials.", +}; + +fn invalid(message: &'static str) -> Refused { + Refused { + reason: INVALID, + message, + } +} + +/// The TLS keys PHP reads from a database URL's query string. SQLx also reads +/// other spellings, which PHP ignores, so they are removed before parsing. +const URL_TLS_KEYS: [&str; 6] = [ + "sslmode", + "ssl-mode", + "sslrootcert", + "ssl-root-cert", + "sslca", + "ssl-ca", +]; + +/// A database URL split into the parts the TLS rules read. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct UrlParts { + pub without_tls: String, + pub sslmode: Option, + pub sslrootcert: Option, + pub host: Option, + pub socket: Option, +} + +fn decode(text: &str) -> String { + form_urlencoded::parse(format!("x={text}").as_bytes()) + .next() + .map(|(_, value)| value.into_owned()) + .unwrap_or_default() +} + +/// Splits a database URL into its TLS inputs and the URL without them. +pub fn url_parts(url: &str) -> UrlParts { + let (base, query) = url.split_once('?').unwrap_or((url, "")); + let mut parts = UrlParts::default(); + let mut kept = form_urlencoded::Serializer::new(String::new()); + let mut any = false; + for (key, value) in form_urlencoded::parse(query.as_bytes()) { + match key.as_ref() { + "sslmode" => parts.sslmode = Some(value.into_owned()), + "sslrootcert" => parts.sslrootcert = Some(value.into_owned()), + key if URL_TLS_KEYS.contains(&key) => {} + key => { + if key == "host" { + parts.host = Some(value.to_string()); + } + if key == "socket" || key == "unix_socket" { + parts.socket = Some(value.to_string()); + } + kept.append_pair(key, &value); + any = true; + } + } + } + if parts.host.is_none() { + let authority = base.split_once("://").map_or("", |(_, rest)| rest); + let authority = authority.split('/').next().unwrap_or_default(); + let host = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + let host = if let Some(bracketed) = host.strip_prefix('[') { + bracketed.split(']').next().unwrap_or_default() + } else { + host.split(':').next().unwrap_or_default() + }; + // Laravel keeps DB_HOST when the URL names no host. + parts.host = Some(decode(host)).filter(|host| !host.is_empty()); + } + parts.without_tls = if any { + format!("{base}?{}", kept.finish()) + } else { + base.to_owned() + }; + parts +} + +/// Laravel's `env()`: an unset variable and the literal `null` are absent; +/// `empty` is an empty string. +fn laravel_env(value: Option) -> Option { + match value?.as_str() { + text if text.eq_ignore_ascii_case("null") || text.eq_ignore_ascii_case("(null)") => None, + text if text.eq_ignore_ascii_case("empty") || text.eq_ignore_ascii_case("(empty)") => { + Some(String::new()) + } + text => Some(text.to_owned()), + } +} + +fn mode_name(value: Option<&str>) -> Option { + let value = value.filter(|value| !value.is_empty())?; + let value = value.trim().to_ascii_lowercase(); + Some( + match value.as_str() { + "disabled" => "disable", + "preferred" => "prefer", + "required" => "require", + "verify_ca" => "verify-ca", + "verify_identity" | "verify-identity" => "verify-full", + other => other, + } + .to_owned(), + ) +} + +fn certificate( + configured: Option<&str>, + alias: Option<&str>, + requested: Option<&str>, +) -> Result, Refused> { + let mut certificate = configured; + if let (Some(alias), Some(current)) = (alias.filter(|alias| !alias.is_empty()), certificate) + && current != alias + { + return Err(invalid( + "DB_SSLROOTCERT and DB_SSL_ROOT_CERT name different CA bundles.", + )); + } + certificate = certificate.or(alias); + if let (Some(requested), Some(current)) = ( + requested.filter(|requested| !requested.is_empty()), + certificate, + ) && current != requested + { + return Err(invalid("The database URL conflicts with DB_SSLROOTCERT.")); + } + let Some(certificate) = certificate.filter(|certificate| !certificate.is_empty()) else { + return Ok(None); + }; + let readable = !certificate.contains([';', '\r', '\n', '\0']) + && std::fs::metadata(certificate).is_ok_and(|metadata| metadata.is_file()) + && std::fs::File::open(certificate).is_ok(); + if !readable { + return Err(invalid( + "The configured database CA bundle must be a readable regular file.", + )); + } + Ok(Some(certificate.to_owned())) +} + +fn require_tcp(driver: Driver, host: Option<&str>, socket: Option<&str>) -> Result<(), Refused> { + let refused = + || invalid("Required database TLS needs an explicit TCP host, not a Unix socket."); + let host = host.unwrap_or_default(); + if host.trim().is_empty() || socket.is_some_and(|socket| !socket.is_empty()) { + return Err(refused()); + } + for endpoint in host.split(',').map(str::trim) { + if endpoint.starts_with('/') + || endpoint.starts_with('@') + || driver == Driver::MySql && endpoint.eq_ignore_ascii_case("localhost") + { + return Err(refused()); + } + } + Ok(()) +} + +/// The inputs PHP's `config/database.php` gathers for one connection. +struct Inputs { + mode: Option, + mode_alias: Option, + mode_requested: Option, + cert: Option, + cert_alias: Option, + cert_requested: Option, + host: Option, + socket: Option, + legacy_ca: Option, +} + +fn inputs(driver: Driver, env: &impl Fn(&str) -> Option, url: Option<&UrlParts>) -> Inputs { + let mode_requested = env("DB_SSLMODE"); + let mode_alias = env("DB_SSL_MODE"); + let mut mode = mode_requested.clone().or_else(|| mode_alias.clone()); + if driver == Driver::Postgres && mode.is_none() { + mode = Some("prefer".into()); + } + let cert_requested = env("DB_SSLROOTCERT"); + let cert_alias = env("DB_SSL_ROOT_CERT"); + let mysql = driver == Driver::MySql; + let mut inputs = Inputs { + mode, + mode_alias, + mode_requested, + cert: cert_requested.clone().or_else(|| cert_alias.clone()), + cert_alias, + cert_requested, + host: env("DB_HOST").or_else(|| Some("127.0.0.1".into())), + socket: mysql.then(|| env("DB_SOCKET")).flatten(), + legacy_ca: mysql + .then(|| env("MYSQL_ATTR_SSL_CA")) + .flatten() + .filter(|legacy| !legacy.is_empty()), + }; + // A URL's values replace the configured ones, as Laravel merges them. + if let Some(url) = url { + inputs.mode = url.sslmode.clone().or(inputs.mode); + inputs.cert = url.sslrootcert.clone().or(inputs.cert); + inputs.host = url.host.clone().or(inputs.host); + inputs.socket = url.socket.clone().or(inputs.socket); + } + inputs +} + +/// Resolves the TLS settings for `driver` from the environment, as PHP's +/// `config/database.php` and `DatabaseTlsConfiguration::configure` do. +pub fn resolve( + driver: Driver, + env: impl Fn(&str) -> Option, +) -> Result<(Settings, Option), Refused> { + let env = |key: &str| laravel_env(env(key)); + let url = env("DB_URL") + .filter(|url| !url.is_empty()) + .map(|url| url_parts(&url)); + let settings = configure(driver, &inputs(driver, &env, url.as_ref()))?; + Ok((settings, url)) +} + +/// `DatabaseTlsConfiguration::configure`. +fn configure(driver: Driver, inputs: &Inputs) -> Result { + let mode = mode_name(inputs.mode.as_deref()); + let alias = mode_name(inputs.mode_alias.as_deref()); + let requested = mode_name(inputs.mode_requested.as_deref()); + if alias.is_some() && mode.is_some() && alias != mode { + return Err(invalid( + "DB_SSLMODE and DB_SSL_MODE request different TLS modes.", + )); + } + if requested.is_some() && mode.is_some() && requested != mode { + return Err(invalid("The database URL conflicts with DB_SSLMODE.")); + } + let root_cert = certificate( + inputs.cert.as_deref(), + inputs.cert_alias.as_deref(), + inputs.cert_requested.as_deref(), + )?; + match mode.or(alias) { + Some(mode) => apply_mode(driver, &mode, root_cert, inputs), + None if root_cert.is_some() => { + Err(invalid("Set DB_SSLMODE=verify-full with DB_SSLROOTCERT.")) + } + None => Ok(Settings { + driver, + mode: None, + root_cert, + }), + } +} + +fn parse_mode(driver: Driver, mode: &str) -> Result { + Ok(match (driver, mode) { + (_, "disable") => Mode::Disable, + (Driver::Postgres, "allow") => Mode::Allow, + (Driver::Postgres, "prefer") => Mode::Prefer, + (_, "require") => Mode::Require, + (Driver::Postgres, "verify-ca") => Mode::VerifyCa, + (_, "verify-full") => Mode::VerifyFull, + _ => { + return Err(invalid( + "The selected database driver does not support the requested TLS mode. MySQL/MariaDB support disable, require and verify-full.", + )); + } + }) +} + +/// `DatabaseTlsConfiguration::applyMode`. +fn apply_mode( + driver: Driver, + mode: &str, + mut root_cert: Option, + inputs: &Inputs, +) -> Result { + let mode = parse_mode(driver, mode)?; + if mode.required() { + require_tcp(driver, inputs.host.as_deref(), inputs.socket.as_deref())?; + } + if root_cert.is_none() && driver == Driver::MySql && mode == Mode::VerifyFull { + root_cert = certificate(inputs.legacy_ca.as_deref(), None, None)?; + } + if matches!(mode, Mode::VerifyCa | Mode::VerifyFull) && root_cert.is_none() { + return Err(invalid( + "Certificate verification requires a readable CA bundle in DB_SSLROOTCERT.", + )); + } + if driver == Driver::MySql { + mysql_options(mode, root_cert.as_deref(), inputs.legacy_ca.as_deref())?; + } + Ok(Settings { + driver, + mode: Some(mode), + root_cert, + }) +} + +/// `DatabaseTlsConfiguration::mysqlOptions`: the legacy PDO CA option must +/// agree, and disabled TLS takes no TLS option. +fn mysql_options(mode: Mode, root_cert: Option<&str>, legacy: Option<&str>) -> Result<(), Refused> { + if root_cert.is_some() && legacy.is_some() && root_cert != legacy { + return Err(invalid( + "DB_SSLROOTCERT and MYSQL_ATTR_SSL_CA name different CA bundles.", + )); + } + if mode == Mode::Disable && (root_cert.is_some() || legacy.is_some()) { + return Err(invalid( + "Disabled database TLS conflicts with configured TLS connection options.", + )); + } + Ok(()) +} + +impl Settings { + pub fn required(&self) -> bool { + self.mode.is_some_and(Mode::required) + } + + pub fn postgres_mode(&self) -> sqlx::postgres::PgSslMode { + use sqlx::postgres::PgSslMode; + match self.mode { + Some(Mode::Disable) => PgSslMode::Disable, + Some(Mode::Allow) => PgSslMode::Allow, + // libpq's default when no mode is configured. + Some(Mode::Prefer) | None => PgSslMode::Prefer, + Some(Mode::Require) => PgSslMode::Require, + Some(Mode::VerifyCa) => PgSslMode::VerifyCa, + Some(Mode::VerifyFull) => PgSslMode::VerifyFull, + } + } + + pub fn mysql_mode(&self) -> sqlx::mysql::MySqlSslMode { + use sqlx::mysql::MySqlSslMode; + match self.mode { + Some(Mode::Disable) => MySqlSslMode::Disabled, + Some(Mode::Require) => MySqlSslMode::Required, + Some(Mode::VerifyFull) => MySqlSslMode::VerifyIdentity, + // Without a configured mode, use TLS when the server offers it. + _ => MySqlSslMode::Preferred, + } + } + + /// The readiness snapshot of what the configuration guarantees, in PHP's + /// shape, for native's own connection. + pub fn snapshot(&self) -> Value { + snapshot(self.mode, true) + } +} + +fn snapshot(mode: Option, network: bool) -> Value { + let required = mode.is_some_and(Mode::required); + let encrypted = if required { + Some(true) + } else if mode == Some(Mode::Disable) { + Some(false) + } else { + None + }; + json!({ + "mode": if network { mode.map(Mode::as_str) } else { None }, + "encrypted": encrypted, + "ca_verified": required.then(|| matches!(mode, Some(Mode::VerifyCa | Mode::VerifyFull))), + "hostname_verified": required.then_some(mode == Some(Mode::VerifyFull)), + "evidence": if required { "successful_driver_enforced_connection" } else { "not_verified" }, + "scope": "rust_to_configured_database_endpoint", + }) +} + +static CURRENT: OnceLock = OnceLock::new(); + +/// Records the serving connection's TLS snapshot for readiness. +pub fn record(settings: &Settings) { + let _ = CURRENT.set(settings.snapshot()); +} + +/// The serving connection's TLS snapshot. SQLite has no network connection. +pub fn current() -> Value { + CURRENT + .get() + .cloned() + .unwrap_or_else(|| snapshot(None, false)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn resolved(driver: Driver, pairs: &[(&str, &str)]) -> Result { + let env: HashMap = pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(); + resolve(driver, |key| env.get(key).cloned()).map(|(settings, _)| settings) + } + + fn message(driver: Driver, pairs: &[(&str, &str)]) -> &'static str { + let refused = resolved(driver, pairs).unwrap_err(); + assert_eq!(refused.reason, INVALID); + refused.message + } + + #[test] + fn inputs_reach_the_selected_connection_as_php_resolves_them() { + let ca = tempfile::NamedTempFile::new().unwrap(); + let ca = ca.path().to_str().unwrap(); + let host = ("DB_HOST", "database.test"); + let postgres = resolved( + Driver::Postgres, + &[host, ("DB_SSLMODE", "verify-full"), ("DB_SSLROOTCERT", ca)], + ) + .unwrap(); + assert_eq!( + (postgres.mode, postgres.root_cert.as_deref()), + (Some(Mode::VerifyFull), Some(ca)) + ); + // The Helm chart's existing input and MySQL's mode names. + assert_eq!( + resolved(Driver::Postgres, &[host, ("DB_SSL_MODE", "require")]) + .unwrap() + .mode, + Some(Mode::Require) + ); + assert_eq!( + resolved(Driver::MySql, &[host, ("DB_SSLMODE", "REQUIRED")]) + .unwrap() + .mode, + Some(Mode::Require) + ); + assert_eq!( + resolved( + Driver::MySql, + &[ + host, + ("DB_SSLMODE", "verify_identity"), + ("DB_SSL_ROOT_CERT", ca) + ] + ) + .unwrap() + .mode, + Some(Mode::VerifyFull) + ); + // MySQL verification may name its CA through the legacy PDO option. + assert_eq!( + resolved( + Driver::MySql, + &[ + host, + ("DB_SSLMODE", "verify-full"), + ("MYSQL_ATTR_SSL_CA", ca) + ] + ) + .unwrap() + .root_cert + .as_deref(), + Some(ca) + ); + // PostgreSQL defaults to prefer; MySQL has no configured mode. + assert_eq!( + resolved(Driver::Postgres, &[]).unwrap().mode, + Some(Mode::Prefer) + ); + assert_eq!(resolved(Driver::MySql, &[]).unwrap().mode, None); + // An explicitly empty mode is no mode, as Laravel reads it. + assert_eq!( + resolved( + Driver::Postgres, + &[("DB_SSLMODE", ""), ("DB_SSLROOTCERT", "")] + ) + .unwrap() + .mode, + None + ); + // A URL's mode applies when DB_SSLMODE agrees or is absent. + let url = resolved( + Driver::Postgres, + &[( + "DB_URL", + "postgres://dw@db.test:5432/app?sslmode=require&application_name=dw", + )], + ) + .unwrap(); + assert_eq!(url.mode, Some(Mode::Require)); + } + + #[test] + fn invalid_configuration_is_refused_before_a_connection() { + let host = ("DB_HOST", "database.test"); + let pg = Driver::Postgres; + assert_eq!( + message(pg, &[host, ("DB_SSLMODE", "silently-insecure")]), + "The selected database driver does not support the requested TLS mode. MySQL/MariaDB support disable, require and verify-full." + ); + assert_eq!( + message( + pg, + &[ + host, + ("DB_SSLMODE", "verify-full"), + ("DB_SSL_MODE", "disable") + ] + ), + "DB_SSLMODE and DB_SSL_MODE request different TLS modes." + ); + assert_eq!( + message( + pg, + &[ + ("DB_SSLMODE", "verify-full"), + ("DB_URL", "postgres://db.test/app?sslmode=disable") + ] + ), + "The database URL conflicts with DB_SSLMODE." + ); + assert_eq!( + message( + pg, + &[ + ("DB_SSLMODE", "verify-full"), + ("DB_SSLROOTCERT", "/original.pem"), + ("DB_URL", "postgres://db.test/app?sslrootcert=/wrong.pem") + ] + ), + "The database URL conflicts with DB_SSLROOTCERT." + ); + assert_eq!( + message(pg, &[host, ("DB_SSLMODE", "verify-full")]), + "Certificate verification requires a readable CA bundle in DB_SSLROOTCERT." + ); + assert_eq!( + message( + pg, + &[ + host, + ("DB_SSLMODE", "verify-full"), + ("DB_SSLROOTCERT", "/not-a-real-database-ca.pem") + ] + ), + "The configured database CA bundle must be a readable regular file." + ); + let socket = "Required database TLS needs an explicit TCP host, not a Unix socket."; + assert_eq!( + message( + pg, + &[ + ("DB_HOST", "/var/run/postgresql"), + ("DB_SSLMODE", "require") + ] + ), + socket + ); + assert_eq!( + message(pg, &[("DB_HOST", "@postgres"), ("DB_SSLMODE", "require")]), + socket + ); + assert_eq!( + message( + Driver::MySql, + &[("DB_HOST", "localhost"), ("DB_SSLMODE", "require")] + ), + socket + ); + assert_eq!( + message( + Driver::MySql, + &[ + host, + ("DB_SOCKET", "/tmp/mysql.sock"), + ("DB_SSLMODE", "require") + ] + ), + socket + ); + assert_eq!( + message(Driver::MySql, &[host, ("DB_SSLMODE", "verify-ca")]), + "The selected database driver does not support the requested TLS mode. MySQL/MariaDB support disable, require and verify-full." + ); + assert_eq!( + message(Driver::MySql, &[host, ("DB_SSLMODE", "preferred")]), + "The selected database driver does not support the requested TLS mode. MySQL/MariaDB support disable, require and verify-full." + ); + let ca = tempfile::NamedTempFile::new().unwrap(); + let ca = ca.path().to_str().unwrap(); + // A CA bundle needs a mode. PostgreSQL's default prefer is one, unless + // the mode is set empty. + assert_eq!( + message(Driver::MySql, &[host, ("DB_SSLROOTCERT", ca)]), + "Set DB_SSLMODE=verify-full with DB_SSLROOTCERT." + ); + assert_eq!( + message(pg, &[host, ("DB_SSLMODE", ""), ("DB_SSLROOTCERT", ca)]), + "Set DB_SSLMODE=verify-full with DB_SSLROOTCERT." + ); + assert_eq!( + resolved(pg, &[host, ("DB_SSLROOTCERT", ca)]).unwrap().mode, + Some(Mode::Prefer) + ); + assert_eq!( + message( + pg, + &[ + host, + ("DB_SSLMODE", "verify-full"), + ("DB_SSLROOTCERT", ca), + ("DB_SSL_ROOT_CERT", "/other.pem") + ] + ), + "DB_SSLROOTCERT and DB_SSL_ROOT_CERT name different CA bundles." + ); + assert_eq!( + message( + Driver::MySql, + &[host, ("DB_SSLMODE", "disable"), ("MYSQL_ATTR_SSL_CA", ca)] + ), + "Disabled database TLS conflicts with configured TLS connection options." + ); + let other = tempfile::NamedTempFile::new().unwrap(); + assert_eq!( + message( + Driver::MySql, + &[ + host, + ("DB_SSLMODE", "verify-full"), + ("DB_SSLROOTCERT", ca), + ("MYSQL_ATTR_SSL_CA", other.path().to_str().unwrap()) + ] + ), + "DB_SSLROOTCERT and MYSQL_ATTR_SSL_CA name different CA bundles." + ); + } + + #[test] + fn url_tls_parameters_are_replaced_by_the_resolved_settings() { + let parts = url_parts( + "mysql://us%40er@[::1]:3306/app?ssl-mode=DISABLED&sslmode=require&charset=utf8mb4&sslca=/x.pem", + ); + assert_eq!(parts.sslmode.as_deref(), Some("require")); + assert_eq!(parts.sslrootcert, None); + assert_eq!(parts.host.as_deref(), Some("::1")); + assert_eq!( + parts.without_tls, + "mysql://us%40er@[::1]:3306/app?charset=utf8mb4" + ); + let socket = url_parts("postgres://user@localhost/app?host=%2Fvar%2Frun%2Fpostgresql"); + assert_eq!(socket.host.as_deref(), Some("/var/run/postgresql")); + assert_eq!( + url_parts("postgres://db.test/app").without_tls, + "postgres://db.test/app" + ); + } + + #[test] + fn snapshots_report_what_the_mode_guarantees() { + let settings = |mode| Settings { + driver: Driver::Postgres, + mode, + root_cert: None, + }; + assert_eq!( + settings(Some(Mode::VerifyFull)).snapshot(), + json!({"mode":"verify-full","encrypted":true,"ca_verified":true, + "hostname_verified":true,"evidence":"successful_driver_enforced_connection","scope":"rust_to_configured_database_endpoint"}) + ); + assert_eq!( + settings(Some(Mode::Require)).snapshot()["ca_verified"], + false + ); + assert_eq!( + settings(Some(Mode::Disable)).snapshot(), + json!({"mode":"disable","encrypted":false,"ca_verified":null, + "hostname_verified":null,"evidence":"not_verified","scope":"rust_to_configured_database_endpoint"}) + ); + assert_eq!( + settings(Some(Mode::Prefer)).snapshot()["encrypted"], + Value::Null + ); + assert_eq!(current()["mode"], Value::Null); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 69e9317b..c02e8166 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,5 +1,6 @@ //! Development foundation for the Rust Server. No HTTP runtime is published yet. pub mod codec; +pub mod database_tls; pub mod external_payload; pub mod runtime; diff --git a/rust/src/main.rs b/rust/src/main.rs index ae8efabc..22003f39 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,58 +1,73 @@ use std::{env, error::Error, net::SocketAddr, str::FromStr}; +use durable_workflow_server::database_tls::{self, Driver, Settings}; use durable_workflow_server::runtime::{ Runtime, RuntimeError, mysql::MySqlStorage, postgres::PostgresStorage, router, }; -use sqlx::mysql::{MySqlConnectOptions, MySqlSslMode}; -use sqlx::postgres::{PgConnectOptions, PgSslMode}; +use sqlx::mysql::MySqlConnectOptions; +use sqlx::postgres::PgConnectOptions; + +/// The connection's TLS settings and its DB_URL without TLS parameters, as +/// published PHP resolves them. A refusal names PHP's reason and message. +fn tls_settings(driver: Driver) -> Result<(Settings, Option), Box> { + let (settings, url) = database_tls::resolve(driver, |key| env::var(key).ok())?; + Ok((settings, url.map(|url| url.without_tls))) +} + +/// A connection that required TLS and failed is reported as PHP reports it. +fn tls_connection_error(settings: &Settings, error: RuntimeError) -> Box { + if settings.required() && matches!(error, RuntimeError::Database(_)) { + return Box::new(database_tls::CONNECTION_FAILED); + } + Box::new(error) +} fn postgres_options() -> Result> { - if let Ok(url) = env::var("DB_URL") { + let (tls, url) = tls_settings(Driver::Postgres)?; + let options = if let Some(url) = url { // Parsing errors must not echo a connection URL or password. - return PgConnectOptions::from_str(&url).map_err(|_| "invalid DB_URL".into()); - } - let database = env::var("DB_DATABASE").map_err(|_| "DB_DATABASE is required")?; - let username = env::var("DB_USERNAME").map_err(|_| "DB_USERNAME is required")?; - let port = env::var("DB_PORT") - .unwrap_or_else(|_| "5432".into()) - .parse::()?; - let ssl = PgSslMode::from_str(&env::var("DB_SSLMODE").unwrap_or_else(|_| "prefer".into())) - .map_err(|_| "invalid DB_SSLMODE")?; - let options = PgConnectOptions::new() - .host(&env::var("DB_HOST").unwrap_or_else(|_| "127.0.0.1".into())) - .port(port) - .database(&database) - .username(&username) - .password(&env::var("DB_PASSWORD").unwrap_or_default()) - .ssl_mode(ssl); - Ok(match env::var("DB_SSLROOTCERT") { - Ok(path) => options.ssl_root_cert(path), - Err(_) => options, + PgConnectOptions::from_str(&url).map_err(|_| "invalid DB_URL")? + } else { + let database = env::var("DB_DATABASE").map_err(|_| "DB_DATABASE is required")?; + let username = env::var("DB_USERNAME").map_err(|_| "DB_USERNAME is required")?; + let port = env::var("DB_PORT") + .unwrap_or_else(|_| "5432".into()) + .parse::()?; + PgConnectOptions::new() + .host(&env::var("DB_HOST").unwrap_or_else(|_| "127.0.0.1".into())) + .port(port) + .database(&database) + .username(&username) + .password(&env::var("DB_PASSWORD").unwrap_or_default()) + }; + let options = options.ssl_mode(tls.postgres_mode()); + Ok(match &tls.root_cert { + Some(path) => options.ssl_root_cert(path), + None => options, }) } fn mysql_options() -> Result> { - if let Ok(url) = env::var("DB_URL") { - return MySqlConnectOptions::from_str(&url).map_err(|_| "invalid DB_URL".into()); - } - let database = env::var("DB_DATABASE").map_err(|_| "DB_DATABASE is required")?; - let username = env::var("DB_USERNAME").map_err(|_| "DB_USERNAME is required")?; - let port = env::var("DB_PORT") - .unwrap_or_else(|_| "3306".into()) - .parse::()?; - let ssl = - MySqlSslMode::from_str(&env::var("DB_SSLMODE").unwrap_or_else(|_| "preferred".into())) - .map_err(|_| "invalid DB_SSLMODE")?; - let options = MySqlConnectOptions::new() - .host(&env::var("DB_HOST").unwrap_or_else(|_| "127.0.0.1".into())) - .port(port) - .database(&database) - .username(&username) - .password(&env::var("DB_PASSWORD").unwrap_or_default()) - .ssl_mode(ssl); - Ok(match env::var("DB_SSLROOTCERT") { - Ok(path) => options.ssl_ca(path), - Err(_) => options, + let (tls, url) = tls_settings(Driver::MySql)?; + let options = if let Some(url) = url { + MySqlConnectOptions::from_str(&url).map_err(|_| "invalid DB_URL")? + } else { + let database = env::var("DB_DATABASE").map_err(|_| "DB_DATABASE is required")?; + let username = env::var("DB_USERNAME").map_err(|_| "DB_USERNAME is required")?; + let port = env::var("DB_PORT") + .unwrap_or_else(|_| "3306".into()) + .parse::()?; + MySqlConnectOptions::new() + .host(&env::var("DB_HOST").unwrap_or_else(|_| "127.0.0.1".into())) + .port(port) + .database(&database) + .username(&username) + .password(&env::var("DB_PASSWORD").unwrap_or_default()) + }; + let options = options.ssl_mode(tls.mysql_mode()); + Ok(match &tls.root_cert { + Some(path) => options.ssl_ca(path), + None => options, }) } @@ -373,8 +388,15 @@ async fn main() -> Result<(), Box> { ), }; if let Err(error) = result { + let driver = if backend == "pgsql" { + Driver::Postgres + } else { + Driver::MySql + }; + let tls_required = tls_settings(driver).is_ok_and(|(tls, _)| tls.required()); let reason = match error { RuntimeError::Refused { reason, .. } => reason, + RuntimeError::Database(_) if tls_required => database_tls::CONNECTION_FAILED.reason, _ => "storage_unavailable", }; eprintln!("{reason}"); @@ -402,8 +424,22 @@ async fn main() -> Result<(), Box> { let database = env::var("DB_DATABASE").map_err(|_| "DB_DATABASE is required")?; Runtime::open(&database, token).await? } - "pgsql" => Runtime::open_postgres(postgres_options()?, token).await?, - "mysql" | "mariadb" => Runtime::open_mysql(mysql_options()?, token).await?, + "pgsql" => { + let (tls, _) = tls_settings(Driver::Postgres)?; + let runtime = Runtime::open_postgres(postgres_options()?, token) + .await + .map_err(|error| tls_connection_error(&tls, error))?; + database_tls::record(&tls); + runtime + } + "mysql" | "mariadb" => { + let (tls, _) = tls_settings(Driver::MySql)?; + let runtime = Runtime::open_mysql(mysql_options()?, token) + .await + .map_err(|error| tls_connection_error(&tls, error))?; + database_tls::record(&tls); + runtime + } _ => { return Err("unsupported development database".into()); } diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index 3f54d55d..ac14a43f 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -574,6 +574,12 @@ async fn health(Extension(runtime): Extension) -> Response { async fn ready(Extension(runtime): Extension) -> Response { let ready = runtime.schema_ready().await; + let mut database = json!({"status": if ready { "ok" } else { "failed" }, + "mode": "schema_read", "write_capacity_verified": false}); + // As PHP does, only a reachable database reports what its TLS guarantees. + if ready { + database["tls"] = crate::database_tls::current(); + } ( if ready { StatusCode::OK @@ -582,8 +588,7 @@ async fn ready(Extension(runtime): Extension) -> Response { }, Json(json!({ "status": if ready { "ready" } else { "not_ready" }, "development": true, - "checks": {"database": {"status": if ready { "ok" } else { "failed" }, - "mode": "schema_read", "write_capacity_verified": false}}, + "checks": {"database": database}, "qualification": "unpublished_execution_slice"})), ) .into_response() diff --git a/scripts/conformance/native-database-tls.sh b/scripts/conformance/native-database-tls.sh new file mode 100755 index 00000000..7f7d6c05 --- /dev/null +++ b/scripts/conformance/native-database-tls.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# Qualifies native database TLS against the same TLS-enabled PostgreSQL, MySQL +# and MariaDB fixtures as scripts/ci/qualify-database-tls.sh, with PHP's cases: +# a verified connection, an encrypted one, a wrong CA, a wrong hostname, a +# missing CA, plaintext with disable, and require against a plaintext server. +# Native refuses an invalid or failed TLS connection at startup with PHP's +# reason; an accepted one reports PHP's readiness snapshot. The database side +# confirms the transport: PostgreSQL's pg_stat_ssl, and MySQL/MariaDB accounts +# that require TLS. +# Usage: native-database-tls.sh NATIVE_BINARY +set -euo pipefail + +binary="$(realpath "${1:?Pass the native server binary.}")" +repository="$(cd -- "$(dirname -- "$0")/../.." && pwd)" +TLS_TEST_IMAGE="${TLS_TEST_IMAGE:-ghcr.io/durable-workflow/server@sha256:28fa0f3968ea253c727aad7fe583bb61c788553f1e4b96ece6ea4570d20faf42}" +TLS_TEST_POSTGRES_IMAGE="${TLS_TEST_POSTGRES_IMAGE:-mirror.gcr.io/library/postgres@sha256:3645570cccdfa447589da9f57dd740faa29b30938e861289a5574b6ca6b03826}" +TLS_TEST_MYSQL_IMAGE="${TLS_TEST_MYSQL_IMAGE:-mirror.gcr.io/library/mysql@sha256:62fb722c78b24245ddff1796a0fcee4a49cc5b87e0aaaf20c92d1da9e0a2497b}" +TLS_TEST_MARIADB_IMAGE="${TLS_TEST_MARIADB_IMAGE:-mirror.gcr.io/library/mariadb@sha256:7db29378d4fdab73f8123bbc2b48905c90d1a4b00cf848b028f1e81e623257f2}" +RUST_RUNTIME_IMAGE="${RUST_RUNTIME_IMAGE:-mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0}" +TLS_TEST_PROJECT="${TLS_TEST_PROJECT:-dw-native-tls-$(date +%s)}" +tls_parent="${TLS_TEST_ROOT:-${TMPDIR:-/tmp}}" +mkdir -p "$tls_parent" +TLS_TEST_ROOT="$(mktemp -d "$tls_parent/dw-native-tls.XXXXXX")" +TLS_TEST_UID="$(id -u)" +TLS_TEST_GID="$(id -g)" +TLS_TEST_PASSWORD="$(openssl rand -hex 24)" +TLS_TEST_ROOT_PASSWORD="$(openssl rand -hex 24)" +export TLS_TEST_PROJECT TLS_TEST_ROOT TLS_TEST_IMAGE TLS_TEST_UID TLS_TEST_GID TLS_TEST_PASSWORD \ + TLS_TEST_ROOT_PASSWORD TLS_TEST_POSTGRES_IMAGE TLS_TEST_MYSQL_IMAGE TLS_TEST_MARIADB_IMAGE +network="${TLS_TEST_PROJECT}_default" +native="$TLS_TEST_PROJECT-native" +failures=0 + +cat > "$TLS_TEST_ROOT/no-ports.yaml" <<'YAML' +services: + tls-pg: &no-ports + ports: !override [] + tls-mysql: *no-ports + tls-maria: *no-ports + plain-pg: *no-ports + plain-mysql: *no-ports + redis: *no-ports + php: *no-ports +YAML +compose() { + docker compose --project-name "$TLS_TEST_PROJECT" --file "$repository/scripts/ci/database-tls-compose.yaml" \ + --file "$TLS_TEST_ROOT/no-ports.yaml" "$@" +} +cleanup() { + local result=$? + trap - EXIT + docker rm -f "$native" >/dev/null 2>&1 || true + compose --profile plaintext down --volumes --remove-orphans >/dev/null 2>&1 || result=1 + rm -rf -- "$TLS_TEST_ROOT" || result=1 + exit "$result" +} +trap cleanup EXIT + +mkdir -p "$TLS_TEST_ROOT/certs" "$TLS_TEST_ROOT/bin" +cp "$binary" "$TLS_TEST_ROOT/bin/server" +docker run --rm --user "$TLS_TEST_UID:$TLS_TEST_GID" --entrypoint sh --cpus 1 --memory 512m \ + -v "$TLS_TEST_ROOT/certs:/certs" -w /certs "$TLS_TEST_IMAGE" -c ' + set -eu + umask 077 + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=fixture-ca -addext basicConstraints=critical,CA:TRUE -keyout ca.key -out ca.pem >/dev/null 2>&1 + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=wrong-ca -addext basicConstraints=critical,CA:TRUE -keyout wrong-ca.key -out wrong-ca.pem >/dev/null 2>&1 + openssl req -new -newkey rsa:2048 -nodes -subj /CN=tls-pg -keyout server.key -out server.csr >/dev/null 2>&1 + printf "subjectAltName=DNS:tls-pg,DNS:tls-mysql,DNS:tls-maria\nbasicConstraints=CA:FALSE\n" > server.ext + openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 1 -extfile server.ext -out server.pem >/dev/null 2>&1 + ' +compose up --wait --wait-timeout 240 tls-pg tls-mysql tls-maria + +# MySQL and MariaDB accept the fixture account only over TLS, so a native +# connection that succeeds there is encrypted. +compose exec -T -e "MYSQL_PWD=$TLS_TEST_ROOT_PASSWORD" tls-mysql \ + mysql --protocol=tcp -h127.0.0.1 -uroot -e "ALTER USER 'dw_tls'@'%' REQUIRE SSL" +compose exec -T -e "MYSQL_PWD=$TLS_TEST_ROOT_PASSWORD" tls-maria \ + mariadb --protocol=tcp -h127.0.0.1 -uroot -e "ALTER USER 'dw_tls'@'%' REQUIRE SSL" + +start_native() { + docker rm -f "$native" >/dev/null 2>&1 || true + docker run -d --name "$native" --network "$network" --user "$TLS_TEST_UID:$TLS_TEST_GID" \ + --cpus 1 --memory 512m -v "$TLS_TEST_ROOT/bin:/native:ro" -v "$TLS_TEST_ROOT/certs:/certs:ro" \ + -e DW_RUST_EXPERIMENTAL=1 -e DW_AUTH_TOKEN=native-tls-fixture-token -e DW_BIND_ADDRESS=0.0.0.0:8080 \ + -e DB_DATABASE=dw_tls -e DB_USERNAME=dw_tls -e "DB_PASSWORD=$TLS_TEST_PASSWORD" "$@" \ + --entrypoint /native/server "$RUST_RUNTIME_IMAGE" >/dev/null +} + +# Prints the readiness body once native answers, or nothing if it exits. +readiness() { + local attempt + for attempt in $(seq 1 60); do + if [[ "$(docker inspect -f '{{.State.Running}}' "$native" 2>/dev/null)" != true ]]; then + return 0 + fi + if docker run --rm --network "$network" --entrypoint curl "$TLS_TEST_IMAGE" -fsS \ + "http://$native:8080/api/ready" 2>/dev/null; then + return 0 + fi + sleep 1 + done +} + +record() { + local label="$1" pass="$2" detail="$3" + printf '{"case":"%s","pass":%s,%s}\n' "$label" "$pass" "$detail" + if [[ "$pass" != true ]]; then + failures=$((failures + 1)) + fi +} + +postgres_transport() { + compose exec -T tls-pg psql -U dw_tls -d dw_tls -Atc \ + "SELECT count(*) || ':' || COALESCE(bool_and(s.ssl)::text, 'none') FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr IS NOT NULL AND a.usename = 'dw_tls'" 2>/dev/null +} + +# accepted LABEL EXPECTED_SNAPSHOT TRANSPORT_CHECK ENV... +accepted() { + local label="$1" expected="$2" transport="$3" + shift 3 + start_native "$@" + local body tls observed="" + body="$(readiness)" + tls="$(jq -cS '.checks.database.tls // null' <<<"${body:-null}" 2>/dev/null || echo null)" + if [[ "$transport" == pg ]]; then + observed="$(postgres_transport)" + fi + local pass=false + if [[ "$tls" == "$(jq -cS . <<<"$expected")" && ( "$transport" != pg || "$observed" =~ ^[1-9][0-9]*:true$ ) ]]; then + pass=true + fi + if [[ "$pass" != true ]]; then + docker logs --tail 5 "$native" 2>&1 | sed 's/^/ native: /' >&2 + fi + record "$label" "$pass" "\"tls\":$tls,\"postgres_transport\":\"$observed\"" + docker rm -f "$native" >/dev/null +} + +# refused LABEL REASON ENV... +refused() { + local label="$1" reason="$2" + shift 2 + start_native "$@" + local status + status="$(timeout 90 docker wait "$native" 2>/dev/null || echo running)" + local logs + logs="$(docker logs "$native" 2>&1 | tail -3)" + local pass=false + if [[ "$status" != running && "$status" != 0 && "$logs" == *"$reason"* ]]; then + pass=true + fi + record "$label" "$pass" "\"exit\":\"$status\",\"expected_reason\":\"$reason\"" + docker rm -f "$native" >/dev/null +} + +verified='{"mode":"verify-full","encrypted":true,"ca_verified":true,"hostname_verified":true,"evidence":"successful_driver_enforced_connection","scope":"rust_to_configured_database_endpoint"}' +encrypted='{"mode":"require","encrypted":true,"ca_verified":false,"hostname_verified":false,"evidence":"successful_driver_enforced_connection","scope":"rust_to_configured_database_endpoint"}' +plaintext='{"mode":"disable","encrypted":false,"ca_verified":null,"hostname_verified":null,"evidence":"not_verified","scope":"rust_to_configured_database_endpoint"}' +invalid=database_tls_configuration_invalid +failed=database_tls_connection_failed + +for driver in pgsql mysql mariadb; do + case "$driver" in + pgsql) host=tls-pg; wrong=wrong-pg; port=5432; transport=pg ;; + mysql) host=tls-mysql; wrong=wrong-mysql; port=3306; transport=account ;; + mariadb) host=tls-maria; wrong=wrong-maria; port=3306; transport=account ;; + *) echo 'Unsupported TLS fixture driver.' >&2; exit 1 ;; + esac + common=(-e "DB_CONNECTION=$driver" -e "DB_PORT=$port") + accepted "$driver verified" "$verified" "$transport" "${common[@]}" -e "DB_HOST=$host" \ + -e DB_SSLMODE=verify-full -e DB_SSLROOTCERT=/certs/ca.pem + accepted "$driver encrypted" "$encrypted" "$transport" "${common[@]}" -e "DB_HOST=$host" \ + -e DB_SSLMODE=require -e DB_SSLROOTCERT= + # The Helm chart's existing names reach the same connection. + accepted "$driver chart aliases" "$verified" "$transport" "${common[@]}" -e "DB_HOST=$host" \ + -e DB_SSL_MODE=verify-full -e DB_SSL_ROOT_CERT=/certs/ca.pem + refused "$driver wrong CA" "$failed" "${common[@]}" -e "DB_HOST=$host" \ + -e DB_SSLMODE=verify-full -e DB_SSLROOTCERT=/certs/wrong-ca.pem + refused "$driver wrong hostname" "$failed" "${common[@]}" -e "DB_HOST=$wrong" \ + -e DB_SSLMODE=verify-full -e DB_SSLROOTCERT=/certs/ca.pem +done +refused "pgsql missing CA" "$invalid" -e DB_CONNECTION=pgsql -e DB_HOST=tls-pg -e DB_PORT=5432 -e DB_SSLMODE=verify-full +refused "pgsql unreadable CA" "$invalid" -e DB_CONNECTION=pgsql -e DB_HOST=tls-pg -e DB_PORT=5432 \ + -e DB_SSLMODE=verify-full -e DB_SSLROOTCERT=/certs/missing.pem +refused "mysql chain-only verification" "$invalid" -e DB_CONNECTION=mysql -e DB_HOST=tls-mysql -e DB_PORT=3306 \ + -e DB_SSLMODE=verify-ca -e DB_SSLROOTCERT=/certs/ca.pem +# A URL's TLS parameters follow the same rules, and cannot weaken DB_SSLMODE. +url="postgres://dw_tls:$TLS_TEST_PASSWORD@tls-pg:5432/dw_tls" +accepted "pgsql URL verified" "$verified" pg -e DB_CONNECTION=pgsql -e "DB_URL=$url?sslmode=verify-full&sslrootcert=/certs/ca.pem" +refused "pgsql URL downgrade" "$invalid" -e DB_CONNECTION=pgsql -e "DB_URL=$url?sslmode=disable" -e DB_SSLMODE=verify-full \ + -e DB_SSLROOTCERT=/certs/ca.pem + +compose stop tls-pg tls-mysql tls-maria >/dev/null +compose --profile plaintext up --wait --wait-timeout 240 plain-pg plain-mysql +for driver in pgsql mysql; do + if [[ "$driver" == pgsql ]]; then host=plain-pg; port=5432; else host=plain-mysql; port=3306; fi + accepted "$driver plaintext" "$plaintext" none -e "DB_CONNECTION=$driver" -e "DB_HOST=$host" -e "DB_PORT=$port" \ + -e DB_SSLMODE=disable -e DB_SSLROOTCERT= + refused "$driver require without server TLS" "$failed" -e "DB_CONNECTION=$driver" -e "DB_HOST=$host" \ + -e "DB_PORT=$port" -e DB_SSLMODE=require -e DB_SSLROOTCERT= +done + +if (( failures > 0 )); then + echo "native database TLS qualification failed $failures case(s)" >&2 + exit 1 +fi +echo '{"outcome":"pass"}'