From 23b9228f826ab5df7ced3aa175e751181b3d0be1 Mon Sep 17 00:00:00 2001 From: Tony Tang <8235790+d0zingcat@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:37:20 +0800 Subject: [PATCH] feat(connection): add StarRocks TLS support --- .../connection/ConnectionDialog.vue | 8 +- crates/dbx-core/src/connection.rs | 50 +++++- crates/dbx-core/src/models/connection.rs | 103 ++++++++++- packages/node-core/src/database.ts | 162 +++++++++++++++++- .../node-core/tests/starrocks-url.test.ts | 62 +++++++ src-tauri/src/commands/connection.rs | 27 ++- 6 files changed, 393 insertions(+), 19 deletions(-) create mode 100644 packages/node-core/tests/starrocks-url.test.ts diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 1eac27c58e..9503899c2e 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -1587,12 +1587,12 @@ const sqliteExtensionPaths = computed({ form.value.url_params = setSqliteExtensionPaths(form.value.url_params, value); }, }); -const tlsCapableDatabaseTypes = new Set(["mysql", "postgres", "redshift", "gaussdb", "kwdb", "opengauss", "questdb", "redis", "etcd", "clickhouse", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "influxdb"]); +const tlsCapableDatabaseTypes = new Set(["mysql", "starrocks", "postgres", "redshift", "gaussdb", "kwdb", "opengauss", "questdb", "redis", "etcd", "clickhouse", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "influxdb"]); const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type)); const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse"); const supportsGenericUrlParams = computed(() => form.value.db_type !== "manticoresearch"); -const bareMysqlProfiles = new Set(["doris", "starrocks", "selectdb", "oceanbase"]); -const supportsMysqlTlsOptions = computed(() => form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value)); +const bareMysqlProfiles = new Set(["doris", "selectdb", "oceanbase"]); +const supportsMysqlTlsOptions = computed(() => form.value.db_type === "starrocks" || (form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value))); const mysqlTlsMode = computed({ get: () => mysqlTlsModeFromParams(form.value.url_params, form.value.ssl), set: (value: string) => { @@ -2132,7 +2132,7 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { config.client_cert_path = undefined; config.client_key_path = undefined; } - if (config.db_type !== "mysql" && config.db_type !== "clickhouse" && config.db_type !== "etcd") { + if (config.db_type !== "mysql" && config.db_type !== "clickhouse" && config.db_type !== "etcd" && config.db_type !== "starrocks") { config.ca_cert_path = undefined; } else { config.ca_cert_path = config.ca_cert_path?.trim() || ""; diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 1018884bdc..289ce00735 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -250,7 +250,8 @@ pub async fn connect_mysql_metadata_pool( let idle_timeout_secs = Some(db_config.idle_timeout_secs); let extra_setup_queries = oceanbase_mysql_setup_queries(db_config); if db_config.needs_bare_mysql() { - return match db::mysql::connect_bare_with_pool_limit_and_setup( + return match connect_bare_mysql_pool_with_setup( + db_config, &url, connect_timeout, max_connections, @@ -265,7 +266,8 @@ pub async fn connect_mysql_metadata_pool( log::info!( "MySQL metadata connection without a default database failed ({err}); retrying with configured default database." ); - db::mysql::connect_bare_with_pool_limit_and_setup( + connect_bare_mysql_pool_with_setup( + db_config, &fallback_url, connect_timeout, max_connections, @@ -328,7 +330,8 @@ pub async fn connect_bare_metadata_pool( let url = connection_url_for_endpoint(db_config, host, port); let extra_setup_queries = oceanbase_mysql_setup_queries(db_config); if db_config.effective_database().is_none() { - return db::mysql::connect_bare_with_pool_limit_and_setup( + return connect_bare_mysql_pool_with_setup( + db_config, &url, connect_timeout, max_connections, @@ -341,7 +344,8 @@ pub async fn connect_bare_metadata_pool( unscoped_config.database = None; let unscoped_url = connection_url_for_endpoint(&unscoped_config, host, port); if unscoped_url == url { - return db::mysql::connect_bare_with_pool_limit_and_setup( + return connect_bare_mysql_pool_with_setup( + db_config, &url, connect_timeout, max_connections, @@ -351,8 +355,9 @@ pub async fn connect_bare_metadata_pool( } let preferred = - db::mysql::connect_bare_with_pool_limit_and_setup(&url, connect_timeout, max_connections, &extra_setup_queries); - let unscoped = db::mysql::connect_bare_with_pool_limit_and_setup( + connect_bare_mysql_pool_with_setup(db_config, &url, connect_timeout, max_connections, &extra_setup_queries); + let unscoped = connect_bare_mysql_pool_with_setup( + db_config, &unscoped_url, connect_timeout, max_connections, @@ -383,6 +388,30 @@ pub async fn connect_bare_metadata_pool( } } +async fn connect_bare_mysql_pool_with_setup( + db_config: &ConnectionConfig, + url: &str, + connect_timeout: std::time::Duration, + max_connections: usize, + extra_setup_queries: &[String], +) -> Result { + if db_config.bare_mysql_uses_tls() { + let idle_timeout_secs = Some(db_config.idle_timeout_secs); + db::mysql::connect_with_ca_cert_pool_limit_idle_and_setup( + url, + Some(&db_config.ca_cert_path), + connect_timeout, + max_connections, + idle_timeout_secs, + extra_setup_queries, + ) + .await + } else { + db::mysql::connect_bare_with_pool_limit_and_setup(url, connect_timeout, max_connections, extra_setup_queries) + .await + } +} + fn mysql_metadata_fallback_url( config: &ConnectionConfig, db_config: &ConnectionConfig, @@ -751,7 +780,14 @@ impl AppState { connect_bare_metadata_pool(&db_config, &host, port, connect_timeout, mysql_pool_max_connections) .await? } else { - db::mysql::connect_bare_with_pool_limit(&url, connect_timeout, mysql_pool_max_connections).await? + connect_bare_mysql_pool_with_setup( + &db_config, + &url, + connect_timeout, + mysql_pool_max_connections, + &oceanbase_mysql_setup_queries(&db_config), + ) + .await? }; PoolKind::Mysql(pool, MysqlMode::Bare) } diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 98e3c0e0c7..438752de2d 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -659,6 +659,25 @@ impl ConnectionConfig { }) } + pub fn is_starrocks(&self) -> bool { + self.db_type == DatabaseType::StarRocks + || self.driver_profile.as_deref().is_some_and(|profile| profile.eq_ignore_ascii_case("starrocks")) + } + + pub fn bare_mysql_supports_tls(&self) -> bool { + self.is_starrocks() + } + + pub fn bare_mysql_uses_tls(&self) -> bool { + if !self.bare_mysql_supports_tls() { + return false; + } + if mysql_url_params_tls_disabled(self.url_params.as_deref()) { + return false; + } + self.mysql_uses_tls() + } + pub fn canonicalized(&self) -> Self { let mut config = self.clone(); if config.db_type == DatabaseType::Mysql @@ -1072,6 +1091,9 @@ impl ConnectionConfig { fn normalized_url_params(&self) -> String { let value = self.url_params.as_deref().unwrap_or("").trim(); if self.needs_bare_mysql() { + if self.bare_mysql_uses_tls() { + return normalize_mysql_url_params(value, true, self.ca_cert_path.trim().is_empty()); + } return normalize_bare_mysql_url_params(value); } match self.db_type { @@ -1097,8 +1119,10 @@ impl ConnectionConfig { self.ssl || url_params_contains_flag(self.url_params.as_deref(), "secure", "true") } - fn mysql_uses_tls(&self) -> bool { - self.ssl || self.host.to_ascii_lowercase().ends_with(".tidbcloud.com") + pub fn mysql_uses_tls(&self) -> bool { + self.ssl + || self.host.to_ascii_lowercase().ends_with(".tidbcloud.com") + || mysql_url_params_require_tls(self.url_params.as_deref()) } fn redis_tls_insecure_fragment(&self) -> &'static str { @@ -1131,6 +1155,50 @@ fn url_params_contains_flag(params: Option<&str>, key: &str, expected: &str) -> ) } +fn mysql_tls_file_param_is(key: &str, target: &str) -> bool { + let normalized = key.to_ascii_lowercase().replace(['-', '_'], ""); + normalized == format!("ssl{target}") +} + +fn mysql_url_params_tls_disabled(params: Option<&str>) -> bool { + params.unwrap_or("").trim().trim_start_matches('?').split('&').any(|part| { + let part = part.trim(); + if part.is_empty() { + return false; + } + let Some((key, value)) = part.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("false")) + || ((key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode")) + && matches!(value.to_ascii_lowercase().replace('-', "_").as_str(), "disabled" | "disable")) + }) +} + +fn mysql_url_params_require_tls(params: Option<&str>) -> bool { + params.unwrap_or("").trim().trim_start_matches('?').split('&').any(|part| { + let part = part.trim(); + if part.is_empty() { + return false; + } + let Some((key, value)) = part.split_once('=') else { + return mysql_tls_file_param_is(part, "cert") || mysql_tls_file_param_is(part, "key"); + }; + let key = key.trim(); + let value = value.trim(); + (key.eq_ignore_ascii_case("require_ssl") && value.eq_ignore_ascii_case("true")) + || mysql_tls_file_param_is(key, "cert") + || mysql_tls_file_param_is(key, "key") + || ((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 normalize_bare_mysql_url_params(value: &str) -> String { value .trim_start_matches('?') @@ -1863,7 +1931,7 @@ mod tests { } #[test] - fn starrocks_profile_omits_mysql_ssl_mode_param() { + fn starrocks_profile_omits_mysql_ssl_mode_param_when_tls_disabled() { let mut config = mysql_config("root", "secret", Some("analytics")); config.driver_profile = Some("starrocks".to_string()); config.url_params = Some( @@ -1872,9 +1940,38 @@ mod tests { ); assert!(config.needs_bare_mysql()); + assert!(!config.bare_mysql_uses_tls()); assert_eq!(config.connection_url(), "mysql://root:secret@10.1.2.3:2883/analytics"); } + #[test] + fn starrocks_profile_preserves_mysql_tls_params_when_enabled() { + let mut config = mysql_config("root", "secret", Some("analytics")); + config.driver_profile = Some("starrocks".to_string()); + config.ssl = true; + config.url_params = Some("verify_ca=true&verify_identity=false".to_string()); + + assert!(config.bare_mysql_uses_tls()); + assert_eq!( + config.connection_url(), + "mysql://root:secret@10.1.2.3:2883/analytics?require_ssl=true&verify_ca=true&verify_identity=false&charset=utf8mb4" + ); + } + + #[test] + fn starrocks_database_type_preserves_mysql_tls_params_when_enabled() { + let mut config = mysql_config("root", "secret", Some("analytics")); + config.db_type = DatabaseType::StarRocks; + config.ssl = true; + config.ca_cert_path = "/tmp/starrocks-ca.pem".to_string(); + + assert!(config.bare_mysql_uses_tls()); + assert_eq!( + config.connection_url(), + "mysql://root:secret@10.1.2.3:2883/analytics?require_ssl=true&verify_identity=false&charset=utf8mb4" + ); + } + #[test] fn starrocks_profile_keeps_non_mysql_tls_params() { let mut config = mysql_config("root", "secret", Some("analytics")); diff --git a/packages/node-core/src/database.ts b/packages/node-core/src/database.ts index 772ebf3926..4c4dfbcc5a 100644 --- a/packages/node-core/src/database.ts +++ b/packages/node-core/src/database.ts @@ -1,4 +1,5 @@ import type { ConnectionConfig, ProxyTunnelConfig } from "./connections.js"; +import type { SslOptions } from "mysql2"; import { createServer, connect as netConnect, type Server, type Socket } from "node:net"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; @@ -148,12 +149,15 @@ async function getMysqlPool(config: ConnectionConfig): Promise {}, 0) }; pools.set(key, entry); resetIdleTimer(key, entry); @@ -209,7 +213,7 @@ async function connectionEndpoint(config: ConnectionConfig): Promise<{ host: str export function buildConnectionUrl(config: ConnectionConfig, endpoint: { host: string; port: number }): string { const db = config.database || ""; if (isMysqlType(config.db_type)) { - const params = config.url_params || ""; + const params = buildMysqlUrlParams(config); const suffix = params ? `?${params}` : ""; return `mysql://${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@${endpoint.host}:${endpoint.port}/${db}${suffix}`; } @@ -396,6 +400,158 @@ function isMysqlType(dbType: string): boolean { return dbType === "mysql" || dbType === "doris" || dbType === "starrocks" || dbType === "manticoresearch"; } +function isStarrocksConnection(config: ConnectionConfig): boolean { + return config.db_type === "starrocks" || config.driver_profile?.toLowerCase() === "starrocks"; +} + +function needsBareMysql(config: ConnectionConfig): boolean { + const profile = config.driver_profile?.toLowerCase(); + return ( + config.db_type === "doris" || + config.db_type === "starrocks" || + config.db_type === "manticoresearch" || + profile === "doris" || + profile === "starrocks" || + profile === "manticoresearch" || + profile === "selectdb" || + profile === "oceanbase" + ); +} + +function mysqlTlsFileParamIs(key: string, target: "cert" | "key"): boolean { + return key.toLowerCase().replace(/[-_]/g, "") === `ssl${target}`; +} + +function mysqlUrlParamsRequireTls(params: string): boolean { + for (const part of params.trim().replace(/^\?/, "").split("&")) { + if (!part) continue; + const [rawKey, rawValue = ""] = splitUrlParam(part); + const key = decodeUrlParamPart(rawKey); + const value = decodeUrlParamPart(rawValue); + if (key.toLowerCase() === "require_ssl" && value.toLowerCase() === "true") return true; + if (mysqlTlsFileParamIs(key, "cert") || mysqlTlsFileParamIs(key, "key")) return true; + if (key.toLowerCase() === "ssl-mode" || key.toLowerCase() === "sslmode") { + const mode = value.toLowerCase().replace(/-/g, "_"); + if (mode === "required" || mode === "require" || mode === "verify_ca" || mode === "verify_identity") return true; + } + } + return false; +} + +function mysqlUrlParamsTlsDisabled(params: string): boolean { + for (const part of params.trim().replace(/^\?/, "").split("&")) { + if (!part) continue; + const [rawKey, rawValue = ""] = splitUrlParam(part); + const key = decodeUrlParamPart(rawKey).toLowerCase(); + const value = decodeUrlParamPart(rawValue).toLowerCase(); + if (key === "require_ssl" && value === "false") return true; + if ((key === "ssl-mode" || key === "sslmode") && (value === "disabled" || value === "disable")) return true; + } + return false; +} + +function mysqlUsesTls(config: ConnectionConfig): boolean { + return !!config.ssl || mysqlUrlParamsRequireTls(config.url_params || ""); +} + +function bareMysqlUsesTls(config: ConnectionConfig): boolean { + if (!isStarrocksConnection(config)) { + return false; + } + if (mysqlUrlParamsTlsDisabled(config.url_params || "")) { + return false; + } + return mysqlUsesTls(config); +} + +function normalizeBareMysqlUrlParams(value: string): string { + return value + .trim() + .replace(/^\?/, "") + .split("&") + .filter((part) => { + if (!part) return false; + const key = decodeUrlParamPart(splitUrlParam(part)[0]).toLowerCase(); + return ( + key !== "charset" && + key !== "ssl-mode" && + key !== "sslmode" && + key !== "require_ssl" && + key !== "verify_ca" && + key !== "verify_identity" + ); + }) + .join("&"); +} + +function normalizeMysqlUrlParams(value: string, forceTls: boolean, acceptInvalidCerts: boolean): string { + const parts = value + .trim() + .replace(/^\?/, "") + .split("&") + .filter((part) => part.length > 0); + + if (forceTls) { + const filtered = parts.filter((part) => { + const key = decodeUrlParamPart(splitUrlParam(part)[0]).toLowerCase(); + return key !== "ssl-mode" && key !== "sslmode" && key !== "require_ssl"; + }); + filtered.unshift("require_ssl=true"); + if (acceptInvalidCerts && !filtered.some((part) => urlParamKeyIs(part, "verify_ca"))) { + filtered.push("verify_ca=false"); + } + if (!filtered.some((part) => urlParamKeyIs(part, "verify_identity"))) { + filtered.push("verify_identity=false"); + } + if (!filtered.some((part) => urlParamKeyIs(part, "charset"))) { + filtered.push("charset=utf8mb4"); + } + return filtered.join("&"); + } + + if ( + !parts.some( + (part) => + urlParamKeyIs(part, "ssl-mode") || urlParamKeyIs(part, "sslmode") || urlParamKeyIs(part, "require_ssl"), + ) + ) { + parts.unshift("ssl-mode=preferred"); + } + if (!parts.some((part) => urlParamKeyIs(part, "charset"))) { + parts.push("charset=utf8mb4"); + } + return parts.join("&"); +} + +function buildMysqlUrlParams(config: ConnectionConfig): string { + const raw = config.url_params || ""; + if (needsBareMysql(config)) { + if (bareMysqlUsesTls(config)) { + return normalizeMysqlUrlParams(raw, true, !config.ca_cert_path?.trim()); + } + return normalizeBareMysqlUrlParams(raw); + } + return raw; +} + +async function mysqlTlsOptions(config: ConnectionConfig): Promise { + if (!bareMysqlUsesTls(config)) return undefined; + + const params = urlParams(config); + const tls: SslOptions = {}; + const verifyCa = (params.get("verify_ca") || "").toLowerCase() === "true"; + const verifyIdentity = (params.get("verify_identity") || "").toLowerCase() === "true"; + if (!verifyCa && !verifyIdentity) { + tls.rejectUnauthorized = false; + } + if (config.ca_cert_path) tls.ca = await readFile(config.ca_cert_path); + const certPath = params.get("ssl-cert") || params.get("sslcert"); + const keyPath = params.get("ssl-key") || params.get("sslkey"); + if (certPath) tls.cert = await readFile(certPath); + if (keyPath) tls.key = await readFile(keyPath); + return tls; +} + function isPostgresType(dbType: string): boolean { return dbType === "postgres" || dbType === "redshift" || dbType === "gaussdb" || dbType === "kwdb" || dbType === "opengauss" || dbType === "questdb"; } diff --git a/packages/node-core/tests/starrocks-url.test.ts b/packages/node-core/tests/starrocks-url.test.ts new file mode 100644 index 0000000000..936248438f --- /dev/null +++ b/packages/node-core/tests/starrocks-url.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import type { ConnectionConfig } from "../src/connections.js"; +import { buildConnectionUrl } from "../src/database.js"; + +function starrocksConfig(overrides: Partial = {}): ConnectionConfig { + return { + id: "sr-1", + name: "starrocks", + db_type: "starrocks", + host: "fe-example.starrocks.aliyuncs.com", + port: 9030, + username: "admin", + password: "secret", + database: "analytics", + ssl: false, + ...overrides, + }; +} + +test("starrocks omits tls params when ssl is disabled", () => { + const url = buildConnectionUrl( + starrocksConfig({ + url_params: "ssl-mode=disabled&require_ssl=true&verify_ca=true&charset=utf8mb4", + }), + { host: "fe-example.starrocks.aliyuncs.com", port: 9030 }, + ); + + assert.equal(url, "mysql://admin:secret@fe-example.starrocks.aliyuncs.com:9030/analytics"); +}); + +test("starrocks preserves tls params when ssl is enabled", () => { + const url = buildConnectionUrl( + starrocksConfig({ + ssl: true, + url_params: "verify_ca=true&verify_identity=false", + }), + { host: "fe-example.starrocks.aliyuncs.com", port: 9030 }, + ); + + assert.equal( + url, + "mysql://admin:secret@fe-example.starrocks.aliyuncs.com:9030/analytics?require_ssl=true&verify_ca=true&verify_identity=false&charset=utf8mb4", + ); +}); + +test("mysql starrocks profile preserves tls params when ssl is enabled", () => { + const url = buildConnectionUrl( + starrocksConfig({ + db_type: "mysql", + driver_profile: "starrocks", + ssl: true, + ca_cert_path: "/tmp/ca.pem", + }), + { host: "fe-example.starrocks.aliyuncs.com", port: 9030 }, + ); + + assert.equal( + url, + "mysql://admin:secret@fe-example.starrocks.aliyuncs.com:9030/analytics?require_ssl=true&verify_identity=false&charset=utf8mb4", + ); +}); diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 35dd7a62c3..0cad1ac121 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -502,7 +502,7 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection let result = match probe_result { Err(e) => Err(e), Ok(()) => match config.db_type { - DatabaseType::Mysql if config.needs_bare_mysql() => { + DatabaseType::Mysql if config.needs_bare_mysql() && !config.bare_mysql_uses_tls() => { match db::mysql::connect_bare(&url, connect_timeout).await { Ok(pool) => { let _ = pool.disconnect().await; @@ -511,6 +511,15 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection Err(e) => Err(e), } } + DatabaseType::Mysql if config.needs_bare_mysql() && config.bare_mysql_uses_tls() => { + match db::mysql::connect_with_ca_cert(&url, Some(&config.ca_cert_path), connect_timeout).await { + Ok(pool) => { + let _ = pool.disconnect().await; + Ok("Connection successful".to_string()) + } + Err(e) => Err(e), + } + } DatabaseType::Mysql => { match db::mysql::connect_with_ca_cert(&url, Some(&config.ca_cert_path), connect_timeout).await { Ok(pool) => { @@ -520,7 +529,7 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection Err(e) => Err(e), } } - DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::ManticoreSearch => { + DatabaseType::Doris | DatabaseType::ManticoreSearch => { match db::mysql::connect_bare(&url, connect_timeout).await { Ok(pool) => { let _ = pool.disconnect().await; @@ -529,6 +538,20 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection Err(e) => Err(e), } } + DatabaseType::StarRocks => { + let connect = if config.bare_mysql_uses_tls() { + db::mysql::connect_with_ca_cert(&url, Some(&config.ca_cert_path), connect_timeout).await + } else { + db::mysql::connect_bare(&url, connect_timeout).await + }; + match connect { + Ok(pool) => { + let _ = pool.disconnect().await; + Ok("Connection successful".to_string()) + } + Err(e) => Err(e), + } + } DatabaseType::Postgres | DatabaseType::Redshift | DatabaseType::Gaussdb