fix(connections): persist SSL settings and honor a URL's sslmode (#36)
Connecting to a managed provider (Neon in the report) failed with "connection is insecure (try using `sslmode=require`)" because the TLS mode never reached the backend: - ConnectionFormData carried no ssl_* fields and both submit handlers (NewConnectionScreen.buildPayload, EditConnectionModal save + test) omitted them, so the SSH/SSL tab's mode was silently dropped before IPC. - SslFields wrote ssl_ca_cert / ssl_client_cert / ssl_client_key — names the Rust ConnectionInput ignores (it expects ssl_ca_path / ssl_cert_path / ssl_key_path). - parseConnectionString ignored query params, so a pasted Neon/Supabase URL's ?sslmode=require was discarded. Pasting a URI now fills every setting it can (sslmode / ssl-mode, including MySQL's REQUIRED and VERIFY_IDENTITY spellings) and offers a derived label (database name, else host/file stem) as a placeholder that Test/Save uses when the field is left blank. Managed presets pre-select their TLS mode (Supabase / Neon → Require, PlanetScale → Verify Full). Backend safety net: db::tls::resolve_ssl_mode defaults *.neon.tech / *.supabase.co / *.psdb.cloud to `require` when a connection has no ssl_mode, so connections saved before this fix keep working; an explicit mode (including disable) always wins. Tests: +15 frontend (parser, form payload, SSL field names, edit modal), +4 Rust (resolve_ssl_mode / tls_decision_for).
This commit is contained in:
@@ -1005,7 +1005,7 @@ pub(crate) async fn run_mysql_connect(
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||
via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
@@ -1084,7 +1084,7 @@ pub async fn db_connect(
|
||||
// connections honor the user's mode. Building this before opening the
|
||||
// tunnel means a config error can't leak the tunnel.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||
will_tunnel,
|
||||
);
|
||||
let tls = crate::db::tls::build_tls_config(
|
||||
|
||||
@@ -265,7 +265,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||
target.via_tunnel,
|
||||
);
|
||||
let tls = match crate::db::tls::build_tls_config(
|
||||
@@ -368,7 +368,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||
target.via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
|
||||
@@ -29,6 +29,37 @@ pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
|
||||
}
|
||||
}
|
||||
|
||||
/// Host suffixes of managed providers that reject plaintext connections with
|
||||
/// `connection is insecure (try using 'sslmode=require')`.
|
||||
const MANAGED_SSL_HOST_SUFFIXES: [&str; 3] = [".neon.tech", ".supabase.co", ".psdb.cloud"];
|
||||
|
||||
/// Resolve the SSL mode to use for a connection, defaulting these managed
|
||||
/// providers to `require` when the connection carries no explicit mode.
|
||||
///
|
||||
/// Connections saved before the UI persisted `ssl_mode` (or imported without
|
||||
/// it) would otherwise connect with TLS disabled and be refused by the server.
|
||||
/// An explicit mode always wins, so a deliberate `disable` is never overridden,
|
||||
/// and the inferred mode only encrypts — it never verifies certificates.
|
||||
pub fn resolve_ssl_mode(host: &str, ssl_mode: Option<&str>) -> Option<String> {
|
||||
if let Some(mode) = ssl_mode {
|
||||
return Some(mode.to_string());
|
||||
}
|
||||
let host = host.to_ascii_lowercase();
|
||||
if MANAGED_SSL_HOST_SUFFIXES
|
||||
.iter()
|
||||
.any(|suffix| host.ends_with(suffix))
|
||||
{
|
||||
return Some("require".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// TLS decision for a connection, applying the managed-provider default from
|
||||
/// [`resolve_ssl_mode`] before mapping to a [`TlsDecision`].
|
||||
pub fn tls_decision_for(host: &str, ssl_mode: Option<&str>) -> TlsDecision {
|
||||
tls_decision(resolve_ssl_mode(host, ssl_mode).as_deref())
|
||||
}
|
||||
|
||||
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
|
||||
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
|
||||
pub fn build_tls_config(
|
||||
@@ -197,6 +228,50 @@ mod tests {
|
||||
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ssl_mode_defaults_managed_hosts_to_require() {
|
||||
// Saved connections that predate the form storing `ssl_mode` still work.
|
||||
for host in [
|
||||
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
|
||||
"db.abcdefghijklmnopqrst.supabase.co",
|
||||
"xxxx.us-east-2.psdb.cloud",
|
||||
"EP-X-POOLER.EU.AWS.NEON.TECH",
|
||||
] {
|
||||
assert_eq!(resolve_ssl_mode(host, None).as_deref(), Some("require"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ssl_mode_leaves_other_hosts_and_explicit_modes_alone() {
|
||||
assert_eq!(resolve_ssl_mode("localhost", None), None);
|
||||
assert_eq!(resolve_ssl_mode("prod.example.com", None), None);
|
||||
// An explicit mode always wins — a deliberate `disable` is never overridden.
|
||||
assert_eq!(
|
||||
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("disable")).as_deref(),
|
||||
Some("disable")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("verify-full")).as_deref(),
|
||||
Some("verify-full")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_decision_for_honors_managed_default_and_explicit_modes() {
|
||||
assert!(matches!(
|
||||
tls_decision_for("ep-x.eu.aws.neon.tech", None),
|
||||
TlsDecision::Require
|
||||
));
|
||||
assert!(matches!(
|
||||
tls_decision_for("ep-x.eu.aws.neon.tech", Some("disable")),
|
||||
TlsDecision::Disable
|
||||
));
|
||||
assert!(matches!(
|
||||
tls_decision_for("db.example.com", None),
|
||||
TlsDecision::Disable
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_disable_returns_none() {
|
||||
assert!(build_tls_config(TlsDecision::Disable, None, None, None)
|
||||
|
||||
Reference in New Issue
Block a user