From 45e67fae6e88c2e69a90f113ab336d228a3ce70f Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 12 May 2026 14:53:45 +0800 Subject: [PATCH] feat(jdbc): support SSH tunnel and proxy for JDBC connections Parse host:port from JDBC URLs and rewrite them when an SSH or proxy tunnel is active, so the Java plugin connects through the local forwarded port. Enable the SSH/Proxy tabs in the connection dialog for JDBC. --- crates/dbx-core/src/connection.rs | 28 +++- crates/dbx-core/src/models/connection.rs | 134 ++++++++++++++++++ .../connection/ConnectionDialog.vue | 6 +- 3 files changed, 162 insertions(+), 6 deletions(-) diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 56280d6d9..f8adcf8d5 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -8,7 +8,9 @@ use crate::db; use crate::db::proxy_tunnel::ProxyTunnelManager; use crate::db::ssh_tunnel::TunnelManager; use crate::external; -use crate::models::connection::{parse_mongo_first_host, ConnectionConfig, DatabaseType}; +use crate::models::connection::{ + parse_jdbc_host_port, parse_mongo_first_host, rewrite_jdbc_url_host, ConnectionConfig, DatabaseType, +}; use crate::plugins::{PluginDriverSession, PluginRegistry}; use crate::query_cancel::RunningQueries; use crate::storage::Storage; @@ -292,7 +294,15 @@ impl AppState { .await?; PoolKind::Gaussdb(Arc::new(tokio::sync::Mutex::new(client))) } - DatabaseType::Jdbc => self.external_driver_pool("jdbc", &db_config).await?, + DatabaseType::Jdbc => { + let mut jdbc_config = db_config.clone(); + if host != config.host || port != config.port { + if let Some(ref url) = jdbc_config.connection_string { + jdbc_config.connection_string = Some(rewrite_jdbc_url_host(url, &host, port)); + } + } + self.external_driver_pool("jdbc", &jdbc_config).await? + } }; self.connections.write().await.insert(pool_key.clone(), pool); @@ -317,6 +327,13 @@ impl AppState { .filter(|s| !s.is_empty()) .and_then(parse_mongo_first_host) .unwrap_or_else(|| (config.host.clone(), config.port)) + } else if config.db_type == DatabaseType::Jdbc { + config + .connection_string + .as_deref() + .filter(|s| !s.is_empty()) + .and_then(parse_jdbc_host_port) + .unwrap_or_else(|| (config.host.clone(), config.port)) } else { (config.host.clone(), config.port) }; @@ -350,6 +367,13 @@ impl AppState { .filter(|s| !s.is_empty()) .and_then(parse_mongo_first_host) .unwrap_or_else(|| (config.host.clone(), config.port)) + } else if config.db_type == DatabaseType::Jdbc { + config + .connection_string + .as_deref() + .filter(|s| !s.is_empty()) + .and_then(parse_jdbc_host_port) + .unwrap_or_else(|| (config.host.clone(), config.port)) } else { (config.host.clone(), config.port) }; diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 3c7f5d8ea..f47e90f2d 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -387,6 +387,53 @@ fn rewrite_mongo_uri_host(uri: &str, new_host: &str, new_port: u16) -> String { result } +pub fn parse_jdbc_host_port(url: &str) -> Option<(String, u16)> { + let rest = url.strip_prefix("jdbc:")?; + + // jdbc:oracle:thin:@host:port:SID or jdbc:oracle:thin:@//host:port/service + if let Some(after) = rest.strip_prefix("oracle:") { + let at_pos = after.find('@')?; + let after_at = &after[at_pos + 1..]; + let after_at = after_at.strip_prefix("//").unwrap_or(after_at); + let host_port = after_at.split(&['/', ':', '?'][..]).next()?; + let port_str = after_at.strip_prefix(host_port)?.strip_prefix(':')?.split(&[':', '/', ';', '?'][..]).next()?; + return Some((host_port.to_string(), port_str.parse().ok()?)); + } + + // jdbc:sqlserver://host:port;prop=val or jdbc:sqlserver://host\instance:port;... + if let Some(after) = rest.strip_prefix("sqlserver://") { + let authority = after.split(';').next().unwrap_or(after); + let authority = authority.split('\\').next().unwrap_or(authority); + return match authority.rsplit_once(':') { + Some((h, p)) => Some((h.to_string(), p.parse().ok()?)), + None => Some((authority.to_string(), 1433)), + }; + } + + // Generic: jdbc:subprotocol://[user:pass@]host:port[/path][?query] + let scheme_end = rest.find("://")?; + let after_scheme = &rest[scheme_end + 3..]; + let authority = after_scheme.split('/').next().unwrap_or(after_scheme); + let authority = authority.split('?').next().unwrap_or(authority); + let host_port = match authority.rfind('@') { + Some(idx) => &authority[idx + 1..], + None => authority, + }; + match host_port.rsplit_once(':') { + Some((h, p)) => Some((h.to_string(), p.parse().ok()?)), + None => None, + } +} + +pub fn rewrite_jdbc_url_host(url: &str, new_host: &str, new_port: u16) -> String { + let Some((old_host, old_port)) = parse_jdbc_host_port(url) else { + return url.to_string(); + }; + let old_authority = format!("{old_host}:{old_port}"); + let new_authority = format!("{new_host}:{new_port}"); + url.replacen(&old_authority, &new_authority, 1) +} + fn encode_url_part(value: &str) -> String { utf8_percent_encode(value, NON_ALPHANUMERIC).to_string() } @@ -722,4 +769,91 @@ mod tests { assert!(url.matches("directConnection").count() == 1); } + + #[test] + fn parse_jdbc_host_port_postgresql() { + let (h, p) = super::parse_jdbc_host_port("jdbc:postgresql://myhost:5432/mydb").unwrap(); + assert_eq!(h, "myhost"); + assert_eq!(p, 5432); + } + + #[test] + fn parse_jdbc_host_port_mysql() { + let (h, p) = super::parse_jdbc_host_port("jdbc:mysql://db.example.com:3306/app?useSSL=false").unwrap(); + assert_eq!(h, "db.example.com"); + assert_eq!(p, 3306); + } + + #[test] + fn parse_jdbc_host_port_with_userinfo() { + let (h, p) = super::parse_jdbc_host_port("jdbc:postgresql://user:pass@pghost:5433/db").unwrap(); + assert_eq!(h, "pghost"); + assert_eq!(p, 5433); + } + + #[test] + fn parse_jdbc_host_port_oracle_thin() { + let (h, p) = super::parse_jdbc_host_port("jdbc:oracle:thin:@orahost:1521:ORCL").unwrap(); + assert_eq!(h, "orahost"); + assert_eq!(p, 1521); + } + + #[test] + fn parse_jdbc_host_port_oracle_service() { + let (h, p) = super::parse_jdbc_host_port("jdbc:oracle:thin:@//orahost:1521/service").unwrap(); + assert_eq!(h, "orahost"); + assert_eq!(p, 1521); + } + + #[test] + fn parse_jdbc_host_port_sqlserver() { + let (h, p) = super::parse_jdbc_host_port("jdbc:sqlserver://mshost:1433;databaseName=master").unwrap(); + assert_eq!(h, "mshost"); + assert_eq!(p, 1433); + } + + #[test] + fn parse_jdbc_host_port_sqlserver_no_port() { + let (h, p) = super::parse_jdbc_host_port("jdbc:sqlserver://mshost;databaseName=master").unwrap(); + assert_eq!(h, "mshost"); + assert_eq!(p, 1433); + } + + #[test] + fn parse_jdbc_host_port_no_port_returns_none() { + assert!(super::parse_jdbc_host_port("jdbc:postgresql://myhost/mydb").is_none()); + } + + #[test] + fn parse_jdbc_host_port_invalid_returns_none() { + assert!(super::parse_jdbc_host_port("not-a-jdbc-url").is_none()); + } + + #[test] + fn rewrite_jdbc_url_postgresql() { + let url = "jdbc:postgresql://myhost:5432/mydb"; + let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321); + assert_eq!(rewritten, "jdbc:postgresql://127.0.0.1:54321/mydb"); + } + + #[test] + fn rewrite_jdbc_url_oracle() { + let url = "jdbc:oracle:thin:@orahost:1521:ORCL"; + let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321); + assert_eq!(rewritten, "jdbc:oracle:thin:@127.0.0.1:54321:ORCL"); + } + + #[test] + fn rewrite_jdbc_url_sqlserver() { + let url = "jdbc:sqlserver://mshost:1433;databaseName=master"; + let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321); + assert_eq!(rewritten, "jdbc:sqlserver://127.0.0.1:54321;databaseName=master"); + } + + #[test] + fn rewrite_jdbc_url_unparseable_returns_original() { + let url = "jdbc:custom:some-opaque-string"; + let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321); + assert_eq!(rewritten, url); + } } diff --git a/src/components/connection/ConnectionDialog.vue b/src/components/connection/ConnectionDialog.vue index 10c1727b9..7b71c77e1 100644 --- a/src/components/connection/ConnectionDialog.vue +++ b/src/components/connection/ConnectionDialog.vue @@ -422,10 +422,8 @@ const selectedDbIcon = computed(() => iconTypeMap[selectedType.value] || selecte const isJdbcConnection = computed(() => form.value.db_type === "jdbc"); const connectionUrlPlaceholder = computed(() => getUrlPlaceholder(form.value.db_type)); -const canUseSsh = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "jdbc"); -const canUseProxy = computed( - () => form.value.db_type !== "sqlite" && form.value.db_type !== "duckdb" && form.value.db_type !== "jdbc", -); +const canUseSsh = computed(() => form.value.db_type !== "sqlite"); +const canUseProxy = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "duckdb"); const testResultMessage = computed(() => { if (!testResult.value) return ""; return testResult.value.ok ? t("connection.testSuccess") : testResult.value.message;