diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index a9e27e06a6..dab43c47ec 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -38,7 +38,7 @@ type DbOption = { value: string; label: string }; type DbCategory = { key: string; title: string; options: DbOption[] }; type DialogStep = "select" | "config"; type DbPickerView = "icon" | "list"; -type ConfigTab = "connection" | "ssh" | "proxy"; +type ConfigTab = "connection" | "tls" | "ssh" | "proxy"; const { t } = useI18n(); const { toast } = useToast(); @@ -550,6 +550,18 @@ const filePathPlaceholder = computed(() => { return "/path/to/database.db or :memory:"; }); const supportsMemoryDatabasePath = computed(() => form.value.db_type === "sqlite" || form.value.db_type === "duckdb"); +const tlsCapableDatabaseTypes = new Set([ + "mysql", + "postgres", + "redshift", + "gaussdb", + "opengauss", + "redis", + "clickhouse", + "elasticsearch", +]); +const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type)); +const supportsCaCertificatePath = computed(() => form.value.db_type === "mysql" || form.value.db_type === "clickhouse"); const canUseSsh = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access"); const canUseProxy = computed( () => form.value.db_type !== "sqlite" && form.value.db_type !== "duckdb" && form.value.db_type !== "access", @@ -821,6 +833,12 @@ watch(canUseProxy, (value) => { } }); +watch(supportsTlsToggle, (value) => { + if (!value && configTab.value === "tls") { + configTab.value = "connection"; + } +}); + async function save() { if (!ensureConnectionHostResolvedFromUrl()) return; if (isSaving.value) return; @@ -1117,9 +1135,13 @@ function openExternalUrl(url: string) { @@ -1454,33 +1469,6 @@ function openExternalUrl(url: string) { -
- - -
- -
- -
- - - - - - {{ t("connection.caCertPathBrowse") }} - -
-
-
+ +
+
+ + +
+ +
+ +
+ + + + + + {{ t("connection.caCertPathBrowse") }} + +
+
+
+
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 67c168be93..d57669455e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -137,6 +137,7 @@ export default { next: "Next", back: "Previous", basicTab: "Connection", + tlsTab: "TLS/SSL", test: "Test", testing: "Testing...", copyTestResult: "Copy test result", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 724f0b7cd1..1790ac9d38 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -127,6 +127,7 @@ export default { next: "Siguiente", back: "Anterior", basicTab: "Conexión", + tlsTab: "TLS/SSL", test: "Probar", testing: "Probando...", copyTestResult: "Copiar resultado de prueba", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 6280023359..05eff8d5f7 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -136,6 +136,7 @@ export default { next: "下一步", back: "上一步", basicTab: "连接信息", + tlsTab: "TLS/SSL", test: "测试", testing: "测试中...", copyTestResult: "复制测试结果", diff --git a/apps/desktop/src/lib/connectionUrl.ts b/apps/desktop/src/lib/connectionUrl.ts index a3d86ced27..8cc6bbbfe8 100644 --- a/apps/desktop/src/lib/connectionUrl.ts +++ b/apps/desktop/src/lib/connectionUrl.ts @@ -66,6 +66,35 @@ function databaseFromPath(pathname: string): string | undefined { return decodeUrlPart(value.split("/")[0]); } +function queryParamValue(params: string, key: string): string | undefined { + for (const part of params.split(/[&;]/)) { + if (!part) continue; + const [rawKey, ...rest] = part.split("="); + if (decodeUrlPart(rawKey).toLowerCase() === key.toLowerCase()) { + return decodeUrlPart(rest.join("=")).trim(); + } + } + return undefined; +} + +function urlParamsRequireTls(dbType: DatabaseType, params: string): boolean { + if (dbType === "mysql") { + const requireSsl = queryParamValue(params, "require_ssl")?.toLowerCase(); + if (requireSsl === "true" || requireSsl === "1" || requireSsl === "yes") return true; + const sslMode = (queryParamValue(params, "ssl-mode") || queryParamValue(params, "sslmode") || "") + .toLowerCase() + .replace("-", "_"); + return sslMode === "required" || sslMode === "require" || sslMode === "verify_ca" || sslMode === "verify_identity"; + } + + if (dbType === "postgres" || dbType === "redshift") { + const sslMode = (queryParamValue(params, "sslmode") || "").toLowerCase(); + return sslMode === "require" || sslMode === "verify-ca" || sslMode === "verify-full"; + } + + return false; +} + export function connectionProfileForScheme(scheme: string, preferredProfile?: string): ConnectionProfile | undefined { if ((scheme === "http" || scheme === "https") && preferredProfile) { return HTTP_SELECTED_PROFILES[preferredProfile]; @@ -226,7 +255,7 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa password: decodeUrlPart(parsed.password), database: databaseFromPath(parsed.pathname), urlParams, - ssl: scheme === "rediss" || scheme === "https", + ssl: scheme === "rediss" || scheme === "https" || urlParamsRequireTls(profile.type, urlParams), }; } diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index f93b784afe..968a7bb848 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -175,7 +175,7 @@ impl AppState { PoolKind::Mysql(db::mysql::connect_bare(&url).await?, MysqlMode::Bare) } DatabaseType::Mysql => { - let pool = db::mysql::connect(&url).await?; + let pool = db::mysql::connect_with_ca_cert(&url, Some(&db_config.ca_cert_path)).await?; let mode = detect_ob_oracle_mode(&db_config, &pool).await; PoolKind::Mysql(pool, mode) } @@ -611,9 +611,14 @@ fn native_postgres_url_config(config: &ConnectionConfig) -> Option serde_json::Value } pub async fn connect(url: &str) -> Result { + connect_with_ca_cert(url, None).await +} + +pub async fn connect_with_ca_cert(url: &str, ca_cert_path: Option<&str>) -> Result { let timeout = super::parse_connect_timeout(url); - let pool = create_pool(url)?; + let pool = create_pool(url, ca_cert_path)?; let result = verify_pool_connection(&pool, timeout).await; if let Err(ref e) = result { if mysql_error_should_retry_without_ssl(e) { if let Some(fallback_url) = ssl_fallback_url(url) { log::info!("SSL handshake failed, retrying with ssl-mode=disabled"); - let fallback_pool = create_pool(&fallback_url)?; + let fallback_pool = create_pool(&fallback_url, None)?; return match verify_pool_connection(&fallback_pool, timeout).await { Ok(()) => Ok(fallback_pool), Err(e) => Err(e), @@ -192,16 +197,24 @@ pub async fn connect(url: &str) -> Result { result.map(|_| pool) } -fn create_pool(url: &str) -> Result { +fn create_pool(url: &str, ca_cert_path: Option<&str>) -> Result { let opts = mysql_async::Opts::from_url(&mysql_async_url(url)).map_err(|e| format!("Invalid MySQL URL: {e}"))?; let pool_opts = mysql_async::PoolOpts::new() .with_constraints(mysql_async::PoolConstraints::new(1, 1).unwrap()) .with_inactive_connection_ttl(Duration::from_secs(300)); - let builder = mysql_async::OptsBuilder::from_opts(opts) + let mut builder = mysql_async::OptsBuilder::from_opts(opts) .stmt_cache_size(0) .prefer_socket(false) .pool_opts(Some(pool_opts)) .setup(mysql_setup_queries(url)); + if let Some(ca_cert_path) = + ca_cert_path.map(str::trim).filter(|path| !path.is_empty()).filter(|_| mysql_url_requires_ssl(url)) + { + let ssl_opts = mysql_async::SslOpts::default() + .with_root_certs(vec![PathBuf::from(ca_cert_path).into()]) + .with_danger_skip_domain_validation(true); + builder = builder.ssl_opts(ssl_opts); + } Ok(MySqlPool::new(builder)) } @@ -251,6 +264,9 @@ fn mysql_error_should_retry_with_text_protocol(error: &str) -> bool { } fn ssl_fallback_url(url: &str) -> Option { + if mysql_url_requires_ssl(url) { + return None; + } if url.contains("ssl-mode=preferred") { Some(url.replace("ssl-mode=preferred", "ssl-mode=disabled")) } else if !url.contains("ssl-mode=") { @@ -261,26 +277,69 @@ fn ssl_fallback_url(url: &str) -> Option { } } +fn mysql_url_requires_ssl(url: &str) -> bool { + let Some((_, query)) = url.split_once('?') else { + return false; + }; + query.split('&').any(|segment| { + let Some((key, value)) = segment.split_once('=') else { + return false; + }; + let key = key.trim(); + let value = value.trim(); + (key.eq_ignore_ascii_case("require_ssl") && value.eq_ignore_ascii_case("true")) + || ((key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode")) + && matches!( + value.to_ascii_lowercase().replace('-', "_").as_str(), + "required" | "require" | "verify_ca" | "verify_identity" + )) + }) +} + fn mysql_async_url(url: &str) -> Cow<'_, str> { let Some((base, query)) = url.split_once('?') else { return Cow::Borrowed(url); }; - let filtered: Vec<&str> = query - .split('&') - .filter(|segment| { - let segment = segment.trim(); - !segment.is_empty() - && !segment.starts_with("ssl-mode=") - && !segment.starts_with("charset=") - && !segment.starts_with("time_zone=") - && !segment.starts_with("time-zone=") - && !segment.to_ascii_lowercase().starts_with("connect_timeout=") - && !segment.to_ascii_lowercase().starts_with("connecttimeout=") - }) - .collect(); + let original_count = query.split('&').filter(|segment| !segment.trim().is_empty()).count(); + let mut filtered: Vec = Vec::new(); + for segment in query.split('&') { + let segment = segment.trim(); + if segment.is_empty() + || segment.starts_with("charset=") + || segment.starts_with("time_zone=") + || segment.starts_with("time-zone=") + || segment.to_ascii_lowercase().starts_with("connect_timeout=") + || segment.to_ascii_lowercase().starts_with("connecttimeout=") + { + continue; + } - if filtered.len() == query.split('&').filter(|segment| !segment.trim().is_empty()).count() { + let Some((key, value)) = segment.split_once('=') else { + filtered.push(segment.to_string()); + continue; + }; + if key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode") { + match value.to_ascii_lowercase().replace('-', "_").as_str() { + "disabled" | "disable" => filtered.push("require_ssl=false".to_string()), + "required" | "require" => { + filtered.push("require_ssl=true".to_string()); + filtered.push("verify_ca=false".to_string()); + filtered.push("verify_identity=false".to_string()); + } + "verify_ca" => { + filtered.push("require_ssl=true".to_string()); + filtered.push("verify_identity=false".to_string()); + } + "verify_identity" => filtered.push("require_ssl=true".to_string()), + _ => {} + } + continue; + } + filtered.push(segment.to_string()); + } + + if filtered.len() == original_count { Cow::Borrowed(url) } else if filtered.is_empty() { Cow::Owned(base.to_string()) @@ -291,7 +350,7 @@ fn mysql_async_url(url: &str) -> Cow<'_, str> { pub async fn connect_bare(url: &str) -> Result { let timeout = super::parse_connect_timeout(url); - let pool = create_pool(url)?; + let pool = create_pool(url, None)?; verify_pool_connection(&pool, timeout).await.map(|_| pool) } @@ -857,6 +916,22 @@ mod tests { assert_eq!(crate::db::parse_connect_timeout(url), default); } + #[test] + fn mysql_async_url_translates_standard_required_ssl_mode() { + let url = "mysql://host:3306/db?ssl-mode=required&charset=utf8mb4"; + + assert_eq!( + mysql_async_url(url).as_ref(), + "mysql://host:3306/db?require_ssl=true&verify_ca=false&verify_identity=false" + ); + } + + #[test] + fn ssl_fallback_does_not_disable_required_tls() { + assert_eq!(ssl_fallback_url("mysql://host:3306/db?require_ssl=true&charset=utf8mb4"), None); + assert_eq!(ssl_fallback_url("mysql://host:3306/db?ssl-mode=verify_ca&charset=utf8mb4"), None); + } + #[test] fn mysql_setup_queries_default_to_utf8mb4() { assert_eq!(mysql_setup_queries("mysql://host:3306/db"), vec!["SET NAMES utf8mb4"]); diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 0f160cf25c..784a7bf220 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -494,26 +494,7 @@ impl ConnectionConfig { }; } match self.db_type { - DatabaseType::Mysql => { - let base = "ssl-mode=preferred&charset=utf8mb4"; - if value.is_empty() { - base.to_string() - } else if value.contains("ssl-mode=") { - let v = value.trim_start_matches('?'); - if v.contains("charset=") { - v.to_string() - } else { - format!("{v}&charset=utf8mb4") - } - } else { - let v = value.trim_start_matches('?'); - if v.contains("charset=") { - format!("ssl-mode=preferred&{v}") - } else { - format!("{base}&{v}") - } - } - } + DatabaseType::Mysql => normalize_mysql_url_params(value, self.ssl, self.ca_cert_path.trim().is_empty()), DatabaseType::Doris | DatabaseType::StarRocks => { let v = value.trim_start_matches('?'); let filtered: Vec<&str> = v @@ -526,7 +507,7 @@ impl ConnectionConfig { format!("ssl-mode=disabled&{}", filtered.join("&")) } } - DatabaseType::Postgres | DatabaseType::Redshift => normalize_postgres_url_params(value), + DatabaseType::Postgres | DatabaseType::Redshift => normalize_postgres_url_params(value, self.ssl), DatabaseType::MongoDb => value.trim_start_matches('?').to_string(), _ => value.trim_start_matches('?').to_string(), } @@ -543,12 +524,37 @@ fn url_params_contains_flag(params: Option<&str>, key: &str, expected: &str) -> ) } -fn normalize_postgres_url_params(value: &str) -> String { +fn normalize_mysql_url_params(value: &str, force_tls: bool, accept_invalid_certs: bool) -> String { let value = value.trim_start_matches('?'); - if value.is_empty() { - return String::new(); + let mut parts: Vec = value.split('&').filter(|part| !part.is_empty()).map(str::to_string).collect(); + + if force_tls { + parts.retain(|part| !url_param_key_is(part, "ssl-mode") && !url_param_key_is(part, "sslmode")); + if !parts.iter().any(|part| url_param_key_is(part, "require_ssl")) { + parts.insert(0, "require_ssl=true".to_string()); + } + if accept_invalid_certs && !parts.iter().any(|part| url_param_key_is(part, "verify_ca")) { + parts.push("verify_ca=false".to_string()); + } + if !parts.iter().any(|part| url_param_key_is(part, "verify_identity")) { + parts.push("verify_identity=false".to_string()); + } + } else if !parts.iter().any(|part| { + url_param_key_is(part, "ssl-mode") || url_param_key_is(part, "sslmode") || url_param_key_is(part, "require_ssl") + }) { + parts.insert(0, "ssl-mode=preferred".to_string()); } + if !parts.iter().any(|part| url_param_key_is(part, "charset")) { + parts.push("charset=utf8mb4".to_string()); + } + + parts.join("&") +} + +fn normalize_postgres_url_params(value: &str, force_tls: bool) -> String { + let value = value.trim_start_matches('?'); + let mut timezone: Option = None; let mut parts: Vec = Vec::new(); @@ -566,6 +572,9 @@ fn normalize_postgres_url_params(value: &str) -> String { } let Some(timezone) = timezone else { + if force_tls && !parts.iter().any(|part| url_param_key_is(part, "sslmode")) { + parts.insert(0, "sslmode=require".to_string()); + } return parts.join("&"); }; @@ -585,9 +594,18 @@ fn normalize_postgres_url_params(value: &str) -> String { parts.push(format!("options={}", encode_url_part(&timezone_option))); } + if force_tls && !parts.iter().any(|part| url_param_key_is(part, "sslmode")) { + parts.insert(0, "sslmode=require".to_string()); + } + parts.join("&") } +fn url_param_key_is(part: &str, expected: &str) -> bool { + let key = part.split_once('=').map(|(key, _)| key).unwrap_or(part); + percent_decode_str(key).decode_utf8_lossy().eq_ignore_ascii_case(expected) +} + fn clickhouse_http_url(config: &ConnectionConfig, host: &str, port: u16) -> String { let trimmed = host.trim(); if let Some(rest) = trimmed.strip_prefix("https://") { @@ -985,6 +1003,29 @@ mod tests { ); } + #[test] + fn mysql_tls_switch_requires_ssl_without_strict_certificate_checks_by_default() { + let mut config = mysql_config("root", "secret", Some("test")); + config.ssl = true; + + assert_eq!( + config.connection_url(), + "mysql://root:secret@10.1.2.3:2883/test?require_ssl=true&verify_ca=false&verify_identity=false&charset=utf8mb4" + ); + } + + #[test] + fn mysql_tls_switch_uses_ca_cert_for_ca_validation() { + let mut config = mysql_config("root", "secret", Some("test")); + config.ssl = true; + config.ca_cert_path = "/tmp/tidb-ca.pem".to_string(); + + assert_eq!( + config.connection_url(), + "mysql://root:secret@10.1.2.3:2883/test?require_ssl=true&verify_identity=false&charset=utf8mb4" + ); + } + #[test] fn postgres_url_appends_custom_params() { let mut config = mysql_config("postgres", "secret", Some("test")); @@ -994,6 +1035,15 @@ mod tests { assert_eq!(config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=disable"); } + #[test] + fn postgres_tls_switch_adds_require_sslmode() { + let mut config = mysql_config("postgres", "secret", Some("test")); + config.db_type = DatabaseType::Postgres; + config.ssl = true; + + assert_eq!(config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=require"); + } + #[test] fn postgres_url_normalizes_timezone_param_into_options() { let mut config = mysql_config("postgres", "secret", Some("test")); diff --git a/packages/app-tests/connectionDialogTls.test.ts b/packages/app-tests/connectionDialogTls.test.ts new file mode 100644 index 0000000000..cb9dc7e9d2 --- /dev/null +++ b/packages/app-tests/connectionDialogTls.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync( + new URL("../../apps/desktop/src/components/connection/ConnectionDialog.vue", import.meta.url), + "utf8", +); + +test("connection dialog exposes generic TLS controls for supported database types", () => { + assert.match(source, /const tlsCapableDatabaseTypes = new Set/); + assert.match(source, /"mysql"/); + assert.match(source, /"postgres"/); + assert.match(source, /const supportsTlsToggle = computed/); + assert.match(source, //); + assert.match(source, / { + assert.match(source, /const supportsCaCertificatePath = computed/); + assert.match(source, /form\.value\.db_type === "mysql"/); + assert.match(source, /v-if="supportsCaCertificatePath"/); + assert.match(source, /v-model="form\.ca_cert_path"/); +}); diff --git a/packages/app-tests/connectionUrl.test.ts b/packages/app-tests/connectionUrl.test.ts index 86fc00065b..eba4db9937 100644 --- a/packages/app-tests/connectionUrl.test.ts +++ b/packages/app-tests/connectionUrl.test.ts @@ -13,7 +13,7 @@ test("parses postgres connection URLs", () => { password: "secret", database: "app", urlParams: "sslmode=require", - ssl: false, + ssl: true, }); }); @@ -30,6 +30,11 @@ test("parses mysql URLs with encoded credentials", () => { assert.equal(parsed.urlParams, "charset=utf8mb4"); }); +test("parses mysql TLS URL params into the SSL switch state", () => { + assert.equal(parseConnectionUrl("mysql://root@tidb.example.com:4000/test?ssl-mode=required").ssl, true); + assert.equal(parseConnectionUrl("mysql://root@tidb.example.com:4000/test?require_ssl=true").ssl, true); +}); + test("parses JDBC URLs by using the inner database URL", () => { const postgres = parseConnectionUrl("jdbc:postgresql://alice:secret@db.example.com:5433/app?sslmode=require"); assert.equal(postgres.dbType, "postgres"); @@ -140,7 +145,10 @@ test("uses selected HTTP-compatible profile for HTTP URLs", () => { }); test("parses HTTPS ClickHouse URLs with selected profile", () => { - const parsed = parseConnectionUrl("https://default:secret@clickhouse.example.com:8443/default?secure=true", "clickhouse"); + const parsed = parseConnectionUrl( + "https://default:secret@clickhouse.example.com:8443/default?secure=true", + "clickhouse", + ); assert.equal(parsed.dbType, "clickhouse"); assert.equal(parsed.driverProfile, "clickhouse"); diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index a2470aae62..60d2793ed9 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -223,7 +223,7 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection } Err(e) => Err(e), }, - DatabaseType::Mysql => match db::mysql::connect(&url).await { + DatabaseType::Mysql => match db::mysql::connect_with_ca_cert(&url, Some(&config.ca_cert_path)).await { Ok(pool) => { let _ = pool.disconnect().await; Ok("Connection successful".to_string()) @@ -348,7 +348,10 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi DatabaseType::Mysql if db_config.needs_bare_mysql() => { PoolKind::Mysql(db::mysql::connect_bare(&url).await?, MysqlMode::Bare) } - DatabaseType::Mysql => PoolKind::Mysql(db::mysql::connect(&url).await?, MysqlMode::Normal), + DatabaseType::Mysql => PoolKind::Mysql( + db::mysql::connect_with_ca_cert(&url, Some(&db_config.ca_cert_path)).await?, + MysqlMode::Normal, + ), DatabaseType::Doris | DatabaseType::StarRocks => { PoolKind::Mysql(db::mysql::connect_bare(&url).await?, MysqlMode::Bare) }