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.
This commit is contained in:
t8y2 2026-05-12 14:53:45 +08:00
parent c8d4a04a31
commit 45e67fae6e
3 changed files with 162 additions and 6 deletions

View File

@ -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)
};

View File

@ -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);
}
}

View File

@ -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;