feat: add ClickHouse TLS connection support

This commit is contained in:
t8y2 committed 2026-05-25 21:07:22 +08:00
1 parent 5fae9808a2
commit fe2ecd60d2
14 files changed
+219 -11

No files matched your search

+7 -1
View File
@@ -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,
@@ -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,
+47 -1
View File
@@ -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<String>,
password: Option<String>,
ca_cert_path: Option<&str>,
) -> Result<Self, String> {
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 {
+68 -2
View File
@@ -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"));
+1
View File
@@ -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()),
+1
View File
@@ -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,
+1
View File
@@ -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,