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:
2026-09-24 03:25:13 +08:00
committed by GitHub
parent f807c22a36
commit 3454ce67fe
16 changed files with 549 additions and 65 deletions
+2 -2
View File
@@ -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 {
+75
View File
@@ -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)