feat: add ClickHouse TLS connection support
This commit is contained in:
1 parent
5fae9808a2
commit
fe2ecd60d2
14 files changed
+219
-11
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user