diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 80b85cf3d5..a9e27e06a6 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -92,6 +92,7 @@ const defaultForm = (): Omit => ({ proxy_username: "", proxy_password: "", ssl: false, + ca_cert_path: "", oracle_connection_type: "service_name", connection_string: undefined, jdbc_driver_class: undefined, @@ -342,6 +343,7 @@ watch( proxy_username: config.proxy_username || "", proxy_password: config.proxy_password || "", ssl: config.ssl || false, + ca_cert_path: config.ca_cert_path || "", oracle_connection_type: config.oracle_connection_type || "service_name", connection_string: config.connection_string, jdbc_driver_class: config.jdbc_driver_class, @@ -662,6 +664,11 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { } else { config.oracle_connection_type = config.oracle_connection_type || "service_name"; } + if (config.db_type !== "clickhouse") { + config.ca_cert_path = undefined; + } else { + config.ca_cert_path = config.ca_cert_path?.trim() || ""; + } if (config.db_type === "jdbc") { config.host = ""; config.port = 0; @@ -871,6 +878,20 @@ async function browseSshKeyPath() { } } +async function browseCaCertPath() { + if (isTauriRuntime()) { + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + title: "Select CA Certificate", + multiple: false, + filters: [{ name: "Certificate", extensions: ["crt", "cer", "pem"] }], + }); + if (selected && typeof selected === "string") { + form.value.ca_cert_path = selected; + } + } +} + async function browseDbFilePath() { if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -1433,6 +1454,33 @@ function openExternalUrl(url: string) { +
+ + +
+ +
+ +
+ + + + + + {{ t("connection.caCertPathBrowse") }} + +
+
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 403e9de842..87ab4d0ae0 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -118,6 +118,10 @@ export default { driverName: "Driver Name", driverNamePlaceholder: "Vendor or environment name", urlParams: "URL Params", + sslEnable: "Enable encrypted connection", + caCertPath: "CA Certificate", + caCertPathPlaceholder: "Optional, e.g. ~/.yandex/RootCA.crt", + caCertPathBrowse: "Browse certificate", connectionUrlOptional: "URL (optional)", connectionUrlPlaceholder: "postgresql://user:pass{'@'}host:5432/db?sslmode=require", parseConnectionUrl: "Parse connection URL", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 72da02f0bf..d0314e0227 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -117,6 +117,10 @@ export default { driverName: "驱动名称", driverNamePlaceholder: "厂商或环境名称", urlParams: "URL 参数", + sslEnable: "启用加密连接", + caCertPath: "CA 证书", + caCertPathPlaceholder: "可选,例如 ~/.yandex/RootCA.crt", + caCertPathBrowse: "选择证书", connectionUrlOptional: "URL(可选)", connectionUrlPlaceholder: "postgresql://user:pass{'@'}host:5432/db?sslmode=require", parseConnectionUrl: "解析连接 URL", diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 5112bdeb36..35793335e9 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -81,6 +81,7 @@ export interface ConnectionConfig { proxy_username?: string; proxy_password?: string; ssl?: boolean; + ca_cert_path?: string; sysdba?: boolean; oracle_connection_type?: "service_name" | "sid"; connection_string?: string; diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 6b1abfb887..dd1bb78159 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -232,7 +232,12 @@ impl AppState { DatabaseType::ClickHouse => { let username = if db_config.username.is_empty() { None } else { Some(db_config.username.clone()) }; let password = if db_config.password.is_empty() { None } else { Some(db_config.password.clone()) }; - let client = db::clickhouse_driver::ChClient::new(&url, username, password); + let client = db::clickhouse_driver::ChClient::new_with_ca_cert( + &url, + username, + password, + Some(&db_config.ca_cert_path), + )?; db::clickhouse_driver::test_connection(&client).await?; PoolKind::ClickHouse(client) } @@ -873,6 +878,7 @@ mod tests { proxy_username: String::new(), proxy_password: String::new(), ssl: false, + ca_cert_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: None, diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index b56c6831ed..4f28f326f0 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -307,6 +307,7 @@ mod tests { proxy_username: String::new(), proxy_password: String::new(), ssl: false, + ca_cert_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: None, diff --git a/crates/dbx-core/src/db/clickhouse_driver.rs b/crates/dbx-core/src/db/clickhouse_driver.rs index c3aa3cf595..68023a0da8 100644 --- a/crates/dbx-core/src/db/clickhouse_driver.rs +++ b/crates/dbx-core/src/db/clickhouse_driver.rs @@ -1,5 +1,6 @@ -use reqwest::Client as HttpClient; +use reqwest::{Certificate, Client as HttpClient}; use serde::Deserialize; +use std::fs; use std::time::Instant; use super::{connection_timeout, with_connection_timeout}; @@ -20,6 +21,51 @@ impl ChClient { HttpClient::builder().connect_timeout(connection_timeout()).build().unwrap_or_else(|_| HttpClient::new()); Self { http, base_url: url.trim_end_matches('/').to_string(), username, password } } + + pub fn new_with_ca_cert( + url: &str, + username: Option, + password: Option, + ca_cert_path: Option<&str>, + ) -> Result { + let mut builder = HttpClient::builder().connect_timeout(connection_timeout()); + if let Some(path) = ca_cert_path.map(str::trim).filter(|path| !path.is_empty()) { + let path = expand_cert_path(path); + let cert_bytes = + fs::read(&path).map_err(|e| format!("Failed to read ClickHouse CA certificate at {path}: {e}"))?; + let cert = Certificate::from_pem(&cert_bytes) + .or_else(|_| Certificate::from_der(&cert_bytes)) + .map_err(|e| format!("Failed to parse ClickHouse CA certificate at {path}: {e}"))?; + builder = builder.add_root_certificate(cert); + } + let http = builder.build().map_err(|e| format!("Failed to configure ClickHouse HTTP client: {e}"))?; + Ok(Self { http, base_url: url.trim_end_matches('/').to_string(), username, password }) + } +} + +fn expand_cert_path(path: &str) -> String { + let home = || std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).ok(); + if path == "~" || path.starts_with("~/") || path.starts_with("~\\") { + if let Some(home) = home() { + return format!("{}{}", home, &path[1..]); + } + } + if let Some(rest) = path.strip_prefix("$HOME") { + if let Some(home) = home() { + return format!("{home}{rest}"); + } + } + if let Some(rest) = path.strip_prefix("${HOME}") { + if let Some(home) = home() { + return format!("{home}{rest}"); + } + } + if let Some(rest) = path.strip_prefix("%USERPROFILE%") { + if let Ok(home) = std::env::var("USERPROFILE") { + return format!("{home}{rest}"); + } + } + path.to_string() } impl Clone for ChClient { diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 7db171055e..baddc620e6 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -55,6 +55,8 @@ pub struct ConnectionConfig { pub proxy_password: String, #[serde(default)] pub ssl: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub ca_cert_path: String, #[serde(default)] pub sysdba: bool, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -241,6 +243,7 @@ impl ConnectionConfig { } pub fn redacted_connection_url_with_host(&self, host: &str, port: u16) -> String { + let raw_host = host; let host = bracket_ipv6(host); let db_part = self.effective_database().map(|d| format!("/{}", encode_url_part(d))).unwrap_or_default(); let params = self.normalized_url_params(); @@ -261,7 +264,7 @@ impl ConnectionConfig { let suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; format!("postgres://{host}:{port}{db_part}{suffix}") } - DatabaseType::ClickHouse => format!("http://{host}:{port}"), + DatabaseType::ClickHouse => clickhouse_http_url(self, raw_host, port), DatabaseType::SqlServer => { format!("server=tcp:{host},{port};database={}", self.database.as_deref().unwrap_or("master")) } @@ -321,6 +324,7 @@ impl ConnectionConfig { } pub fn connection_url_with_host(&self, host: &str, port: u16) -> String { + let raw_host = host; let host = bracket_ipv6(host); let db_part = self.effective_database().map(|d| format!("/{}", encode_url_part(d))).unwrap_or_default(); let username = encode_url_part(&self.username); @@ -349,7 +353,7 @@ impl ConnectionConfig { let suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; format!("postgres://{}:{}@{host}:{port}{db_part}{suffix}", username, password) } - DatabaseType::ClickHouse => format!("http://{host}:{port}"), + DatabaseType::ClickHouse => clickhouse_http_url(self, raw_host, port), DatabaseType::SqlServer => format!( "server=tcp:{host},{port};user={};password={};database={}", self.username, @@ -528,6 +532,41 @@ impl ConnectionConfig { _ => value.trim_start_matches('?').to_string(), } } + + pub fn clickhouse_uses_tls(&self) -> bool { + self.ssl || url_params_contains_flag(self.url_params.as_deref(), "secure", "true") + } +} + +fn url_params_contains_flag(params: Option<&str>, key: &str, expected: &str) -> bool { + params.unwrap_or("").trim().trim_start_matches('?').split(['&', ';']).filter_map(|part| part.split_once('=')).any( + |(part_key, value)| part_key.trim().eq_ignore_ascii_case(key) && value.trim().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://") { + return format!("https://{}", trim_clickhouse_host_port(rest, port)); + } + if let Some(rest) = trimmed.strip_prefix("http://") { + let scheme = if config.clickhouse_uses_tls() { "https" } else { "http" }; + return format!("{scheme}://{}", trim_clickhouse_host_port(rest, port)); + } + let scheme = if config.clickhouse_uses_tls() { "https" } else { "http" }; + format!("{scheme}://{}:{port}", bracket_ipv6(trimmed)) +} + +fn trim_clickhouse_host_port(value: &str, default_port: u16) -> String { + let authority = value.trim_end_matches('/').split('/').next().unwrap_or(value).split('?').next().unwrap_or(value); + if authority.starts_with('[') && !authority.contains("]:") { + return format!("{authority}:{default_port}"); + } + if authority.rsplit_once(':').is_some() { + authority.to_string() + } else { + format!("{authority}:{default_port}") + } } pub fn parse_mongo_first_host(uri: &str) -> Option<(String, u16)> { @@ -671,6 +710,7 @@ mod tests { proxy_username: String::new(), proxy_password: String::new(), ssl: false, + ca_cert_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: None, @@ -853,6 +893,32 @@ mod tests { assert_eq!(config.effective_database(), Some("default")); } + #[test] + fn clickhouse_tls_uses_https_from_ssl_or_secure_param() { + let mut config = mysql_config("default", "", None); + config.db_type = DatabaseType::ClickHouse; + config.port = 8443; + + assert_eq!(config.connection_url(), "http://10.1.2.3:8443"); + + config.ssl = true; + assert_eq!(config.connection_url(), "https://10.1.2.3:8443"); + + config.ssl = false; + config.url_params = Some("secure=true".to_string()); + assert_eq!(config.connection_url(), "https://10.1.2.3:8443"); + } + + #[test] + fn clickhouse_host_may_include_http_scheme() { + let mut config = mysql_config("default", "", None); + config.db_type = DatabaseType::ClickHouse; + config.host = "https://clickhouse.example.com".to_string(); + config.port = 8443; + + assert_eq!(config.connection_url(), "https://clickhouse.example.com:8443"); + } + #[test] fn mysql_url_encodes_password_and_database() { let config = mysql_config("root", "p@ss:word#1", Some("db/name")); diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index 9b909632a1..7b8ca7663e 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -1399,6 +1399,7 @@ mod tests { proxy_username: String::new(), proxy_password: String::new(), ssl: false, + ca_cert_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: Some("jdbc:h2:mem:test".to_string()), diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 324398a0dc..6b6d35bade 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -1224,6 +1224,7 @@ mod tests { proxy_username: String::new(), proxy_password: String::new(), ssl: false, + ca_cert_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: None, diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 46ee0b754d..5790a9486f 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -160,6 +160,7 @@ mod tests { proxy_username: String::new(), proxy_password: String::new(), ssl: false, + ca_cert_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: None, diff --git a/packages/app-tests/connectionUrl.test.ts b/packages/app-tests/connectionUrl.test.ts index a2ddcce6ac..86fc00065b 100644 --- a/packages/app-tests/connectionUrl.test.ts +++ b/packages/app-tests/connectionUrl.test.ts @@ -139,6 +139,20 @@ test("uses selected HTTP-compatible profile for HTTP URLs", () => { assert.equal(parsed.ssl, true); }); +test("parses HTTPS ClickHouse URLs with selected profile", () => { + const parsed = parseConnectionUrl("https://default:secret@clickhouse.example.com:8443/default?secure=true", "clickhouse"); + + assert.equal(parsed.dbType, "clickhouse"); + assert.equal(parsed.driverProfile, "clickhouse"); + assert.equal(parsed.host, "clickhouse.example.com"); + assert.equal(parsed.port, 8443); + assert.equal(parsed.username, "default"); + assert.equal(parsed.password, "secret"); + assert.equal(parsed.database, "default"); + assert.equal(parsed.urlParams, "secure=true"); + assert.equal(parsed.ssl, true); +}); + test("rejects unsupported URL schemes", () => { assert.throws(() => parseConnectionUrl("ftp://example.com"), /Unsupported connection URL scheme/); }); diff --git a/packages/node-core/src/connections.ts b/packages/node-core/src/connections.ts index 6f2391a33a..003f8c298f 100644 --- a/packages/node-core/src/connections.ts +++ b/packages/node-core/src/connections.ts @@ -23,6 +23,7 @@ export interface ConnectionConfig { proxy_username?: string; proxy_password?: string; ssl: boolean; + ca_cert_path?: string; oracle_connection_type?: "service_name" | "sid"; } diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 2e8278c0a9..a2470aae62 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -150,6 +150,7 @@ mod tests { proxy_username: String::new(), proxy_password: String::new(), ssl: false, + ca_cert_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: Some( @@ -282,7 +283,12 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection DatabaseType::ClickHouse => { let username = if config.username.is_empty() { None } else { Some(config.username.clone()) }; let password = if config.password.is_empty() { None } else { Some(config.password.clone()) }; - let client = db::clickhouse_driver::ChClient::new(&url, username, password); + let client = db::clickhouse_driver::ChClient::new_with_ca_cert( + &url, + username, + password, + Some(&config.ca_cert_path), + )?; db::clickhouse_driver::test_connection(&client).await.map(|_| "Connection successful".to_string()) } DatabaseType::SqlServer => { @@ -390,7 +396,12 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi let username = if db_config.username.is_empty() { None } else { Some(db_config.username.clone()) }; let password = if db_config.password.is_empty() { None } else { Some(db_config.password.clone()) }; log::info!("[connect_db] ClickHouse url={url} user={:?} has_pass={}", username, password.is_some()); - let client = db::clickhouse_driver::ChClient::new(&url, username, password); + let client = db::clickhouse_driver::ChClient::new_with_ca_cert( + &url, + username, + password, + Some(&db_config.ca_cert_path), + )?; db::clickhouse_driver::test_connection(&client).await?; PoolKind::ClickHouse(client) }